From cbb38f32eb1fb35396a0e4d5d24171b121b23271 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 30 Jun 2026 15:08:37 -0500 Subject: [PATCH 01/45] docs(stores): add initiatives guide + a real example Add a short, plain-language guide for grouping related changes under one shared plan ("an initiative"), shown in a single repo and across many repos via a store. Includes a real worked example built from changes already in this repo, and links it from the docs index. Beta, alongside the existing stores guide. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/README.md | 1 + docs/stores-beta/initiatives.md | 153 ++++++++++++++++++ openspec/initiatives/smoother-setup/README.md | 31 ++++ .../initiatives/smoother-setup/inventory.md | 23 +++ 4 files changed, 208 insertions(+) create mode 100644 docs/stores-beta/initiatives.md create mode 100644 openspec/initiatives/smoother-setup/README.md create mode 100644 openspec/initiatives/smoother-setup/inventory.md diff --git a/docs/README.md b/docs/README.md index 627d76e31d..ae5b117d21 100644 --- a/docs/README.md +++ b/docs/README.md @@ -83,6 +83,7 @@ That second one matters more than it looks. OpenSpec has two halves: a command l | Doc | What it gives you | |-----|-------------------| | [Stores: User Guide](stores-beta/user-guide.md) | Plan in its own repo when your work spans repos or teams | +| [Initiatives](stores-beta/initiatives.md) | Group related changes under one shared plan, in one repo or many | | [Agent Contract](agent-contract.md) | The machine-readable CLI surfaces agents drive | ## The thirty-second version diff --git a/docs/stores-beta/initiatives.md b/docs/stores-beta/initiatives.md new file mode 100644 index 0000000000..11b51637a1 --- /dev/null +++ b/docs/stores-beta/initiatives.md @@ -0,0 +1,153 @@ +# Group related work with initiatives + +> **Beta.** This builds on [stores](user-guide.md), which are still new. Names and +> file shapes may change between releases. + +Big work is rarely one change. A goal like "make setup smoother" or "add search" +is really a handful of changes that go together. Today there is no clear home for +that bigger picture, so people track it in their head or in a stray document. + +An **initiative** is that home. It is a small folder that groups related changes +and shows where each one stands. You can keep it in your own repo, or in a +**store** so more than one repo can share it. + +This page shows both: one repo first, then many repos. + +## The two ideas, in one line each + +- **A store** is a planning repo you register by name, so any project can read it. +- **An initiative** is a folder that groups related changes and tracks their status. + +## One repo: make an initiative + +An initiative is just a folder with two files: + +``` +openspec/ + initiatives/ + smoother-setup/ + README.md the shared plan: what this is and why these changes go together + inventory.md the list of changes, and where each one stands +``` + +That is the whole shape. The `README.md` holds the plan in plain words. The +`inventory.md` is a simple table — one row per change. + +There is a real example in this repo: [smoother-setup](../../openspec/initiatives/smoother-setup/README.md). +It groups four real changes that all make setup easier. + +### See where each change stands + +You do not track status by hand. Ask OpenSpec: + +``` +openspec list --changes +``` + +``` +Changes: + simplify-skill-installation ✓ Complete 8d ago + fix-opencode-commands-directory ✓ Complete 8d ago + add-global-install-scope 0/38 tasks 8d ago + schema-alias-support No tasks 8d ago + ... +``` + +The inventory names the changes; this command shows their live status. Open any +one to read its full plan: + +``` +openspec show simplify-skill-installation +``` + +That is the one-repo case. Your big-picture plan and your changes now live side +by side, and your coding agent can read both. + +## Many repos: share the initiative in a store + +Sometimes the plan is bigger than one repo. Several repos build toward the same +goal, or one team owns the plan and others build against it. Put the initiative +in a **store**, and every repo can read it by name. + +Register a planning repo as a store once: + +``` +openspec store register ./path-to-plans --id team-plans +``` + +``` +OpenSpec root: ready +Registry: registered +``` + +Now any repo can read that plan without copying it. A code repo adds one line to +its `openspec/config.yaml`: + +```yaml +references: + - team-plans +``` + +From that repo, OpenSpec shows the shared plan as read-only context: + +``` +openspec context +``` + +``` +OpenSpec root + consumer-demo /path/to/consumer-demo + +Referenced stores + team-plans /path/to/plans + Fetch: openspec show --type spec --store team-plans +``` + +And your coding agent, working in the code repo, automatically sees the shared +plan it should build against: + +``` + +Store team-plans (/path/to/plans): + - ai-tool-paths: Define AI tool path metadata used to generate OpenSpec skills... + - artifact-graph: Define the artifact graph model, dependency validation... + - change-creation: Provide programmatic utilities for creating and validating... + +``` + +You can also read the plan by name from anywhere: + +``` +openspec list --specs --store team-plans +``` + +`openspec doctor` checks that every referenced store is present, and prints a +copy-paste fix if one is missing. Nothing syncs on its own — a store is a normal +git repo, so you share it by pushing and pulling like any other. + +That is the many-repo case. One plan, one home, read by name from every project — +instead of copied around and left to drift. + +## Optional: make the initiative a checked artifact + +The folder above is enough to start. If you want OpenSpec to treat an initiative +as a first-class artifact — with its own template and checks — you can describe +one with a small schema file. Schemas are how you **define your own artifact +types** (an initiative, a decision record, a one-pager), beyond the built-in +ones. See [the stores guide](user-guide.md) and the schema files under +`schemas/` for the format. Start with the folder; add a schema only when you want +the extra structure. + +## What works today, and what is still rough + +Honest notes from building this: + +- **Works now, no extra tools:** the initiative folder, `openspec list` for + status, and reading a store by name from any repo. Everything above ran on a + normal OpenSpec install. +- **Still rough:** defining your own artifact types is supported but light on + docs. And a store cannot yet list the repos that read from it, so a full + "across every repo" rollup needs a bit of glue today. + +Start simple: one initiative folder, grouping a few real changes. Move it into a +store when more than one repo needs it. diff --git a/openspec/initiatives/smoother-setup/README.md b/openspec/initiatives/smoother-setup/README.md new file mode 100644 index 0000000000..efd5e153cb --- /dev/null +++ b/openspec/initiatives/smoother-setup/README.md @@ -0,0 +1,31 @@ +# Initiative: Smoother setup + +> An **initiative** is a group of related changes that go together, plus the +> shared plan that holds them. Each change is still its own piece of work. This +> page is the plan they share. +> +> This is a real example, built from changes already in this repo. It shows how +> to group related work in one place. See [the guide](../../../docs/stores-beta/initiatives.md). + +## What this is + +A group of changes that make OpenSpec faster and simpler to set up. + +## Why these go together + +New users should get value fast. Each change here removes some setup friction: +fewer skills to learn at first, the right folders for each tool, a clear choice +of where things install, and friendlier schema names. On their own they are +small. Together they add up to a smoother first run. + +## The plan + +1. Cut first-run friction, so a new user reaches a win quickly. +2. Fix install paths, so files land where each tool expects them. +3. Let users choose where things install. +4. Make schema names easier to understand, without breaking old projects. + +## The work + +See [inventory.md](inventory.md) for the list of changes and where each one +stands. The status there comes from `openspec list`. diff --git a/openspec/initiatives/smoother-setup/inventory.md b/openspec/initiatives/smoother-setup/inventory.md new file mode 100644 index 0000000000..742867b42d --- /dev/null +++ b/openspec/initiatives/smoother-setup/inventory.md @@ -0,0 +1,23 @@ +# Inventory: Smoother setup + +One row per change. This is the "where does each piece stand" view. The status +column comes from `openspec list` — run it to see the live numbers. + +| # | Change | What it does | Status | +|---|--------|--------------|--------| +| 1 | simplify-skill-installation | Fewer default skills, so the first run is quick | Done | +| 2 | fix-opencode-commands-directory | Use the folder OpenCode expects for commands | Done | +| 3 | add-global-install-scope | Let users choose where tools install | In progress | +| 4 | schema-alias-support | Let `spec-driven` and `openspec-default` both work | Planned | + +See live status any time: + +``` +openspec list --changes +``` + +Open any change to see its full plan: + +``` +openspec show simplify-skill-installation +``` From e179ae43130523a5f1f0960397d8787cf8c81efa Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 30 Jun 2026 15:21:06 -0500 Subject: [PATCH 02/45] docs(stores): show an initiative as a collection of artifact types Expand the example and guide to show that an initiative holds more than changes: personas (who the work serves), decision records (ADRs), and an optional schema that defines your own artifact types (persona -> adr -> spec -> tasks). The schema output shown is real (captured from `openspec schemas`). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/stores-beta/initiatives.md | 100 +++++++++++++++--- openspec/initiatives/smoother-setup/README.md | 15 +++ .../decisions/0001-aliases-over-rename.md | 26 +++++ .../decisions/0002-tool-native-paths.md | 23 ++++ .../smoother-setup/example-schema/schema.yaml | 54 ++++++++++ .../example-schema/templates/adr.md | 18 ++++ .../example-schema/templates/persona.md | 13 +++ .../example-schema/templates/spec.md | 12 +++ .../example-schema/templates/tasks.md | 6 ++ .../smoother-setup/personas/coding-agent.md | 20 ++++ .../smoother-setup/personas/new-user.md | 24 +++++ .../smoother-setup/personas/team-lead.md | 20 ++++ 12 files changed, 315 insertions(+), 16 deletions(-) create mode 100644 openspec/initiatives/smoother-setup/decisions/0001-aliases-over-rename.md create mode 100644 openspec/initiatives/smoother-setup/decisions/0002-tool-native-paths.md create mode 100644 openspec/initiatives/smoother-setup/example-schema/schema.yaml create mode 100644 openspec/initiatives/smoother-setup/example-schema/templates/adr.md create mode 100644 openspec/initiatives/smoother-setup/example-schema/templates/persona.md create mode 100644 openspec/initiatives/smoother-setup/example-schema/templates/spec.md create mode 100644 openspec/initiatives/smoother-setup/example-schema/templates/tasks.md create mode 100644 openspec/initiatives/smoother-setup/personas/coding-agent.md create mode 100644 openspec/initiatives/smoother-setup/personas/new-user.md create mode 100644 openspec/initiatives/smoother-setup/personas/team-lead.md diff --git a/docs/stores-beta/initiatives.md b/docs/stores-beta/initiatives.md index 11b51637a1..b4ae61f4be 100644 --- a/docs/stores-beta/initiatives.md +++ b/docs/stores-beta/initiatives.md @@ -30,8 +30,8 @@ openspec/ inventory.md the list of changes, and where each one stands ``` -That is the whole shape. The `README.md` holds the plan in plain words. The -`inventory.md` is a simple table — one row per change. +That is the whole shape to start. The `README.md` holds the plan in plain words. +The `inventory.md` is a simple table — one row per change. There is a real example in this repo: [smoother-setup](../../openspec/initiatives/smoother-setup/README.md). It groups four real changes that all make setup easier. @@ -63,6 +63,31 @@ openspec show simplify-skill-installation That is the one-repo case. Your big-picture plan and your changes now live side by side, and your coding agent can read both. +### An initiative can hold more than changes + +Real work needs more than a change list. It helps to write down *who* you are +building for and *why* you made the calls you did. An initiative is a good home +for those too — as plain files, right next to the plan: + +``` +smoother-setup/ + README.md the plan + inventory.md the changes, and where each one stands + personas/ who we are building for + decisions/ the key calls, and why (one short record each) +``` + +In the [example](../../openspec/initiatives/smoother-setup/README.md): + +- **Personas** name the people the work serves — a + [new user](../../openspec/initiatives/smoother-setup/personas/new-user.md), a + [lead across repos](../../openspec/initiatives/smoother-setup/personas/team-lead.md), + and an [AI coding agent](../../openspec/initiatives/smoother-setup/personas/coding-agent.md). +- **Decision records** (ADRs) capture one call each and why, so the reason is not + lost later — for example, [why we added an alias instead of renaming](../../openspec/initiatives/smoother-setup/decisions/0001-aliases-over-rename.md). + +All of it sits in one place, in plain Markdown your coding agent can read. + ## Many repos: share the initiative in a store Sometimes the plan is bigger than one repo. Several repos build toward the same @@ -128,26 +153,69 @@ git repo, so you share it by pushing and pulling like any other. That is the many-repo case. One plan, one home, read by name from every project — instead of copied around and left to drift. -## Optional: make the initiative a checked artifact +## Define your own artifact types + +Plain files are enough to start. When you want OpenSpec to *check* these +artifacts — give each one a template and a clear order — you describe them in a +small **schema**. A schema is how you define your own artifact types, beyond the +built-in ones. + +The example ships one: +[example-schema/](../../openspec/initiatives/smoother-setup/example-schema/schema.yaml). +It defines four types — a persona, a decision record, a spec, and a task list — +and the order they build in. Copy that folder into `openspec/schemas/initiative/` +and OpenSpec picks it up: + +``` +openspec schemas +``` + +``` +Available schemas: + + initiative (project) + A workflow that keeps the "who and why" next to the "what". It produces a + persona, a decision record, a spec, and a task list. + Artifacts: persona → adr → spec → tasks + + spec-driven + Default OpenSpec workflow - proposal → specs → design → tasks + Artifacts: proposal → specs → design → tasks +``` + +Now you can start a change on your own types, and OpenSpec tracks the order for +you: + +``` +openspec new change improve-onboarding --schema initiative +openspec status --change improve-onboarding +``` + +``` +[ ] persona +[-] adr (blocked by: persona) +[-] spec (blocked by: adr) +[-] tasks (blocked by: spec) +``` + +This is the heart of it: a store is not limited to changes and specs. You decide +what artifacts your work needs — personas, decision records, one-pagers — and +they all live in one place your team and your coding agent can read. -The folder above is enough to start. If you want OpenSpec to treat an initiative -as a first-class artifact — with its own template and checks — you can describe -one with a small schema file. Schemas are how you **define your own artifact -types** (an initiative, a decision record, a one-pager), beyond the built-in -ones. See [the stores guide](user-guide.md) and the schema files under -`schemas/` for the format. Start with the folder; add a schema only when you want -the extra structure. +Start with plain files. Add a schema when you want the structure and the checks. ## What works today, and what is still rough Honest notes from building this: -- **Works now, no extra tools:** the initiative folder, `openspec list` for - status, and reading a store by name from any repo. Everything above ran on a - normal OpenSpec install. -- **Still rough:** defining your own artifact types is supported but light on - docs. And a store cannot yet list the repos that read from it, so a full - "across every repo" rollup needs a bit of glue today. +- **Works now, no extra tools:** the initiative folder, personas and decision + records as files, `openspec list` for status, your own artifact types via a + schema, and reading a store by name from any repo. Every command and its output + above came from a normal OpenSpec install. +- **Still rough:** the folder layout for an initiative is a convention here, not a + built-in command, so it is on you to keep it tidy. And a store cannot yet list + the repos that read from it, so a full "across every repo" rollup needs a bit of + glue today. Start simple: one initiative folder, grouping a few real changes. Move it into a store when more than one repo needs it. diff --git a/openspec/initiatives/smoother-setup/README.md b/openspec/initiatives/smoother-setup/README.md index efd5e153cb..5764fa4a5f 100644 --- a/openspec/initiatives/smoother-setup/README.md +++ b/openspec/initiatives/smoother-setup/README.md @@ -29,3 +29,18 @@ small. Together they add up to a smoother first run. See [inventory.md](inventory.md) for the list of changes and where each one stands. The status there comes from `openspec list`. + +## What is in this folder + +An initiative can hold more than a plan and a change list. It is a home for every +artifact that supports the work: + +- [inventory.md](inventory.md) — the changes, and where each one stands. +- [personas/](personas/) — who we are building for ([new user](personas/new-user.md), + [a lead across repos](personas/team-lead.md), [an AI coding agent](personas/coding-agent.md)). +- [decisions/](decisions/) — the key decisions and why, as short records + ([0001](decisions/0001-aliases-over-rename.md), [0002](decisions/0002-tool-native-paths.md)). +- [example-schema/](example-schema/) — an optional schema that turns these into + typed, checked artifacts. See [the guide](../../../docs/stores-beta/initiatives.md). + +These are all artifacts in one place. Your coding agent can read any of them. diff --git a/openspec/initiatives/smoother-setup/decisions/0001-aliases-over-rename.md b/openspec/initiatives/smoother-setup/decisions/0001-aliases-over-rename.md new file mode 100644 index 0000000000..10d3701a04 --- /dev/null +++ b/openspec/initiatives/smoother-setup/decisions/0001-aliases-over-rename.md @@ -0,0 +1,26 @@ +# ADR 0001: Add a schema alias instead of renaming + +> An ADR (Architecture Decision Record) captures one decision and why it was +> made, so the reason is not lost later. + +## Status + +Accepted + +## Context + +We want `spec-driven` to be called `openspec-default`, which is a clearer name. +But many projects already have `schema: spec-driven` in their config. A direct +rename would break them. + +## Decision + +Add alias support, so both names point to the same schema. No project breaks, +and the new name can roll out over time. + +## Trade-offs + +- Easier: a smooth rename with nothing breaking. +- Harder: two names mean a little more to explain until the old one is retired. + +_Source: the `schema-alias-support` change in this repo._ diff --git a/openspec/initiatives/smoother-setup/decisions/0002-tool-native-paths.md b/openspec/initiatives/smoother-setup/decisions/0002-tool-native-paths.md new file mode 100644 index 0000000000..21a8302a50 --- /dev/null +++ b/openspec/initiatives/smoother-setup/decisions/0002-tool-native-paths.md @@ -0,0 +1,23 @@ +# ADR 0002: Match each tool's own folder names + +## Status + +Accepted + +## Context + +One adapter wrote commands to a folder that did not match the tool's documented +path, while every other adapter used the tool's expected (plural) folder name. +This was inconsistent and confusing. + +## Decision + +Use the folder each tool expects. Keep the old path working for a while, so +existing setups do not break. + +## Trade-offs + +- Easier: commands land where the tool actually looks for them. +- Harder: we carry a backward-compatible path until old setups move over. + +_Source: the `fix-opencode-commands-directory` change in this repo._ diff --git a/openspec/initiatives/smoother-setup/example-schema/schema.yaml b/openspec/initiatives/smoother-setup/example-schema/schema.yaml new file mode 100644 index 0000000000..d806c93c1b --- /dev/null +++ b/openspec/initiatives/smoother-setup/example-schema/schema.yaml @@ -0,0 +1,54 @@ +name: initiative +version: 1 +description: >- + A workflow that keeps the "who and why" next to the "what". It produces a + persona, a decision record, a spec, and a task list. Copy this folder into + openspec/schemas/initiative/ to use it, then create a change with + --schema initiative. + +artifacts: + - id: persona + generates: persona.md + description: Who we are building for + template: persona.md + requires: [] + instruction: | + Describe one kind of user: who they are, what they want, and what gets in + their way. Keep it short and real. + + - id: adr + generates: adr.md + description: A key decision and why + template: adr.md + requires: + - persona + instruction: | + Record one decision: the context, the choice, and the trade-offs. One + decision per record. + + - id: spec + generates: "specs/**/*.md" + description: What the system should do + template: spec.md + requires: + - adr + instruction: | + Write the behavior as requirements and scenarios. Each requirement needs + at least one scenario. Scenarios use four hashtags (####). + + - id: tasks + generates: tasks.md + description: The work to do + template: tasks.md + requires: + - spec + instruction: | + Break the work into small, checkable tasks. Order them so earlier tasks + unblock later ones. + +apply: + requires: + - tasks + tracks: tasks.md + instruction: | + Work through the tasks and check them off as you go. diff --git a/openspec/initiatives/smoother-setup/example-schema/templates/adr.md b/openspec/initiatives/smoother-setup/example-schema/templates/adr.md new file mode 100644 index 0000000000..20bcf490f6 --- /dev/null +++ b/openspec/initiatives/smoother-setup/example-schema/templates/adr.md @@ -0,0 +1,18 @@ +# ADR: [Decision title] + +## Status + +[proposed | accepted | replaced] + +## Context + +[The situation that forces a choice.] + +## Decision + +[What we are doing, in plain words.] + +## Trade-offs + +- Easier: [what this makes easier] +- Harder: [what this makes harder] diff --git a/openspec/initiatives/smoother-setup/example-schema/templates/persona.md b/openspec/initiatives/smoother-setup/example-schema/templates/persona.md new file mode 100644 index 0000000000..90d3f5266b --- /dev/null +++ b/openspec/initiatives/smoother-setup/example-schema/templates/persona.md @@ -0,0 +1,13 @@ +# Persona: [Name of the user type] + +## Who they are + +[A short description.] + +## What they want + +[The outcome they are after.] + +## What gets in their way + +[The friction or blocker.] diff --git a/openspec/initiatives/smoother-setup/example-schema/templates/spec.md b/openspec/initiatives/smoother-setup/example-schema/templates/spec.md new file mode 100644 index 0000000000..55a8b79103 --- /dev/null +++ b/openspec/initiatives/smoother-setup/example-schema/templates/spec.md @@ -0,0 +1,12 @@ +# [Capability name] + +## ADDED Requirements + +### Requirement: [Name] + +The system SHALL [observable behavior]. + +#### Scenario: [Name] + +- **WHEN** [something happens] +- **THEN** [the expected result] diff --git a/openspec/initiatives/smoother-setup/example-schema/templates/tasks.md b/openspec/initiatives/smoother-setup/example-schema/templates/tasks.md new file mode 100644 index 0000000000..9fd4e78e13 --- /dev/null +++ b/openspec/initiatives/smoother-setup/example-schema/templates/tasks.md @@ -0,0 +1,6 @@ +# Tasks + +## 1. [Group] + +- [ ] 1.1 [Task] +- [ ] 1.2 [Task] diff --git a/openspec/initiatives/smoother-setup/personas/coding-agent.md b/openspec/initiatives/smoother-setup/personas/coding-agent.md new file mode 100644 index 0000000000..7a26bd23f2 --- /dev/null +++ b/openspec/initiatives/smoother-setup/personas/coding-agent.md @@ -0,0 +1,20 @@ +# Persona: AI coding agent + +## Who they are + +The AI assistant a developer works with. It reads the plan and helps write the +code. + +## What they want + +Clear, structured context it can read on its own, without a person pasting it in. + +## What gets in their way + +Plans kept in formats it cannot read, or spread across many tools. + +## How this initiative helps + +Specs, changes, decisions, and personas all live in one readable place the agent +can open. When the plan lives in a store, the agent can read it from any repo +that references it. diff --git a/openspec/initiatives/smoother-setup/personas/new-user.md b/openspec/initiatives/smoother-setup/personas/new-user.md new file mode 100644 index 0000000000..b0b2f6eabd --- /dev/null +++ b/openspec/initiatives/smoother-setup/personas/new-user.md @@ -0,0 +1,24 @@ +# Persona: New user on an existing codebase + +> A persona is a short note about a kind of person you build for. It keeps the +> work pointed at a real need. + +## Who they are + +A developer trying OpenSpec for the first time, on a project that already has a +lot of code. + +## What they want + +A quick win. To see value in the first minute, without learning ten new +commands. + +## What gets in their way + +Too many skills and commands at once. Setup steps that do not match the tool +they use. + +## How this initiative helps + +Fewer default skills, so the first run is short (`simplify-skill-installation`). +Commands that land in the folder their tool expects (`fix-opencode-commands-directory`). diff --git a/openspec/initiatives/smoother-setup/personas/team-lead.md b/openspec/initiatives/smoother-setup/personas/team-lead.md new file mode 100644 index 0000000000..7218a47041 --- /dev/null +++ b/openspec/initiatives/smoother-setup/personas/team-lead.md @@ -0,0 +1,20 @@ +# Persona: Lead coordinating several repos + +## Who they are + +Someone who owns a goal that spans more than one repo, or more than one person. + +## What they want + +One shared plan everyone can see, and a clear view of what is done and what is +left. + +## What gets in their way + +Plans copied into each repo, slowly going out of date. No single source of +truth. + +## How this initiative helps + +The initiative groups the related changes in one place. A store shares that plan +across repos, so every project reads the same source instead of a copy. From 8825ed2ed2f88839a30095e6d5cc89d9cf530dc7 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 30 Jun 2026 16:24:01 -0500 Subject: [PATCH 03/45] docs(stores): add one-pager artifact type + a completed example graph Add a one-pager (brief) artifact to the example schema, making the graph one-pager -> persona -> adr -> spec -> tasks. Include finished-example/, a change taken fully through that graph (real files), and show the completed run in the guide: status 5/5 complete and `validate --strict` passing. All output captured from a real run; the schema is not added to the repo's active set. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/stores-beta/initiatives.md | 47 +++++++++++++++---- openspec/initiatives/smoother-setup/README.md | 4 +- .../finished-example/.openspec.yaml | 2 + .../example-schema/finished-example/adr.md | 20 ++++++++ .../finished-example/one-pager.md | 23 +++++++++ .../finished-example/persona.md | 14 ++++++ .../specs/first-run-skills/spec.md | 21 +++++++++ .../example-schema/finished-example/tasks.md | 11 +++++ .../smoother-setup/example-schema/schema.yaml | 16 +++++-- .../example-schema/templates/one-pager.md | 21 +++++++++ 10 files changed, 166 insertions(+), 13 deletions(-) create mode 100644 openspec/initiatives/smoother-setup/example-schema/finished-example/.openspec.yaml create mode 100644 openspec/initiatives/smoother-setup/example-schema/finished-example/adr.md create mode 100644 openspec/initiatives/smoother-setup/example-schema/finished-example/one-pager.md create mode 100644 openspec/initiatives/smoother-setup/example-schema/finished-example/persona.md create mode 100644 openspec/initiatives/smoother-setup/example-schema/finished-example/specs/first-run-skills/spec.md create mode 100644 openspec/initiatives/smoother-setup/example-schema/finished-example/tasks.md create mode 100644 openspec/initiatives/smoother-setup/example-schema/templates/one-pager.md diff --git a/docs/stores-beta/initiatives.md b/docs/stores-beta/initiatives.md index b4ae61f4be..ad674fe6ec 100644 --- a/docs/stores-beta/initiatives.md +++ b/docs/stores-beta/initiatives.md @@ -162,9 +162,9 @@ built-in ones. The example ships one: [example-schema/](../../openspec/initiatives/smoother-setup/example-schema/schema.yaml). -It defines four types — a persona, a decision record, a spec, and a task list — -and the order they build in. Copy that folder into `openspec/schemas/initiative/` -and OpenSpec picks it up: +It defines five types — a one-pager, a persona, a decision record, a spec, and a +task list — and the order they build in. Copy that folder into +`openspec/schemas/initiative/` and OpenSpec picks it up: ``` openspec schemas @@ -175,16 +175,16 @@ Available schemas: initiative (project) A workflow that keeps the "who and why" next to the "what". It produces a - persona, a decision record, a spec, and a task list. - Artifacts: persona → adr → spec → tasks + one-pager, a persona, a decision record, a spec, and a task list. + Artifacts: one-pager → persona → adr → spec → tasks spec-driven Default OpenSpec workflow - proposal → specs → design → tasks Artifacts: proposal → specs → design → tasks ``` -Now you can start a change on your own types, and OpenSpec tracks the order for -you: +Now you can start a change on your own types. OpenSpec tracks the order — each +artifact unlocks the next: ``` openspec new change improve-onboarding --schema initiative @@ -192,14 +192,43 @@ openspec status --change improve-onboarding ``` ``` -[ ] persona +[ ] one-pager +[-] persona (blocked by: one-pager) [-] adr (blocked by: persona) [-] spec (blocked by: adr) [-] tasks (blocked by: spec) ``` +Fill each artifact in order and the graph fills in. Here is a finished one — you +can read the real files in +[finished-example/](../../openspec/initiatives/smoother-setup/example-schema/finished-example/): + +``` +openspec status --change example-first-run +``` + +``` +Progress: 5/5 artifacts complete + +[x] one-pager +[x] persona +[x] adr +[x] spec +[x] tasks + +All artifacts complete! +``` + +``` +openspec validate example-first-run --type change --strict +``` + +``` +Change 'example-first-run' is valid +``` + This is the heart of it: a store is not limited to changes and specs. You decide -what artifacts your work needs — personas, decision records, one-pagers — and +what artifacts your work needs — one-pagers, personas, decision records — and they all live in one place your team and your coding agent can read. Start with plain files. Add a schema when you want the structure and the checks. diff --git a/openspec/initiatives/smoother-setup/README.md b/openspec/initiatives/smoother-setup/README.md index 5764fa4a5f..85e519a3d1 100644 --- a/openspec/initiatives/smoother-setup/README.md +++ b/openspec/initiatives/smoother-setup/README.md @@ -41,6 +41,8 @@ artifact that supports the work: - [decisions/](decisions/) — the key decisions and why, as short records ([0001](decisions/0001-aliases-over-rename.md), [0002](decisions/0002-tool-native-paths.md)). - [example-schema/](example-schema/) — an optional schema that turns these into - typed, checked artifacts. See [the guide](../../../docs/stores-beta/initiatives.md). + typed, checked artifacts (one-pager → persona → adr → spec → tasks), with a + [finished-example/](example-schema/finished-example/) showing a completed set. + See [the guide](../../../docs/stores-beta/initiatives.md). These are all artifacts in one place. Your coding agent can read any of them. diff --git a/openspec/initiatives/smoother-setup/example-schema/finished-example/.openspec.yaml b/openspec/initiatives/smoother-setup/example-schema/finished-example/.openspec.yaml new file mode 100644 index 0000000000..eafef4ffdd --- /dev/null +++ b/openspec/initiatives/smoother-setup/example-schema/finished-example/.openspec.yaml @@ -0,0 +1,2 @@ +schema: initiative +created: 2026-06-30 diff --git a/openspec/initiatives/smoother-setup/example-schema/finished-example/adr.md b/openspec/initiatives/smoother-setup/example-schema/finished-example/adr.md new file mode 100644 index 0000000000..539591403d --- /dev/null +++ b/openspec/initiatives/smoother-setup/example-schema/finished-example/adr.md @@ -0,0 +1,20 @@ +# ADR: Ship a small default skill set + +## Status + +Accepted + +## Context + +Ten skills at once overwhelm new users. But power users rely on the full set, so +we cannot simply remove skills. + +## Decision + +Install a small default set on first run. Make the full set available through a +simple choice, so nothing is lost. + +## Trade-offs + +- Easier: a calm, fast first run for new users. +- Harder: two paths to explain — the default set and the full set. diff --git a/openspec/initiatives/smoother-setup/example-schema/finished-example/one-pager.md b/openspec/initiatives/smoother-setup/example-schema/finished-example/one-pager.md new file mode 100644 index 0000000000..58ce7e3ab7 --- /dev/null +++ b/openspec/initiatives/smoother-setup/example-schema/finished-example/one-pager.md @@ -0,0 +1,23 @@ +# One-pager: Fewer skills on first run + +## Problem + +A new user runs OpenSpec for the first time and sees a long list of skills and +commands at once. It feels heavy. The first run should feel quick. + +## Who it is for + +A new user on an existing codebase. See [persona.md](persona.md). + +## What we will do + +Install a small default set of skills. Keep the full set available for people who +want it. + +## Out of scope + +Removing any skill. Changing what skills do. + +## How we will know it worked + +A new user reaches a first win in under a minute. diff --git a/openspec/initiatives/smoother-setup/example-schema/finished-example/persona.md b/openspec/initiatives/smoother-setup/example-schema/finished-example/persona.md new file mode 100644 index 0000000000..8061d3ab09 --- /dev/null +++ b/openspec/initiatives/smoother-setup/example-schema/finished-example/persona.md @@ -0,0 +1,14 @@ +# Persona: New user on an existing codebase + +## Who they are + +A developer trying OpenSpec for the first time, on a project that already has a +lot of code. + +## What they want + +A quick win, without learning ten commands first. + +## What gets in their way + +Too many skills shown at once on the first run. diff --git a/openspec/initiatives/smoother-setup/example-schema/finished-example/specs/first-run-skills/spec.md b/openspec/initiatives/smoother-setup/example-schema/finished-example/specs/first-run-skills/spec.md new file mode 100644 index 0000000000..0208c3e733 --- /dev/null +++ b/openspec/initiatives/smoother-setup/example-schema/finished-example/specs/first-run-skills/spec.md @@ -0,0 +1,21 @@ +# First-run skills + +## ADDED Requirements + +### Requirement: Small default skill set + +The system SHALL install a small default set of skills on first run. + +#### Scenario: New project is set up + +- **WHEN** a user runs init on a new project +- **THEN** only the default skill set is installed + +### Requirement: Full set available on request + +The system SHALL let users install the full skill set when they choose to. + +#### Scenario: User asks for the full set + +- **WHEN** a user opts into the full set +- **THEN** all skills are installed diff --git a/openspec/initiatives/smoother-setup/example-schema/finished-example/tasks.md b/openspec/initiatives/smoother-setup/example-schema/finished-example/tasks.md new file mode 100644 index 0000000000..f8611b2445 --- /dev/null +++ b/openspec/initiatives/smoother-setup/example-schema/finished-example/tasks.md @@ -0,0 +1,11 @@ +# Tasks + +## 1. Defaults + +- [x] 1.1 Pick the default skill set +- [x] 1.2 Install only the default set on init + +## 2. Full set + +- [x] 2.1 Add a way to install the full set +- [x] 2.2 Document both paths diff --git a/openspec/initiatives/smoother-setup/example-schema/schema.yaml b/openspec/initiatives/smoother-setup/example-schema/schema.yaml index d806c93c1b..21a2030de1 100644 --- a/openspec/initiatives/smoother-setup/example-schema/schema.yaml +++ b/openspec/initiatives/smoother-setup/example-schema/schema.yaml @@ -2,16 +2,26 @@ name: initiative version: 1 description: >- A workflow that keeps the "who and why" next to the "what". It produces a - persona, a decision record, a spec, and a task list. Copy this folder into - openspec/schemas/initiative/ to use it, then create a change with + one-pager, a persona, a decision record, a spec, and a task list. Copy this + folder into openspec/schemas/initiative/ to use it, then create a change with --schema initiative. artifacts: + - id: one-pager + generates: one-pager.md + description: The brief — why this work, and what it is, at a glance + template: one-pager.md + requires: [] + instruction: | + Write a one-page brief: the problem, who it is for, what we will do, and + what is out of scope. Keep it to a page. + - id: persona generates: persona.md description: Who we are building for template: persona.md - requires: [] + requires: + - one-pager instruction: | Describe one kind of user: who they are, what they want, and what gets in their way. Keep it short and real. diff --git a/openspec/initiatives/smoother-setup/example-schema/templates/one-pager.md b/openspec/initiatives/smoother-setup/example-schema/templates/one-pager.md new file mode 100644 index 0000000000..0f74cfb175 --- /dev/null +++ b/openspec/initiatives/smoother-setup/example-schema/templates/one-pager.md @@ -0,0 +1,21 @@ +# One-pager: [Title] + +## Problem + +[The problem, in a sentence or two. Who feels it, and when.] + +## Who it is for + +[The main user type. Link to a persona if you have one.] + +## What we will do + +[The shape of the solution, at a high level.] + +## Out of scope + +[What this work will not cover.] + +## How we will know it worked + +[The signal that tells you it landed.] From 4aa2ebe4dbb229d077a62507b823c55eddb3a4b1 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 30 Jun 2026 16:52:57 -0500 Subject: [PATCH 04/45] docs(stores): add a "Where this could go" section Frame the prototype's rough edges as a short forward plan (an initiative command, cross-repo status rollup, ready-made artifact types), so the guide reads as a plan, not only a how-to. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/stores-beta/initiatives.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/stores-beta/initiatives.md b/docs/stores-beta/initiatives.md index ad674fe6ec..35dbc8bc05 100644 --- a/docs/stores-beta/initiatives.md +++ b/docs/stores-beta/initiatives.md @@ -246,5 +246,20 @@ Honest notes from building this: the repos that read from it, so a full "across every repo" rollup needs a bit of glue today. +## Where this could go + +This works today with plain files and a copied schema. A first-class version +could: + +- Add an `openspec initiative` command to create and list initiatives, so the + layout is more than a convention. +- Roll up status across every repo that references a store, so "where does the + whole effort stand" is one command. +- Ship a few ready-made artifact types (one-pager, persona, decision record) you + can turn on, instead of copying a schema. + +None of these are needed to start. They are the natural next steps once teams +lean on initiatives. + Start simple: one initiative folder, grouping a few real changes. Move it into a store when more than one repo needs it. From 928842497c99dcc3d51e1e928082eb4de4c7e249 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 1 Jul 2026 15:02:07 -0500 Subject: [PATCH 05/45] docs(stores): rework initiatives prototype around the define-your-own-artifacts superpower Lead on the wedge (define your own artifact types, share them in a store every repo reads), deliver it through the cross-repo happy path, and propose the build (store-aware artifact/schema discovery + initiative precedence) as a validated OpenSpec change. Trim the example from ~20 files to 9 while keeping personas + decision records to show customizability. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/README.md | 2 +- docs/stores-beta/initiatives.md | 316 ++++++------------ .../changes/initiatives-in-stores/design.md | 81 +++++ .../changes/initiatives-in-stores/proposal.md | 68 ++++ .../specs/store-aware-artifacts/spec.md | 59 ++++ .../changes/initiatives-in-stores/tasks.md | 31 ++ openspec/initiatives/smoother-setup/README.md | 48 --- openspec/initiatives/smoother-setup/brief.md | 46 +++ .../finished-example/.openspec.yaml | 2 - .../example-schema/finished-example/adr.md | 20 -- .../finished-example/one-pager.md | 23 -- .../finished-example/persona.md | 14 - .../specs/first-run-skills/spec.md | 21 -- .../example-schema/finished-example/tasks.md | 11 - .../smoother-setup/example-schema/schema.yaml | 58 +--- .../templates/{one-pager.md => brief.md} | 2 +- .../templates/{adr.md => decision.md} | 2 +- .../example-schema/templates/spec.md | 12 - .../example-schema/templates/tasks.md | 6 - .../initiatives/smoother-setup/inventory.md | 23 -- .../smoother-setup/personas/coding-agent.md | 20 -- 21 files changed, 404 insertions(+), 461 deletions(-) create mode 100644 openspec/changes/initiatives-in-stores/design.md create mode 100644 openspec/changes/initiatives-in-stores/proposal.md create mode 100644 openspec/changes/initiatives-in-stores/specs/store-aware-artifacts/spec.md create mode 100644 openspec/changes/initiatives-in-stores/tasks.md delete mode 100644 openspec/initiatives/smoother-setup/README.md create mode 100644 openspec/initiatives/smoother-setup/brief.md delete mode 100644 openspec/initiatives/smoother-setup/example-schema/finished-example/.openspec.yaml delete mode 100644 openspec/initiatives/smoother-setup/example-schema/finished-example/adr.md delete mode 100644 openspec/initiatives/smoother-setup/example-schema/finished-example/one-pager.md delete mode 100644 openspec/initiatives/smoother-setup/example-schema/finished-example/persona.md delete mode 100644 openspec/initiatives/smoother-setup/example-schema/finished-example/specs/first-run-skills/spec.md delete mode 100644 openspec/initiatives/smoother-setup/example-schema/finished-example/tasks.md rename openspec/initiatives/smoother-setup/example-schema/templates/{one-pager.md => brief.md} (94%) rename openspec/initiatives/smoother-setup/example-schema/templates/{adr.md => decision.md} (88%) delete mode 100644 openspec/initiatives/smoother-setup/example-schema/templates/spec.md delete mode 100644 openspec/initiatives/smoother-setup/example-schema/templates/tasks.md delete mode 100644 openspec/initiatives/smoother-setup/inventory.md delete mode 100644 openspec/initiatives/smoother-setup/personas/coding-agent.md diff --git a/docs/README.md b/docs/README.md index ae5b117d21..8f38c02956 100644 --- a/docs/README.md +++ b/docs/README.md @@ -83,7 +83,7 @@ That second one matters more than it looks. OpenSpec has two halves: a command l | Doc | What it gives you | |-----|-------------------| | [Stores: User Guide](stores-beta/user-guide.md) | Plan in its own repo when your work spans repos or teams | -| [Initiatives](stores-beta/initiatives.md) | Group related changes under one shared plan, in one repo or many | +| [Initiatives](stores-beta/initiatives.md) | Define your own planning artifacts and share them in a store every repo can read | | [Agent Contract](agent-contract.md) | The machine-readable CLI surfaces agents drive | ## The thirty-second version diff --git a/docs/stores-beta/initiatives.md b/docs/stores-beta/initiatives.md index 35dbc8bc05..e296b9579a 100644 --- a/docs/stores-beta/initiatives.md +++ b/docs/stores-beta/initiatives.md @@ -1,265 +1,151 @@ -# Group related work with initiatives +# Initiatives: your plan, your artifacts, shared across repos -> **Beta.** This builds on [stores](user-guide.md), which are still new. Names and -> file shapes may change between releases. +> **Beta.** This builds on [stores](user-guide.md). Names and file shapes may +> change between releases. -Big work is rarely one change. A goal like "make setup smoother" or "add search" -is really a handful of changes that go together. Today there is no clear home for -that bigger picture, so people track it in their head or in a stray document. +## The one idea -An **initiative** is that home. It is a small folder that groups related changes -and shows where each one stands. You can keep it in your own repo, or in a -**store** so more than one repo can share it. +Kiro hands you `requirements → design → tasks`. Spec-Kit hands you +`spec → plan → tasks`. You use the artifacts they picked. -This page shows both: one repo first, then many repos. +OpenSpec is different: -## The two ideas, in one line each +> **You define the artifacts your team actually uses — a brief, a persona, a +> decision record, anything — and share them in one place every repo can read.** -- **A store** is a planning repo you register by name, so any project can read it. -- **An initiative** is a folder that groups related changes and tracks their status. +That one place is a **store**. The plan that lives there is an **initiative**. -## One repo: make an initiative +## What an initiative is -An initiative is just a folder with two files: +An **initiative** is the home for a big effort — work that is too large for a +single change. -``` -openspec/ - initiatives/ - smoother-setup/ - README.md the shared plan: what this is and why these changes go together - inventory.md the list of changes, and where each one stands -``` - -That is the whole shape to start. The `README.md` holds the plan in plain words. -The `inventory.md` is a simple table — one row per change. - -There is a real example in this repo: [smoother-setup](../../openspec/initiatives/smoother-setup/README.md). -It groups four real changes that all make setup easier. +It holds three things: -### See where each change stands +- **the brief** — what you are doing, and why +- **the artifacts you choose** — personas, decision records, whatever your team + works from +- **the changes** that carry it out — each one still a normal OpenSpec change, + with live status -You do not track status by hand. Ask OpenSpec: +One effort. One home. Read by every repo that needs it. -``` -openspec list --changes -``` - -``` -Changes: - simplify-skill-installation ✓ Complete 8d ago - fix-opencode-commands-directory ✓ Complete 8d ago - add-global-install-scope 0/38 tasks 8d ago - schema-alias-support No tasks 8d ago - ... +```text +acme-plans (a store: planning in its own repo) + openspec/initiatives/ + smoother-setup/ + brief.md what this is, and why + personas/ who we build for ← your own artifact types + decisions/ the calls we made ← your own artifact types + (changes grouped here, with live status) ``` -The inventory names the changes; this command shows their live status. Open any -one to read its full plan: - -``` -openspec show simplify-skill-installation -``` +## The happy path -That is the one-repo case. Your big-picture plan and your changes now live side -by side, and your coding agent can read both. +*A team is making setup smoother. The work spans a CLI repo and a docs repo. +The plan should not live in either one.* -### An initiative can hold more than changes +### 1. Put the plan in a store -Real work needs more than a change list. It helps to write down *who* you are -building for and *why* you made the calls you did. An initiative is a good home -for those too — as plain files, right next to the plan: +A store is planning in its own git repo. Stand one up once: +```bash +openspec store setup acme-plans --path ~/openspec/acme-plans ``` -smoother-setup/ - README.md the plan - inventory.md the changes, and where each one stands - personas/ who we are building for - decisions/ the key calls, and why (one short record each) -``` - -In the [example](../../openspec/initiatives/smoother-setup/README.md): - -- **Personas** name the people the work serves — a - [new user](../../openspec/initiatives/smoother-setup/personas/new-user.md), a - [lead across repos](../../openspec/initiatives/smoother-setup/personas/team-lead.md), - and an [AI coding agent](../../openspec/initiatives/smoother-setup/personas/coding-agent.md). -- **Decision records** (ADRs) capture one call each and why, so the reason is not - lost later — for example, [why we added an alias instead of renaming](../../openspec/initiatives/smoother-setup/decisions/0001-aliases-over-rename.md). -All of it sits in one place, in plain Markdown your coding agent can read. +Now the plan has a home of its own — not buried in anyone's code. -## Many repos: share the initiative in a store +### 2. Define your own artifacts -Sometimes the plan is bigger than one repo. Several repos build toward the same -goal, or one team owns the plan and others build against it. Put the initiative -in a **store**, and every repo can read it by name. - -Register a planning repo as a store once: - -``` -openspec store register ./path-to-plans --id team-plans -``` - -``` -OpenSpec root: ready -Registry: registered -``` - -Now any repo can read that plan without copying it. A code repo adds one line to -its `openspec/config.yaml`: +This is the superpower. Your team works from personas and decision records, so +make those first-class. Describe them once, in a small **schema**: ```yaml -references: - - team-plans -``` - -From that repo, OpenSpec shows the shared plan as read-only context: - -``` -openspec context +# acme-plans/openspec/schemas/initiative/schema.yaml +artifacts: + - id: brief # what and why + - id: persona # who we build for + - id: decision # a call we made, and the trade-off ``` -``` -OpenSpec root - consumer-demo /path/to/consumer-demo +These are *your* artifact types — labeled in your words. OpenSpec now knows them +and can check them, the same way it checks specs and tasks. -Referenced stores - team-plans /path/to/plans - Fetch: openspec show --type spec --store team-plans -``` +### 3. Group the changes it takes -And your coding agent, working in the code repo, automatically sees the shared -plan it should build against: +The initiative names the changes that carry it out. Each change is a normal +OpenSpec change — nothing new to learn. You never track status by hand; ask +OpenSpec: -``` - -Store team-plans (/path/to/plans): - - ai-tool-paths: Define AI tool path metadata used to generate OpenSpec skills... - - artifact-graph: Define the artifact graph model, dependency validation... - - change-creation: Provide programmatic utilities for creating and validating... - -``` - -You can also read the plan by name from anywhere: - -``` -openspec list --specs --store team-plans -``` - -`openspec doctor` checks that every referenced store is present, and prints a -copy-paste fix if one is missing. Nothing syncs on its own — a store is a normal -git repo, so you share it by pushing and pulling like any other. - -That is the many-repo case. One plan, one home, read by name from every project — -instead of copied around and left to drift. - -## Define your own artifact types - -Plain files are enough to start. When you want OpenSpec to *check* these -artifacts — give each one a template and a clear order — you describe them in a -small **schema**. A schema is how you define your own artifact types, beyond the -built-in ones. - -The example ships one: -[example-schema/](../../openspec/initiatives/smoother-setup/example-schema/schema.yaml). -It defines five types — a one-pager, a persona, a decision record, a spec, and a -task list — and the order they build in. Copy that folder into -`openspec/schemas/initiative/` and OpenSpec picks it up: - -``` -openspec schemas +```bash +openspec list --changes --store acme-plans ``` +```text +Changes: + simplify-skill-installation ✓ Complete 8d ago + fix-opencode-commands-directory ✓ Complete 8d ago + add-global-install-scope 0/38 tasks 8d ago + schema-alias-support No tasks 8d ago ``` -Available schemas: - - initiative (project) - A workflow that keeps the "who and why" next to the "what". It produces a - one-pager, a persona, a decision record, a spec, and a task list. - Artifacts: one-pager → persona → adr → spec → tasks - spec-driven - Default OpenSpec workflow - proposal → specs → design → tasks - Artifacts: proposal → specs → design → tasks -``` +One command. Where the whole effort stands. -Now you can start a change on your own types. OpenSpec tracks the order — each -artifact unlocks the next: +### 4. Every repo reads it — nothing copied -``` -openspec new change improve-onboarding --schema initiative -openspec status --change improve-onboarding -``` +Each code repo adds one line to its `openspec/config.yaml`: -``` -[ ] one-pager -[-] persona (blocked by: one-pager) -[-] adr (blocked by: persona) -[-] spec (blocked by: adr) -[-] tasks (blocked by: spec) +```yaml +references: + - acme-plans ``` -Fill each artifact in order and the graph fills in. Here is a finished one — you -can read the real files in -[finished-example/](../../openspec/initiatives/smoother-setup/example-schema/finished-example/): +Now the coding agent in *that* repo sees the shared plan — the brief, the +personas, the decisions, the changes — and builds against it. No pasting. No +drift. The plan is read by name, live from the store. -``` -openspec status --change example-first-run -``` - -``` -Progress: 5/5 artifacts complete +That is the whole happy path: **your store, your artifacts, read by every +repo, with live status.** -[x] one-pager -[x] persona -[x] adr -[x] spec -[x] tasks +## When two initiatives collide -All artifacts complete! -``` +The question every team hits: the store has an initiative called +`smoother-setup`, and someone starts a local one by the same name in their own +repo. Which wins? -``` -openspec validate example-first-run --type change --strict -``` +**Best default: the store is canonical.** The store holds the shared, +agreed-upon initiative. A local one with the same name is treated as a *draft +that shadows it* — OpenSpec keeps letting you work locally, but tells you +plainly: +```text +initiative 'smoother-setup' (local) shadows the canonical one in store 'acme-plans' ``` -Change 'example-first-run' is valid -``` - -This is the heart of it: a store is not limited to changes and specs. You decide -what artifacts your work needs — one-pagers, personas, decision records — and -they all live in one place your team and your coding agent can read. - -Start with plain files. Add a schema when you want the structure and the checks. - -## What works today, and what is still rough - -Honest notes from building this: -- **Works now, no extra tools:** the initiative folder, personas and decision - records as files, `openspec list` for status, your own artifact types via a - schema, and reading a store by name from any repo. Every command and its output - above came from a normal OpenSpec install. -- **Still rough:** the folder layout for an initiative is a convention here, not a - built-in command, so it is on you to keep it tidy. And a store cannot yet list - the repos that read from it, so a full "across every repo" rollup needs a bit of - glue today. +Nothing diverges silently. You always know there is a shared source of truth, +and you can reconcile when you are ready. (This mirrors how OpenSpec already +handles schemas: a project schema *shadows* a package one, and `openspec schema +which` shows you it is happening.) -## Where this could go +## What works today, and what we are building -This works today with plain files and a copied schema. A first-class version -could: +Honest scope — this is a prototype. -- Add an `openspec initiative` command to create and list initiatives, so the - layout is more than a convention. -- Roll up status across every repo that references a store, so "where does the - whole effort stand" is one command. -- Ship a few ready-made artifact types (one-pager, persona, decision record) you - can turn on, instead of copying a schema. +**Works today:** +- An initiative folder in a store: the brief, your own artifact types as plain + files, and the changes it groups. +- Live status for those changes with `openspec list --changes --store`. +- A repo reading the store by name via `references:`. +- Defining your own artifact types in a schema (`openspec schema`). -None of these are needed to start. They are the natural next steps once teams -lean on initiatives. +**The gap we are closing (this is the build):** +- Today, `openspec schemas` and artifact discovery look only at the current + repo — they are not store-aware. So a repo that *references* a store cannot + yet see the store's custom artifact types, and initiative precedence + (canonical vs. local shadow) is a convention, not something OpenSpec enforces. +- **The prototype makes both work across repos:** the store's initiatives and + artifact types become discoverable and checkable from any repo that + references it, with the canonical-vs-shadow rule built in. -Start simple: one initiative folder, grouping a few real changes. Move it into a -store when more than one repo needs it. +Start simple: one initiative folder in a store, grouping a few real changes, +with the artifacts your team already uses. The rest is what we build next. diff --git a/openspec/changes/initiatives-in-stores/design.md b/openspec/changes/initiatives-in-stores/design.md new file mode 100644 index 0000000000..21042b6c94 --- /dev/null +++ b/openspec/changes/initiatives-in-stores/design.md @@ -0,0 +1,81 @@ +## Context + +Stores let planning live in its own repo, and a code repo can `reference` a +store for read-only context. Separately, OpenSpec's artifact-graph lets a +project define its own artifact types in a schema (`openspec/schemas//`), +resolved project → user → package. + +These two systems do not meet yet. Schema/artifact discovery resolves against +the *current* repo only. So the compelling story — "define your own artifacts, +share them in a store, read them from every repo" — is true for *authoring* but +not for *discovery across repos*. This design closes that seam and settles the +one open product question: initiative precedence. + +## Goals / Non-Goals + +**Goals:** +- A repo that references a store can discover that store's schemas, artifact + types, and initiatives. +- One clear, defensible default for initiative precedence. +- Reuse existing machinery (the resolver's root argument, the shadowing model, + `openspec list` status) rather than new subsystems. + +**Non-Goals:** +- Reviving the deleted heavyweight initiative command groups (collections, + resolution, templates). Kept deliberately light. +- Syncing stores. Stores are plain git repos; OpenSpec never clones or pulls. +- Changing where commands *act*. References stay read-only context. + +## Decisions + +### 1. Discovery follows references, read-only + +Referenced stores already resolve to on-disk roots for `context`/`doctor`. Feed +those same roots into the artifact-graph resolver (which already takes a +`projectRoot`) so a store's `openspec/schemas/` participates in discovery. A +referenced store's artifacts are always read-only context, never a place +commands write. + +**Alternative considered:** copy a store's schemas into the referencing repo. +Rejected — that is the drift we are trying to remove. + +### 2. Initiative precedence: canonical store, local shadow + +When a local initiative and a store initiative share an id, the **store is +canonical** and the local one is a **shadow**: work continues locally, but +OpenSpec reports the shadow so nothing diverges silently. + +This is the store-of-truth stance the product wants, expressed through a +pattern OpenSpec *already uses* for schemas (`openspec schema which` shows +`shadows:`). It is consistent, discoverable, and non-destructive. + +**Alternative considered:** local silently wins (today's root-selection order, +where the nearest `openspec/` takes precedence). Rejected for initiative +*identity* — silence is exactly what lets shared plans drift. Note this only +governs *identity/reporting*; it does not change where commands act. + +**Owner decision point:** if the owner prefers "local always wins, no warning" +or "store hard-wins and local is blocked," both are small variations on the same +reporting hook. The default here is the safe middle: local works, store is +named as canonical. + +## Risks / Trade-offs + +- [Discovery surfaces stale store content if a checkout is behind] → same as all + store reads today; references are indexed live from disk, and `openspec + doctor` already reports missing/registered stores. +- [Two precedence concepts in the codebase — root-selection order vs. initiative + identity] → mitigated by scoping identity-precedence to reporting only, and + documenting that commands still act per root-selection. + +## Migration Plan + +Purely additive. No existing command changes behavior when no store is +referenced. Ship behind the existing stores beta surface. + +## Open Questions + +- Final precedence policy (canonical-shadow vs. local-wins vs. store-hard-wins) — + defaulted here, owner to confirm. +- Whether initiatives get a thin scaffold command later, or stay a plain-folder + convention for the prototype. This change assumes the latter. diff --git a/openspec/changes/initiatives-in-stores/proposal.md b/openspec/changes/initiatives-in-stores/proposal.md new file mode 100644 index 0000000000..b81d078f07 --- /dev/null +++ b/openspec/changes/initiatives-in-stores/proposal.md @@ -0,0 +1,68 @@ +## Why + +A team's biggest work is never one change — it is an *initiative*: a brief, the +artifacts the team works from (personas, decisions, whatever they use), and the +changes that carry it out. OpenSpec has no shared home for that today, so it +lives in someone's head or a stray doc. + +Stores (beta) give planning its own repo. And OpenSpec already lets a team +**define its own artifact types** in a schema — something Kiro, Spec-Kit, and +GSD do not. Put those two together and OpenSpec can do what none of them can: +**a shared initiative, with your own artifacts, read by every repo.** + +The blocker is that artifact discovery is not store-aware. `openspec schemas`, +`openspec templates`, and `openspec view` look only at the current repo. So a +repo that *references* a store cannot see the store's initiatives or custom +artifact types, and there is no rule for what happens when a local initiative +and a store initiative share a name. This change closes that gap. + +## What Changes + +### 1. Store-aware artifact and schema discovery + +Make discovery follow references. When a repo references a store, its custom +artifact types and initiatives become discoverable from that repo: + +- `openspec schemas --store ` lists a store's schemas (today it is cwd-only). +- Referenced stores' schemas and initiatives appear in `openspec context` and in + the agent instruction block, each with a one-line summary and a fetch command. + +### 2. Initiative precedence: canonical vs. shadow + +Define the rule the owner asked about — a local initiative vs. a store one of the +same name: + +- The **store initiative is canonical.** A local initiative with the same id is a + **shadow** — you keep working locally, but OpenSpec tells you it shadows the + canonical one, so nothing diverges silently. +- This reuses the shadowing model OpenSpec already applies to schemas (project + shadows user shadows package), surfaced the same way `openspec schema which` + surfaces it. + +### 3. Initiative as a first-class, checkable grouping + +An initiative is a folder with a brief and the changes it groups. Give it just +enough structure to be checked and rolled up — no revival of the old +heavyweight initiative command groups. + +- `openspec list --initiatives [--store ]` lists initiatives and rolls up the + live status of the changes each one groups. + +## Capabilities + +### New Capabilities +- `store-aware-artifacts`: discover and resolve a store's schemas, artifact + types, and initiatives from a repo that references it, with a defined + canonical-vs-shadow precedence rule. + +### Modified Capabilities +None. + +## Impact + +- Affected commands: `schemas`, `context`, `instructions`, `list` (additive + `--store` / `--initiatives` behavior; existing behavior unchanged). +- Affected core: artifact-graph resolver (accept a store root), root-selection + (initiative precedence). +- No breaking changes. Everything degrades to today's cwd-only behavior when no + store is referenced. diff --git a/openspec/changes/initiatives-in-stores/specs/store-aware-artifacts/spec.md b/openspec/changes/initiatives-in-stores/specs/store-aware-artifacts/spec.md new file mode 100644 index 0000000000..841b8b729b --- /dev/null +++ b/openspec/changes/initiatives-in-stores/specs/store-aware-artifacts/spec.md @@ -0,0 +1,59 @@ +## ADDED Requirements + +### Requirement: Store-aware schema discovery + +The system SHALL discover schemas and artifact types from a store when the store +is selected with `--store` or referenced by the current repo, in addition to the +current repo's own schemas. + +#### Scenario: Listing a store's schemas + +- **WHEN** a user runs `openspec schemas --store acme-plans` +- **THEN** the system lists the schemas defined in the `acme-plans` store +- **AND** each schema shows its name and description + +#### Scenario: Referenced store schemas are visible from a repo + +- **WHEN** a repo references the `acme-plans` store and the user runs + `openspec context` +- **THEN** the referenced store's custom artifact types are listed as read-only + context +- **AND** each entry includes a one-line summary and a fetch command + +#### Scenario: No store referenced falls back to current repo + +- **WHEN** the current repo references no store and the user runs + `openspec schemas` +- **THEN** the system lists only the current repo's schemas, exactly as it does + today + +### Requirement: Initiative precedence between a store and a local repo + +The system SHALL treat a store initiative as canonical and a local initiative +with the same id as a shadow, and SHALL report the shadow without blocking local +work. + +#### Scenario: Local initiative shadows a canonical store initiative + +- **WHEN** a repo references a store that has an initiative `smoother-setup` +- **AND** the repo also has a local initiative `smoother-setup` +- **THEN** the system reports that the local initiative shadows the canonical one + in the store +- **AND** local commands continue to operate on the local initiative + +#### Scenario: No collision means no shadow report + +- **WHEN** a local initiative id does not match any initiative in a referenced + store +- **THEN** the system reports no shadow for that initiative + +### Requirement: Initiative status rollup + +The system SHALL list initiatives and roll up the live status of the changes each +initiative groups. + +#### Scenario: Listing initiatives in a store + +- **WHEN** a user runs `openspec list --initiatives --store acme-plans` +- **THEN** the system lists each initiative in the store +- **AND** each initiative shows the rolled-up status of the changes it groups diff --git a/openspec/changes/initiatives-in-stores/tasks.md b/openspec/changes/initiatives-in-stores/tasks.md new file mode 100644 index 0000000000..9dad366718 --- /dev/null +++ b/openspec/changes/initiatives-in-stores/tasks.md @@ -0,0 +1,31 @@ +## 1. Store-aware discovery + +- [ ] 1.1 Let the artifact-graph resolver take a store root, so a store's + `openspec/schemas/` participates in schema/artifact resolution +- [ ] 1.2 Add `--store ` to `openspec schemas` (list a store's schemas) +- [ ] 1.3 Include referenced stores' schemas and artifact types in + `openspec context` and the agent instruction block, each with a summary and + a fetch command + +## 2. Initiative precedence + +- [ ] 2.1 Detect when a local initiative id matches an initiative in a referenced + store +- [ ] 2.2 Report the local one as a shadow of the canonical store initiative, + reusing the schema-shadowing reporting shape (`shadows: …`) +- [ ] 2.3 Confirm local commands still act on the local initiative (no behavior + change to where commands act) + +## 3. Initiative status rollup + +- [ ] 3.1 Add `openspec list --initiatives [--store ]` +- [ ] 3.2 Roll up the live status of the changes each initiative groups, reusing + the existing `openspec list --changes` status source + +## 4. Proof + +- [ ] 4.1 Update the `smoother-setup` example so it reads as an initiative in a + store with your-own artifact types +- [ ] 4.2 Capture real command output for the guide (`schemas --store`, `list + --initiatives`, the shadow report) +- [ ] 4.3 `openspec validate initiatives-in-stores --strict` passes diff --git a/openspec/initiatives/smoother-setup/README.md b/openspec/initiatives/smoother-setup/README.md deleted file mode 100644 index 85e519a3d1..0000000000 --- a/openspec/initiatives/smoother-setup/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# Initiative: Smoother setup - -> An **initiative** is a group of related changes that go together, plus the -> shared plan that holds them. Each change is still its own piece of work. This -> page is the plan they share. -> -> This is a real example, built from changes already in this repo. It shows how -> to group related work in one place. See [the guide](../../../docs/stores-beta/initiatives.md). - -## What this is - -A group of changes that make OpenSpec faster and simpler to set up. - -## Why these go together - -New users should get value fast. Each change here removes some setup friction: -fewer skills to learn at first, the right folders for each tool, a clear choice -of where things install, and friendlier schema names. On their own they are -small. Together they add up to a smoother first run. - -## The plan - -1. Cut first-run friction, so a new user reaches a win quickly. -2. Fix install paths, so files land where each tool expects them. -3. Let users choose where things install. -4. Make schema names easier to understand, without breaking old projects. - -## The work - -See [inventory.md](inventory.md) for the list of changes and where each one -stands. The status there comes from `openspec list`. - -## What is in this folder - -An initiative can hold more than a plan and a change list. It is a home for every -artifact that supports the work: - -- [inventory.md](inventory.md) — the changes, and where each one stands. -- [personas/](personas/) — who we are building for ([new user](personas/new-user.md), - [a lead across repos](personas/team-lead.md), [an AI coding agent](personas/coding-agent.md)). -- [decisions/](decisions/) — the key decisions and why, as short records - ([0001](decisions/0001-aliases-over-rename.md), [0002](decisions/0002-tool-native-paths.md)). -- [example-schema/](example-schema/) — an optional schema that turns these into - typed, checked artifacts (one-pager → persona → adr → spec → tasks), with a - [finished-example/](example-schema/finished-example/) showing a completed set. - See [the guide](../../../docs/stores-beta/initiatives.md). - -These are all artifacts in one place. Your coding agent can read any of them. diff --git a/openspec/initiatives/smoother-setup/brief.md b/openspec/initiatives/smoother-setup/brief.md new file mode 100644 index 0000000000..3a76d8dc64 --- /dev/null +++ b/openspec/initiatives/smoother-setup/brief.md @@ -0,0 +1,46 @@ +# Initiative: Smoother setup + +> An **initiative** is the home for a big effort: a brief, the artifacts your +> team works from, and the changes that carry it out. This is a real example, +> built from changes already in this repo. See +> [the guide](../../../docs/stores-beta/initiatives.md). + +## What this is + +The effort to make OpenSpec faster and simpler to set up. + +## Why these changes go together + +New users should reach a win fast. Each change here removes some setup friction: +fewer skills to learn at first, the right folders for each tool, a clear choice +of where things install, friendlier schema names. Small on their own. Together, +a smoother first run. + +## The artifacts this initiative works from + +This is the part other tools do not let you do: **the artifact types are ours, +not the tool's.** + +- [personas/](personas/) — who we build for + ([a new user](personas/new-user.md), [a lead across repos](personas/team-lead.md)). +- [decisions/](decisions/) — the calls we made, and the trade-offs, one record + each ([0001](decisions/0001-aliases-over-rename.md), [0002](decisions/0002-tool-native-paths.md)). +- [example-schema/](example-schema/schema.yaml) — the same three types + (`brief → persona → decision`) described as a schema, so OpenSpec can check + them. Illustrative here; it is not in this repo's active schema set. + +## The changes it groups + +Four real changes carry this initiative. You never track their status by hand — +ask OpenSpec: + +```bash +openspec list --changes +``` + +- `simplify-skill-installation` — fewer default skills, so the first run is quick +- `fix-opencode-commands-directory` — use the folder OpenCode expects +- `add-global-install-scope` — let users choose where tools install +- `schema-alias-support` — let `spec-driven` and `openspec-default` both work + +Open any one to read its full plan: `openspec show simplify-skill-installation`. diff --git a/openspec/initiatives/smoother-setup/example-schema/finished-example/.openspec.yaml b/openspec/initiatives/smoother-setup/example-schema/finished-example/.openspec.yaml deleted file mode 100644 index eafef4ffdd..0000000000 --- a/openspec/initiatives/smoother-setup/example-schema/finished-example/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: initiative -created: 2026-06-30 diff --git a/openspec/initiatives/smoother-setup/example-schema/finished-example/adr.md b/openspec/initiatives/smoother-setup/example-schema/finished-example/adr.md deleted file mode 100644 index 539591403d..0000000000 --- a/openspec/initiatives/smoother-setup/example-schema/finished-example/adr.md +++ /dev/null @@ -1,20 +0,0 @@ -# ADR: Ship a small default skill set - -## Status - -Accepted - -## Context - -Ten skills at once overwhelm new users. But power users rely on the full set, so -we cannot simply remove skills. - -## Decision - -Install a small default set on first run. Make the full set available through a -simple choice, so nothing is lost. - -## Trade-offs - -- Easier: a calm, fast first run for new users. -- Harder: two paths to explain — the default set and the full set. diff --git a/openspec/initiatives/smoother-setup/example-schema/finished-example/one-pager.md b/openspec/initiatives/smoother-setup/example-schema/finished-example/one-pager.md deleted file mode 100644 index 58ce7e3ab7..0000000000 --- a/openspec/initiatives/smoother-setup/example-schema/finished-example/one-pager.md +++ /dev/null @@ -1,23 +0,0 @@ -# One-pager: Fewer skills on first run - -## Problem - -A new user runs OpenSpec for the first time and sees a long list of skills and -commands at once. It feels heavy. The first run should feel quick. - -## Who it is for - -A new user on an existing codebase. See [persona.md](persona.md). - -## What we will do - -Install a small default set of skills. Keep the full set available for people who -want it. - -## Out of scope - -Removing any skill. Changing what skills do. - -## How we will know it worked - -A new user reaches a first win in under a minute. diff --git a/openspec/initiatives/smoother-setup/example-schema/finished-example/persona.md b/openspec/initiatives/smoother-setup/example-schema/finished-example/persona.md deleted file mode 100644 index 8061d3ab09..0000000000 --- a/openspec/initiatives/smoother-setup/example-schema/finished-example/persona.md +++ /dev/null @@ -1,14 +0,0 @@ -# Persona: New user on an existing codebase - -## Who they are - -A developer trying OpenSpec for the first time, on a project that already has a -lot of code. - -## What they want - -A quick win, without learning ten commands first. - -## What gets in their way - -Too many skills shown at once on the first run. diff --git a/openspec/initiatives/smoother-setup/example-schema/finished-example/specs/first-run-skills/spec.md b/openspec/initiatives/smoother-setup/example-schema/finished-example/specs/first-run-skills/spec.md deleted file mode 100644 index 0208c3e733..0000000000 --- a/openspec/initiatives/smoother-setup/example-schema/finished-example/specs/first-run-skills/spec.md +++ /dev/null @@ -1,21 +0,0 @@ -# First-run skills - -## ADDED Requirements - -### Requirement: Small default skill set - -The system SHALL install a small default set of skills on first run. - -#### Scenario: New project is set up - -- **WHEN** a user runs init on a new project -- **THEN** only the default skill set is installed - -### Requirement: Full set available on request - -The system SHALL let users install the full skill set when they choose to. - -#### Scenario: User asks for the full set - -- **WHEN** a user opts into the full set -- **THEN** all skills are installed diff --git a/openspec/initiatives/smoother-setup/example-schema/finished-example/tasks.md b/openspec/initiatives/smoother-setup/example-schema/finished-example/tasks.md deleted file mode 100644 index f8611b2445..0000000000 --- a/openspec/initiatives/smoother-setup/example-schema/finished-example/tasks.md +++ /dev/null @@ -1,11 +0,0 @@ -# Tasks - -## 1. Defaults - -- [x] 1.1 Pick the default skill set -- [x] 1.2 Install only the default set on init - -## 2. Full set - -- [x] 2.1 Add a way to install the full set -- [x] 2.2 Document both paths diff --git a/openspec/initiatives/smoother-setup/example-schema/schema.yaml b/openspec/initiatives/smoother-setup/example-schema/schema.yaml index 21a2030de1..59286c6dff 100644 --- a/openspec/initiatives/smoother-setup/example-schema/schema.yaml +++ b/openspec/initiatives/smoother-setup/example-schema/schema.yaml @@ -1,64 +1,36 @@ name: initiative version: 1 description: >- - A workflow that keeps the "who and why" next to the "what". It produces a - one-pager, a persona, a decision record, a spec, and a task list. Copy this - folder into openspec/schemas/initiative/ to use it, then create a change with - --schema initiative. + Your own artifact types for an initiative. This one keeps the "who and why" + next to the plan: a brief, a persona, and a decision record. Illustrative — + copy it into openspec/schemas/initiative/ to make it active. artifacts: - - id: one-pager - generates: one-pager.md - description: The brief — why this work, and what it is, at a glance - template: one-pager.md + - id: brief + generates: brief.md + description: What this effort is, and why — at a glance + template: brief.md requires: [] instruction: | - Write a one-page brief: the problem, who it is for, what we will do, and - what is out of scope. Keep it to a page. + Write the brief: the problem, who it is for, what we will do, and what is + out of scope. Keep it to a page. - id: persona - generates: persona.md + generates: "personas/**/*.md" description: Who we are building for template: persona.md requires: - - one-pager + - brief instruction: | Describe one kind of user: who they are, what they want, and what gets in their way. Keep it short and real. - - id: adr - generates: adr.md - description: A key decision and why - template: adr.md + - id: decision + generates: "decisions/**/*.md" + description: A call we made, and the trade-off + template: decision.md requires: - persona instruction: | Record one decision: the context, the choice, and the trade-offs. One decision per record. - - - id: spec - generates: "specs/**/*.md" - description: What the system should do - template: spec.md - requires: - - adr - instruction: | - Write the behavior as requirements and scenarios. Each requirement needs - at least one scenario. Scenarios use four hashtags (####). - - - id: tasks - generates: tasks.md - description: The work to do - template: tasks.md - requires: - - spec - instruction: | - Break the work into small, checkable tasks. Order them so earlier tasks - unblock later ones. - -apply: - requires: - - tasks - tracks: tasks.md - instruction: | - Work through the tasks and check them off as you go. diff --git a/openspec/initiatives/smoother-setup/example-schema/templates/one-pager.md b/openspec/initiatives/smoother-setup/example-schema/templates/brief.md similarity index 94% rename from openspec/initiatives/smoother-setup/example-schema/templates/one-pager.md rename to openspec/initiatives/smoother-setup/example-schema/templates/brief.md index 0f74cfb175..ab51f05089 100644 --- a/openspec/initiatives/smoother-setup/example-schema/templates/one-pager.md +++ b/openspec/initiatives/smoother-setup/example-schema/templates/brief.md @@ -1,4 +1,4 @@ -# One-pager: [Title] +# Brief: [Title] ## Problem diff --git a/openspec/initiatives/smoother-setup/example-schema/templates/adr.md b/openspec/initiatives/smoother-setup/example-schema/templates/decision.md similarity index 88% rename from openspec/initiatives/smoother-setup/example-schema/templates/adr.md rename to openspec/initiatives/smoother-setup/example-schema/templates/decision.md index 20bcf490f6..1eb5b16680 100644 --- a/openspec/initiatives/smoother-setup/example-schema/templates/adr.md +++ b/openspec/initiatives/smoother-setup/example-schema/templates/decision.md @@ -1,4 +1,4 @@ -# ADR: [Decision title] +# Decision: [Decision title] ## Status diff --git a/openspec/initiatives/smoother-setup/example-schema/templates/spec.md b/openspec/initiatives/smoother-setup/example-schema/templates/spec.md deleted file mode 100644 index 55a8b79103..0000000000 --- a/openspec/initiatives/smoother-setup/example-schema/templates/spec.md +++ /dev/null @@ -1,12 +0,0 @@ -# [Capability name] - -## ADDED Requirements - -### Requirement: [Name] - -The system SHALL [observable behavior]. - -#### Scenario: [Name] - -- **WHEN** [something happens] -- **THEN** [the expected result] diff --git a/openspec/initiatives/smoother-setup/example-schema/templates/tasks.md b/openspec/initiatives/smoother-setup/example-schema/templates/tasks.md deleted file mode 100644 index 9fd4e78e13..0000000000 --- a/openspec/initiatives/smoother-setup/example-schema/templates/tasks.md +++ /dev/null @@ -1,6 +0,0 @@ -# Tasks - -## 1. [Group] - -- [ ] 1.1 [Task] -- [ ] 1.2 [Task] diff --git a/openspec/initiatives/smoother-setup/inventory.md b/openspec/initiatives/smoother-setup/inventory.md deleted file mode 100644 index 742867b42d..0000000000 --- a/openspec/initiatives/smoother-setup/inventory.md +++ /dev/null @@ -1,23 +0,0 @@ -# Inventory: Smoother setup - -One row per change. This is the "where does each piece stand" view. The status -column comes from `openspec list` — run it to see the live numbers. - -| # | Change | What it does | Status | -|---|--------|--------------|--------| -| 1 | simplify-skill-installation | Fewer default skills, so the first run is quick | Done | -| 2 | fix-opencode-commands-directory | Use the folder OpenCode expects for commands | Done | -| 3 | add-global-install-scope | Let users choose where tools install | In progress | -| 4 | schema-alias-support | Let `spec-driven` and `openspec-default` both work | Planned | - -See live status any time: - -``` -openspec list --changes -``` - -Open any change to see its full plan: - -``` -openspec show simplify-skill-installation -``` diff --git a/openspec/initiatives/smoother-setup/personas/coding-agent.md b/openspec/initiatives/smoother-setup/personas/coding-agent.md deleted file mode 100644 index 7a26bd23f2..0000000000 --- a/openspec/initiatives/smoother-setup/personas/coding-agent.md +++ /dev/null @@ -1,20 +0,0 @@ -# Persona: AI coding agent - -## Who they are - -The AI assistant a developer works with. It reads the plan and helps write the -code. - -## What they want - -Clear, structured context it can read on its own, without a person pasting it in. - -## What gets in their way - -Plans kept in formats it cannot read, or spread across many tools. - -## How this initiative helps - -Specs, changes, decisions, and personas all live in one readable place the agent -can open. When the plan lives in a store, the agent can read it from any repo -that references it. From 2e1ce302e6c1ec0092a579d46d1ff827f97cd48c Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 1 Jul 2026 15:16:36 -0500 Subject: [PATCH 06/45] docs(stores): lock initiative precedence to canonical-store/local-shadow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decide Option 1 as the build: the store initiative is canonical, a same-id local one shadows it and is reported (never silently wins, never hard-blocks). Best for orgs — governance + no silent drift + velocity — and reuses schema shadowing. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/stores-beta/initiatives.md | 7 ++-- .../changes/initiatives-in-stores/design.md | 35 +++++++++++-------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/docs/stores-beta/initiatives.md b/docs/stores-beta/initiatives.md index e296b9579a..0745b09af8 100644 --- a/docs/stores-beta/initiatives.md +++ b/docs/stores-beta/initiatives.md @@ -113,10 +113,9 @@ The question every team hits: the store has an initiative called `smoother-setup`, and someone starts a local one by the same name in their own repo. Which wins? -**Best default: the store is canonical.** The store holds the shared, -agreed-upon initiative. A local one with the same name is treated as a *draft -that shadows it* — OpenSpec keeps letting you work locally, but tells you -plainly: +**The rule: the store is canonical.** The store holds the shared, agreed-upon +initiative. A local one with the same name is treated as a *draft that shadows +it* — OpenSpec keeps letting you work locally, but tells you plainly: ```text initiative 'smoother-setup' (local) shadows the canonical one in store 'acme-plans' diff --git a/openspec/changes/initiatives-in-stores/design.md b/openspec/changes/initiatives-in-stores/design.md index 21042b6c94..03003e2bc2 100644 --- a/openspec/changes/initiatives-in-stores/design.md +++ b/openspec/changes/initiatives-in-stores/design.md @@ -39,25 +39,32 @@ commands write. **Alternative considered:** copy a store's schemas into the referencing repo. Rejected — that is the drift we are trying to remove. -### 2. Initiative precedence: canonical store, local shadow +### 2. Initiative precedence: canonical store, local shadow (decided) When a local initiative and a store initiative share an id, the **store is canonical** and the local one is a **shadow**: work continues locally, but OpenSpec reports the shadow so nothing diverges silently. -This is the store-of-truth stance the product wants, expressed through a -pattern OpenSpec *already uses* for schemas (`openspec schema which` shows -`shadows:`). It is consistent, discoverable, and non-destructive. +**This is the policy we are building.** It is the only option that serves an org +on all three axes at once: -**Alternative considered:** local silently wins (today's root-selection order, -where the nearest `openspec/` takes precedence). Rejected for initiative -*identity* — silence is exactly what lets shared plans drift. Note this only -governs *identity/reporting*; it does not change where commands act. +- **Governance** — the store stays the single source of truth every repo points + at, so the shared plan is auditable and one place. +- **No silent drift** — the shadow is surfaced, so a local divergence is visible + and reconcilable, not discovered later in review. +- **Velocity** — local work is never blocked, so a developer can still iterate. -**Owner decision point:** if the owner prefers "local always wins, no warning" -or "store hard-wins and local is blocked," both are small variations on the same -reporting hook. The default here is the safe middle: local works, store is -named as canonical. +It also reuses a pattern OpenSpec *already uses* for schemas (`openspec schema +which` shows `shadows:`) — the least code and the most native-feeling. Note it +governs *identity/reporting* only; it does not change where commands act. + +**Alternatives rejected:** + +- *Local silently wins* (today's root-selection order, nearest `openspec/` + takes precedence). Rejected — it fails governance: a repo can quietly override + the shared plan and nobody knows. +- *Store hard-wins, local blocked.* Rejected — it fails velocity: devs can't + experiment locally, so they route around OpenSpec, defeating the point. ## Risks / Trade-offs @@ -75,7 +82,7 @@ referenced. Ship behind the existing stores beta surface. ## Open Questions -- Final precedence policy (canonical-shadow vs. local-wins vs. store-hard-wins) — - defaulted here, owner to confirm. - Whether initiatives get a thin scaffold command later, or stay a plain-folder convention for the prototype. This change assumes the latter. + +(Precedence policy is settled: canonical store, local shadow — see Decision 2.) From 7324d11ba41e90525611ddd2b89fd5d64b859948 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 1 Jul 2026 15:32:22 -0500 Subject: [PATCH 07/45] =?UTF-8?q?feat(stores):=20working=20prototype=20?= =?UTF-8?q?=E2=80=94=20store-aware=20schemas,=20initiative=20rollup=20+=20?= =?UTF-8?q?shadow=20precedence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the initiatives-in-stores prototype: - openspec schemas --store : store-aware schema discovery. Success keeps the bare-array agent contract; only which root it reads changes. - openspec list --initiatives [--store ]: lists initiatives (initiative.yaml manifest) and rolls up the live status of the changes each groups, from the same source as list --changes. - Canonical-vs-shadow precedence: a local initiative colliding with a referenced store's is reported (shadowsStore in JSON, "(shadows: )" in the list), never silently overriding, never blocking local work. Keep the completion registry + store-selection guidance + agent contract in sync (schemas joins the store-aware command set), add unit tests, and flip the guide from proposed to working with real captured output. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/agent-contract.md | 6 +- docs/stores-beta/initiatives.md | 70 ++++--- docs/stores-beta/user-guide.md | 7 +- .../changes/initiatives-in-stores/tasks.md | 28 +-- .../smoother-setup/initiative.yaml | 8 + src/cli/index.ts | 54 ++++- src/commands/workflow/schemas.ts | 17 +- src/core/completions/command-registry.ts | 5 + src/core/initiatives.ts | 197 ++++++++++++++++++ .../templates/workflows/store-selection.ts | 2 +- .../core/completions/command-registry.test.ts | 1 + test/core/initiatives.test.ts | 131 ++++++++++++ 12 files changed, 472 insertions(+), 54 deletions(-) create mode 100644 openspec/initiatives/smoother-setup/initiative.yaml create mode 100644 src/core/initiatives.ts create mode 100644 test/core/initiatives.test.ts diff --git a/docs/agent-contract.md b/docs/agent-contract.md index 9f64d66d36..6553569a19 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -45,7 +45,7 @@ Successful JSON payloads embed the root: ## 4. Command JSON shapes ### 4.1 `list --json` -`{ "changes": [ { "name", "completedTasks", "totalTasks", "lastModified", "status": "no-tasks"|"complete"|"in-progress" } ], "root": RootOutput }` — note the per-change `status` is a string enum here. `--specs`: `{ "specs": [ { "id", "requirementCount" } ], "root" }`. +`{ "changes": [ { "name", "completedTasks", "totalTasks", "lastModified", "status": "no-tasks"|"complete"|"in-progress" } ], "root": RootOutput }` — note the per-change `status` is a string enum here. `--specs`: `{ "specs": [ { "id", "requirementCount" } ], "root" }`. `--initiatives`: `{ "initiatives": [ { "id", "title", "changes": [], "changesComplete", "changesTotal", "tasksComplete", "tasksTotal", "shadowsStore"? } ], "root" }` — `shadowsStore` is the id of a referenced store whose canonical initiative this local one shadows (absent when there is no collision). ### 4.2 `show --json` Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id", "title", "overview", "requirementCount", "requirements": [...], "metadata": { "version", "format", "sourcePath"? }, "root" }`. @@ -80,7 +80,7 @@ Success: `{ "archive": { "change", "archivedAs": "YYYY-MM-DD-name", "path", "spe setup/register: `{ "store": {id, root, metadata_path?}, "registry": {path, registered, already_registered}, "git": {is_repository, initialized, committed}, "created_files": [], "status": [] }`. unregister/remove: `{ "store", "registry": {path, removed}, "files": {deleted, deleted_path, left_on_disk}, "status": [] }`. list: `{ "stores": [{id, root}], "status": [] }`. doctor: `{ "stores": [ { id, root, metadata_path?, openspec_root: {...healthy, status}, metadata: {present, valid, id?, remote}, git: {is_repository, has_commits, has_uncommitted_changes, has_remote, origin_url}, status } ], "status": [] }` (`null` = unknown/not probed). Health findings exit 0; failures exit 1 with the matching null-shape. Prompt cancellation exits 130. ### 4.12 `schemas --json` / `templates --json` -`schemas`: bare array `[ {name, description, artifacts, source} ]`. `templates`: keyed object `{ "": {path, source} }`. Both cwd-based, no root/status keys. +`schemas`: bare array `[ {name, description, artifacts, source} ]` — supports `--store ` to list a store's schemas (success stays a bare array; a store-resolution failure emits `{status: [...]}` like other commands). `templates`: keyed object `{ "": {path, source} }`, cwd-based, no `--store`. ## 5. Exit-code contract @@ -133,5 +133,5 @@ Recorded by the capstone audit; published-key renames are product decisions defe 4. Four parallel envelope type declarations exist in src; archive diagnostics never carry `target`. 5. `list --json` reuses the `status` key as a string enum per change. 6. Only `validate` output carries a `version` field. -7. `schemas`/`templates` ignore root selection (cwd-based, no `--store`). +7. `schemas` honors root selection (`--store ` lists a store's schemas); `templates` is still cwd-based (no `--store`). 8. Deprecated noun forms (`change`/`spec` subcommands) emit unenveloped payloads without `root`/`status`. diff --git a/docs/stores-beta/initiatives.md b/docs/stores-beta/initiatives.md index 0745b09af8..eba2a08c43 100644 --- a/docs/stores-beta/initiatives.md +++ b/docs/stores-beta/initiatives.md @@ -34,10 +34,10 @@ One effort. One home. Read by every repo that needs it. acme-plans (a store: planning in its own repo) openspec/initiatives/ smoother-setup/ + initiative.yaml the title, and the changes it groups brief.md what this is, and why personas/ who we build for ← your own artifact types decisions/ the calls we made ← your own artifact types - (changes grouped here, with live status) ``` ## The happy path @@ -68,8 +68,24 @@ artifacts: - id: decision # a call we made, and the trade-off ``` -These are *your* artifact types — labeled in your words. OpenSpec now knows them -and can check them, the same way it checks specs and tasks. +These are *your* artifact types — labeled in your words. OpenSpec knows them and +lists them like any other schema, from any repo that can reach the store: + +```bash +openspec schemas --store acme-plans +``` + +```text +Available schemas: + + spec-driven + Default OpenSpec workflow - proposal → specs → design → tasks + Artifacts: proposal → specs → design → tasks + + team-brief (project) + Our own planning artifacts — a brief, then a decision record. + Artifacts: brief → decision +``` ### 3. Group the changes it takes @@ -78,15 +94,12 @@ OpenSpec change — nothing new to learn. You never track status by hand; ask OpenSpec: ```bash -openspec list --changes --store acme-plans +openspec list --initiatives --store acme-plans ``` ```text -Changes: - simplify-skill-installation ✓ Complete 8d ago - fix-opencode-commands-directory ✓ Complete 8d ago - add-global-install-scope 0/38 tasks 8d ago - schema-alias-support No tasks 8d ago +Initiatives: + smoother-setup 2/4 changes complete ``` One command. Where the whole effort stands. @@ -115,10 +128,12 @@ repo. Which wins? **The rule: the store is canonical.** The store holds the shared, agreed-upon initiative. A local one with the same name is treated as a *draft that shadows -it* — OpenSpec keeps letting you work locally, but tells you plainly: +it* — OpenSpec keeps letting you work locally, but tells you plainly, right in +the list: ```text -initiative 'smoother-setup' (local) shadows the canonical one in store 'acme-plans' +Initiatives: + smoother-setup 2/4 changes complete (shadows: acme-plans) ``` Nothing diverges silently. You always know there is a shared source of truth, @@ -126,25 +141,24 @@ and you can reconcile when you are ready. (This mirrors how OpenSpec already handles schemas: a project schema *shadows* a package one, and `openspec schema which` shows you it is happening.) -## What works today, and what we are building +## What works in this prototype -Honest scope — this is a prototype. +This is a working prototype — every command above runs today: -**Works today:** -- An initiative folder in a store: the brief, your own artifact types as plain - files, and the changes it groups. -- Live status for those changes with `openspec list --changes --store`. -- A repo reading the store by name via `references:`. -- Defining your own artifact types in a schema (`openspec schema`). +- **Your own artifact types, discoverable across the store boundary.** + `openspec schemas --store ` lists a store's custom schemas from any repo. +- **Initiatives with live, rolled-up status.** `openspec list --initiatives` + reads each initiative's manifest and rolls up the status of the changes it + groups, from the same source as `openspec list --changes`. +- **Canonical-vs-shadow precedence, enforced.** A local initiative that shares an + id with one in a referenced store is reported as a shadow — never silently + overriding it, never blocking your local work. +- **Read by name via `references:`.** A repo declares the store once and its + agent builds against the shared plan; nothing is copied. -**The gap we are closing (this is the build):** -- Today, `openspec schemas` and artifact discovery look only at the current - repo — they are not store-aware. So a repo that *references* a store cannot - yet see the store's custom artifact types, and initiative precedence - (canonical vs. local shadow) is a convention, not something OpenSpec enforces. -- **The prototype makes both work across repos:** the store's initiatives and - artifact types become discoverable and checkable from any repo that - references it, with the canonical-vs-shadow rule built in. +Still a prototype: the initiative folder is a light convention (an +`initiative.yaml` manifest — not a revived command group), and richer rollups +(tasks across every referencing repo) are the next step. Start simple: one initiative folder in a store, grouping a few real changes, -with the artifacts your team already uses. The rest is what we build next. +with the artifacts your team already uses. diff --git a/docs/stores-beta/user-guide.md b/docs/stores-beta/user-guide.md index 78433ef4d0..c8b906c8ae 100644 --- a/docs/stores-beta/user-guide.md +++ b/docs/stores-beta/user-guide.md @@ -308,9 +308,10 @@ tells you which case you're in. - **No sync, ever — by design.** OpenSpec never clones, pulls, or pushes. A stale checkout shows stale specs until *you* pull; references are indexed live from whatever is on disk. -- **Some commands stay where they are.** `view`, `templates`, `schemas`, - and the deprecated noun forms (`openspec change show`, ...) act on the - current directory only — no `--store`. +- **Some commands stay where they are.** `view`, `templates`, and the + deprecated noun forms (`openspec change show`, ...) act on the current + directory only — no `--store`. (`schemas` now accepts `--store` — see the + [initiatives guide](initiatives.md).) - **Per-machine state is per-machine.** The store registry and worksets are local settings. Nothing about your machine's layout is ever committed to shared planning. diff --git a/openspec/changes/initiatives-in-stores/tasks.md b/openspec/changes/initiatives-in-stores/tasks.md index 9dad366718..e7713ea7e7 100644 --- a/openspec/changes/initiatives-in-stores/tasks.md +++ b/openspec/changes/initiatives-in-stores/tasks.md @@ -1,31 +1,33 @@ ## 1. Store-aware discovery -- [ ] 1.1 Let the artifact-graph resolver take a store root, so a store's +- [x] 1.1 Let the artifact-graph resolver take a store root, so a store's `openspec/schemas/` participates in schema/artifact resolution -- [ ] 1.2 Add `--store ` to `openspec schemas` (list a store's schemas) + (via `resolveRootForCommand` → `listSchemasWithInfo(root.path)`) +- [x] 1.2 Add `--store ` to `openspec schemas` (list a store's schemas) - [ ] 1.3 Include referenced stores' schemas and artifact types in `openspec context` and the agent instruction block, each with a summary and - a fetch command + a fetch command (next step) ## 2. Initiative precedence -- [ ] 2.1 Detect when a local initiative id matches an initiative in a referenced +- [x] 2.1 Detect when a local initiative id matches an initiative in a referenced store -- [ ] 2.2 Report the local one as a shadow of the canonical store initiative, - reusing the schema-shadowing reporting shape (`shadows: …`) -- [ ] 2.3 Confirm local commands still act on the local initiative (no behavior +- [x] 2.2 Report the local one as a shadow of the canonical store initiative + (`(shadows: )` in the list; `shadowsStore` in JSON) +- [x] 2.3 Confirm local commands still act on the local initiative (no behavior change to where commands act) ## 3. Initiative status rollup -- [ ] 3.1 Add `openspec list --initiatives [--store ]` -- [ ] 3.2 Roll up the live status of the changes each initiative groups, reusing +- [x] 3.1 Add `openspec list --initiatives [--store ]` +- [x] 3.2 Roll up the live status of the changes each initiative groups, reusing the existing `openspec list --changes` status source ## 4. Proof -- [ ] 4.1 Update the `smoother-setup` example so it reads as an initiative in a - store with your-own artifact types -- [ ] 4.2 Capture real command output for the guide (`schemas --store`, `list +- [x] 4.1 Update the `smoother-setup` example so it reads as an initiative in a + store with your-own artifact types (adds `initiative.yaml`) +- [x] 4.2 Capture real command output for the guide (`schemas --store`, `list --initiatives`, the shadow report) -- [ ] 4.3 `openspec validate initiatives-in-stores --strict` passes +- [x] 4.3 `openspec validate initiatives-in-stores --strict` passes; unit tests + for the rollup and shadow rule in `test/core/initiatives.test.ts` diff --git a/openspec/initiatives/smoother-setup/initiative.yaml b/openspec/initiatives/smoother-setup/initiative.yaml new file mode 100644 index 0000000000..e75b73dca3 --- /dev/null +++ b/openspec/initiatives/smoother-setup/initiative.yaml @@ -0,0 +1,8 @@ +# The initiative manifest: its title and the changes it groups. +# Status for these changes is rolled up live by `openspec list --initiatives`. +title: Smoother setup +changes: + - simplify-skill-installation + - fix-opencode-commands-directory + - add-global-install-scope + - schema-alias-support diff --git a/src/cli/index.ts b/src/cli/index.ts index 98505b02fe..b81676dbe6 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -8,9 +8,10 @@ import { promises as fs } from 'fs'; import { AI_TOOLS } from '../core/config.js'; import { UpdateCommand } from '../core/update.js'; import { ListCommand } from '../core/list.js'; +import { listInitiatives } from '../core/initiatives.js'; import { ArchiveCommand, type ArchiveOptions } from '../core/archive.js'; import { ViewCommand } from '../core/view.js'; -import { resolveRootForCommand, toRootOutput } from '../core/root-selection.js'; +import { resolveRootForCommand, toRootOutput, type ResolvedOpenSpecRoot } from '../core/root-selection.js'; import { registerSpecCommand } from '../commands/spec.js'; import { ChangeCommand } from '../commands/change.js'; import { ValidateCommand } from '../commands/validate.js'; @@ -53,6 +54,39 @@ function hiddenStorePathOption(): Option { ).hideHelp(); } +async function renderInitiatives( + root: ResolvedOpenSpecRoot, + options: { json?: boolean } +): Promise { + const initiatives = await listInitiatives(root.path); + + if (options.json) { + console.log( + JSON.stringify({ initiatives, root: toRootOutput(root) }, null, 2) + ); + return; + } + + if (initiatives.length === 0) { + console.log('No initiatives found.'); + return; + } + + console.log('Initiatives:'); + const nameWidth = Math.max(...initiatives.map((i) => i.id.length)); + for (const initiative of initiatives) { + const name = initiative.id.padEnd(nameWidth); + const rollup = + initiative.changesTotal === 0 + ? 'no changes' + : `${initiative.changesComplete}/${initiative.changesTotal} changes complete`; + const shadow = initiative.shadowsStore + ? ` (shadows: ${initiative.shadowsStore})` + : ''; + console.log(` ${name} ${rollup.padEnd(22)}${shadow}`); + } +} + function failWithError( error: unknown, json?: { enabled: boolean | undefined; payload?: Record; fallbackCode?: string } @@ -214,22 +248,32 @@ program program .command('list') - .description('List items (changes by default). Use --specs to list specs.') + .description('List items (changes by default). Use --specs to list specs, --initiatives to list initiatives.') .option('--specs', 'List specs instead of changes') .option('--changes', 'List changes explicitly (default)') + .option('--initiatives', 'List initiatives with rolled-up change status') .option('--sort ', 'Sort order: "recent" (default) or "name"', 'recent') .option('--json', 'Output as JSON (for programmatic use)') .option('--store ', STORE_OPTION_DESCRIPTION) .addOption(hiddenStorePathOption()) - .action(async (options?: { specs?: boolean; changes?: boolean; sort?: string; json?: boolean; store?: string; storePath?: string }) => { + .action(async (options?: { specs?: boolean; changes?: boolean; initiatives?: boolean; sort?: string; json?: boolean; store?: string; storePath?: string }) => { try { + const failurePayload = options?.initiatives + ? { initiatives: [], root: null } + : options?.specs + ? { specs: [], root: null } + : { changes: [], root: null }; const root = await resolveRootForCommand(options ?? {}, { json: options?.json, - failurePayload: options?.specs ? { specs: [], root: null } : { changes: [], root: null }, + failurePayload, }); if (!root) { return; } + if (options?.initiatives) { + await renderInitiatives(root, { json: options?.json }); + return; + } const listCommand = new ListCommand(); const mode: 'changes' | 'specs' = options?.specs ? 'specs' : 'changes'; const sort = options?.sort === 'name' ? 'name' : 'recent'; @@ -544,6 +588,8 @@ program .command('schemas') .description('List available workflow schemas with descriptions') .option('--json', 'Output as JSON (for agent use)') + .option('--store ', STORE_OPTION_DESCRIPTION) + .addOption(hiddenStorePathOption()) .action(async (options: SchemasOptions) => { try { await schemasCommand(options); diff --git a/src/commands/workflow/schemas.ts b/src/commands/workflow/schemas.ts index b9af74a677..65c2cb5ba9 100644 --- a/src/commands/workflow/schemas.ts +++ b/src/commands/workflow/schemas.ts @@ -6,6 +6,7 @@ import chalk from 'chalk'; import { listSchemasWithInfo } from '../../core/artifact-graph/index.js'; +import { resolveRootForCommand } from '../../core/root-selection.js'; // ----------------------------------------------------------------------------- // Types @@ -13,6 +14,8 @@ import { listSchemasWithInfo } from '../../core/artifact-graph/index.js'; export interface SchemasOptions { json?: boolean; + store?: string; + storePath?: string; } // ----------------------------------------------------------------------------- @@ -20,8 +23,18 @@ export interface SchemasOptions { // ----------------------------------------------------------------------------- export async function schemasCommand(options: SchemasOptions): Promise { - const projectRoot = process.cwd(); - const schemas = listSchemasWithInfo(projectRoot); + // Resolve the OpenSpec root the same way normal commands do, so + // `--store ` lists a store's schemas and a repo's own schemas are + // still found when no store is selected. The JSON shape stays a bare + // array (per the agent contract); only which root is read changes. + const root = await resolveRootForCommand(options, { + json: options.json, + failurePayload: {}, + }); + if (!root) { + return; + } + const schemas = listSchemasWithInfo(root.path); if (options.json) { console.log(JSON.stringify(schemas, null, 2)); diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 76f2a28587..6ecee9bb3e 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -50,6 +50,10 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ name: 'changes', description: 'List changes explicitly (default)', }, + { + name: 'initiatives', + description: 'List initiatives with rolled-up change status', + }, { name: 'sort', description: 'Sort order: "recent" (default) or "name"', @@ -213,6 +217,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'List available workflow schemas with descriptions', flags: [ COMMON_FLAGS.json, + COMMON_FLAGS.store, ], }, { diff --git a/src/core/initiatives.ts b/src/core/initiatives.ts new file mode 100644 index 0000000000..a3d95979d2 --- /dev/null +++ b/src/core/initiatives.ts @@ -0,0 +1,197 @@ +/** + * Initiatives (stores beta). + * + * An initiative is the home for a big effort: a brief, the artifacts the team + * works from, and the changes that carry it out. It is a plain folder under + * `openspec/initiatives//` with an `initiative.yaml` manifest that names + * the changes it groups. + * + * This module lists initiatives, rolls up the live status of the changes each + * one groups (reusing the same task-progress source as `openspec list`), and + * detects when a local initiative shadows a canonical one in a referenced + * store. Precedence rule: the store initiative is canonical; a same-id local + * one is a shadow that is reported but never blocked — mirroring how OpenSpec + * reports schema shadowing. + */ + +import { promises as fs } from 'fs'; +import path from 'path'; +import { parse as parseYaml } from 'yaml'; + +import { getTaskProgressForChange } from '../utils/task-progress.js'; +import { readProjectConfig } from './project-config.js'; +import { + listStoreRegistryEntries, + readStoreRegistryState, +} from './store/foundation.js'; +import { getStoreRootForBackend } from './store/registry.js'; + +export const INITIATIVES_DIRNAME = 'initiatives'; +const INITIATIVE_MANIFEST = 'initiative.yaml'; + +export interface InitiativeInfo { + id: string; + title: string; + /** Change ids this initiative groups. */ + changes: string[]; + /** Rolled-up status of the grouped changes. */ + changesComplete: number; + changesTotal: number; + tasksComplete: number; + tasksTotal: number; + /** Set when a same-id canonical initiative exists in a referenced store. */ + shadowsStore?: string; +} + +interface InitiativeManifest { + title?: string; + changes?: string[]; +} + +function initiativesDir(root: string): string { + return path.join(root, 'openspec', INITIATIVES_DIRNAME); +} + +/** + * Reads an initiative's manifest. Returns null when the folder has no + * `initiative.yaml` (a plain folder that is not yet a tracked initiative). + */ +async function readManifest( + dir: string +): Promise { + const manifestPath = path.join(dir, INITIATIVE_MANIFEST); + let raw: string; + try { + raw = await fs.readFile(manifestPath, 'utf-8'); + } catch { + return null; + } + try { + const parsed = parseYaml(raw) as InitiativeManifest | null; + return parsed ?? {}; + } catch { + return {}; + } +} + +/** Lists initiative folder ids under a root's `openspec/initiatives/`. */ +async function listInitiativeIds(root: string): Promise { + let entries; + try { + entries = await fs.readdir(initiativesDir(root), { withFileTypes: true }); + } catch { + return []; + } + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); +} + +/** + * Resolves the on-disk roots of the stores this root references and that are + * registered on this machine. Read-only; unregistered/invalid references are + * skipped (they surface through `openspec doctor`, not here). + */ +async function referencedStoreRoots( + root: string, + globalDataDir?: string +): Promise> { + const config = readProjectConfig(root); + const references = config?.references ?? []; + if (references.length === 0) { + return []; + } + + const registry = await readStoreRegistryState( + globalDataDir ? { globalDataDir } : {} + ); + const entries = registry ? listStoreRegistryEntries(registry) : []; + + const roots: Array<{ id: string; root: string }> = []; + for (const reference of references) { + const entry = entries.find((candidate) => candidate.id === reference.id); + if (!entry) continue; + try { + roots.push({ id: reference.id, root: getStoreRootForBackend(entry.backend) }); + } catch { + // Unusable backend — skipped; doctor reports it. + } + } + return roots; +} + +/** + * Builds the shadow lookup: for each referenced store, the set of initiative + * ids it defines. A local initiative whose id appears here shadows the store's + * canonical one. + */ +async function buildShadowLookup( + root: string, + globalDataDir?: string +): Promise> { + const lookup = new Map(); + const stores = await referencedStoreRoots(root, globalDataDir); + for (const store of stores) { + for (const id of await listInitiativeIds(store.root)) { + // First referenced store to define an id is named as canonical. + if (!lookup.has(id)) { + lookup.set(id, store.id); + } + } + } + return lookup; +} + +/** + * Lists the initiatives at a root, with rolled-up change status and shadow + * detection against referenced stores. + */ +export async function listInitiatives( + root: string, + options: { globalDataDir?: string } = {} +): Promise { + const ids = await listInitiativeIds(root); + if (ids.length === 0) { + return []; + } + + const changesDir = path.join(root, 'openspec', 'changes'); + const shadows = await buildShadowLookup(root, options.globalDataDir); + const initiatives: InitiativeInfo[] = []; + + for (const id of ids) { + const manifest = await readManifest(path.join(initiativesDir(root), id)); + if (manifest === null) { + // A plain folder without a manifest is not a tracked initiative. + continue; + } + + const changes = manifest.changes ?? []; + let changesComplete = 0; + let tasksComplete = 0; + let tasksTotal = 0; + + for (const changeId of changes) { + const progress = await getTaskProgressForChange(changesDir, changeId); + tasksComplete += progress.completed; + tasksTotal += progress.total; + if (progress.total > 0 && progress.completed === progress.total) { + changesComplete += 1; + } + } + + initiatives.push({ + id, + title: manifest.title ?? id, + changes, + changesComplete, + changesTotal: changes.length, + tasksComplete, + tasksTotal, + ...(shadows.has(id) ? { shadowsStore: shadows.get(id) } : {}), + }); + } + + initiatives.sort((a, b) => a.id.localeCompare(b.id)); + return initiatives; +} diff --git a/src/core/templates/workflows/store-selection.ts b/src/core/templates/workflows/store-selection.ts index d40ed7d94d..1c39b38323 100644 --- a/src/core/templates/workflows/store-selection.ts +++ b/src/core/templates/workflows/store-selection.ts @@ -4,4 +4,4 @@ * Interpolated into every workflow's instructions so generated skills * consistently teach how to target a registered store with `--store `. */ -export const STORE_SELECTION_GUIDANCE = `**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run \`openspec store list --json\` to discover registered store ids, then pass \`--store \` on the commands that read or write specs and changes (\`new change\`, \`status\`, \`instructions\`, \`list\`, \`show\`, \`validate\`, \`archive\`, \`doctor\`, \`context\`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local \`openspec/\` root.`; +export const STORE_SELECTION_GUIDANCE = `**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run \`openspec store list --json\` to discover registered store ids, then pass \`--store \` on the commands that act on a selected root (\`new change\`, \`status\`, \`instructions\`, \`list\`, \`show\`, \`validate\`, \`archive\`, \`doctor\`, \`context\`, \`schemas\`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local \`openspec/\` root.`; diff --git a/test/core/completions/command-registry.test.ts b/test/core/completions/command-registry.test.ts index 8ac1e0775b..2e28ed6ccf 100644 --- a/test/core/completions/command-registry.test.ts +++ b/test/core/completions/command-registry.test.ts @@ -171,6 +171,7 @@ describe('command completion registry', () => { 'instructions', 'list', 'new change', + 'schemas', 'show', 'status', 'validate', diff --git a/test/core/initiatives.test.ts b/test/core/initiatives.test.ts new file mode 100644 index 0000000000..b4185e0e99 --- /dev/null +++ b/test/core/initiatives.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { listInitiatives } from '../../src/core/initiatives.js'; +import { + readStoreRegistryState, + writeStoreRegistryState, +} from '../../src/core/store/foundation.js'; + +describe('listInitiatives', () => { + let tempDir: string; + let globalDataDir: string; + let savedXdgDataHome: string | undefined; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-initiatives-')); + globalDataDir = path.join(tempDir, 'data', 'openspec'); + savedXdgDataHome = process.env.XDG_DATA_HOME; + process.env.XDG_DATA_HOME = path.join(tempDir, 'xdg'); + }); + + afterEach(() => { + if (savedXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = savedXdgDataHome; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function write(relativePath: string, content: string): void { + const full = path.join(tempDir, relativePath); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + + function initiative(root: string, id: string, manifest: string): void { + write(`${root}/openspec/initiatives/${id}/initiative.yaml`, manifest); + } + + function change(root: string, id: string, tasks: string): void { + write(`${root}/openspec/changes/${id}/tasks.md`, tasks); + } + + async function registerStore(id: string): Promise { + const storeRoot = path.join(tempDir, 'stores', id); + fs.mkdirSync(storeRoot, { recursive: true }); + const existing = await readStoreRegistryState({ globalDataDir }).catch( + () => null + ); + await writeStoreRegistryState( + { + version: 1, + stores: { + ...(existing?.stores ?? {}), + [id]: { backend: { type: 'git', local_path: storeRoot } }, + }, + }, + { globalDataDir } + ); + return storeRoot; + } + + it('rolls up the live status of the changes an initiative groups', async () => { + initiative( + 'app', + 'smoother-setup', + 'title: Smoother setup\nchanges:\n - done-change\n - wip-change\n' + ); + change('app', 'done-change', '- [x] 1.1 a\n- [x] 1.2 b\n'); + change('app', 'wip-change', '- [x] 1.1 a\n- [ ] 1.2 b\n'); + + const [result] = await listInitiatives(path.join(tempDir, 'app'), { + globalDataDir, + }); + + expect(result.id).toBe('smoother-setup'); + expect(result.title).toBe('Smoother setup'); + expect(result.changesTotal).toBe(2); + expect(result.changesComplete).toBe(1); // only done-change is fully complete + expect(result.tasksTotal).toBe(4); + expect(result.tasksComplete).toBe(3); + expect(result.shadowsStore).toBeUndefined(); + }); + + it('flags a local initiative that shadows a canonical one in a referenced store', async () => { + const storeRoot = await registerStore('acme-plans'); + fs.mkdirSync( + path.join(storeRoot, 'openspec', 'initiatives', 'smoother-setup'), + { recursive: true } + ); + fs.writeFileSync( + path.join( + storeRoot, + 'openspec', + 'initiatives', + 'smoother-setup', + 'initiative.yaml' + ), + 'title: Canonical\nchanges: []\n' + ); + + write('consumer/openspec/config.yaml', 'references:\n - acme-plans\n'); + initiative('consumer', 'smoother-setup', 'title: Local draft\nchanges: []\n'); + initiative('consumer', 'unrelated', 'title: Unrelated\nchanges: []\n'); + + const results = await listInitiatives(path.join(tempDir, 'consumer'), { + globalDataDir, + }); + const byId = Object.fromEntries(results.map((i) => [i.id, i])); + + expect(byId['smoother-setup'].shadowsStore).toBe('acme-plans'); + expect(byId['unrelated'].shadowsStore).toBeUndefined(); + }); + + it('ignores plain folders without an initiative.yaml manifest', async () => { + fs.mkdirSync( + path.join(tempDir, 'app', 'openspec', 'initiatives', 'just-a-folder'), + { recursive: true } + ); + initiative('app', 'tracked', 'title: Tracked\nchanges: []\n'); + + const results = await listInitiatives(path.join(tempDir, 'app'), { + globalDataDir, + }); + + expect(results.map((i) => i.id)).toEqual(['tracked']); + }); +}); From 84681ba9fdf140a4b46bfeef89d5208e3b8855ce Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 1 Jul 2026 15:45:23 -0500 Subject: [PATCH 08/45] feat(stores): surface a store's artifact types + initiatives in the agent instruction block Extend the referenced-store index so a repo that references a store sees that store's own custom artifact types (project schemas) and initiatives, each with a one-line summary and a fetch command (openspec schemas/list --initiatives --store ). Rendered in the instruction block and the apply section; keys are omitted when a store defines none, so existing output is unchanged. Also regenerate the skill-templates-parity golden hashes for the store-selection guidance update from the previous commit (schemas joined the store-aware set); the feedback template hash is unchanged, confirming the blast radius. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/agent-contract.md | 2 +- src/core/initiatives.ts | 19 ++++ src/core/references.ts | 88 +++++++++++++++++++ test/core/references.test.ts | 58 ++++++++++++ .../templates/skill-templates-parity.test.ts | 66 +++++++------- 5 files changed, 199 insertions(+), 34 deletions(-) diff --git a/docs/agent-contract.md b/docs/agent-contract.md index 6553569a19..d48df75cdf 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -59,7 +59,7 @@ Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id ### 4.5 `instructions --json` `{ "changeName", "artifactId", "schemaName", "changeDir", "planningHome"?, "outputPath", "resolvedOutputPath", "existingOutputPaths", "description", "instruction"?, "context"?, "rules"?, "references"?: ReferenceIndexEntry[], "template", "dependencies": [{id,done,path,description}], "unlocks", "root" }`. -`ReferenceIndexEntry`: `{ "store_id", "root"?, "specs"?: [{id,summary}], "fetch"?, "status": [] }` — resolved entries carry root/specs/fetch; unresolved carry store_id + warning status. Index capped at 50KB (`reference_index_truncated`). +`ReferenceIndexEntry`: `{ "store_id", "root"?, "specs"?: [{id,summary}], "schemas"?: [{id,summary,artifacts}], "initiatives"?: [{id,summary}], "fetch"?, "status": [] }` — resolved entries carry root/specs/fetch; `schemas` (the store's own project-local artifact types) and `initiatives` are present only when the store defines them; unresolved carry store_id + warning status. Index capped at 50KB (`reference_index_truncated`). ### 4.6 `instructions apply --json` `{ "changeName", "changeDir", "schemaName", "contextFiles": { "": ["/abs", ...] }, "progress": {total,complete,remaining}, "tasks": [{id,description,done}], "state": "blocked"|"all_done"|"ready", "missingArtifacts"?, "instruction", "references"?, "root" }`. diff --git a/src/core/initiatives.ts b/src/core/initiatives.ts index a3d95979d2..607b5907cf 100644 --- a/src/core/initiatives.ts +++ b/src/core/initiatives.ts @@ -142,6 +142,25 @@ async function buildShadowLookup( return lookup; } +/** + * Lightweight id + title for each initiative at a root — no change rollup and + * no shadow detection. Used to index a referenced store's initiatives without + * the cost of resolving that store's own references. + */ +export async function listInitiativeSummaries( + root: string +): Promise> { + const ids = await listInitiativeIds(root); + const summaries: Array<{ id: string; title: string }> = []; + for (const id of ids) { + const manifest = await readManifest(path.join(initiativesDir(root), id)); + if (manifest === null) continue; + summaries.push({ id, title: manifest.title ?? id }); + } + summaries.sort((a, b) => a.id.localeCompare(b.id)); + return summaries; +} + /** * Lists the initiatives at a root, with rolled-up change status and shadow * detection against referenced stores. diff --git a/src/core/references.ts b/src/core/references.ts index 7edb8a225b..5e1ad60f9d 100644 --- a/src/core/references.ts +++ b/src/core/references.ts @@ -23,16 +23,33 @@ import { inspectRegisteredStore, type ResolvedOpenSpecRoot } from './root-select import { getSpecIds } from '../utils/item-discovery.js'; import { FileSystemUtils } from '../utils/file-system.js'; import { MAX_CONTEXT_SIZE, type DeclarationEntry } from './project-config.js'; +import { listSchemasWithInfo } from './artifact-graph/index.js'; +import { listInitiativeSummaries } from './initiatives.js'; export interface ReferenceSpecEntry { id: string; summary: string; } +/** A store's own custom artifact type (schema), for the reference index. */ +export interface ReferenceSchemaEntry { + id: string; + summary: string; + artifacts: string[]; +} + +/** A store's initiative, for the reference index. */ +export interface ReferenceInitiativeEntry { + id: string; + summary: string; +} + export interface ReferenceIndexEntry { store_id: string; root?: string; specs?: ReferenceSpecEntry[]; + schemas?: ReferenceSchemaEntry[]; + initiatives?: ReferenceInitiativeEntry[]; fetch?: string; status: StoreDiagnostic[]; } @@ -140,10 +157,54 @@ async function collectSpecEntries(referencedRoot: string): Promise schema.source === 'project') + .map((schema) => ({ + id: sanitizeInline(schema.name, 100), + summary: sanitizeInline(schema.description), + artifacts: schema.artifacts.map((artifact) => sanitizeInline(artifact, 60)), + })); +} + +async function collectInitiativeEntries( + referencedRoot: string +): Promise { + let summaries; + try { + summaries = await listInitiativeSummaries(referencedRoot); + } catch { + return []; + } + return summaries.map((initiative) => ({ + id: sanitizeInline(initiative.id, 100), + summary: sanitizeInline(initiative.title), + })); +} + export function fetchRecipe(storeId: string): string { return `openspec show --type spec --store ${storeId}`; } +export function schemasFetchRecipe(storeId: string): string { + return `openspec schemas --store ${storeId}`; +} + +export function initiativesFetchRecipe(storeId: string): string { + return `openspec list --initiatives --store ${storeId}`; +} + function specLine(spec: ReferenceSpecEntry): string { // Ids are raw directory names from cloned content; summaries are // sanitized at index time (collectSpecEntries). @@ -205,6 +266,29 @@ function renderEntryLines(entry: ReferenceIndexEntry): string[] { for (const spec of entry.specs ?? []) { lines.push(specLine(spec)); } + if (entry.schemas && entry.schemas.length > 0) { + lines.push(` Artifact types (${schemasFetchRecipe(entry.store_id)}):`); + for (const schema of entry.schemas) { + const graph = schema.artifacts.length > 0 + ? ` [${schema.artifacts.join(' → ')}]` + : ''; + lines.push( + schema.summary + ? ` - ${schema.id}: ${schema.summary}${graph}` + : ` - ${schema.id}${graph}` + ); + } + } + if (entry.initiatives && entry.initiatives.length > 0) { + lines.push(` Initiatives (${initiativesFetchRecipe(entry.store_id)}):`); + for (const initiative of entry.initiatives) { + lines.push( + initiative.summary + ? ` - ${initiative.id}: ${initiative.summary}` + : ` - ${initiative.id}` + ); + } + } if (entry.fetch) { lines.push(` Fetch: ${entry.fetch}`); } @@ -368,10 +452,14 @@ export async function assembleReferenceIndex( } const specs = await collectSpecEntries(inspection.canonicalRoot); + const schemas = collectSchemaEntries(inspection.canonicalRoot); + const initiatives = await collectInitiativeEntries(inspection.canonicalRoot); const entry: ReferenceIndexEntry = { store_id: id, root: inspection.canonicalRoot, specs, + ...(schemas.length > 0 ? { schemas } : {}), + ...(initiatives.length > 0 ? { initiatives } : {}), fetch: fetchRecipe(id), status: [], }; diff --git a/test/core/references.test.ts b/test/core/references.test.ts index c129e9e420..fd61e0c1c7 100644 --- a/test/core/references.test.ts +++ b/test/core/references.test.ts @@ -130,6 +130,64 @@ describe('reference index assembly', () => { expect(entries[0].status).toEqual([]); }); + it("indexes a store's own artifact types and initiatives with fetch commands", async () => { + const storeRoot = await registerStore('team-context'); + // A project-local schema (custom artifact types) in the store. + const schemaDir = path.join(storeRoot, 'openspec', 'schemas', 'team-brief'); + fs.mkdirSync(path.join(schemaDir, 'templates'), { recursive: true }); + fs.writeFileSync( + path.join(schemaDir, 'schema.yaml'), + 'name: team-brief\nversion: 1\ndescription: Our own planning artifacts.\n' + + 'artifacts:\n - id: brief\n generates: brief.md\n description: What and why\n' + + ' template: brief.md\n requires: []\n instruction: Write it.\n' + ); + fs.writeFileSync(path.join(schemaDir, 'templates', 'brief.md'), '# Brief\n'); + // An initiative in the store. + const initiativeDir = path.join( + storeRoot, + 'openspec', + 'initiatives', + 'smoother-setup' + ); + fs.mkdirSync(initiativeDir, { recursive: true }); + fs.writeFileSync( + path.join(initiativeDir, 'initiative.yaml'), + 'title: Smoother setup\nchanges: []\n' + ); + + const [entry] = await assemble(['team-context']); + + expect(entry.schemas).toEqual([ + { + id: 'team-brief', + summary: 'Our own planning artifacts.', + artifacts: ['brief'], + }, + ]); + expect(entry.initiatives).toEqual([ + { id: 'smoother-setup', summary: 'Smoother setup' }, + ]); + + const block = renderReferencedStoresBlock([entry]); + expect(block).toContain( + 'Artifact types (openspec schemas --store team-context):' + ); + expect(block).toContain('- team-brief: Our own planning artifacts. [brief]'); + expect(block).toContain( + 'Initiatives (openspec list --initiatives --store team-context):' + ); + expect(block).toContain('- smoother-setup: Smoother setup'); + }); + + it('omits schemas/initiatives keys for a store that has none', async () => { + await registerStore('bare-context'); + + const [entry] = await assemble(['bare-context']); + + expect(entry.schemas).toBeUndefined(); + expect(entry.initiatives).toBeUndefined(); + }); + it('degrades an unregistered reference to reference_unresolved with a pasteable fix', async () => { const entries = await assemble(['missing-context']); diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index cc6ec7bc12..d135e3bea8 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -35,43 +35,43 @@ import { import { STORE_SELECTION_GUIDANCE } from '../../../src/core/templates/workflows/store-selection.js'; const EXPECTED_FUNCTION_HASHES: Record = { - getExploreSkillTemplate: '7d2f54e74fffcb36aaaa4498a4a8b033142bb25945fb9b2de532354acbe76b9c', - getNewChangeSkillTemplate: '39663a6d2037e6697020393a66f6327506e3e3bc573b7a3556dcb7f9457dc51d', - getContinueChangeSkillTemplate: '1bb28875d6e5946ea2ec5f12e90f55d9784c2fa1f6e4c4e2d0eda53d861d4c75', - getApplyChangeSkillTemplate: '0f5a15fc7fb9ad6059a5643d0e01365d27642637a4aaebf182f9eabb45348197', - getFfChangeSkillTemplate: '9f4c12a1c58c723c9c45a139307eb90caf39cedd93c435bc960d0817328875e2', - getSyncSpecsSkillTemplate: '75abb20572256e2b8a647e77befae99f109ab5c4dc954a9c3c184829b5fcaa40', - getOnboardSkillTemplate: 'e871d8ce172bb805ae62a7611aee7a3154d89414f427ad5ef31721c903f13002', - getOpsxExploreCommandTemplate: '37e53590aae7ac6621d4393aa80a5b8af21881323887fa924ed329199fda27e0', - getOpsxNewCommandTemplate: '57c600cce318d16b9b4308a18d0d983ea3c0673034e606a7cceec07b4c705e87', - getOpsxContinueCommandTemplate: '418108b417107a87019d4020b26c105792d2ef0110fe6920445e255889216716', - getOpsxApplyCommandTemplate: 'daeb507206707169de73c828e199648dde5732cbc17791ef2a027adffd028574', - getOpsxFfCommandTemplate: '36973ae0dd00ab169fbaaa42bf565f97e1bc97cf63ae7c07307734cc1ca8c1fd', - getArchiveChangeSkillTemplate: 'c511a1c943bcfc5f9f3833b8c0ff284b22d34864a08f5f553cec471ee485d38f', - getBulkArchiveChangeSkillTemplate: '0f635913757ae3d1609e111f4a8f699443ca47cbaaf8a1b21eb652f7b96a1d13', - getOpsxSyncCommandTemplate: '86cf706886d0f18069e2cfa16948b7357028fd348210efb58588c88c416d8622', - getVerifyChangeSkillTemplate: 'd718c79aad649223a73fdb11036c93fb3842ac5a780f4934d50bfa03c9692683', - getOpsxArchiveCommandTemplate: '6985bddb310cb45b6b50350bfcebe31bf67146135ca0084c94930920280970a4', - getOpsxOnboardCommandTemplate: '0673f34a0f81fd173bcfb8c3ac83e2b1c617f7b7564e24e5298d3bd5665a05a9', - getOpsxBulkArchiveCommandTemplate: '9f444fc7b27a5b788077b5e3aa4f61af45aa8c8004ac8d899d204fa362ff89b7', - getOpsxVerifyCommandTemplate: '011509480a20a60342c993906f0f9280c0e9ba5d019d335bdc1ef4d53213a5a8', - getOpsxProposeSkillTemplate: '8dfb5e9c719d5ba547aff0d3953c076dca6b33d7223be98cbffc396b8f1e0048', - getOpsxProposeCommandTemplate: '7cd569beb32d99cdabd0b49615a8245160a8e152b6ea67a99fc4dd71e3f39f50', + getExploreSkillTemplate: '26675478b220715bafe3749311db95677043afc85da4dd01c53726a83f198704', + getNewChangeSkillTemplate: 'a647a6602e361cda6a5277ee8195c76cc59c6105c155731bb67ae61bd14c627f', + getContinueChangeSkillTemplate: '03d2a37c9a703a379d4df38633009e63f30f26df78c13e5e259001a4e0391c76', + getApplyChangeSkillTemplate: 'dfbf04bd25ffedba1f8b764623a3c91e0e6f43ed14de97d7642421944ac57ac1', + getFfChangeSkillTemplate: 'bf6f4918e96f681922b715338b56dba7a60a46051e3fbaf2ef2d026e51eed07c', + getSyncSpecsSkillTemplate: '8af60d91a626e4751d6a2aef7e47b1522daa1db01c213386fc206a54ce176ab1', + getOnboardSkillTemplate: 'eb746e0f6e720794f2565e487a8bb7e50273e4b8494308b00e7afeed2c31a5e2', + getOpsxExploreCommandTemplate: '1a99984ace5e8ae76a05d357c04a2c6e4b13407451f4545534d4ce95d507bb54', + getOpsxNewCommandTemplate: 'd761624274af2856e60096847dc9fab4beaec0ee55f49ee1e885d3af0496570f', + getOpsxContinueCommandTemplate: 'bd7776b401467c98b07ead76a6965802e99dc150d59804c1014e7ef5f7f89fb3', + getOpsxApplyCommandTemplate: '0a65c9b1fa9b00953fa8c68bf3ba03e69b7d8167376b1cbf656d151c23a067b4', + getOpsxFfCommandTemplate: '610a96f70073eb6643f1387723dec01ffbfa75a2e48ddd471845d47fc1183378', + getArchiveChangeSkillTemplate: '6457b47ef91bbb964e434725ca845851ed69497a5f770c4ef9713019fd1378ae', + getBulkArchiveChangeSkillTemplate: '0ce6933682e5f74b8d8b3fd49270957d6c14572eb0490eb65ad63628152e865e', + getOpsxSyncCommandTemplate: 'f50af3e913e32269b5b17bfef71f4f2175c327b3546f848cff9e7f8e017df3bd', + getVerifyChangeSkillTemplate: '9ec56494eac8d7970f985c00938987f784ee3402e407e239004e6da7d0af1896', + getOpsxArchiveCommandTemplate: 'eaa988fb7eacfd8d27c89b0dbac75bdf34ae45f11dccedf5a30caf16dfe0ac80', + getOpsxOnboardCommandTemplate: 'f5d6072c4c6d0c9e2704c469d06f2d56f638c1450088df644f88f013af4074d4', + getOpsxBulkArchiveCommandTemplate: 'be07bc0d7ea413aebaad181ee22dc856bbfc6210fe394310b6c8b552ee891bc0', + getOpsxVerifyCommandTemplate: 'b4a5717c25883ca74dc8da091aef2432492e2a377c8cd9c1a0369b6b854dc32c', + getOpsxProposeSkillTemplate: 'ad11a374aa8f3978a93af3a2d5b4cea736d6a5f7b3f913b38a2b34aeeb2bec21', + getOpsxProposeCommandTemplate: '8aa4d2e0ca201ad8e913e8d490223063cef9c2a7e2b7a464d5aa0452540ccc09', getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d', }; const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { - 'openspec-explore': '08f905865f86e787262fed252c59ed343ac24db8befa31e5cf8fd99af947263b', - 'openspec-new-change': 'bdb534d6d5a00b235f63852af089f904fd20df34be526ef67990ec3183829f33', - 'openspec-continue-change': '5d2aea621310d74d89e547d705d2e08e6d5a44da7bca93ba049ed43ebf60295e', - 'openspec-apply-change': '54cffa61274c6a499d2b3775e9f6db29255fd8e5ad99d7352c1e3bbe2edb45ed', - 'openspec-ff-change': 'cbb7844c130bd188319ff2b3f0c0320243b5ae5b588a0f816cd4e29408f25676', - 'openspec-sync-specs': 'a81fd87f5e871874eab72e57c10a1949fde46d1d07d95f8ea3bc1a52b4e78c43', - 'openspec-archive-change': '833290ade47ddaed7f5e523d07437c7cef2497340021e944096bce449e290c22', - 'openspec-bulk-archive-change': '244b195e53d3f010a99892c1922c800fd8f02e7745d0f34ec18b5fe9b5548706', - 'openspec-verify-change': '97d1eed5b900788706c28339e27c1d2d9c548626316253f43ebd00d8d52d02d6', - 'openspec-onboard': 'd136b6ab7134d6bceeca73bc2f6037624506587e8df99059f77fe88874256ed1', - 'openspec-propose': '5c350d80247722489374a49ec9853d5fda55a827f421fbb32b6b6a078fcb69ee', + 'openspec-explore': '0b7f81479edf27f85eb8a7deffb37aa6b9782adb1203ac1b541ce7e85b03fc64', + 'openspec-new-change': '81f414bfce1e11931b93d87ccadd0674ec460789bfe4079c73f483358a960859', + 'openspec-continue-change': '987043d62f6703f01258b0d09c0b9fd0481d845d6d738049d62dde5dfdd5160c', + 'openspec-apply-change': '3ad97a4a515021299002eb48279a2e5ee9ea77a9dd251a7156193f5263b0de3f', + 'openspec-ff-change': '30e8e5cc2e73f58699ccefad6f941bc8d8b9c5d0d77b12ef5d3140e220aa6079', + 'openspec-sync-specs': 'defce99383e7269a691cccf1577139bded63b6d3136055eb55b27847aa915602', + 'openspec-archive-change': 'fc9ecf88d9855e36a157f42518e9533b9df35b5370c20413931cef74e5bd353e', + 'openspec-bulk-archive-change': 'bfb25fdacb3bb2959535b79e6d321b0319d43cbde52614337f1318a065929b6f', + 'openspec-verify-change': '2aa862e0f32bf85d1a073629a1dce44dac7c005bf76ba2f63fd19b0953228f30', + 'openspec-onboard': 'abd8b125dd67edb482a6fcba2304ea4327cc3ac3689eea68ee805e5913065523', + 'openspec-propose': 'd4a35ab16a2ca89f65c6aa1cc931cba21c3f01d5de356855a027d114b92047ec', }; // Intentionally excludes getFeedbackSkillTemplate: this list only models templates From 567456a30e79e31bb78afb65005d8a1136faa6d0 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 1 Jul 2026 15:49:44 -0500 Subject: [PATCH 09/45] feat(stores): surface a store's artifact types + initiatives in `openspec context` Enrich available referenced-store members with the store's own custom artifact types (project schema names) and initiative ids, rendered in the human listing and carried in --json (artifactTypes/initiatives, present only when non-empty). Read-only enrichment in the context command; the working-set model and code-workspace builder are unchanged. Completes task 1.3 (both the agent instruction block and context now advertise a store's artifacts). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/agent-contract.md | 2 +- .../changes/initiatives-in-stores/tasks.md | 6 +-- src/commands/context.ts | 50 +++++++++++++++++++ src/core/working-set.ts | 5 ++ test/commands/context.test.ts | 40 +++++++++++++++ 5 files changed, 99 insertions(+), 4 deletions(-) diff --git a/docs/agent-contract.md b/docs/agent-contract.md index d48df75cdf..8255424c12 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -74,7 +74,7 @@ Success: `{ "archive": { "change", "archivedAs": "YYYY-MM-DD-name", "path", "spe `{ "root": { "path", "source", "store_id"?, "healthy", "status": [] }, "store": { "id", "metadata": {present,valid,remote?}, "origin_url"?, "status": [] } | null, "references": [...], "status": [] }`. Health findings of any severity exit 0. Failure payload: `{ "root": null, "store": null, "references": [], "status": [d] }`, exit 1. ### 4.10 `context --json` -`{ "root": { "path", "source", "store_id"?, "role": "openspec_root" }, "members": [ { "role": "referenced_store", "id", "path"?, "remote"?, "fetch"?, "status": [] } ], "status": [] }`. AVAILABLE = path present AND status empty. `--code-workspace ` writes `{folders:[{name,path}]}` (available referenced stores only, `ref:` prefixes); in JSON mode the write runs before printing so stdout holds exactly one document even on write failure. Failure: `{ "root": null, "members": [], "status": [d] }`, exit 1. +`{ "root": { "path", "source", "store_id"?, "role": "openspec_root" }, "members": [ { "role": "referenced_store", "id", "path"?, "remote"?, "fetch"?, "artifactTypes"?: [string], "initiatives"?: [string], "status": [] } ], "status": [] }`. AVAILABLE = path present AND status empty. `artifactTypes` (the store's own project-local schema names) and `initiatives` are present only on available members whose store defines them. `--code-workspace ` writes `{folders:[{name,path}]}` (available referenced stores only, `ref:` prefixes); in JSON mode the write runs before printing so stdout holds exactly one document even on write failure. Failure: `{ "root": null, "members": [], "status": [d] }`, exit 1. ### 4.11 `store ... --json` setup/register: `{ "store": {id, root, metadata_path?}, "registry": {path, registered, already_registered}, "git": {is_repository, initialized, committed}, "created_files": [], "status": [] }`. unregister/remove: `{ "store", "registry": {path, removed}, "files": {deleted, deleted_path, left_on_disk}, "status": [] }`. list: `{ "stores": [{id, root}], "status": [] }`. doctor: `{ "stores": [ { id, root, metadata_path?, openspec_root: {...healthy, status}, metadata: {present, valid, id?, remote}, git: {is_repository, has_commits, has_uncommitted_changes, has_remote, origin_url}, status } ], "status": [] }` (`null` = unknown/not probed). Health findings exit 0; failures exit 1 with the matching null-shape. Prompt cancellation exits 130. diff --git a/openspec/changes/initiatives-in-stores/tasks.md b/openspec/changes/initiatives-in-stores/tasks.md index e7713ea7e7..886ea9e99e 100644 --- a/openspec/changes/initiatives-in-stores/tasks.md +++ b/openspec/changes/initiatives-in-stores/tasks.md @@ -4,9 +4,9 @@ `openspec/schemas/` participates in schema/artifact resolution (via `resolveRootForCommand` → `listSchemasWithInfo(root.path)`) - [x] 1.2 Add `--store ` to `openspec schemas` (list a store's schemas) -- [ ] 1.3 Include referenced stores' schemas and artifact types in - `openspec context` and the agent instruction block, each with a summary and - a fetch command (next step) +- [x] 1.3 Include referenced stores' schemas/artifact types and initiatives in + the agent instruction block (``) and `openspec context` + (human + JSON), each with a summary and a fetch command ## 2. Initiative precedence diff --git a/src/commands/context.ts b/src/commands/context.ts index 1a4b4a8312..c6d9204e4e 100644 --- a/src/commands/context.ts +++ b/src/commands/context.ts @@ -26,6 +26,9 @@ import { COMMAND_REGISTRY } from '../core/completions/command-registry.js'; import { COMMON_FLAGS } from '../core/completions/shared-flags.js'; import { emitFailure, printJson } from './shared-output.js'; import { gatherRelationshipData } from './shared-gather.js'; +import { listSchemasWithInfo } from '../core/artifact-graph/index.js'; +import { listInitiativeSummaries } from '../core/initiatives.js'; +import { schemasFetchRecipe, initiativesFetchRecipe } from '../core/references.js'; const FAILURE_PAYLOAD = { root: null, members: [] }; @@ -59,6 +62,42 @@ function memberLine(member: WorkingSetMember): string { return ` ${member.id} ${member.path}`; } +/** + * Enrich available referenced-store members with the store's own custom + * artifact types and initiatives, so `context` reports what a repo draws on + * beyond specs. Read-only; failures degrade to an unenriched member. + */ +async function enrichMembersWithStoreArtifacts( + workingSet: WorkingSet +): Promise { + for (const member of workingSet.members) { + if (member.role !== 'referenced_store' || !isAvailableMember(member)) { + continue; + } + const storeRoot = member.path as string; + try { + const artifactTypes = listSchemasWithInfo(storeRoot) + .filter((schema) => schema.source === 'project') + .map((schema) => schema.name); + if (artifactTypes.length > 0) { + member.artifactTypes = artifactTypes; + } + } catch { + // Unreadable schemas dir: leave the member unenriched. + } + try { + const initiatives = (await listInitiativeSummaries(storeRoot)).map( + (initiative) => initiative.id + ); + if (initiatives.length > 0) { + member.initiatives = initiatives; + } + } catch { + // Unreadable initiatives dir: leave the member unenriched. + } + } +} + function printHumanWorkingSet(workingSet: WorkingSet, declaredReferenceCount: number): void { const rootLabel = workingSet.root.store_id ?? path.basename(workingSet.root.path); console.log(`Working context for ${rootLabel} (${workingSet.root.path})`); @@ -79,6 +118,16 @@ function printHumanWorkingSet(workingSet: WorkingSet, declaredReferenceCount: nu if (member.fetch) { console.log(` Fetch: ${member.fetch}`); } + if (member.artifactTypes && member.artifactTypes.length > 0) { + console.log( + ` Artifact types: ${member.artifactTypes.join(', ')} (${schemasFetchRecipe(member.id)})` + ); + } + if (member.initiatives && member.initiatives.length > 0) { + console.log( + ` Initiatives: ${member.initiatives.join(', ')} (${initiativesFetchRecipe(member.id)})` + ); + } } } @@ -190,6 +239,7 @@ export function registerContextCommand(program: Command): void { } const { workingSet, declaredReferenceCount } = await gatherWorkingSet(root); + await enrichMembersWithStoreArtifacts(workingSet); if (options.json) { // The write runs FIRST: a write failure must leave stdout diff --git a/src/core/working-set.ts b/src/core/working-set.ts index 007c336801..333e9de8e9 100644 --- a/src/core/working-set.ts +++ b/src/core/working-set.ts @@ -18,6 +18,11 @@ export interface WorkingSetMember { path?: string; remote?: string; fetch?: string; + /** A referenced store's own custom artifact types (project schema names). + * Present only when the store defines some and it is available. */ + artifactTypes?: string[]; + /** A referenced store's initiative ids. Present only when it has some. */ + initiatives?: string[]; status: StoreDiagnostic[]; } diff --git a/test/commands/context.test.ts b/test/commands/context.test.ts index 709a366a1c..e736faa43e 100644 --- a/test/commands/context.test.ts +++ b/test/commands/context.test.ts @@ -103,6 +103,46 @@ describe('openspec context (4.1)', () => { expect(parseJson(declared).members).toHaveLength(2); }); + it("surfaces a referenced store's own artifact types and initiatives", async () => { + // The upstream store defines a custom artifact type and an initiative. + const schemaDir = path.join(upstream, 'openspec', 'schemas', 'team-brief'); + fs.mkdirSync(path.join(schemaDir, 'templates'), { recursive: true }); + fs.writeFileSync( + path.join(schemaDir, 'schema.yaml'), + 'name: team-brief\nversion: 1\ndescription: Our own artifacts.\n' + + 'artifacts:\n - id: brief\n generates: brief.md\n description: x\n' + + ' template: brief.md\n requires: []\n instruction: y\n' + ); + fs.writeFileSync(path.join(schemaDir, 'templates', 'brief.md'), '# Brief\n'); + const initiativeDir = path.join(upstream, 'openspec', 'initiatives', 'roadmap'); + fs.mkdirSync(initiativeDir, { recursive: true }); + fs.writeFileSync( + path.join(initiativeDir, 'initiative.yaml'), + 'title: Roadmap\nchanges: []\n' + ); + + const json = await runCLI(['context', '--json', '--store', 'team-context'], { + cwd: tempDir, + env, + }); + const upstreamMember = parseJson(json).members.find( + (member: any) => member.id === 'upstream-context' + ); + expect(upstreamMember.artifactTypes).toEqual(['team-brief']); + expect(upstreamMember.initiatives).toEqual(['roadmap']); + + const human = await runCLI(['context', '--store', 'team-context'], { + cwd: tempDir, + env, + }); + expect(human.stdout).toContain( + 'Artifact types: team-brief (openspec schemas --store upstream-context)' + ); + expect(human.stdout).toContain( + 'Initiatives: roadmap (openspec list --initiatives --store upstream-context)' + ); + }); + it('distinguishes self-reference omission from nothing declared', async () => { fs.writeFileSync( path.join(storeRoot, 'openspec', 'config.yaml'), From 44223bc1540c58fa5f3e31d832b6e45ce7ed145f Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 1 Jul 2026 15:50:39 -0500 Subject: [PATCH 10/45] docs(stores): reflect that cross-repo artifact/initiative discovery now works Guide's "Read by name" bullet now states the agent sees a store's artifact types and initiatives in its context (instruction block + openspec context), each with a summary and fetch command. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/stores-beta/initiatives.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/stores-beta/initiatives.md b/docs/stores-beta/initiatives.md index eba2a08c43..d8c192dae8 100644 --- a/docs/stores-beta/initiatives.md +++ b/docs/stores-beta/initiatives.md @@ -153,8 +153,10 @@ This is a working prototype — every command above runs today: - **Canonical-vs-shadow precedence, enforced.** A local initiative that shares an id with one in a referenced store is reported as a shadow — never silently overriding it, never blocking your local work. -- **Read by name via `references:`.** A repo declares the store once and its - agent builds against the shared plan; nothing is copied. +- **Read by name via `references:`.** A repo declares the store once, and its + coding agent sees the store's artifact types and initiatives right in its + context — in the instruction block and in `openspec context` — each with a + one-line summary and the command to fetch it. Nothing is copied. Still a prototype: the initiative folder is a light convention (an `initiative.yaml` manifest — not a revived command group), and richer rollups From 74e7360ae5fb12192d354fefcce67ef723357a50 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 1 Jul 2026 16:00:10 -0500 Subject: [PATCH 11/45] feat(stores): add `openspec new initiative` thin scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffold an initiative folder (initiative.yaml manifest + brief.md stub) under the existing `new` group — --store-aware and --title-aware, with a duplicate guard and --json. Deliberately thin: a folder and a manifest, not a revived initiative command group. Sync the store-selection guidance (new initiative joins the store-aware set), the completion registry, the registry test, and the skill-templates-parity golden hashes (feedback hash unchanged confirms the blast radius). Add new-initiative command tests + agent-contract entry. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/agent-contract.md | 3 + .../changes/initiatives-in-stores/design.md | 8 +- .../changes/initiatives-in-stores/proposal.md | 3 + .../changes/initiatives-in-stores/tasks.md | 6 + src/cli/index.ts | 18 +++ src/commands/workflow/index.ts | 3 + src/commands/workflow/new-initiative.ts | 153 ++++++++++++++++++ src/core/completions/command-registry.ts | 15 ++ .../templates/workflows/store-selection.ts | 2 +- test/commands/new-initiative.test.ts | 69 ++++++++ .../core/completions/command-registry.test.ts | 1 + .../templates/skill-templates-parity.test.ts | 66 ++++---- 12 files changed, 309 insertions(+), 38 deletions(-) create mode 100644 src/commands/workflow/new-initiative.ts create mode 100644 test/commands/new-initiative.test.ts diff --git a/docs/agent-contract.md b/docs/agent-contract.md index 8255424c12..e2d7cfd42d 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -67,6 +67,9 @@ Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id ### 4.7 `new change --json` Success: `{ "change": { "id", "path", "metadataPath", "schema" }, "root" }`. Failure: `{ "change": null, "status": [d] }`, exit 1. +### 4.7a `new initiative --json` +Success: `{ "initiative": { "id", "path", "manifestPath", "title" }, "root" }`. Failure: `{ "initiative": null, "status": [d] }`, exit 1. + ### 4.8 `archive --json` Success: `{ "archive": { "change", "archivedAs": "YYYY-MM-DD-name", "path", "specsUpdated", "totals"? }, "root" }`. Failure: `{ "archive": null, "root"?, "status": [d] }`, exit 1. JSON mode is strictly non-interactive: every prompt point becomes an `archive_*` code. diff --git a/openspec/changes/initiatives-in-stores/design.md b/openspec/changes/initiatives-in-stores/design.md index 03003e2bc2..cadbc68d24 100644 --- a/openspec/changes/initiatives-in-stores/design.md +++ b/openspec/changes/initiatives-in-stores/design.md @@ -82,7 +82,7 @@ referenced. Ship behind the existing stores beta surface. ## Open Questions -- Whether initiatives get a thin scaffold command later, or stay a plain-folder - convention for the prototype. This change assumes the latter. - -(Precedence policy is settled: canonical store, local shadow — see Decision 2.) +(Precedence policy is settled: canonical store, local shadow — see Decision 2. +Initiatives get a *thin* scaffold — `openspec new initiative ` writes the +folder + `initiative.yaml` + a `brief.md` stub, and nothing more — added under +the existing `new` group rather than reviving an `initiative` command group.) diff --git a/openspec/changes/initiatives-in-stores/proposal.md b/openspec/changes/initiatives-in-stores/proposal.md index b81d078f07..3e4030e019 100644 --- a/openspec/changes/initiatives-in-stores/proposal.md +++ b/openspec/changes/initiatives-in-stores/proposal.md @@ -47,6 +47,9 @@ heavyweight initiative command groups. - `openspec list --initiatives [--store ]` lists initiatives and rolls up the live status of the changes each one groups. +- `openspec new initiative [--store ] [--title ]` scaffolds the + folder, an `initiative.yaml` manifest, and a `brief.md` stub — a thin scaffold + under the existing `new` group, nothing more. ## Capabilities diff --git a/openspec/changes/initiatives-in-stores/tasks.md b/openspec/changes/initiatives-in-stores/tasks.md index 886ea9e99e..73d3365f5f 100644 --- a/openspec/changes/initiatives-in-stores/tasks.md +++ b/openspec/changes/initiatives-in-stores/tasks.md @@ -23,6 +23,12 @@ - [x] 3.2 Roll up the live status of the changes each initiative groups, reusing the existing `openspec list --changes` status source +## 3b. Thin scaffold + +- [x] 3b.1 Add `openspec new initiative [--store ] [--title ]` + that writes the folder + `initiative.yaml` + a `brief.md` stub (under the + existing `new` group; not a revived initiative command group) + ## 4. Proof - [x] 4.1 Update the `smoother-setup` example so it reads as an initiative in a diff --git a/src/cli/index.ts b/src/cli/index.ts index b81676dbe6..3d8263075b 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -31,12 +31,14 @@ import { templatesCommand, schemasCommand, newChangeCommand, + newInitiativeCommand, DEFAULT_SCHEMA, type StatusOptions, type InstructionsOptions, type TemplatesOptions, type SchemasOptions, type NewChangeOptions, + type NewInitiativeOptions, } from '../commands/workflow/index.js'; import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js'; import { COMMON_FLAGS } from '../core/completions/shared-flags.js'; @@ -624,6 +626,22 @@ newCmd } }); +newCmd + .command('initiative ') + .description('Scaffold an initiative folder (initiative.yaml + brief.md)') + .option('--title ', 'Human title (default: derived from the id)') + .option('--json', 'Output as JSON') + .option('--store ', STORE_OPTION_DESCRIPTION) + .addOption(hiddenStorePathOption()) + .action(async (name: string, options: NewInitiativeOptions) => { + try { + await newInitiativeCommand(name, options); + } catch (error) { + failWithError(error); + process.exit(1); + } + }); + export { program }; export function runCli(argv = process.argv): void { diff --git a/src/commands/workflow/index.ts b/src/commands/workflow/index.ts index 232b2dbe34..d7198a3d57 100644 --- a/src/commands/workflow/index.ts +++ b/src/commands/workflow/index.ts @@ -19,4 +19,7 @@ export type { SchemasOptions } from './schemas.js'; export { newChangeCommand } from './new-change.js'; export type { NewChangeOptions } from './new-change.js'; +export { newInitiativeCommand } from './new-initiative.js'; +export type { NewInitiativeOptions } from './new-initiative.js'; + export { DEFAULT_SCHEMA } from './shared.js'; diff --git a/src/commands/workflow/new-initiative.ts b/src/commands/workflow/new-initiative.ts new file mode 100644 index 0000000000..1f9e500ff1 --- /dev/null +++ b/src/commands/workflow/new-initiative.ts @@ -0,0 +1,153 @@ +/** + * New Initiative Command + * + * Scaffolds an initiative folder in the resolved OpenSpec root (or a store via + * `--store `): an `initiative.yaml` manifest plus a `brief.md` stub. This + * is a deliberately thin scaffold — a folder and a manifest — not a revival of + * the removed heavyweight initiative command group. + */ + +import { existsSync, promises as fs } from 'fs'; +import path from 'path'; +import ora from 'ora'; + +import { validateChangeName } from '../../utils/change-utils.js'; +import { + resolveRootForCommand, + toRootOutput, + withStoreFlag, + type RootOutput, +} from '../../core/root-selection.js'; +import { INITIATIVES_DIRNAME } from '../../core/initiatives.js'; +import { printJson, statusFromError } from './shared.js'; + +export interface NewInitiativeOptions { + title?: string; + store?: string; + storePath?: string; + json?: boolean; +} + +interface NewInitiativeOutput { + initiative: { + id: string; + path: string; + manifestPath: string; + title: string; + }; + root: RootOutput; +} + +/** "smoother-setup" -> "Smoother setup". */ +function titleFromId(id: string): string { + const spaced = id.replace(/-/g, ' '); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); +} + +function manifestContent(title: string): string { + return ( + '# The initiative manifest: its title and the changes it groups.\n' + + '# Status for these changes is rolled up live by `openspec list --initiatives`.\n' + + `title: ${title}\n` + + 'changes: []\n' + ); +} + +function briefContent(title: string): string { + return ( + `# Initiative: ${title}\n\n` + + '## What this is\n\n' + + '\n\n' + + '## Why these changes go together\n\n' + + '\n\n' + + '## The changes it groups\n\n' + + 'List change ids in `initiative.yaml`. See live status with ' + + '`openspec list --initiatives`.\n' + ); +} + +export async function newInitiativeCommand( + name: string | undefined, + options: NewInitiativeOptions +): Promise { + const spinner = options.json ? undefined : ora(); + + try { + if (!name) { + throw new Error('Missing required argument '); + } + + const validation = validateChangeName(name); + if (!validation.valid) { + throw new Error(validation.error); + } + + const root = await resolveRootForCommand(options, { + json: options.json, + failurePayload: { initiative: null }, + }); + if (!root) { + return; + } + + const initiativeDir = path.join( + root.path, + 'openspec', + INITIATIVES_DIRNAME, + name + ); + + if (existsSync(initiativeDir)) { + throw new Error( + `Initiative '${name}' already exists at ${initiativeDir}` + ); + } + + const title = options.title ?? titleFromId(name); + if (spinner) { + spinner.start(`Creating initiative '${name}'...`); + } + + await fs.mkdir(initiativeDir, { recursive: true }); + const manifestPath = path.join(initiativeDir, 'initiative.yaml'); + await fs.writeFile(manifestPath, manifestContent(title), 'utf-8'); + await fs.writeFile( + path.join(initiativeDir, 'brief.md'), + briefContent(title), + 'utf-8' + ); + + const payload: NewInitiativeOutput = { + initiative: { + id: name, + path: initiativeDir, + manifestPath, + title, + }, + root: toRootOutput(root), + }; + + if (options.json) { + printJson(payload); + return; + } + + spinner?.stop(); + console.log(`Created initiative '${name}' at ${initiativeDir}/`); + console.log( + `Next: add change ids to ${path.join(name, 'initiative.yaml')}, then ` + + withStoreFlag(root, 'openspec list --initiatives') + ); + } catch (error) { + spinner?.stop(); + if (options.json) { + printJson({ + initiative: null, + status: [statusFromError(error)], + }); + process.exitCode = 1; + return; + } + throw error; + } +} diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 6ecee9bb3e..fd03d0ccde 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -250,6 +250,21 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ COMMON_FLAGS.store, ], }, + { + name: 'initiative', + description: 'Scaffold an initiative folder (initiative.yaml + brief.md)', + acceptsPositional: true, + positionals: [{ name: 'name' }], + flags: [ + { + name: 'title', + description: 'Human title (default: derived from the id)', + takesValue: true, + }, + COMMON_FLAGS.json, + COMMON_FLAGS.store, + ], + }, ], }, { diff --git a/src/core/templates/workflows/store-selection.ts b/src/core/templates/workflows/store-selection.ts index 1c39b38323..cbdfa66512 100644 --- a/src/core/templates/workflows/store-selection.ts +++ b/src/core/templates/workflows/store-selection.ts @@ -4,4 +4,4 @@ * Interpolated into every workflow's instructions so generated skills * consistently teach how to target a registered store with `--store `. */ -export const STORE_SELECTION_GUIDANCE = `**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run \`openspec store list --json\` to discover registered store ids, then pass \`--store \` on the commands that act on a selected root (\`new change\`, \`status\`, \`instructions\`, \`list\`, \`show\`, \`validate\`, \`archive\`, \`doctor\`, \`context\`, \`schemas\`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local \`openspec/\` root.`; +export const STORE_SELECTION_GUIDANCE = `**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run \`openspec store list --json\` to discover registered store ids, then pass \`--store \` on the commands that act on a selected root (\`new change\`, \`new initiative\`, \`status\`, \`instructions\`, \`list\`, \`show\`, \`validate\`, \`archive\`, \`doctor\`, \`context\`, \`schemas\`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local \`openspec/\` root.`; diff --git a/test/commands/new-initiative.test.ts b/test/commands/new-initiative.test.ts new file mode 100644 index 0000000000..1521e320ac --- /dev/null +++ b/test/commands/new-initiative.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { runCLI } from '../helpers/run-cli.js'; +import { createOpenSpecRoot } from '../helpers/openspec-fixtures.js'; + +describe('openspec new initiative', () => { + let tempDir: string; + let env: NodeJS.ProcessEnv; + + beforeEach(() => { + tempDir = fs.realpathSync.native( + fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-new-initiative-')) + ); + createOpenSpecRoot(tempDir); + env = { + XDG_DATA_HOME: path.join(tempDir, 'data'), + OPEN_SPEC_INTERACTIVE: '0', + OPENSPEC_TELEMETRY: '0', + }; + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function initiativePath(id: string, ...rest: string[]): string { + return path.join(tempDir, 'openspec', 'initiatives', id, ...rest); + } + + it('scaffolds a manifest and brief, derives a title, and lists it', async () => { + const created = await runCLI(['new', 'initiative', 'smoother-setup'], { + cwd: tempDir, + env, + }); + expect(created.exitCode).toBe(0); + expect(fs.existsSync(initiativePath('smoother-setup', 'initiative.yaml'))).toBe(true); + expect(fs.existsSync(initiativePath('smoother-setup', 'brief.md'))).toBe(true); + expect( + fs.readFileSync(initiativePath('smoother-setup', 'initiative.yaml'), 'utf-8') + ).toContain('title: Smoother setup'); + + const list = await runCLI(['list', '--initiatives'], { cwd: tempDir, env }); + expect(list.stdout).toContain('smoother-setup'); + }); + + it('honors an explicit --title and emits JSON', async () => { + const created = await runCLI( + ['new', 'initiative', 'payments', '--title', 'Payments Revamp', '--json'], + { cwd: tempDir, env } + ); + expect(created.exitCode).toBe(0); + const payload = JSON.parse(created.stdout); + expect(payload.initiative.id).toBe('payments'); + expect(payload.initiative.title).toBe('Payments Revamp'); + expect( + fs.readFileSync(initiativePath('payments', 'initiative.yaml'), 'utf-8') + ).toContain('title: Payments Revamp'); + }); + + it('refuses to overwrite an existing initiative', async () => { + await runCLI(['new', 'initiative', 'dup'], { cwd: tempDir, env }); + const second = await runCLI(['new', 'initiative', 'dup'], { cwd: tempDir, env }); + expect(second.exitCode).toBe(1); + expect(second.stderr + second.stdout).toContain('already exists'); + }); +}); diff --git a/test/core/completions/command-registry.test.ts b/test/core/completions/command-registry.test.ts index 2e28ed6ccf..55b3ebbeb4 100644 --- a/test/core/completions/command-registry.test.ts +++ b/test/core/completions/command-registry.test.ts @@ -171,6 +171,7 @@ describe('command completion registry', () => { 'instructions', 'list', 'new change', + 'new initiative', 'schemas', 'show', 'status', diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index d135e3bea8..c2cae91e32 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -35,43 +35,43 @@ import { import { STORE_SELECTION_GUIDANCE } from '../../../src/core/templates/workflows/store-selection.js'; const EXPECTED_FUNCTION_HASHES: Record = { - getExploreSkillTemplate: '26675478b220715bafe3749311db95677043afc85da4dd01c53726a83f198704', - getNewChangeSkillTemplate: 'a647a6602e361cda6a5277ee8195c76cc59c6105c155731bb67ae61bd14c627f', - getContinueChangeSkillTemplate: '03d2a37c9a703a379d4df38633009e63f30f26df78c13e5e259001a4e0391c76', - getApplyChangeSkillTemplate: 'dfbf04bd25ffedba1f8b764623a3c91e0e6f43ed14de97d7642421944ac57ac1', - getFfChangeSkillTemplate: 'bf6f4918e96f681922b715338b56dba7a60a46051e3fbaf2ef2d026e51eed07c', - getSyncSpecsSkillTemplate: '8af60d91a626e4751d6a2aef7e47b1522daa1db01c213386fc206a54ce176ab1', - getOnboardSkillTemplate: 'eb746e0f6e720794f2565e487a8bb7e50273e4b8494308b00e7afeed2c31a5e2', - getOpsxExploreCommandTemplate: '1a99984ace5e8ae76a05d357c04a2c6e4b13407451f4545534d4ce95d507bb54', - getOpsxNewCommandTemplate: 'd761624274af2856e60096847dc9fab4beaec0ee55f49ee1e885d3af0496570f', - getOpsxContinueCommandTemplate: 'bd7776b401467c98b07ead76a6965802e99dc150d59804c1014e7ef5f7f89fb3', - getOpsxApplyCommandTemplate: '0a65c9b1fa9b00953fa8c68bf3ba03e69b7d8167376b1cbf656d151c23a067b4', - getOpsxFfCommandTemplate: '610a96f70073eb6643f1387723dec01ffbfa75a2e48ddd471845d47fc1183378', - getArchiveChangeSkillTemplate: '6457b47ef91bbb964e434725ca845851ed69497a5f770c4ef9713019fd1378ae', - getBulkArchiveChangeSkillTemplate: '0ce6933682e5f74b8d8b3fd49270957d6c14572eb0490eb65ad63628152e865e', - getOpsxSyncCommandTemplate: 'f50af3e913e32269b5b17bfef71f4f2175c327b3546f848cff9e7f8e017df3bd', - getVerifyChangeSkillTemplate: '9ec56494eac8d7970f985c00938987f784ee3402e407e239004e6da7d0af1896', - getOpsxArchiveCommandTemplate: 'eaa988fb7eacfd8d27c89b0dbac75bdf34ae45f11dccedf5a30caf16dfe0ac80', - getOpsxOnboardCommandTemplate: 'f5d6072c4c6d0c9e2704c469d06f2d56f638c1450088df644f88f013af4074d4', - getOpsxBulkArchiveCommandTemplate: 'be07bc0d7ea413aebaad181ee22dc856bbfc6210fe394310b6c8b552ee891bc0', - getOpsxVerifyCommandTemplate: 'b4a5717c25883ca74dc8da091aef2432492e2a377c8cd9c1a0369b6b854dc32c', - getOpsxProposeSkillTemplate: 'ad11a374aa8f3978a93af3a2d5b4cea736d6a5f7b3f913b38a2b34aeeb2bec21', - getOpsxProposeCommandTemplate: '8aa4d2e0ca201ad8e913e8d490223063cef9c2a7e2b7a464d5aa0452540ccc09', + getExploreSkillTemplate: '48cbd02b12009c0e573aeca55dd6100b8b6ce455bd8bdd066bbccd4c729b03f7', + getNewChangeSkillTemplate: 'e0d3a0772dfea90c04ae5e8d4208f8e7b930e6e8b13750ce451494688ec15217', + getContinueChangeSkillTemplate: '11c19058472441295ddf608ded7a4b5fb2ad61ecba40e23b34d307fe59e55919', + getApplyChangeSkillTemplate: '8fba9936ca420c6fd58e193426b96090e7c9fb17fe3f61a634f0961528e59b19', + getFfChangeSkillTemplate: 'cf2488c33e0e0887ef7796e14071f18dccdd7c3660589d909108fdeabd5a2658', + getSyncSpecsSkillTemplate: 'd6c1318bf4be083854cc1614f3a8173305f882ad3ad9568e6e716de94217648a', + getOnboardSkillTemplate: '70a35eea8d6307cd6f7ab5846d5ad332b84d4b14027270c9c424c3b017676e6b', + getOpsxExploreCommandTemplate: 'b747835aab935e6de79ffb1ec57600b70b007281ea516e0cae3c8b0e17fb3fa9', + getOpsxNewCommandTemplate: 'be163b04b8af25cf33a17bb5f3e4c36febec9b74a381670e57b403dd0529cacf', + getOpsxContinueCommandTemplate: '571de5710023b5a17f4e3a37e03d9a7af90fedce88552bfc43c9dff82e6bbe28', + getOpsxApplyCommandTemplate: 'c46308f178f29d8ce66c86f2848cd182c1ee884c421a67d3e9cf644762f9f8cc', + getOpsxFfCommandTemplate: 'd2e94a55f1597959cf2f65e3fc5a4c587676e9de2c7d7a84edf46a01d86b292e', + getArchiveChangeSkillTemplate: 'ba42fde5ef6ee385edcdb4c25d66cee1aa7f701b6c4ad6fbcb0d846d80711393', + getBulkArchiveChangeSkillTemplate: '25698132be5b11118896029828d9d85645653354383dc2e9dea0fd334c49767a', + getOpsxSyncCommandTemplate: '558ad519502a0774447fab1322424754474bcfcb03bf53292577d129cf8d14fe', + getVerifyChangeSkillTemplate: 'cbc5dcb9c7814f95740f502a7d8fdf3063ee4935762a71502efefc36f107639d', + getOpsxArchiveCommandTemplate: 'dfe3ce852ac3b3af2119d99fa6fe0c2f0b543bca783c4b19c5ddd7c0cdb68319', + getOpsxOnboardCommandTemplate: '83edd627153a5280356abfeba111d947b9c13ffd34f8ff9446931f041584df1f', + getOpsxBulkArchiveCommandTemplate: '01dc410f437c49f606778c58493023676d432353698113c501c5cb910f95ec16', + getOpsxVerifyCommandTemplate: '979bac81bf9c3ae054d7b7dea2c0d8b5d6d55d2eee51709281effb806ee280cd', + getOpsxProposeSkillTemplate: '1a46a9da3ed6ce1b8c0733d52e63f267a7dad6907adda4f7a329b47b27807a00', + getOpsxProposeCommandTemplate: '9218180a6752692398eac69dc960099960680bb9dbd363ca6c9295f9c5428b7c', getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d', }; const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { - 'openspec-explore': '0b7f81479edf27f85eb8a7deffb37aa6b9782adb1203ac1b541ce7e85b03fc64', - 'openspec-new-change': '81f414bfce1e11931b93d87ccadd0674ec460789bfe4079c73f483358a960859', - 'openspec-continue-change': '987043d62f6703f01258b0d09c0b9fd0481d845d6d738049d62dde5dfdd5160c', - 'openspec-apply-change': '3ad97a4a515021299002eb48279a2e5ee9ea77a9dd251a7156193f5263b0de3f', - 'openspec-ff-change': '30e8e5cc2e73f58699ccefad6f941bc8d8b9c5d0d77b12ef5d3140e220aa6079', - 'openspec-sync-specs': 'defce99383e7269a691cccf1577139bded63b6d3136055eb55b27847aa915602', - 'openspec-archive-change': 'fc9ecf88d9855e36a157f42518e9533b9df35b5370c20413931cef74e5bd353e', - 'openspec-bulk-archive-change': 'bfb25fdacb3bb2959535b79e6d321b0319d43cbde52614337f1318a065929b6f', - 'openspec-verify-change': '2aa862e0f32bf85d1a073629a1dce44dac7c005bf76ba2f63fd19b0953228f30', - 'openspec-onboard': 'abd8b125dd67edb482a6fcba2304ea4327cc3ac3689eea68ee805e5913065523', - 'openspec-propose': 'd4a35ab16a2ca89f65c6aa1cc931cba21c3f01d5de356855a027d114b92047ec', + 'openspec-explore': 'a453c7d19485ca751186c1373ad598b0077e7c208355e9b386dd8c8494a63553', + 'openspec-new-change': '2e6c2f1adfc2f1c717055f3032cc76c1403436c57572fc8be64cd3125bd2903f', + 'openspec-continue-change': '3e2df28eb3f8988dcc75b9a96125f36aa8558058e17b39e363d929a6217a70ae', + 'openspec-apply-change': '4ff8ddf628866be3b79cf116d3a29ac9d1d87961387de130220498c9fab0e74d', + 'openspec-ff-change': '3a35d39f2e925df1d91921e1c78fd3b67288b51888f61dff299deabcbba2cc61', + 'openspec-sync-specs': '536408e97bf275a3a20fea3bfe34bbf9e6e184dac08812064f90071bff13dcd7', + 'openspec-archive-change': '427ac51eb00a4572178b8924db7f986b24b1f0bcd9a59bdc687ef5bc69ac7392', + 'openspec-bulk-archive-change': '89add53ae210265287b661ca64f1c4f536457d1873cfa68c727e13d85651d8e5', + 'openspec-verify-change': '8ebcd05be99c3742d0f7eec6779dd211f7318c3af22a519521a3d9e6b1f74f1d', + 'openspec-onboard': 'dd51c8620d4813c4dab1dda7d7a0d889b1240cee31b2cdc03fcdd667e974ea6e', + 'openspec-propose': 'adf8d619738ffec65cf9588f44bccf8c9c89446069a46e6f82f9ec88213c9c95', }; // Intentionally excludes getFeedbackSkillTemplate: this list only models templates From dec96522ee204f1768a81c205c2f4c2dc0e54f83 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 1 Jul 2026 16:01:42 -0500 Subject: [PATCH 12/45] docs(stores): use `openspec new initiative` in the guide's happy path Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/stores-beta/initiatives.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/stores-beta/initiatives.md b/docs/stores-beta/initiatives.md index d8c192dae8..52178a41ec 100644 --- a/docs/stores-beta/initiatives.md +++ b/docs/stores-beta/initiatives.md @@ -45,15 +45,18 @@ acme-plans (a store: planning in its own repo) *A team is making setup smoother. The work spans a CLI repo and a docs repo. The plan should not live in either one.* -### 1. Put the plan in a store +### 1. Put the plan in a store, and start an initiative -A store is planning in its own git repo. Stand one up once: +A store is planning in its own git repo. Stand one up once, then scaffold the +initiative inside it: ```bash openspec store setup acme-plans --path ~/openspec/acme-plans +openspec new initiative smoother-setup --store acme-plans --title "Smoother setup" ``` -Now the plan has a home of its own — not buried in anyone's code. +Now the plan has a home of its own — an `initiative.yaml` and a `brief.md`, +not buried in anyone's code. ### 2. Define your own artifacts From f3a57675305a32217d8f2f5290f8827fb96c7f44 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 1 Jul 2026 19:03:16 -0500 Subject: [PATCH 13/45] feat(stores): roll up initiative status across repos (cross-repo) An initiative.yaml change entry may now be `{ id, store }`, so an initiative in a planning store aggregates the live task status of changes implemented in the code repos where they actually live. list --initiatives resolves each change against its named store's root (or the initiative's own root for plain ids), sums the rollup, shows a per-change breakdown with each change's home, marks a missing change/store as not-found, and reports the distinct `stores` span. Plain-string entries (the solo/local case) are unchanged. Adds changeStatuses/stores to the JSON, a cross-repo breakdown in the human output, tests (cross-repo + unregistered-store not-found), an agent-contract update, a spec scenario, and a "solo vs across repos" section in the guide. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/agent-contract.md | 2 +- docs/stores-beta/initiatives.md | 62 ++++++- .../specs/store-aware-artifacts/spec.md | 18 +- .../changes/initiatives-in-stores/tasks.md | 4 + src/cli/index.ts | 34 +++- src/core/initiatives.ts | 172 +++++++++++++----- test/core/initiatives.test.ts | 65 +++++++ 7 files changed, 308 insertions(+), 49 deletions(-) diff --git a/docs/agent-contract.md b/docs/agent-contract.md index e2d7cfd42d..d733cb9245 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -45,7 +45,7 @@ Successful JSON payloads embed the root: ## 4. Command JSON shapes ### 4.1 `list --json` -`{ "changes": [ { "name", "completedTasks", "totalTasks", "lastModified", "status": "no-tasks"|"complete"|"in-progress" } ], "root": RootOutput }` — note the per-change `status` is a string enum here. `--specs`: `{ "specs": [ { "id", "requirementCount" } ], "root" }`. `--initiatives`: `{ "initiatives": [ { "id", "title", "changes": [], "changesComplete", "changesTotal", "tasksComplete", "tasksTotal", "shadowsStore"? } ], "root" }` — `shadowsStore` is the id of a referenced store whose canonical initiative this local one shadows (absent when there is no collision). +`{ "changes": [ { "name", "completedTasks", "totalTasks", "lastModified", "status": "no-tasks"|"complete"|"in-progress" } ], "root": RootOutput }` — note the per-change `status` is a string enum here. `--specs`: `{ "specs": [ { "id", "requirementCount" } ], "root" }`. `--initiatives`: `{ "initiatives": [ { "id", "title", "changes": [string], "changeStatuses": [ { "id", "store"?, "completedTasks", "totalTasks", "state": "complete"|"in-progress"|"no-tasks"|"not-found" } ], "changesComplete", "changesTotal", "tasksComplete", "tasksTotal", "stores": [string], "shadowsStore"? } ], "root" }`. Each change lives in the initiative's own root, or — when its manifest entry is `{ id, store }` — in that registered store's root; `changeStatuses[].store` names it, `stores` is the distinct set (cross-repo), and `not-found` means the change (or its named store) is missing. `shadowsStore` is the id of a referenced store whose canonical initiative this local one shadows. ### 4.2 `show --json` Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id", "title", "overview", "requirementCount", "requirements": [...], "metadata": { "version", "format", "sourcePath"? }, "root" }`. diff --git a/docs/stores-beta/initiatives.md b/docs/stores-beta/initiatives.md index 52178a41ec..fd80a77378 100644 --- a/docs/stores-beta/initiatives.md +++ b/docs/stores-beta/initiatives.md @@ -40,6 +40,54 @@ acme-plans (a store: planning in its own repo) decisions/ the calls we made ← your own artifact types ``` +## Two ways to use it: solo, or across repos + +The *same* initiative works whether you are one person in one repo or a team +across many. The only thing that changes is where the grouped changes live. + +**Solo — one repo, one machine.** Keep the initiative in your own repo. Its +changes are your normal changes, right beside it. The manifest just lists them: + +```yaml +# openspec/initiatives/smoother-setup/initiative.yaml +title: Smoother setup +changes: + - simplify-skill-installation + - add-global-install-scope +``` + +```text +$ openspec list --initiatives + smoother-setup 2/4 changes complete +``` + +Nothing else to set up. This is the whole story for a solo dev. + +**Across repos — a store, a team.** The plan lives in a store; the work is +implemented in several code repos. Each change entry says which repo it lives +in (any registered store id), and the status rolls up across all of them: + +```yaml +# team-plans/openspec/initiatives/payments-launch/initiative.yaml +title: Payments launch +changes: + - { id: add-payments-api, store: api-server } + - { id: add-payments-ui, store: web-app } + - update-docs # lives here, in the store +``` + +```text +$ openspec list --initiatives --store team-plans + payments-launch 2/5 changes complete across api-server, web-app + ✓ add-payments-api api-server 5/5 tasks + · add-payments-ui web-app 1/3 tasks + ✓ update-docs here 2/2 tasks + ? add-refunds web-app not found +``` + +One command answers "where does the whole effort stand?" — across every repo, +with each change's home shown next to it. + ## The happy path *A team is making setup smoother. The work spans a CLI repo and a docs repo. @@ -150,9 +198,12 @@ This is a working prototype — every command above runs today: - **Your own artifact types, discoverable across the store boundary.** `openspec schemas --store ` lists a store's custom schemas from any repo. -- **Initiatives with live, rolled-up status.** `openspec list --initiatives` - reads each initiative's manifest and rolls up the status of the changes it - groups, from the same source as `openspec list --changes`. +- **Initiatives with live, rolled-up status — solo or across repos.** + `openspec list --initiatives` reads each initiative's manifest and rolls up + the status of the changes it groups, from the same source as + `openspec list --changes`. When a change lives in another repo (`{ id, store }`), + the rollup resolves it there and aggregates — one number for the whole effort, + with each change's home shown next to it. - **Canonical-vs-shadow precedence, enforced.** A local initiative that shares an id with one in a referenced store is reported as a shadow — never silently overriding it, never blocking your local work. @@ -162,8 +213,9 @@ This is a working prototype — every command above runs today: one-line summary and the command to fetch it. Nothing is copied. Still a prototype: the initiative folder is a light convention (an -`initiative.yaml` manifest — not a revived command group), and richer rollups -(tasks across every referencing repo) are the next step. +`initiative.yaml` manifest — not a revived command group). A change in another +repo is named explicitly (`{ id, store }`); OpenSpec does not auto-discover +which repos a store's changes live in. Start simple: one initiative folder in a store, grouping a few real changes, with the artifacts your team already uses. diff --git a/openspec/changes/initiatives-in-stores/specs/store-aware-artifacts/spec.md b/openspec/changes/initiatives-in-stores/specs/store-aware-artifacts/spec.md index 841b8b729b..86f000e13a 100644 --- a/openspec/changes/initiatives-in-stores/specs/store-aware-artifacts/spec.md +++ b/openspec/changes/initiatives-in-stores/specs/store-aware-artifacts/spec.md @@ -50,10 +50,26 @@ work. ### Requirement: Initiative status rollup The system SHALL list initiatives and roll up the live status of the changes each -initiative groups. +initiative groups, resolving a change against a named store when the manifest +entry specifies one and against the initiative's own root otherwise. #### Scenario: Listing initiatives in a store - **WHEN** a user runs `openspec list --initiatives --store acme-plans` - **THEN** the system lists each initiative in the store - **AND** each initiative shows the rolled-up status of the changes it groups + +#### Scenario: Rolling up changes that live in other repos + +- **WHEN** an initiative's manifest groups changes with `{ id, store }` entries + that name other registered stores +- **THEN** the system resolves each change against its named store's root and + aggregates the task status across all of them +- **AND** the output shows each change's home and marks a change whose store or + directory is missing as not found + +#### Scenario: Solo initiative with changes in its own root + +- **WHEN** an initiative's manifest lists changes as plain ids +- **THEN** the system resolves each change against the initiative's own root +- **AND** no external stores are reported for that initiative diff --git a/openspec/changes/initiatives-in-stores/tasks.md b/openspec/changes/initiatives-in-stores/tasks.md index 73d3365f5f..ed77204f0d 100644 --- a/openspec/changes/initiatives-in-stores/tasks.md +++ b/openspec/changes/initiatives-in-stores/tasks.md @@ -22,6 +22,10 @@ - [x] 3.1 Add `openspec list --initiatives [--store ]` - [x] 3.2 Roll up the live status of the changes each initiative groups, reusing the existing `openspec list --changes` status source +- [x] 3.3 Cross-repo rollup: a manifest change entry may be `{ id, store }`, so + an initiative in a planning store aggregates status across the code repos + where its changes live (per-change breakdown + `stores` span; unresolved + changes reported as `not-found`) ## 3b. Thin scaffold diff --git a/src/cli/index.ts b/src/cli/index.ts index 3d8263075b..703005af71 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -82,10 +82,42 @@ async function renderInitiatives( initiative.changesTotal === 0 ? 'no changes' : `${initiative.changesComplete}/${initiative.changesTotal} changes complete`; + const across = + initiative.stores.length > 0 + ? ` across ${initiative.stores.join(', ')}` + : ''; const shadow = initiative.shadowsStore ? ` (shadows: ${initiative.shadowsStore})` : ''; - console.log(` ${name} ${rollup.padEnd(22)}${shadow}`); + console.log(` ${name} ${rollup.padEnd(22)}${across}${shadow}`); + + // Cross-repo initiatives show where each change lives — the proof that + // one effort's status is rolled up across several repos. + if (initiative.stores.length > 0 && initiative.changeStatuses.length > 0) { + const changeWidth = Math.max( + ...initiative.changeStatuses.map((c) => c.id.length) + ); + for (const change of initiative.changeStatuses) { + const mark = + change.state === 'complete' + ? '✓' + : change.state === 'not-found' + ? '?' + : change.state === 'no-tasks' + ? '–' + : '·'; + const where = change.store ?? 'here'; + const tasks = + change.state === 'not-found' + ? 'not found' + : change.totalTasks === 0 + ? 'no tasks' + : `${change.completedTasks}/${change.totalTasks} tasks`; + console.log( + ` ${mark} ${change.id.padEnd(changeWidth)} ${where.padEnd(14)} ${tasks}` + ); + } + } } } diff --git a/src/core/initiatives.ts b/src/core/initiatives.ts index 607b5907cf..3da134db08 100644 --- a/src/core/initiatives.ts +++ b/src/core/initiatives.ts @@ -14,7 +14,7 @@ * reports schema shadowing. */ -import { promises as fs } from 'fs'; +import { existsSync, promises as fs } from 'fs'; import path from 'path'; import { parse as parseYaml } from 'yaml'; @@ -29,23 +29,55 @@ import { getStoreRootForBackend } from './store/registry.js'; export const INITIATIVES_DIRNAME = 'initiatives'; const INITIATIVE_MANIFEST = 'initiative.yaml'; +export type InitiativeChangeState = + | 'complete' + | 'in-progress' + | 'no-tasks' + | 'not-found'; + +/** The live status of one change an initiative groups. */ +export interface InitiativeChangeStatus { + id: string; + /** Registered store id where the change lives; absent = the initiative's + * own root (the solo/local case). */ + store?: string; + completedTasks: number; + totalTasks: number; + state: InitiativeChangeState; +} + export interface InitiativeInfo { id: string; title: string; - /** Change ids this initiative groups. */ + /** Change ids this initiative groups (in manifest order). */ changes: string[]; + /** Per-change live status, including where each change lives. */ + changeStatuses: InitiativeChangeStatus[]; /** Rolled-up status of the grouped changes. */ changesComplete: number; changesTotal: number; tasksComplete: number; tasksTotal: number; + /** Distinct store ids this initiative's changes span (cross-repo). Empty + * when every change lives in the initiative's own root. */ + stores: string[]; /** Set when a same-id canonical initiative exists in a referenced store. */ shadowsStore?: string; } +/** A change entry: `""` (own root) or `{ id, store }` (in a store). */ +type ManifestChange = string | { id?: string; store?: string }; + interface InitiativeManifest { title?: string; - changes?: string[]; + changes?: ManifestChange[]; +} + +function normalizeChange(entry: ManifestChange): { id: string; store?: string } { + if (typeof entry === 'string') { + return { id: entry }; + } + return { id: entry.id ?? '', ...(entry.store ? { store: entry.store } : {}) }; } function initiativesDir(root: string): string { @@ -88,60 +120,101 @@ async function listInitiativeIds(root: string): Promise { } /** - * Resolves the on-disk roots of the stores this root references and that are - * registered on this machine. Read-only; unregistered/invalid references are - * skipped (they surface through `openspec doctor`, not here). + * Every registered store id → its on-disk root. Read once and shared by the + * shadow lookup and per-change resolution. Unusable backends are skipped + * (doctor reports them). */ -async function referencedStoreRoots( - root: string, +async function buildStoreRootMap( globalDataDir?: string -): Promise> { - const config = readProjectConfig(root); - const references = config?.references ?? []; - if (references.length === 0) { - return []; - } - +): Promise> { const registry = await readStoreRegistryState( globalDataDir ? { globalDataDir } : {} ); const entries = registry ? listStoreRegistryEntries(registry) : []; - - const roots: Array<{ id: string; root: string }> = []; - for (const reference of references) { - const entry = entries.find((candidate) => candidate.id === reference.id); - if (!entry) continue; + const map = new Map(); + for (const entry of entries) { try { - roots.push({ id: reference.id, root: getStoreRootForBackend(entry.backend) }); + map.set(entry.id, getStoreRootForBackend(entry.backend)); } catch { - // Unusable backend — skipped; doctor reports it. + // Unusable backend — skipped. } } - return roots; + return map; } /** - * Builds the shadow lookup: for each referenced store, the set of initiative - * ids it defines. A local initiative whose id appears here shadows the store's - * canonical one. + * Builds the shadow lookup: for each store this root *references* (and that is + * registered), the set of initiative ids it defines. A local initiative whose + * id appears here shadows the store's canonical one. */ async function buildShadowLookup( root: string, - globalDataDir?: string + storeRoots: Map ): Promise> { const lookup = new Map(); - const stores = await referencedStoreRoots(root, globalDataDir); - for (const store of stores) { - for (const id of await listInitiativeIds(store.root)) { + const config = readProjectConfig(root); + const references = config?.references ?? []; + for (const reference of references) { + const storeRoot = storeRoots.get(reference.id); + if (!storeRoot) continue; + for (const id of await listInitiativeIds(storeRoot)) { // First referenced store to define an id is named as canonical. if (!lookup.has(id)) { - lookup.set(id, store.id); + lookup.set(id, reference.id); } } } return lookup; } +/** + * Resolves one change's live status against the changes dir it lives in. + * A missing change dir is reported as `not-found` (distinct from a change + * that exists but has no tasks yet). + */ +async function resolveChangeStatus( + changeId: string, + store: string | undefined, + localChangesDir: string, + storeRoots: Map +): Promise { + let changesDir: string | null; + if (store) { + const storeRoot = storeRoots.get(store); + changesDir = storeRoot + ? path.join(storeRoot, 'openspec', 'changes') + : null; // named store not registered on this machine + } else { + changesDir = localChangesDir; + } + + const base: InitiativeChangeStatus = { + id: changeId, + ...(store ? { store } : {}), + completedTasks: 0, + totalTasks: 0, + state: 'not-found', + }; + + if (changesDir === null || !existsSync(path.join(changesDir, changeId))) { + return base; + } + + const progress = await getTaskProgressForChange(changesDir, changeId); + const state: InitiativeChangeState = + progress.total === 0 + ? 'no-tasks' + : progress.completed === progress.total + ? 'complete' + : 'in-progress'; + return { + ...base, + completedTasks: progress.completed, + totalTasks: progress.total, + state, + }; +} + /** * Lightweight id + title for each initiative at a root — no change rollup and * no shadow detection. Used to index a referenced store's initiatives without @@ -174,8 +247,9 @@ export async function listInitiatives( return []; } - const changesDir = path.join(root, 'openspec', 'changes'); - const shadows = await buildShadowLookup(root, options.globalDataDir); + const localChangesDir = path.join(root, 'openspec', 'changes'); + const storeRoots = await buildStoreRootMap(options.globalDataDir); + const shadows = await buildShadowLookup(root, storeRoots); const initiatives: InitiativeInfo[] = []; for (const id of ids) { @@ -185,28 +259,44 @@ export async function listInitiatives( continue; } - const changes = manifest.changes ?? []; + const entries = (manifest.changes ?? []) + .map(normalizeChange) + .filter((entry) => entry.id.length > 0); + + const changeStatuses: InitiativeChangeStatus[] = []; let changesComplete = 0; let tasksComplete = 0; let tasksTotal = 0; + const stores = new Set(); - for (const changeId of changes) { - const progress = await getTaskProgressForChange(changesDir, changeId); - tasksComplete += progress.completed; - tasksTotal += progress.total; - if (progress.total > 0 && progress.completed === progress.total) { + for (const entry of entries) { + const status = await resolveChangeStatus( + entry.id, + entry.store, + localChangesDir, + storeRoots + ); + changeStatuses.push(status); + tasksComplete += status.completedTasks; + tasksTotal += status.totalTasks; + if (status.state === 'complete') { changesComplete += 1; } + if (entry.store) { + stores.add(entry.store); + } } initiatives.push({ id, title: manifest.title ?? id, - changes, + changes: entries.map((entry) => entry.id), + changeStatuses, changesComplete, - changesTotal: changes.length, + changesTotal: entries.length, tasksComplete, tasksTotal, + stores: [...stores].sort(), ...(shadows.has(id) ? { shadowsStore: shadows.get(id) } : {}), }); } diff --git a/test/core/initiatives.test.ts b/test/core/initiatives.test.ts index b4185e0e99..4196a46f8f 100644 --- a/test/core/initiatives.test.ts +++ b/test/core/initiatives.test.ts @@ -83,6 +83,71 @@ describe('listInitiatives', () => { expect(result.tasksTotal).toBe(4); expect(result.tasksComplete).toBe(3); expect(result.shadowsStore).toBeUndefined(); + // Solo/local: no external stores, changes resolve in the initiative's root. + expect(result.stores).toEqual([]); + expect(result.changeStatuses.map((c) => c.store)).toEqual([ + undefined, + undefined, + ]); + }); + + it('rolls up change status across stores named per change (cross-repo)', async () => { + await registerStore('api-server'); + await registerStore('web-app'); + change('stores/api-server', 'add-payments-api', '- [x] a\n- [x] b\n'); // complete + change('stores/api-server', 'add-search', '- [x] a\n- [ ] b\n'); // in-progress + change('stores/web-app', 'add-payments-ui', '- [ ] a\n'); // in-progress + change('app', 'update-docs', '- [x] a\n'); // complete, local to the initiative + + initiative( + 'app', + 'payments-launch', + 'title: Payments launch\n' + + 'changes:\n' + + ' - { id: add-payments-api, store: api-server }\n' + + ' - { id: add-search, store: api-server }\n' + + ' - { id: add-payments-ui, store: web-app }\n' + + ' - update-docs\n' + + ' - { id: add-refunds, store: web-app }\n' // never created -> not-found + ); + + const [info] = await listInitiatives(path.join(tempDir, 'app'), { + globalDataDir, + }); + + expect(info.changesTotal).toBe(5); + expect(info.changesComplete).toBe(2); // add-payments-api + update-docs + expect(info.tasksComplete).toBe(4); // 2 + 1 + 0 + 1 + 0 + expect(info.tasksTotal).toBe(6); // 2 + 2 + 1 + 1 + 0 + expect(info.stores).toEqual(['api-server', 'web-app']); + + const byId = Object.fromEntries( + info.changeStatuses.map((c) => [c.id, c]) + ); + expect(byId['add-payments-api'].state).toBe('complete'); + expect(byId['add-payments-api'].store).toBe('api-server'); + expect(byId['add-search'].state).toBe('in-progress'); + expect(byId['update-docs'].store).toBeUndefined(); // local + expect(byId['update-docs'].state).toBe('complete'); + expect(byId['add-refunds'].state).toBe('not-found'); // named store lacks it + }); + + it('marks a change as not-found when its named store is not registered', async () => { + change('app', 'here-change', '- [x] a\n'); + initiative( + 'app', + 'effort', + 'title: Effort\nchanges:\n - here-change\n - { id: elsewhere, store: ghost-store }\n' + ); + + const [info] = await listInitiatives(path.join(tempDir, 'app'), { + globalDataDir, + }); + + const byId = Object.fromEntries(info.changeStatuses.map((c) => [c.id, c])); + expect(byId['here-change'].state).toBe('complete'); + expect(byId['elsewhere'].state).toBe('not-found'); + expect(info.stores).toEqual(['ghost-store']); // declared, even if unresolved }); it('flags a local initiative that shadows a canonical one in a referenced store', async () => { From f0164d21cff990f20354f8c4cc401d1d7dd3ec32 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Thu, 2 Jul 2026 17:21:24 -0500 Subject: [PATCH 14/45] =?UTF-8?q?feat(stores):=20the=20plan=20folder=20?= =?UTF-8?q?=E2=80=94=20one=20clean=20prototype=20for=20work=20above=20a=20?= =?UTF-8?q?single=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plan is one folder, openspec/plan/. Numbered subfolders are ordered stages; names and contents are the user's own; unnumbered entries are context. Changes point UP with one metadata line (plan: local | ) — no manifest. `openspec list --plan [--store ]` rolls up the stages plus every change on this machine pointing at the plan, across registered repos, with live task status. Referenced stores' plan stages surface in `openspec context` and the agent instruction block. One explore-style skill (openspec-plan, /opsx:plan) drives translation stage-to-stage and syncs status back up; it is instructed to write less, not more. Solo: plan in your repo, `plan: local`. Team: same folder in a store — the store is the parent/global level, repos are children pointing up. Replaces the manifest-based initiative surfaces from earlier in this PR. Dogfooded: this repo's own openspec/plan/ groups four real changes; the change record openspec/changes/add-plan-folder validates --strict. Co-Authored-By: Claude Fable 5 --- docs/README.md | 2 +- docs/agent-contract.md | 9 +- docs/stores-beta/initiatives.md | 221 ------------- docs/stores-beta/plan.md | 108 +++++++ docs/stores-beta/user-guide.md | 2 +- .../add-global-install-scope/.openspec.yaml | 1 + openspec/changes/add-plan-folder/design.md | 50 +++ openspec/changes/add-plan-folder/proposal.md | 40 +++ .../add-plan-folder/specs/plan-folder/spec.md | 60 ++++ openspec/changes/add-plan-folder/tasks.md | 24 ++ .../.openspec.yaml | 1 + .../changes/initiatives-in-stores/design.md | 88 ----- .../changes/initiatives-in-stores/proposal.md | 71 ---- .../specs/store-aware-artifacts/spec.md | 75 ----- .../changes/initiatives-in-stores/tasks.md | 43 --- .../schema-alias-support/.openspec.yaml | 1 + .../.openspec.yaml | 1 + openspec/initiatives/smoother-setup/brief.md | 46 --- .../decisions/0001-aliases-over-rename.md | 26 -- .../decisions/0002-tool-native-paths.md | 23 -- .../smoother-setup/example-schema/schema.yaml | 36 --- .../example-schema/templates/brief.md | 21 -- .../example-schema/templates/decision.md | 18 -- .../example-schema/templates/persona.md | 13 - .../smoother-setup/initiative.yaml | 8 - .../smoother-setup/personas/new-user.md | 24 -- .../smoother-setup/personas/team-lead.md | 20 -- openspec/plan/00_goal/goal.md | 7 + openspec/plan/01_who/personas.md | 10 + openspec/plan/02_decisions/decisions.md | 7 + openspec/plan/03_changes/inventory.md | 11 + src/cli/index.ts | 123 +++---- src/commands/context.ts | 21 +- src/commands/workflow/index.ts | 3 - src/commands/workflow/new-change.ts | 3 + src/commands/workflow/new-initiative.ts | 153 --------- src/core/change-metadata/schema.ts | 3 + src/core/completions/command-registry.ts | 18 +- src/core/init.ts | 1 + src/core/initiatives.ts | 306 ------------------ src/core/plan.ts | 240 ++++++++++++++ src/core/profile-sync-drift.ts | 1 + src/core/profiles.ts | 1 + src/core/references.ts | 45 +-- src/core/shared/skill-generation.ts | 4 + src/core/shared/tool-detection.ts | 1 + src/core/templates/skill-templates.ts | 1 + src/core/templates/workflows/plan.ts | 86 +++++ .../templates/workflows/store-selection.ts | 2 +- src/core/working-set.ts | 4 +- src/utils/change-utils.ts | 2 +- test/commands/context.test.ts | 20 +- test/commands/new-initiative.test.ts | 69 ---- .../core/completions/command-registry.test.ts | 2 +- test/core/initiatives.test.ts | 196 ----------- test/core/plan.test.ts | 126 ++++++++ test/core/profiles.test.ts | 6 +- test/core/references.test.ts | 30 +- test/core/shared/skill-generation.test.ts | 12 +- .../templates/skill-templates-parity.test.ts | 74 +++-- 60 files changed, 944 insertions(+), 1676 deletions(-) delete mode 100644 docs/stores-beta/initiatives.md create mode 100644 docs/stores-beta/plan.md create mode 100644 openspec/changes/add-plan-folder/design.md create mode 100644 openspec/changes/add-plan-folder/proposal.md create mode 100644 openspec/changes/add-plan-folder/specs/plan-folder/spec.md create mode 100644 openspec/changes/add-plan-folder/tasks.md delete mode 100644 openspec/changes/initiatives-in-stores/design.md delete mode 100644 openspec/changes/initiatives-in-stores/proposal.md delete mode 100644 openspec/changes/initiatives-in-stores/specs/store-aware-artifacts/spec.md delete mode 100644 openspec/changes/initiatives-in-stores/tasks.md delete mode 100644 openspec/initiatives/smoother-setup/brief.md delete mode 100644 openspec/initiatives/smoother-setup/decisions/0001-aliases-over-rename.md delete mode 100644 openspec/initiatives/smoother-setup/decisions/0002-tool-native-paths.md delete mode 100644 openspec/initiatives/smoother-setup/example-schema/schema.yaml delete mode 100644 openspec/initiatives/smoother-setup/example-schema/templates/brief.md delete mode 100644 openspec/initiatives/smoother-setup/example-schema/templates/decision.md delete mode 100644 openspec/initiatives/smoother-setup/example-schema/templates/persona.md delete mode 100644 openspec/initiatives/smoother-setup/initiative.yaml delete mode 100644 openspec/initiatives/smoother-setup/personas/new-user.md delete mode 100644 openspec/initiatives/smoother-setup/personas/team-lead.md create mode 100644 openspec/plan/00_goal/goal.md create mode 100644 openspec/plan/01_who/personas.md create mode 100644 openspec/plan/02_decisions/decisions.md create mode 100644 openspec/plan/03_changes/inventory.md delete mode 100644 src/commands/workflow/new-initiative.ts delete mode 100644 src/core/initiatives.ts create mode 100644 src/core/plan.ts create mode 100644 src/core/templates/workflows/plan.ts delete mode 100644 test/commands/new-initiative.test.ts delete mode 100644 test/core/initiatives.test.ts create mode 100644 test/core/plan.test.ts diff --git a/docs/README.md b/docs/README.md index 8f38c02956..e00fe3110c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -83,7 +83,7 @@ That second one matters more than it looks. OpenSpec has two halves: a command l | Doc | What it gives you | |-----|-------------------| | [Stores: User Guide](stores-beta/user-guide.md) | Plan in its own repo when your work spans repos or teams | -| [Initiatives](stores-beta/initiatives.md) | Define your own planning artifacts and share them in a store every repo can read | +| [The plan folder](stores-beta/plan.md) | The way in above a single change: your stages, your artifacts, live status | | [Agent Contract](agent-contract.md) | The machine-readable CLI surfaces agents drive | ## The thirty-second version diff --git a/docs/agent-contract.md b/docs/agent-contract.md index d733cb9245..8e7568440f 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -45,7 +45,7 @@ Successful JSON payloads embed the root: ## 4. Command JSON shapes ### 4.1 `list --json` -`{ "changes": [ { "name", "completedTasks", "totalTasks", "lastModified", "status": "no-tasks"|"complete"|"in-progress" } ], "root": RootOutput }` — note the per-change `status` is a string enum here. `--specs`: `{ "specs": [ { "id", "requirementCount" } ], "root" }`. `--initiatives`: `{ "initiatives": [ { "id", "title", "changes": [string], "changeStatuses": [ { "id", "store"?, "completedTasks", "totalTasks", "state": "complete"|"in-progress"|"no-tasks"|"not-found" } ], "changesComplete", "changesTotal", "tasksComplete", "tasksTotal", "stores": [string], "shadowsStore"? } ], "root" }`. Each change lives in the initiative's own root, or — when its manifest entry is `{ id, store }` — in that registered store's root; `changeStatuses[].store` names it, `stores` is the distinct set (cross-repo), and `not-found` means the change (or its named store) is missing. `shadowsStore` is the id of a referenced store whose canonical initiative this local one shadows. +`{ "changes": [ { "name", "completedTasks", "totalTasks", "lastModified", "status": "no-tasks"|"complete"|"in-progress" } ], "root": RootOutput }` — note the per-change `status` is a string enum here. `--specs`: `{ "specs": [ { "id", "requirementCount" } ], "root" }`. `--plan`: `{ "plan": { "path", "stages": [{name,files}], "context": [string], "changes": [ { "id", "store"?, "completedTasks", "totalTasks", "state": "complete"|"in-progress"|"no-tasks" } ], "changesComplete", "changesTotal", "tasksComplete", "tasksTotal" } | null, "root" }` — `plan` is null when the root has no `openspec/plan/` folder. `changes` are discovered by scanning `.openspec.yaml` files for `plan: local` (the root's own changes) and `plan: ` (changes in other registered roots pointing at this store's plan); `changes[].store` names where a change lives, absent = the plan's own root. ### 4.2 `show --json` Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id", "title", "overview", "requirementCount", "requirements": [...], "metadata": { "version", "format", "sourcePath"? }, "root" }`. @@ -59,7 +59,7 @@ Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id ### 4.5 `instructions --json` `{ "changeName", "artifactId", "schemaName", "changeDir", "planningHome"?, "outputPath", "resolvedOutputPath", "existingOutputPaths", "description", "instruction"?, "context"?, "rules"?, "references"?: ReferenceIndexEntry[], "template", "dependencies": [{id,done,path,description}], "unlocks", "root" }`. -`ReferenceIndexEntry`: `{ "store_id", "root"?, "specs"?: [{id,summary}], "schemas"?: [{id,summary,artifacts}], "initiatives"?: [{id,summary}], "fetch"?, "status": [] }` — resolved entries carry root/specs/fetch; `schemas` (the store's own project-local artifact types) and `initiatives` are present only when the store defines them; unresolved carry store_id + warning status. Index capped at 50KB (`reference_index_truncated`). +`ReferenceIndexEntry`: `{ "store_id", "root"?, "specs"?: [{id,summary}], "schemas"?: [{id,summary,artifacts}], "plan"?: [string], "fetch"?, "status": [] }` — resolved entries carry root/specs/fetch; `schemas` (the store's own project-local artifact types) and `plan` (the store's plan stage names, in order) are present only when the store defines them; unresolved carry store_id + warning status. Index capped at 50KB (`reference_index_truncated`). ### 4.6 `instructions apply --json` `{ "changeName", "changeDir", "schemaName", "contextFiles": { "": ["/abs", ...] }, "progress": {total,complete,remaining}, "tasks": [{id,description,done}], "state": "blocked"|"all_done"|"ready", "missingArtifacts"?, "instruction", "references"?, "root" }`. @@ -67,9 +67,6 @@ Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id ### 4.7 `new change --json` Success: `{ "change": { "id", "path", "metadataPath", "schema" }, "root" }`. Failure: `{ "change": null, "status": [d] }`, exit 1. -### 4.7a `new initiative --json` -Success: `{ "initiative": { "id", "path", "manifestPath", "title" }, "root" }`. Failure: `{ "initiative": null, "status": [d] }`, exit 1. - ### 4.8 `archive --json` Success: `{ "archive": { "change", "archivedAs": "YYYY-MM-DD-name", "path", "specsUpdated", "totals"? }, "root" }`. Failure: `{ "archive": null, "root"?, "status": [d] }`, exit 1. JSON mode is strictly non-interactive: every prompt point becomes an `archive_*` code. @@ -77,7 +74,7 @@ Success: `{ "archive": { "change", "archivedAs": "YYYY-MM-DD-name", "path", "spe `{ "root": { "path", "source", "store_id"?, "healthy", "status": [] }, "store": { "id", "metadata": {present,valid,remote?}, "origin_url"?, "status": [] } | null, "references": [...], "status": [] }`. Health findings of any severity exit 0. Failure payload: `{ "root": null, "store": null, "references": [], "status": [d] }`, exit 1. ### 4.10 `context --json` -`{ "root": { "path", "source", "store_id"?, "role": "openspec_root" }, "members": [ { "role": "referenced_store", "id", "path"?, "remote"?, "fetch"?, "artifactTypes"?: [string], "initiatives"?: [string], "status": [] } ], "status": [] }`. AVAILABLE = path present AND status empty. `artifactTypes` (the store's own project-local schema names) and `initiatives` are present only on available members whose store defines them. `--code-workspace ` writes `{folders:[{name,path}]}` (available referenced stores only, `ref:` prefixes); in JSON mode the write runs before printing so stdout holds exactly one document even on write failure. Failure: `{ "root": null, "members": [], "status": [d] }`, exit 1. +`{ "root": { "path", "source", "store_id"?, "role": "openspec_root" }, "members": [ { "role": "referenced_store", "id", "path"?, "remote"?, "fetch"?, "artifactTypes"?: [string], "plan"?: [string], "status": [] } ], "status": [] }`. AVAILABLE = path present AND status empty. `artifactTypes` (the store's own project-local schema names) and `plan` (the store's plan stage names, in order) are present only on available members whose store defines them. `--code-workspace ` writes `{folders:[{name,path}]}` (available referenced stores only, `ref:` prefixes); in JSON mode the write runs before printing so stdout holds exactly one document even on write failure. Failure: `{ "root": null, "members": [], "status": [d] }`, exit 1. ### 4.11 `store ... --json` setup/register: `{ "store": {id, root, metadata_path?}, "registry": {path, registered, already_registered}, "git": {is_repository, initialized, committed}, "created_files": [], "status": [] }`. unregister/remove: `{ "store", "registry": {path, removed}, "files": {deleted, deleted_path, left_on_disk}, "status": [] }`. list: `{ "stores": [{id, root}], "status": [] }`. doctor: `{ "stores": [ { id, root, metadata_path?, openspec_root: {...healthy, status}, metadata: {present, valid, id?, remote}, git: {is_repository, has_commits, has_uncommitted_changes, has_remote, origin_url}, status } ], "status": [] }` (`null` = unknown/not probed). Health findings exit 0; failures exit 1 with the matching null-shape. Prompt cancellation exits 130. diff --git a/docs/stores-beta/initiatives.md b/docs/stores-beta/initiatives.md deleted file mode 100644 index fd80a77378..0000000000 --- a/docs/stores-beta/initiatives.md +++ /dev/null @@ -1,221 +0,0 @@ -# Initiatives: your plan, your artifacts, shared across repos - -> **Beta.** This builds on [stores](user-guide.md). Names and file shapes may -> change between releases. - -## The one idea - -Kiro hands you `requirements → design → tasks`. Spec-Kit hands you -`spec → plan → tasks`. You use the artifacts they picked. - -OpenSpec is different: - -> **You define the artifacts your team actually uses — a brief, a persona, a -> decision record, anything — and share them in one place every repo can read.** - -That one place is a **store**. The plan that lives there is an **initiative**. - -## What an initiative is - -An **initiative** is the home for a big effort — work that is too large for a -single change. - -It holds three things: - -- **the brief** — what you are doing, and why -- **the artifacts you choose** — personas, decision records, whatever your team - works from -- **the changes** that carry it out — each one still a normal OpenSpec change, - with live status - -One effort. One home. Read by every repo that needs it. - -```text -acme-plans (a store: planning in its own repo) - openspec/initiatives/ - smoother-setup/ - initiative.yaml the title, and the changes it groups - brief.md what this is, and why - personas/ who we build for ← your own artifact types - decisions/ the calls we made ← your own artifact types -``` - -## Two ways to use it: solo, or across repos - -The *same* initiative works whether you are one person in one repo or a team -across many. The only thing that changes is where the grouped changes live. - -**Solo — one repo, one machine.** Keep the initiative in your own repo. Its -changes are your normal changes, right beside it. The manifest just lists them: - -```yaml -# openspec/initiatives/smoother-setup/initiative.yaml -title: Smoother setup -changes: - - simplify-skill-installation - - add-global-install-scope -``` - -```text -$ openspec list --initiatives - smoother-setup 2/4 changes complete -``` - -Nothing else to set up. This is the whole story for a solo dev. - -**Across repos — a store, a team.** The plan lives in a store; the work is -implemented in several code repos. Each change entry says which repo it lives -in (any registered store id), and the status rolls up across all of them: - -```yaml -# team-plans/openspec/initiatives/payments-launch/initiative.yaml -title: Payments launch -changes: - - { id: add-payments-api, store: api-server } - - { id: add-payments-ui, store: web-app } - - update-docs # lives here, in the store -``` - -```text -$ openspec list --initiatives --store team-plans - payments-launch 2/5 changes complete across api-server, web-app - ✓ add-payments-api api-server 5/5 tasks - · add-payments-ui web-app 1/3 tasks - ✓ update-docs here 2/2 tasks - ? add-refunds web-app not found -``` - -One command answers "where does the whole effort stand?" — across every repo, -with each change's home shown next to it. - -## The happy path - -*A team is making setup smoother. The work spans a CLI repo and a docs repo. -The plan should not live in either one.* - -### 1. Put the plan in a store, and start an initiative - -A store is planning in its own git repo. Stand one up once, then scaffold the -initiative inside it: - -```bash -openspec store setup acme-plans --path ~/openspec/acme-plans -openspec new initiative smoother-setup --store acme-plans --title "Smoother setup" -``` - -Now the plan has a home of its own — an `initiative.yaml` and a `brief.md`, -not buried in anyone's code. - -### 2. Define your own artifacts - -This is the superpower. Your team works from personas and decision records, so -make those first-class. Describe them once, in a small **schema**: - -```yaml -# acme-plans/openspec/schemas/initiative/schema.yaml -artifacts: - - id: brief # what and why - - id: persona # who we build for - - id: decision # a call we made, and the trade-off -``` - -These are *your* artifact types — labeled in your words. OpenSpec knows them and -lists them like any other schema, from any repo that can reach the store: - -```bash -openspec schemas --store acme-plans -``` - -```text -Available schemas: - - spec-driven - Default OpenSpec workflow - proposal → specs → design → tasks - Artifacts: proposal → specs → design → tasks - - team-brief (project) - Our own planning artifacts — a brief, then a decision record. - Artifacts: brief → decision -``` - -### 3. Group the changes it takes - -The initiative names the changes that carry it out. Each change is a normal -OpenSpec change — nothing new to learn. You never track status by hand; ask -OpenSpec: - -```bash -openspec list --initiatives --store acme-plans -``` - -```text -Initiatives: - smoother-setup 2/4 changes complete -``` - -One command. Where the whole effort stands. - -### 4. Every repo reads it — nothing copied - -Each code repo adds one line to its `openspec/config.yaml`: - -```yaml -references: - - acme-plans -``` - -Now the coding agent in *that* repo sees the shared plan — the brief, the -personas, the decisions, the changes — and builds against it. No pasting. No -drift. The plan is read by name, live from the store. - -That is the whole happy path: **your store, your artifacts, read by every -repo, with live status.** - -## When two initiatives collide - -The question every team hits: the store has an initiative called -`smoother-setup`, and someone starts a local one by the same name in their own -repo. Which wins? - -**The rule: the store is canonical.** The store holds the shared, agreed-upon -initiative. A local one with the same name is treated as a *draft that shadows -it* — OpenSpec keeps letting you work locally, but tells you plainly, right in -the list: - -```text -Initiatives: - smoother-setup 2/4 changes complete (shadows: acme-plans) -``` - -Nothing diverges silently. You always know there is a shared source of truth, -and you can reconcile when you are ready. (This mirrors how OpenSpec already -handles schemas: a project schema *shadows* a package one, and `openspec schema -which` shows you it is happening.) - -## What works in this prototype - -This is a working prototype — every command above runs today: - -- **Your own artifact types, discoverable across the store boundary.** - `openspec schemas --store ` lists a store's custom schemas from any repo. -- **Initiatives with live, rolled-up status — solo or across repos.** - `openspec list --initiatives` reads each initiative's manifest and rolls up - the status of the changes it groups, from the same source as - `openspec list --changes`. When a change lives in another repo (`{ id, store }`), - the rollup resolves it there and aggregates — one number for the whole effort, - with each change's home shown next to it. -- **Canonical-vs-shadow precedence, enforced.** A local initiative that shares an - id with one in a referenced store is reported as a shadow — never silently - overriding it, never blocking your local work. -- **Read by name via `references:`.** A repo declares the store once, and its - coding agent sees the store's artifact types and initiatives right in its - context — in the instruction block and in `openspec context` — each with a - one-line summary and the command to fetch it. Nothing is copied. - -Still a prototype: the initiative folder is a light convention (an -`initiative.yaml` manifest — not a revived command group). A change in another -repo is named explicitly (`{ id, store }`); OpenSpec does not auto-discover -which repos a store's changes live in. - -Start simple: one initiative folder in a store, grouping a few real changes, -with the artifacts your team already uses. diff --git a/docs/stores-beta/plan.md b/docs/stores-beta/plan.md new file mode 100644 index 0000000000..f80d5bbf51 --- /dev/null +++ b/docs/stores-beta/plan.md @@ -0,0 +1,108 @@ +# The plan folder + +> **Beta.** Builds on [stores](user-guide.md). Names and shapes may change. + +Big work is bigger than one change. A roadmap, a PRD, a vision — today that +lives outside OpenSpec, and there is no good way in. The plan folder is the +way in. + +## The whole idea in one picture + +```text +openspec/plan/ the plan: one folder + 00_goal/ numbered folders are stages, in order + 01_requirements/ names and contents are YOURS — any artifact + 02_changes/ the last stage meets execution + vision.md unnumbered = context, not a stage + +openspec/changes/ + add-search/ + .openspec.yaml plan: local ← one line points the change UP +``` + +That is everything. No manifest, no new file format, no required artifact +types. Rename the folders and you have changed the workflow. + +## Solo: one repo, one machine + +The plan sits in your repo, next to your changes. + +```bash +mkdir -p openspec/plan/00_goal # a plan starts as an empty folder +openspec new change add-search --plan local +openspec list --plan +``` + +```text +Plan: openspec/plan +Stages: + 00_goal 1 file +Changes pointing here: 0/1 complete + · add-search here 0/4 tasks +``` + +Status is live from the changes' own task lists. Nothing to keep in sync. + +## Team: the plan is the parent, repos are children + +Put the same folder in a [store](user-guide.md) — a planning repo the whole +team registers by name. The store is the global level; each code repo is a +child that points up at it. + +```bash +openspec store setup team-plans --path ~/openspec/team-plans +mkdir -p ~/openspec/team-plans/openspec/plan/00_goal + +# in any code repo: +openspec new change add-search --plan team-plans +``` + +```bash +openspec list --plan --store team-plans +``` + +```text +Plan: ~/openspec/team-plans/openspec/plan +Stages: + 00_goal 1 file +Changes pointing here: 1/3 complete + ✓ add-payments-api api-server 5/5 tasks + · add-search web-app 2/4 tasks + · update-docs here 1/2 tasks +``` + +One command answers "where does the whole effort stand?" — across every repo +on your machine that points at the plan. + +Repos that add `references: [team-plans]` to their `openspec/config.yaml` +also get the plan in their agent's context automatically: + +```text +Store team-plans (…): + Plan: 00_goal → 01_requirements → 02_changes (openspec list --plan --store team-plans) +``` + +## The skill + +One skill drives it: `openspec-plan` (`/opsx:plan`). It is a stance, not a +procedure — it reads the folders to see where the effort stands, helps +translate each stage into the next (a PRD into a feature list, a feature into +changes), and syncs the high level against live change status. It is also +told to write *less*: one page per artifact, tables over prose. + +## What the pieces are + +| Piece | What it is | +|---|---| +| `openspec/plan/` | one folder; numbered subfolders are ordered stages | +| `plan: local` / `plan: ` | one line in a change's `.openspec.yaml` | +| `openspec new change --plan ` | create a change already pointing up | +| `openspec list --plan [--store ]` | stages + every linked change, live | +| `openspec-plan` skill | the guide through all of it | + +## Honest limits + +- Rollup scans repos registered on this machine — it never clones or syncs. +- Stage order is a naming convention, not a gate. Nothing blocks working out + of order; the skill just knows what comes next. +- This repo dogfoods it: see [openspec/plan/](../../openspec/plan/00_goal/goal.md). diff --git a/docs/stores-beta/user-guide.md b/docs/stores-beta/user-guide.md index c8b906c8ae..08f6f547fe 100644 --- a/docs/stores-beta/user-guide.md +++ b/docs/stores-beta/user-guide.md @@ -311,7 +311,7 @@ tells you which case you're in. - **Some commands stay where they are.** `view`, `templates`, and the deprecated noun forms (`openspec change show`, ...) act on the current directory only — no `--store`. (`schemas` now accepts `--store` — see the - [initiatives guide](initiatives.md).) + [plan folder guide](plan.md).) - **Per-machine state is per-machine.** The store registry and worksets are local settings. Nothing about your machine's layout is ever committed to shared planning. diff --git a/openspec/changes/add-global-install-scope/.openspec.yaml b/openspec/changes/add-global-install-scope/.openspec.yaml index 910badd983..5f9a35144b 100644 --- a/openspec/changes/add-global-install-scope/.openspec.yaml +++ b/openspec/changes/add-global-install-scope/.openspec.yaml @@ -1,2 +1,3 @@ schema: spec-driven created: 2026-02-21 +plan: local diff --git a/openspec/changes/add-plan-folder/design.md b/openspec/changes/add-plan-folder/design.md new file mode 100644 index 0000000000..788638091e --- /dev/null +++ b/openspec/changes/add-plan-folder/design.md @@ -0,0 +1,50 @@ +## Context + +The upstream layer (roadmap → requirements → changes) has no home in +OpenSpec. The design constraint: give it one without OpenSpec modeling the +team's workflow — every team's is different. + +## Goals / Non-Goals + +**Goals:** the thinnest possible wrapper; any artifact shape; one repo or +many; one skill. + +**Non-Goals:** typed upstream artifacts, relationship taxonomies, sync +machinery, auto-discovery of unregistered repos. + +## Decisions + +**1. Ordered folders are the workflow.** Numbered folders under +`openspec/plan/` define a linear sequence by convention alone. Reconfiguring +the workflow is renaming folders. Rejected: a schema for the plan (types the +artifacts — the thing users want to keep freeform). If checking is ever +wanted, stages can later map onto the existing schema machinery. + +**2. Changes reference upward.** `plan: local | ` in change +metadata; rollup scans for it. Rejected: a downward manifest — a central file +invites merge conflicts and goes stale when someone forgets it. The store is +the parent/global level; repos are children pointing up, so there is no +local-vs-store precedence question left to arbitrate. + +**3. One explore-style skill.** The translation judgment (decompose into +self-contained items, flag merge/split calls, filter input against the +vision, write one-page artifacts) lives in the `openspec-plan` skill prompt, +not in code. Rejected: per-stage commands or role-based skills — navigation +derives from what is on disk, so anyone can pick the work up. + +## Risks / Trade-offs + +- [Scan cost across registered repos] → registries are small; scan is + per-invocation and read-only. +- [`plan:` values are unvalidated against real stores] → rollup simply finds + nothing for a bad value; the skill and `list --plan` make that visible. + +## Migration Plan + +Additive only. Legacy `initiative:` metadata remains readable; nothing else +existed to migrate. + +## Open Questions + +None blocking. Naming ("plan") is the simplest word we had; cheap to change +while beta. diff --git a/openspec/changes/add-plan-folder/proposal.md b/openspec/changes/add-plan-folder/proposal.md new file mode 100644 index 0000000000..83c7e75615 --- /dev/null +++ b/openspec/changes/add-plan-folder/proposal.md @@ -0,0 +1,40 @@ +## Why + +Big work is bigger than one change, and OpenSpec has no way in above +`propose`. Roadmaps, PRDs, and visions live outside the tool, and the +translation from them into changes is hand-rolled by every serious team. + +The plan folder is that way in. OpenSpec owns almost nothing: a folder +convention, one metadata line, one rollup, one skill. + +## What Changes + +- **A convention, not a format.** A plan is one folder, `openspec/plan/`. + Numbered subfolders are ordered stages; names and contents are the user's + own. Unnumbered entries are context. +- **Changes point up.** One line in a change's `.openspec.yaml` — `plan: local` + or `plan: `. No manifest anywhere. `new change` gains `--plan`. +- **One rollup.** `openspec list --plan [--store ]` shows the stages and + every change on this machine pointing at the plan, with live task status — + including changes in other registered repos pointing at a store's plan. +- **One skill.** `openspec-plan` (`/opsx:plan`): explore-style stance that + reads the folders, translates stage to stage, bridges into changes, and + syncs status back up. It is instructed to write less, not more. +- Referenced stores' plan stages appear in `openspec context` and the agent + instruction block. + +## Capabilities + +### New Capabilities +- `plan-folder`: the plan convention, the upward `plan:` link, the + `list --plan` rollup, and plan surfacing in context/instructions. + +### Modified Capabilities +None. + +## Impact + +- Commands: `list` (adds `--plan`), `new change` (adds `--plan`), `context` + and `instructions` (surface a store's plan stages). All additive. +- Skill set: one new optional workflow, `plan` (not in the core profile). +- No breaking changes. diff --git a/openspec/changes/add-plan-folder/specs/plan-folder/spec.md b/openspec/changes/add-plan-folder/specs/plan-folder/spec.md new file mode 100644 index 0000000000..db9b3c6af9 --- /dev/null +++ b/openspec/changes/add-plan-folder/specs/plan-folder/spec.md @@ -0,0 +1,60 @@ +## ADDED Requirements + +### Requirement: Plan folder convention + +The system SHALL treat numbered folders under `openspec/plan/` as ordered +stages and unnumbered entries as ambient context, without requiring any +manifest or configuration. + +#### Scenario: Stages and context are read from folder names + +- **WHEN** `openspec/plan/` contains `00_goal/`, `01_requirements/`, and `vision.md` +- **THEN** the system reports `00_goal` and `01_requirements` as stages, in order +- **AND** reports `vision.md` as context, not a stage + +### Requirement: Changes link upward to a plan + +The system SHALL let a change declare the plan it serves with a `plan` value +in its `.openspec.yaml` — `local` for the plan in its own root, or a +registered store id for that store's plan. + +#### Scenario: Creating a change linked to a plan + +- **WHEN** a user runs `openspec new change add-search --plan local` +- **THEN** the created change's metadata contains `plan: local` + +### Requirement: Plan rollup + +The system SHALL show a plan's stages and every change on this machine that +points at it, with live task status, via `openspec list --plan`. + +#### Scenario: Rolling up local changes + +- **WHEN** a repo has a plan folder and changes whose metadata says `plan: local` +- **AND** the user runs `openspec list --plan` +- **THEN** the output lists the stages and each linked change with its task status + +#### Scenario: Rolling up across repos toward a store plan + +- **WHEN** a store has a plan folder and changes in other registered repos say + `plan: ` +- **AND** the user runs `openspec list --plan --store ` +- **THEN** the output includes those changes with the repo each lives in + +#### Scenario: No plan folder + +- **WHEN** the resolved root has no `openspec/plan/` folder +- **THEN** `openspec list --plan` says so and suggests how to start one + +### Requirement: Plan stages surface in agent context + +The system SHALL include a referenced store's plan stage names in +`openspec context` and in the agent instruction block, with the command to +fetch the full rollup. + +#### Scenario: A referenced store's plan appears in context + +- **WHEN** a repo references a store whose plan folder has stages +- **AND** the user runs `openspec context` +- **THEN** the store's member entry lists the stage names in order +- **AND** shows the `openspec list --plan --store ` fetch command diff --git a/openspec/changes/add-plan-folder/tasks.md b/openspec/changes/add-plan-folder/tasks.md new file mode 100644 index 0000000000..6468d8c1b7 --- /dev/null +++ b/openspec/changes/add-plan-folder/tasks.md @@ -0,0 +1,24 @@ +## 1. Core + +- [x] 1.1 Plan module: stage/context split, upward-ref scan, cross-repo rollup +- [x] 1.2 `plan` field in change metadata + +## 2. Surfaces + +- [x] 2.1 `openspec list --plan [--store ]` (human + JSON) +- [x] 2.2 `openspec new change --plan ` +- [x] 2.3 Plan stages in `openspec context` and the instruction block +- [x] 2.4 Retire superseded prototype surfaces + +## 3. The skill + +- [x] 3.1 `openspec-plan` skill + `/opsx:plan` command (explore-style stance), + registered as an optional workflow (not in the core profile) + +## 4. Proof + +- [x] 4.1 Dogfood: this repo's `openspec/plan/` groups four real changes via + `plan: local`; `openspec list --plan` rolls them up live +- [x] 4.2 Tests: plan module (solo + cross-repo), context/instructions + surfacing, registries, skill parity +- [x] 4.3 `openspec validate add-plan-folder --strict` passes diff --git a/openspec/changes/fix-opencode-commands-directory/.openspec.yaml b/openspec/changes/fix-opencode-commands-directory/.openspec.yaml index e331c975d9..29888c80b8 100644 --- a/openspec/changes/fix-opencode-commands-directory/.openspec.yaml +++ b/openspec/changes/fix-opencode-commands-directory/.openspec.yaml @@ -1,2 +1,3 @@ schema: spec-driven created: 2026-02-25 +plan: local diff --git a/openspec/changes/initiatives-in-stores/design.md b/openspec/changes/initiatives-in-stores/design.md deleted file mode 100644 index cadbc68d24..0000000000 --- a/openspec/changes/initiatives-in-stores/design.md +++ /dev/null @@ -1,88 +0,0 @@ -## Context - -Stores let planning live in its own repo, and a code repo can `reference` a -store for read-only context. Separately, OpenSpec's artifact-graph lets a -project define its own artifact types in a schema (`openspec/schemas//`), -resolved project → user → package. - -These two systems do not meet yet. Schema/artifact discovery resolves against -the *current* repo only. So the compelling story — "define your own artifacts, -share them in a store, read them from every repo" — is true for *authoring* but -not for *discovery across repos*. This design closes that seam and settles the -one open product question: initiative precedence. - -## Goals / Non-Goals - -**Goals:** -- A repo that references a store can discover that store's schemas, artifact - types, and initiatives. -- One clear, defensible default for initiative precedence. -- Reuse existing machinery (the resolver's root argument, the shadowing model, - `openspec list` status) rather than new subsystems. - -**Non-Goals:** -- Reviving the deleted heavyweight initiative command groups (collections, - resolution, templates). Kept deliberately light. -- Syncing stores. Stores are plain git repos; OpenSpec never clones or pulls. -- Changing where commands *act*. References stay read-only context. - -## Decisions - -### 1. Discovery follows references, read-only - -Referenced stores already resolve to on-disk roots for `context`/`doctor`. Feed -those same roots into the artifact-graph resolver (which already takes a -`projectRoot`) so a store's `openspec/schemas/` participates in discovery. A -referenced store's artifacts are always read-only context, never a place -commands write. - -**Alternative considered:** copy a store's schemas into the referencing repo. -Rejected — that is the drift we are trying to remove. - -### 2. Initiative precedence: canonical store, local shadow (decided) - -When a local initiative and a store initiative share an id, the **store is -canonical** and the local one is a **shadow**: work continues locally, but -OpenSpec reports the shadow so nothing diverges silently. - -**This is the policy we are building.** It is the only option that serves an org -on all three axes at once: - -- **Governance** — the store stays the single source of truth every repo points - at, so the shared plan is auditable and one place. -- **No silent drift** — the shadow is surfaced, so a local divergence is visible - and reconcilable, not discovered later in review. -- **Velocity** — local work is never blocked, so a developer can still iterate. - -It also reuses a pattern OpenSpec *already uses* for schemas (`openspec schema -which` shows `shadows:`) — the least code and the most native-feeling. Note it -governs *identity/reporting* only; it does not change where commands act. - -**Alternatives rejected:** - -- *Local silently wins* (today's root-selection order, nearest `openspec/` - takes precedence). Rejected — it fails governance: a repo can quietly override - the shared plan and nobody knows. -- *Store hard-wins, local blocked.* Rejected — it fails velocity: devs can't - experiment locally, so they route around OpenSpec, defeating the point. - -## Risks / Trade-offs - -- [Discovery surfaces stale store content if a checkout is behind] → same as all - store reads today; references are indexed live from disk, and `openspec - doctor` already reports missing/registered stores. -- [Two precedence concepts in the codebase — root-selection order vs. initiative - identity] → mitigated by scoping identity-precedence to reporting only, and - documenting that commands still act per root-selection. - -## Migration Plan - -Purely additive. No existing command changes behavior when no store is -referenced. Ship behind the existing stores beta surface. - -## Open Questions - -(Precedence policy is settled: canonical store, local shadow — see Decision 2. -Initiatives get a *thin* scaffold — `openspec new initiative ` writes the -folder + `initiative.yaml` + a `brief.md` stub, and nothing more — added under -the existing `new` group rather than reviving an `initiative` command group.) diff --git a/openspec/changes/initiatives-in-stores/proposal.md b/openspec/changes/initiatives-in-stores/proposal.md deleted file mode 100644 index 3e4030e019..0000000000 --- a/openspec/changes/initiatives-in-stores/proposal.md +++ /dev/null @@ -1,71 +0,0 @@ -## Why - -A team's biggest work is never one change — it is an *initiative*: a brief, the -artifacts the team works from (personas, decisions, whatever they use), and the -changes that carry it out. OpenSpec has no shared home for that today, so it -lives in someone's head or a stray doc. - -Stores (beta) give planning its own repo. And OpenSpec already lets a team -**define its own artifact types** in a schema — something Kiro, Spec-Kit, and -GSD do not. Put those two together and OpenSpec can do what none of them can: -**a shared initiative, with your own artifacts, read by every repo.** - -The blocker is that artifact discovery is not store-aware. `openspec schemas`, -`openspec templates`, and `openspec view` look only at the current repo. So a -repo that *references* a store cannot see the store's initiatives or custom -artifact types, and there is no rule for what happens when a local initiative -and a store initiative share a name. This change closes that gap. - -## What Changes - -### 1. Store-aware artifact and schema discovery - -Make discovery follow references. When a repo references a store, its custom -artifact types and initiatives become discoverable from that repo: - -- `openspec schemas --store ` lists a store's schemas (today it is cwd-only). -- Referenced stores' schemas and initiatives appear in `openspec context` and in - the agent instruction block, each with a one-line summary and a fetch command. - -### 2. Initiative precedence: canonical vs. shadow - -Define the rule the owner asked about — a local initiative vs. a store one of the -same name: - -- The **store initiative is canonical.** A local initiative with the same id is a - **shadow** — you keep working locally, but OpenSpec tells you it shadows the - canonical one, so nothing diverges silently. -- This reuses the shadowing model OpenSpec already applies to schemas (project - shadows user shadows package), surfaced the same way `openspec schema which` - surfaces it. - -### 3. Initiative as a first-class, checkable grouping - -An initiative is a folder with a brief and the changes it groups. Give it just -enough structure to be checked and rolled up — no revival of the old -heavyweight initiative command groups. - -- `openspec list --initiatives [--store ]` lists initiatives and rolls up the - live status of the changes each one groups. -- `openspec new initiative [--store ] [--title ]` scaffolds the - folder, an `initiative.yaml` manifest, and a `brief.md` stub — a thin scaffold - under the existing `new` group, nothing more. - -## Capabilities - -### New Capabilities -- `store-aware-artifacts`: discover and resolve a store's schemas, artifact - types, and initiatives from a repo that references it, with a defined - canonical-vs-shadow precedence rule. - -### Modified Capabilities -None. - -## Impact - -- Affected commands: `schemas`, `context`, `instructions`, `list` (additive - `--store` / `--initiatives` behavior; existing behavior unchanged). -- Affected core: artifact-graph resolver (accept a store root), root-selection - (initiative precedence). -- No breaking changes. Everything degrades to today's cwd-only behavior when no - store is referenced. diff --git a/openspec/changes/initiatives-in-stores/specs/store-aware-artifacts/spec.md b/openspec/changes/initiatives-in-stores/specs/store-aware-artifacts/spec.md deleted file mode 100644 index 86f000e13a..0000000000 --- a/openspec/changes/initiatives-in-stores/specs/store-aware-artifacts/spec.md +++ /dev/null @@ -1,75 +0,0 @@ -## ADDED Requirements - -### Requirement: Store-aware schema discovery - -The system SHALL discover schemas and artifact types from a store when the store -is selected with `--store` or referenced by the current repo, in addition to the -current repo's own schemas. - -#### Scenario: Listing a store's schemas - -- **WHEN** a user runs `openspec schemas --store acme-plans` -- **THEN** the system lists the schemas defined in the `acme-plans` store -- **AND** each schema shows its name and description - -#### Scenario: Referenced store schemas are visible from a repo - -- **WHEN** a repo references the `acme-plans` store and the user runs - `openspec context` -- **THEN** the referenced store's custom artifact types are listed as read-only - context -- **AND** each entry includes a one-line summary and a fetch command - -#### Scenario: No store referenced falls back to current repo - -- **WHEN** the current repo references no store and the user runs - `openspec schemas` -- **THEN** the system lists only the current repo's schemas, exactly as it does - today - -### Requirement: Initiative precedence between a store and a local repo - -The system SHALL treat a store initiative as canonical and a local initiative -with the same id as a shadow, and SHALL report the shadow without blocking local -work. - -#### Scenario: Local initiative shadows a canonical store initiative - -- **WHEN** a repo references a store that has an initiative `smoother-setup` -- **AND** the repo also has a local initiative `smoother-setup` -- **THEN** the system reports that the local initiative shadows the canonical one - in the store -- **AND** local commands continue to operate on the local initiative - -#### Scenario: No collision means no shadow report - -- **WHEN** a local initiative id does not match any initiative in a referenced - store -- **THEN** the system reports no shadow for that initiative - -### Requirement: Initiative status rollup - -The system SHALL list initiatives and roll up the live status of the changes each -initiative groups, resolving a change against a named store when the manifest -entry specifies one and against the initiative's own root otherwise. - -#### Scenario: Listing initiatives in a store - -- **WHEN** a user runs `openspec list --initiatives --store acme-plans` -- **THEN** the system lists each initiative in the store -- **AND** each initiative shows the rolled-up status of the changes it groups - -#### Scenario: Rolling up changes that live in other repos - -- **WHEN** an initiative's manifest groups changes with `{ id, store }` entries - that name other registered stores -- **THEN** the system resolves each change against its named store's root and - aggregates the task status across all of them -- **AND** the output shows each change's home and marks a change whose store or - directory is missing as not found - -#### Scenario: Solo initiative with changes in its own root - -- **WHEN** an initiative's manifest lists changes as plain ids -- **THEN** the system resolves each change against the initiative's own root -- **AND** no external stores are reported for that initiative diff --git a/openspec/changes/initiatives-in-stores/tasks.md b/openspec/changes/initiatives-in-stores/tasks.md deleted file mode 100644 index ed77204f0d..0000000000 --- a/openspec/changes/initiatives-in-stores/tasks.md +++ /dev/null @@ -1,43 +0,0 @@ -## 1. Store-aware discovery - -- [x] 1.1 Let the artifact-graph resolver take a store root, so a store's - `openspec/schemas/` participates in schema/artifact resolution - (via `resolveRootForCommand` → `listSchemasWithInfo(root.path)`) -- [x] 1.2 Add `--store ` to `openspec schemas` (list a store's schemas) -- [x] 1.3 Include referenced stores' schemas/artifact types and initiatives in - the agent instruction block (``) and `openspec context` - (human + JSON), each with a summary and a fetch command - -## 2. Initiative precedence - -- [x] 2.1 Detect when a local initiative id matches an initiative in a referenced - store -- [x] 2.2 Report the local one as a shadow of the canonical store initiative - (`(shadows: )` in the list; `shadowsStore` in JSON) -- [x] 2.3 Confirm local commands still act on the local initiative (no behavior - change to where commands act) - -## 3. Initiative status rollup - -- [x] 3.1 Add `openspec list --initiatives [--store ]` -- [x] 3.2 Roll up the live status of the changes each initiative groups, reusing - the existing `openspec list --changes` status source -- [x] 3.3 Cross-repo rollup: a manifest change entry may be `{ id, store }`, so - an initiative in a planning store aggregates status across the code repos - where its changes live (per-change breakdown + `stores` span; unresolved - changes reported as `not-found`) - -## 3b. Thin scaffold - -- [x] 3b.1 Add `openspec new initiative [--store ] [--title ]` - that writes the folder + `initiative.yaml` + a `brief.md` stub (under the - existing `new` group; not a revived initiative command group) - -## 4. Proof - -- [x] 4.1 Update the `smoother-setup` example so it reads as an initiative in a - store with your-own artifact types (adds `initiative.yaml`) -- [x] 4.2 Capture real command output for the guide (`schemas --store`, `list - --initiatives`, the shadow report) -- [x] 4.3 `openspec validate initiatives-in-stores --strict` passes; unit tests - for the rollup and shadow rule in `test/core/initiatives.test.ts` diff --git a/openspec/changes/schema-alias-support/.openspec.yaml b/openspec/changes/schema-alias-support/.openspec.yaml index 749f7518cb..bad31aea74 100644 --- a/openspec/changes/schema-alias-support/.openspec.yaml +++ b/openspec/changes/schema-alias-support/.openspec.yaml @@ -1,2 +1,3 @@ schema: spec-driven created: 2026-01-20 +plan: local diff --git a/openspec/changes/simplify-skill-installation/.openspec.yaml b/openspec/changes/simplify-skill-installation/.openspec.yaml index c8d3976aee..fa26e9a3f0 100644 --- a/openspec/changes/simplify-skill-installation/.openspec.yaml +++ b/openspec/changes/simplify-skill-installation/.openspec.yaml @@ -1,2 +1,3 @@ schema: spec-driven created: 2026-02-17 +plan: local diff --git a/openspec/initiatives/smoother-setup/brief.md b/openspec/initiatives/smoother-setup/brief.md deleted file mode 100644 index 3a76d8dc64..0000000000 --- a/openspec/initiatives/smoother-setup/brief.md +++ /dev/null @@ -1,46 +0,0 @@ -# Initiative: Smoother setup - -> An **initiative** is the home for a big effort: a brief, the artifacts your -> team works from, and the changes that carry it out. This is a real example, -> built from changes already in this repo. See -> [the guide](../../../docs/stores-beta/initiatives.md). - -## What this is - -The effort to make OpenSpec faster and simpler to set up. - -## Why these changes go together - -New users should reach a win fast. Each change here removes some setup friction: -fewer skills to learn at first, the right folders for each tool, a clear choice -of where things install, friendlier schema names. Small on their own. Together, -a smoother first run. - -## The artifacts this initiative works from - -This is the part other tools do not let you do: **the artifact types are ours, -not the tool's.** - -- [personas/](personas/) — who we build for - ([a new user](personas/new-user.md), [a lead across repos](personas/team-lead.md)). -- [decisions/](decisions/) — the calls we made, and the trade-offs, one record - each ([0001](decisions/0001-aliases-over-rename.md), [0002](decisions/0002-tool-native-paths.md)). -- [example-schema/](example-schema/schema.yaml) — the same three types - (`brief → persona → decision`) described as a schema, so OpenSpec can check - them. Illustrative here; it is not in this repo's active schema set. - -## The changes it groups - -Four real changes carry this initiative. You never track their status by hand — -ask OpenSpec: - -```bash -openspec list --changes -``` - -- `simplify-skill-installation` — fewer default skills, so the first run is quick -- `fix-opencode-commands-directory` — use the folder OpenCode expects -- `add-global-install-scope` — let users choose where tools install -- `schema-alias-support` — let `spec-driven` and `openspec-default` both work - -Open any one to read its full plan: `openspec show simplify-skill-installation`. diff --git a/openspec/initiatives/smoother-setup/decisions/0001-aliases-over-rename.md b/openspec/initiatives/smoother-setup/decisions/0001-aliases-over-rename.md deleted file mode 100644 index 10d3701a04..0000000000 --- a/openspec/initiatives/smoother-setup/decisions/0001-aliases-over-rename.md +++ /dev/null @@ -1,26 +0,0 @@ -# ADR 0001: Add a schema alias instead of renaming - -> An ADR (Architecture Decision Record) captures one decision and why it was -> made, so the reason is not lost later. - -## Status - -Accepted - -## Context - -We want `spec-driven` to be called `openspec-default`, which is a clearer name. -But many projects already have `schema: spec-driven` in their config. A direct -rename would break them. - -## Decision - -Add alias support, so both names point to the same schema. No project breaks, -and the new name can roll out over time. - -## Trade-offs - -- Easier: a smooth rename with nothing breaking. -- Harder: two names mean a little more to explain until the old one is retired. - -_Source: the `schema-alias-support` change in this repo._ diff --git a/openspec/initiatives/smoother-setup/decisions/0002-tool-native-paths.md b/openspec/initiatives/smoother-setup/decisions/0002-tool-native-paths.md deleted file mode 100644 index 21a8302a50..0000000000 --- a/openspec/initiatives/smoother-setup/decisions/0002-tool-native-paths.md +++ /dev/null @@ -1,23 +0,0 @@ -# ADR 0002: Match each tool's own folder names - -## Status - -Accepted - -## Context - -One adapter wrote commands to a folder that did not match the tool's documented -path, while every other adapter used the tool's expected (plural) folder name. -This was inconsistent and confusing. - -## Decision - -Use the folder each tool expects. Keep the old path working for a while, so -existing setups do not break. - -## Trade-offs - -- Easier: commands land where the tool actually looks for them. -- Harder: we carry a backward-compatible path until old setups move over. - -_Source: the `fix-opencode-commands-directory` change in this repo._ diff --git a/openspec/initiatives/smoother-setup/example-schema/schema.yaml b/openspec/initiatives/smoother-setup/example-schema/schema.yaml deleted file mode 100644 index 59286c6dff..0000000000 --- a/openspec/initiatives/smoother-setup/example-schema/schema.yaml +++ /dev/null @@ -1,36 +0,0 @@ -name: initiative -version: 1 -description: >- - Your own artifact types for an initiative. This one keeps the "who and why" - next to the plan: a brief, a persona, and a decision record. Illustrative — - copy it into openspec/schemas/initiative/ to make it active. - -artifacts: - - id: brief - generates: brief.md - description: What this effort is, and why — at a glance - template: brief.md - requires: [] - instruction: | - Write the brief: the problem, who it is for, what we will do, and what is - out of scope. Keep it to a page. - - - id: persona - generates: "personas/**/*.md" - description: Who we are building for - template: persona.md - requires: - - brief - instruction: | - Describe one kind of user: who they are, what they want, and what gets in - their way. Keep it short and real. - - - id: decision - generates: "decisions/**/*.md" - description: A call we made, and the trade-off - template: decision.md - requires: - - persona - instruction: | - Record one decision: the context, the choice, and the trade-offs. One - decision per record. diff --git a/openspec/initiatives/smoother-setup/example-schema/templates/brief.md b/openspec/initiatives/smoother-setup/example-schema/templates/brief.md deleted file mode 100644 index ab51f05089..0000000000 --- a/openspec/initiatives/smoother-setup/example-schema/templates/brief.md +++ /dev/null @@ -1,21 +0,0 @@ -# Brief: [Title] - -## Problem - -[The problem, in a sentence or two. Who feels it, and when.] - -## Who it is for - -[The main user type. Link to a persona if you have one.] - -## What we will do - -[The shape of the solution, at a high level.] - -## Out of scope - -[What this work will not cover.] - -## How we will know it worked - -[The signal that tells you it landed.] diff --git a/openspec/initiatives/smoother-setup/example-schema/templates/decision.md b/openspec/initiatives/smoother-setup/example-schema/templates/decision.md deleted file mode 100644 index 1eb5b16680..0000000000 --- a/openspec/initiatives/smoother-setup/example-schema/templates/decision.md +++ /dev/null @@ -1,18 +0,0 @@ -# Decision: [Decision title] - -## Status - -[proposed | accepted | replaced] - -## Context - -[The situation that forces a choice.] - -## Decision - -[What we are doing, in plain words.] - -## Trade-offs - -- Easier: [what this makes easier] -- Harder: [what this makes harder] diff --git a/openspec/initiatives/smoother-setup/example-schema/templates/persona.md b/openspec/initiatives/smoother-setup/example-schema/templates/persona.md deleted file mode 100644 index 90d3f5266b..0000000000 --- a/openspec/initiatives/smoother-setup/example-schema/templates/persona.md +++ /dev/null @@ -1,13 +0,0 @@ -# Persona: [Name of the user type] - -## Who they are - -[A short description.] - -## What they want - -[The outcome they are after.] - -## What gets in their way - -[The friction or blocker.] diff --git a/openspec/initiatives/smoother-setup/initiative.yaml b/openspec/initiatives/smoother-setup/initiative.yaml deleted file mode 100644 index e75b73dca3..0000000000 --- a/openspec/initiatives/smoother-setup/initiative.yaml +++ /dev/null @@ -1,8 +0,0 @@ -# The initiative manifest: its title and the changes it groups. -# Status for these changes is rolled up live by `openspec list --initiatives`. -title: Smoother setup -changes: - - simplify-skill-installation - - fix-opencode-commands-directory - - add-global-install-scope - - schema-alias-support diff --git a/openspec/initiatives/smoother-setup/personas/new-user.md b/openspec/initiatives/smoother-setup/personas/new-user.md deleted file mode 100644 index b0b2f6eabd..0000000000 --- a/openspec/initiatives/smoother-setup/personas/new-user.md +++ /dev/null @@ -1,24 +0,0 @@ -# Persona: New user on an existing codebase - -> A persona is a short note about a kind of person you build for. It keeps the -> work pointed at a real need. - -## Who they are - -A developer trying OpenSpec for the first time, on a project that already has a -lot of code. - -## What they want - -A quick win. To see value in the first minute, without learning ten new -commands. - -## What gets in their way - -Too many skills and commands at once. Setup steps that do not match the tool -they use. - -## How this initiative helps - -Fewer default skills, so the first run is short (`simplify-skill-installation`). -Commands that land in the folder their tool expects (`fix-opencode-commands-directory`). diff --git a/openspec/initiatives/smoother-setup/personas/team-lead.md b/openspec/initiatives/smoother-setup/personas/team-lead.md deleted file mode 100644 index 7218a47041..0000000000 --- a/openspec/initiatives/smoother-setup/personas/team-lead.md +++ /dev/null @@ -1,20 +0,0 @@ -# Persona: Lead coordinating several repos - -## Who they are - -Someone who owns a goal that spans more than one repo, or more than one person. - -## What they want - -One shared plan everyone can see, and a clear view of what is done and what is -left. - -## What gets in their way - -Plans copied into each repo, slowly going out of date. No single source of -truth. - -## How this initiative helps - -The initiative groups the related changes in one place. A store shares that plan -across repos, so every project reads the same source instead of a copy. diff --git a/openspec/plan/00_goal/goal.md b/openspec/plan/00_goal/goal.md new file mode 100644 index 0000000000..7383c710cb --- /dev/null +++ b/openspec/plan/00_goal/goal.md @@ -0,0 +1,7 @@ +# Goal: smoother setup + +New users should reach a first win fast. Today the first run asks too much: +too many skills up front, files landing where tools don't expect them, no say +in where things install, schema names that confuse. + +Done looks like: install, init, first change — with nothing to explain. diff --git a/openspec/plan/01_who/personas.md b/openspec/plan/01_who/personas.md new file mode 100644 index 0000000000..cdccc50ec9 --- /dev/null +++ b/openspec/plan/01_who/personas.md @@ -0,0 +1,10 @@ +# Who this serves + +**A new user.** Wants a working first change in minutes. Blocked by too many +concepts before the first win. + +**A lead across repos.** Wants one plan several repos can read. Blocked by +plans living in wikis their agents can't see. + +**A coding agent.** Wants clear intent and a next action. Blocked by context +scattered across tools. diff --git a/openspec/plan/02_decisions/decisions.md b/openspec/plan/02_decisions/decisions.md new file mode 100644 index 0000000000..b3f0572d51 --- /dev/null +++ b/openspec/plan/02_decisions/decisions.md @@ -0,0 +1,7 @@ +# Decisions + +**Aliases over rename.** `spec-driven` and `openspec-default` both work. +Easier: nothing breaks. Harder: two names for one thing, for a while. + +**Tool-native paths.** Files go where each tool expects, not where we prefer. +Easier: everything just works per tool. Harder: our layout varies by tool. diff --git a/openspec/plan/03_changes/inventory.md b/openspec/plan/03_changes/inventory.md new file mode 100644 index 0000000000..6f21290d1b --- /dev/null +++ b/openspec/plan/03_changes/inventory.md @@ -0,0 +1,11 @@ +# The changes + +One row per change. Each carries `plan: local` in its `.openspec.yaml`, so +status is live — run `openspec list --plan`, don't update this table. + +| Change | What it does | +|---|---| +| simplify-skill-installation | Fewer default skills, quicker first run | +| fix-opencode-commands-directory | Files where OpenCode expects them | +| add-global-install-scope | Choose where tools install | +| schema-alias-support | Friendlier schema names, nothing breaks | diff --git a/src/cli/index.ts b/src/cli/index.ts index 703005af71..8764adf32c 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -8,7 +8,7 @@ import { promises as fs } from 'fs'; import { AI_TOOLS } from '../core/config.js'; import { UpdateCommand } from '../core/update.js'; import { ListCommand } from '../core/list.js'; -import { listInitiatives } from '../core/initiatives.js'; +import { rollupPlan } from '../core/plan.js'; import { ArchiveCommand, type ArchiveOptions } from '../core/archive.js'; import { ViewCommand } from '../core/view.js'; import { resolveRootForCommand, toRootOutput, type ResolvedOpenSpecRoot } from '../core/root-selection.js'; @@ -31,14 +31,12 @@ import { templatesCommand, schemasCommand, newChangeCommand, - newInitiativeCommand, DEFAULT_SCHEMA, type StatusOptions, type InstructionsOptions, type TemplatesOptions, type SchemasOptions, type NewChangeOptions, - type NewInitiativeOptions, } from '../commands/workflow/index.js'; import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js'; import { COMMON_FLAGS } from '../core/completions/shared-flags.js'; @@ -56,68 +54,56 @@ function hiddenStorePathOption(): Option { ).hideHelp(); } -async function renderInitiatives( +async function renderPlan( root: ResolvedOpenSpecRoot, options: { json?: boolean } ): Promise { - const initiatives = await listInitiatives(root.path); + const plan = await rollupPlan(root.path); if (options.json) { - console.log( - JSON.stringify({ initiatives, root: toRootOutput(root) }, null, 2) - ); + console.log(JSON.stringify({ plan, root: toRootOutput(root) }, null, 2)); return; } - if (initiatives.length === 0) { - console.log('No initiatives found.'); + if (plan === null) { + console.log('No plan folder found at openspec/plan/.'); + console.log('Start one: mkdir -p openspec/plan/00_goal'); return; } - console.log('Initiatives:'); - const nameWidth = Math.max(...initiatives.map((i) => i.id.length)); - for (const initiative of initiatives) { - const name = initiative.id.padEnd(nameWidth); - const rollup = - initiative.changesTotal === 0 - ? 'no changes' - : `${initiative.changesComplete}/${initiative.changesTotal} changes complete`; - const across = - initiative.stores.length > 0 - ? ` across ${initiative.stores.join(', ')}` - : ''; - const shadow = initiative.shadowsStore - ? ` (shadows: ${initiative.shadowsStore})` - : ''; - console.log(` ${name} ${rollup.padEnd(22)}${across}${shadow}`); - - // Cross-repo initiatives show where each change lives — the proof that - // one effort's status is rolled up across several repos. - if (initiative.stores.length > 0 && initiative.changeStatuses.length > 0) { - const changeWidth = Math.max( - ...initiative.changeStatuses.map((c) => c.id.length) - ); - for (const change of initiative.changeStatuses) { - const mark = - change.state === 'complete' - ? '✓' - : change.state === 'not-found' - ? '?' - : change.state === 'no-tasks' - ? '–' - : '·'; - const where = change.store ?? 'here'; - const tasks = - change.state === 'not-found' - ? 'not found' - : change.totalTasks === 0 - ? 'no tasks' - : `${change.completedTasks}/${change.totalTasks} tasks`; - console.log( - ` ${mark} ${change.id.padEnd(changeWidth)} ${where.padEnd(14)} ${tasks}` - ); - } + console.log(`Plan: ${plan.path}`); + if (plan.stages.length > 0) { + console.log('Stages:'); + const stageWidth = Math.max(...plan.stages.map((s) => s.name.length)); + for (const stage of plan.stages) { + const files = stage.files === 1 ? '1 file' : `${stage.files} files`; + console.log(` ${stage.name.padEnd(stageWidth)} ${stage.files === 0 ? 'empty' : files}`); } + } else { + console.log('Stages: none yet (numbered folders become stages, e.g. 00_goal/)'); + } + + if (plan.changes.length === 0) { + console.log('Changes pointing here: none yet'); + console.log('Link one: openspec new change --plan local'); + return; + } + + console.log( + `Changes pointing here: ${plan.changesComplete}/${plan.changesTotal} complete` + ); + const changeWidth = Math.max(...plan.changes.map((c) => c.id.length)); + for (const change of plan.changes) { + const mark = + change.state === 'complete' ? '✓' : change.state === 'no-tasks' ? '–' : '·'; + const where = change.store ?? 'here'; + const tasks = + change.totalTasks === 0 + ? 'no tasks' + : `${change.completedTasks}/${change.totalTasks} tasks`; + console.log( + ` ${mark} ${change.id.padEnd(changeWidth)} ${where.padEnd(14)} ${tasks}` + ); } } @@ -282,18 +268,18 @@ program program .command('list') - .description('List items (changes by default). Use --specs to list specs, --initiatives to list initiatives.') + .description('List items (changes by default). Use --specs to list specs, --plan to see the plan rollup.') .option('--specs', 'List specs instead of changes') .option('--changes', 'List changes explicitly (default)') - .option('--initiatives', 'List initiatives with rolled-up change status') + .option('--plan', "Show the root's plan: its stages, and every change pointing at it") .option('--sort ', 'Sort order: "recent" (default) or "name"', 'recent') .option('--json', 'Output as JSON (for programmatic use)') .option('--store ', STORE_OPTION_DESCRIPTION) .addOption(hiddenStorePathOption()) - .action(async (options?: { specs?: boolean; changes?: boolean; initiatives?: boolean; sort?: string; json?: boolean; store?: string; storePath?: string }) => { + .action(async (options?: { specs?: boolean; changes?: boolean; plan?: boolean; sort?: string; json?: boolean; store?: string; storePath?: string }) => { try { - const failurePayload = options?.initiatives - ? { initiatives: [], root: null } + const failurePayload = options?.plan + ? { plan: null, root: null } : options?.specs ? { specs: [], root: null } : { changes: [], root: null }; @@ -304,8 +290,8 @@ program if (!root) { return; } - if (options?.initiatives) { - await renderInitiatives(root, { json: options?.json }); + if (options?.plan) { + await renderPlan(root, { json: options?.json }); return; } const listCommand = new ListCommand(); @@ -642,6 +628,7 @@ newCmd .option('--description ', 'Description to add to README.md') .option('--goal ', 'Optional goal metadata to store with the change') .option('--schema ', `Workflow schema to use (default: ${DEFAULT_SCHEMA})`) + .option('--plan ', "Link this change to a plan: 'local' or a store id") .option('--json', 'Output as JSON') .option('--store ', STORE_OPTION_DESCRIPTION) .addOption(hiddenStorePathOption()) @@ -658,22 +645,6 @@ newCmd } }); -newCmd - .command('initiative ') - .description('Scaffold an initiative folder (initiative.yaml + brief.md)') - .option('--title ', 'Human title (default: derived from the id)') - .option('--json', 'Output as JSON') - .option('--store ', STORE_OPTION_DESCRIPTION) - .addOption(hiddenStorePathOption()) - .action(async (name: string, options: NewInitiativeOptions) => { - try { - await newInitiativeCommand(name, options); - } catch (error) { - failWithError(error); - process.exit(1); - } - }); - export { program }; export function runCli(argv = process.argv): void { diff --git a/src/commands/context.ts b/src/commands/context.ts index c6d9204e4e..a8013b2245 100644 --- a/src/commands/context.ts +++ b/src/commands/context.ts @@ -27,8 +27,8 @@ import { COMMON_FLAGS } from '../core/completions/shared-flags.js'; import { emitFailure, printJson } from './shared-output.js'; import { gatherRelationshipData } from './shared-gather.js'; import { listSchemasWithInfo } from '../core/artifact-graph/index.js'; -import { listInitiativeSummaries } from '../core/initiatives.js'; -import { schemasFetchRecipe, initiativesFetchRecipe } from '../core/references.js'; +import { readPlanStages } from '../core/plan.js'; +import { schemasFetchRecipe, planFetchRecipe } from '../core/references.js'; const FAILURE_PAYLOAD = { root: null, members: [] }; @@ -64,7 +64,7 @@ function memberLine(member: WorkingSetMember): string { /** * Enrich available referenced-store members with the store's own custom - * artifact types and initiatives, so `context` reports what a repo draws on + * artifact types and plan stages, so `context` reports what a repo draws on * beyond specs. Read-only; failures degrade to an unenriched member. */ async function enrichMembersWithStoreArtifacts( @@ -86,14 +86,13 @@ async function enrichMembersWithStoreArtifacts( // Unreadable schemas dir: leave the member unenriched. } try { - const initiatives = (await listInitiativeSummaries(storeRoot)).map( - (initiative) => initiative.id - ); - if (initiatives.length > 0) { - member.initiatives = initiatives; + const plan = await readPlanStages(storeRoot); + const stages = (plan?.stages ?? []).map((stage) => stage.name); + if (stages.length > 0) { + member.plan = stages; } } catch { - // Unreadable initiatives dir: leave the member unenriched. + // Unreadable plan dir: leave the member unenriched. } } } @@ -123,9 +122,9 @@ function printHumanWorkingSet(workingSet: WorkingSet, declaredReferenceCount: nu ` Artifact types: ${member.artifactTypes.join(', ')} (${schemasFetchRecipe(member.id)})` ); } - if (member.initiatives && member.initiatives.length > 0) { + if (member.plan && member.plan.length > 0) { console.log( - ` Initiatives: ${member.initiatives.join(', ')} (${initiativesFetchRecipe(member.id)})` + ` Plan: ${member.plan.join(' → ')} (${planFetchRecipe(member.id)})` ); } } diff --git a/src/commands/workflow/index.ts b/src/commands/workflow/index.ts index d7198a3d57..232b2dbe34 100644 --- a/src/commands/workflow/index.ts +++ b/src/commands/workflow/index.ts @@ -19,7 +19,4 @@ export type { SchemasOptions } from './schemas.js'; export { newChangeCommand } from './new-change.js'; export type { NewChangeOptions } from './new-change.js'; -export { newInitiativeCommand } from './new-initiative.js'; -export type { NewInitiativeOptions } from './new-initiative.js'; - export { DEFAULT_SCHEMA } from './shared.js'; diff --git a/src/commands/workflow/new-change.ts b/src/commands/workflow/new-change.ts index 3e059242dc..e14386caae 100644 --- a/src/commands/workflow/new-change.ts +++ b/src/commands/workflow/new-change.ts @@ -31,6 +31,8 @@ export interface NewChangeOptions { description?: string; goal?: string; schema?: string; + /** Link the change to a plan: 'local' or a registered store id. */ + plan?: string; store?: string; storePath?: string; initiative?: string; @@ -126,6 +128,7 @@ export async function newChangeCommand(name: string | undefined, options: NewCha changesDir: root.changesDir, metadata: { ...(options.goal ? { goal: options.goal } : {}), + ...(options.plan ? { plan: options.plan } : {}), }, }); diff --git a/src/commands/workflow/new-initiative.ts b/src/commands/workflow/new-initiative.ts deleted file mode 100644 index 1f9e500ff1..0000000000 --- a/src/commands/workflow/new-initiative.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * New Initiative Command - * - * Scaffolds an initiative folder in the resolved OpenSpec root (or a store via - * `--store `): an `initiative.yaml` manifest plus a `brief.md` stub. This - * is a deliberately thin scaffold — a folder and a manifest — not a revival of - * the removed heavyweight initiative command group. - */ - -import { existsSync, promises as fs } from 'fs'; -import path from 'path'; -import ora from 'ora'; - -import { validateChangeName } from '../../utils/change-utils.js'; -import { - resolveRootForCommand, - toRootOutput, - withStoreFlag, - type RootOutput, -} from '../../core/root-selection.js'; -import { INITIATIVES_DIRNAME } from '../../core/initiatives.js'; -import { printJson, statusFromError } from './shared.js'; - -export interface NewInitiativeOptions { - title?: string; - store?: string; - storePath?: string; - json?: boolean; -} - -interface NewInitiativeOutput { - initiative: { - id: string; - path: string; - manifestPath: string; - title: string; - }; - root: RootOutput; -} - -/** "smoother-setup" -> "Smoother setup". */ -function titleFromId(id: string): string { - const spaced = id.replace(/-/g, ' '); - return spaced.charAt(0).toUpperCase() + spaced.slice(1); -} - -function manifestContent(title: string): string { - return ( - '# The initiative manifest: its title and the changes it groups.\n' + - '# Status for these changes is rolled up live by `openspec list --initiatives`.\n' + - `title: ${title}\n` + - 'changes: []\n' - ); -} - -function briefContent(title: string): string { - return ( - `# Initiative: ${title}\n\n` + - '## What this is\n\n' + - '\n\n' + - '## Why these changes go together\n\n' + - '\n\n' + - '## The changes it groups\n\n' + - 'List change ids in `initiative.yaml`. See live status with ' + - '`openspec list --initiatives`.\n' - ); -} - -export async function newInitiativeCommand( - name: string | undefined, - options: NewInitiativeOptions -): Promise { - const spinner = options.json ? undefined : ora(); - - try { - if (!name) { - throw new Error('Missing required argument '); - } - - const validation = validateChangeName(name); - if (!validation.valid) { - throw new Error(validation.error); - } - - const root = await resolveRootForCommand(options, { - json: options.json, - failurePayload: { initiative: null }, - }); - if (!root) { - return; - } - - const initiativeDir = path.join( - root.path, - 'openspec', - INITIATIVES_DIRNAME, - name - ); - - if (existsSync(initiativeDir)) { - throw new Error( - `Initiative '${name}' already exists at ${initiativeDir}` - ); - } - - const title = options.title ?? titleFromId(name); - if (spinner) { - spinner.start(`Creating initiative '${name}'...`); - } - - await fs.mkdir(initiativeDir, { recursive: true }); - const manifestPath = path.join(initiativeDir, 'initiative.yaml'); - await fs.writeFile(manifestPath, manifestContent(title), 'utf-8'); - await fs.writeFile( - path.join(initiativeDir, 'brief.md'), - briefContent(title), - 'utf-8' - ); - - const payload: NewInitiativeOutput = { - initiative: { - id: name, - path: initiativeDir, - manifestPath, - title, - }, - root: toRootOutput(root), - }; - - if (options.json) { - printJson(payload); - return; - } - - spinner?.stop(); - console.log(`Created initiative '${name}' at ${initiativeDir}/`); - console.log( - `Next: add change ids to ${path.join(name, 'initiative.yaml')}, then ` + - withStoreFlag(root, 'openspec list --initiatives') - ); - } catch (error) { - spinner?.stop(); - if (options.json) { - printJson({ - initiative: null, - status: [statusFromError(error)], - }); - process.exitCode = 1; - return; - } - throw error; - } -} diff --git a/src/core/change-metadata/schema.ts b/src/core/change-metadata/schema.ts index d97d9a9a3f..dedc9509b5 100644 --- a/src/core/change-metadata/schema.ts +++ b/src/core/change-metadata/schema.ts @@ -33,6 +33,9 @@ export const ChangeMetadataSchema = z.object({ goal: z.string().min(1).optional(), affected_areas: z.array(z.string().min(1)).optional(), initiative: InitiativeLinkSchema.optional(), + // The upward link to a plan folder: 'local' for the plan in this change's + // own repo, or a registered store id for that store's plan. + plan: KebabIdentifierSchema('plan').optional(), }); export type ChangeMetadata = z.infer; diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index fd03d0ccde..c4045ea56e 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -51,8 +51,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'List changes explicitly (default)', }, { - name: 'initiatives', - description: 'List initiatives with rolled-up change status', + name: 'plan', + description: "Show the root's plan: its stages, and every change pointing at it", }, { name: 'sort', @@ -246,19 +246,9 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Workflow schema to use', takesValue: true, }, - COMMON_FLAGS.json, - COMMON_FLAGS.store, - ], - }, - { - name: 'initiative', - description: 'Scaffold an initiative folder (initiative.yaml + brief.md)', - acceptsPositional: true, - positionals: [{ name: 'name' }], - flags: [ { - name: 'title', - description: 'Human title (default: derived from the id)', + name: 'plan', + description: "Link this change to a plan: 'local' or a store id", takesValue: true, }, COMMON_FLAGS.json, diff --git a/src/core/init.ts b/src/core/init.ts index 7f5149dd46..46393e031e 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -74,6 +74,7 @@ const WORKFLOW_TO_SKILL_DIR: Record = { 'verify': 'openspec-verify-change', 'onboard': 'openspec-onboard', 'propose': 'openspec-propose', + 'plan': 'openspec-plan', }; // ----------------------------------------------------------------------------- diff --git a/src/core/initiatives.ts b/src/core/initiatives.ts deleted file mode 100644 index 3da134db08..0000000000 --- a/src/core/initiatives.ts +++ /dev/null @@ -1,306 +0,0 @@ -/** - * Initiatives (stores beta). - * - * An initiative is the home for a big effort: a brief, the artifacts the team - * works from, and the changes that carry it out. It is a plain folder under - * `openspec/initiatives//` with an `initiative.yaml` manifest that names - * the changes it groups. - * - * This module lists initiatives, rolls up the live status of the changes each - * one groups (reusing the same task-progress source as `openspec list`), and - * detects when a local initiative shadows a canonical one in a referenced - * store. Precedence rule: the store initiative is canonical; a same-id local - * one is a shadow that is reported but never blocked — mirroring how OpenSpec - * reports schema shadowing. - */ - -import { existsSync, promises as fs } from 'fs'; -import path from 'path'; -import { parse as parseYaml } from 'yaml'; - -import { getTaskProgressForChange } from '../utils/task-progress.js'; -import { readProjectConfig } from './project-config.js'; -import { - listStoreRegistryEntries, - readStoreRegistryState, -} from './store/foundation.js'; -import { getStoreRootForBackend } from './store/registry.js'; - -export const INITIATIVES_DIRNAME = 'initiatives'; -const INITIATIVE_MANIFEST = 'initiative.yaml'; - -export type InitiativeChangeState = - | 'complete' - | 'in-progress' - | 'no-tasks' - | 'not-found'; - -/** The live status of one change an initiative groups. */ -export interface InitiativeChangeStatus { - id: string; - /** Registered store id where the change lives; absent = the initiative's - * own root (the solo/local case). */ - store?: string; - completedTasks: number; - totalTasks: number; - state: InitiativeChangeState; -} - -export interface InitiativeInfo { - id: string; - title: string; - /** Change ids this initiative groups (in manifest order). */ - changes: string[]; - /** Per-change live status, including where each change lives. */ - changeStatuses: InitiativeChangeStatus[]; - /** Rolled-up status of the grouped changes. */ - changesComplete: number; - changesTotal: number; - tasksComplete: number; - tasksTotal: number; - /** Distinct store ids this initiative's changes span (cross-repo). Empty - * when every change lives in the initiative's own root. */ - stores: string[]; - /** Set when a same-id canonical initiative exists in a referenced store. */ - shadowsStore?: string; -} - -/** A change entry: `""` (own root) or `{ id, store }` (in a store). */ -type ManifestChange = string | { id?: string; store?: string }; - -interface InitiativeManifest { - title?: string; - changes?: ManifestChange[]; -} - -function normalizeChange(entry: ManifestChange): { id: string; store?: string } { - if (typeof entry === 'string') { - return { id: entry }; - } - return { id: entry.id ?? '', ...(entry.store ? { store: entry.store } : {}) }; -} - -function initiativesDir(root: string): string { - return path.join(root, 'openspec', INITIATIVES_DIRNAME); -} - -/** - * Reads an initiative's manifest. Returns null when the folder has no - * `initiative.yaml` (a plain folder that is not yet a tracked initiative). - */ -async function readManifest( - dir: string -): Promise { - const manifestPath = path.join(dir, INITIATIVE_MANIFEST); - let raw: string; - try { - raw = await fs.readFile(manifestPath, 'utf-8'); - } catch { - return null; - } - try { - const parsed = parseYaml(raw) as InitiativeManifest | null; - return parsed ?? {}; - } catch { - return {}; - } -} - -/** Lists initiative folder ids under a root's `openspec/initiatives/`. */ -async function listInitiativeIds(root: string): Promise { - let entries; - try { - entries = await fs.readdir(initiativesDir(root), { withFileTypes: true }); - } catch { - return []; - } - return entries - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name); -} - -/** - * Every registered store id → its on-disk root. Read once and shared by the - * shadow lookup and per-change resolution. Unusable backends are skipped - * (doctor reports them). - */ -async function buildStoreRootMap( - globalDataDir?: string -): Promise> { - const registry = await readStoreRegistryState( - globalDataDir ? { globalDataDir } : {} - ); - const entries = registry ? listStoreRegistryEntries(registry) : []; - const map = new Map(); - for (const entry of entries) { - try { - map.set(entry.id, getStoreRootForBackend(entry.backend)); - } catch { - // Unusable backend — skipped. - } - } - return map; -} - -/** - * Builds the shadow lookup: for each store this root *references* (and that is - * registered), the set of initiative ids it defines. A local initiative whose - * id appears here shadows the store's canonical one. - */ -async function buildShadowLookup( - root: string, - storeRoots: Map -): Promise> { - const lookup = new Map(); - const config = readProjectConfig(root); - const references = config?.references ?? []; - for (const reference of references) { - const storeRoot = storeRoots.get(reference.id); - if (!storeRoot) continue; - for (const id of await listInitiativeIds(storeRoot)) { - // First referenced store to define an id is named as canonical. - if (!lookup.has(id)) { - lookup.set(id, reference.id); - } - } - } - return lookup; -} - -/** - * Resolves one change's live status against the changes dir it lives in. - * A missing change dir is reported as `not-found` (distinct from a change - * that exists but has no tasks yet). - */ -async function resolveChangeStatus( - changeId: string, - store: string | undefined, - localChangesDir: string, - storeRoots: Map -): Promise { - let changesDir: string | null; - if (store) { - const storeRoot = storeRoots.get(store); - changesDir = storeRoot - ? path.join(storeRoot, 'openspec', 'changes') - : null; // named store not registered on this machine - } else { - changesDir = localChangesDir; - } - - const base: InitiativeChangeStatus = { - id: changeId, - ...(store ? { store } : {}), - completedTasks: 0, - totalTasks: 0, - state: 'not-found', - }; - - if (changesDir === null || !existsSync(path.join(changesDir, changeId))) { - return base; - } - - const progress = await getTaskProgressForChange(changesDir, changeId); - const state: InitiativeChangeState = - progress.total === 0 - ? 'no-tasks' - : progress.completed === progress.total - ? 'complete' - : 'in-progress'; - return { - ...base, - completedTasks: progress.completed, - totalTasks: progress.total, - state, - }; -} - -/** - * Lightweight id + title for each initiative at a root — no change rollup and - * no shadow detection. Used to index a referenced store's initiatives without - * the cost of resolving that store's own references. - */ -export async function listInitiativeSummaries( - root: string -): Promise> { - const ids = await listInitiativeIds(root); - const summaries: Array<{ id: string; title: string }> = []; - for (const id of ids) { - const manifest = await readManifest(path.join(initiativesDir(root), id)); - if (manifest === null) continue; - summaries.push({ id, title: manifest.title ?? id }); - } - summaries.sort((a, b) => a.id.localeCompare(b.id)); - return summaries; -} - -/** - * Lists the initiatives at a root, with rolled-up change status and shadow - * detection against referenced stores. - */ -export async function listInitiatives( - root: string, - options: { globalDataDir?: string } = {} -): Promise { - const ids = await listInitiativeIds(root); - if (ids.length === 0) { - return []; - } - - const localChangesDir = path.join(root, 'openspec', 'changes'); - const storeRoots = await buildStoreRootMap(options.globalDataDir); - const shadows = await buildShadowLookup(root, storeRoots); - const initiatives: InitiativeInfo[] = []; - - for (const id of ids) { - const manifest = await readManifest(path.join(initiativesDir(root), id)); - if (manifest === null) { - // A plain folder without a manifest is not a tracked initiative. - continue; - } - - const entries = (manifest.changes ?? []) - .map(normalizeChange) - .filter((entry) => entry.id.length > 0); - - const changeStatuses: InitiativeChangeStatus[] = []; - let changesComplete = 0; - let tasksComplete = 0; - let tasksTotal = 0; - const stores = new Set(); - - for (const entry of entries) { - const status = await resolveChangeStatus( - entry.id, - entry.store, - localChangesDir, - storeRoots - ); - changeStatuses.push(status); - tasksComplete += status.completedTasks; - tasksTotal += status.totalTasks; - if (status.state === 'complete') { - changesComplete += 1; - } - if (entry.store) { - stores.add(entry.store); - } - } - - initiatives.push({ - id, - title: manifest.title ?? id, - changes: entries.map((entry) => entry.id), - changeStatuses, - changesComplete, - changesTotal: entries.length, - tasksComplete, - tasksTotal, - stores: [...stores].sort(), - ...(shadows.has(id) ? { shadowsStore: shadows.get(id) } : {}), - }); - } - - initiatives.sort((a, b) => a.id.localeCompare(b.id)); - return initiatives; -} diff --git a/src/core/plan.ts b/src/core/plan.ts new file mode 100644 index 0000000000..34c319bcdb --- /dev/null +++ b/src/core/plan.ts @@ -0,0 +1,240 @@ +/** + * The plan folder (stores beta). + * + * A plan is the home for work above a single change: `openspec/plan/`. + * Numbered folders inside it are stages, in order (`00_goal/`, + * `01_requirements/`, ...) — the numbering is the order; the names and + * contents are the user's own. Unnumbered entries are ambient context + * (vision, notes, meetings). + * + * Changes point UP at a plan with one metadata line in `.openspec.yaml`: + * `plan: local` (the plan in the change's own repo) or `plan: ` + * (the plan in that registered store). There is no manifest — rollup + * discovers changes by scanning for that line, so teams tag their own work + * and nothing central needs maintaining. + */ + +import { promises as fs } from 'fs'; +import path from 'path'; +import { parse as parseYaml } from 'yaml'; + +import { getTaskProgressForChange } from '../utils/task-progress.js'; +import { + listStoreRegistryEntries, + readStoreRegistryState, +} from './store/foundation.js'; +import { getStoreRootForBackend } from './store/registry.js'; + +export const PLAN_DIRNAME = 'plan'; + +/** Numbered entries are stages: 00_goal, 01-requirements, 2_design ... */ +const STAGE_PATTERN = /^\d+[-_]/; + +export interface PlanStage { + name: string; + files: number; +} + +export interface PlanChangeStatus { + id: string; + /** Registered store the change lives in; absent = the plan's own root. */ + store?: string; + completedTasks: number; + totalTasks: number; + state: 'complete' | 'in-progress' | 'no-tasks'; +} + +export interface PlanInfo { + path: string; + stages: PlanStage[]; + /** Unnumbered entries: ambient context, always worth reading. */ + context: string[]; + changes: PlanChangeStatus[]; + changesComplete: number; + changesTotal: number; + tasksComplete: number; + tasksTotal: number; +} + +function planDir(root: string): string { + return path.join(root, 'openspec', PLAN_DIRNAME); +} + +async function countFiles(dir: string): Promise { + let count = 0; + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return 0; + } + for (const entry of entries) { + if (entry.isDirectory()) { + count += await countFiles(path.join(dir, entry.name)); + } else { + count += 1; + } + } + return count; +} + +/** + * Reads a root's plan folder: numbered stages (sorted) and unnumbered + * context entries. Returns null when there is no plan folder. + */ +export async function readPlanStages( + root: string +): Promise<{ stages: PlanStage[]; context: string[] } | null> { + let entries; + try { + entries = await fs.readdir(planDir(root), { withFileTypes: true }); + } catch { + return null; + } + + const stages: PlanStage[] = []; + const context: string[] = []; + for (const entry of entries) { + if (entry.isDirectory() && STAGE_PATTERN.test(entry.name)) { + stages.push({ + name: entry.name, + files: await countFiles(path.join(planDir(root), entry.name)), + }); + } else if (!entry.name.startsWith('.')) { + context.push(entry.name); + } + } + stages.sort((a, b) => a.name.localeCompare(b.name)); + context.sort(); + return { stages, context }; +} + +/** + * Reads the `plan:` line from a change's `.openspec.yaml`. Tolerant: any + * unreadable or planless metadata simply means "not part of a plan". + */ +async function readPlanRef(changeDir: string): Promise { + try { + const raw = await fs.readFile(path.join(changeDir, '.openspec.yaml'), 'utf-8'); + const parsed = parseYaml(raw) as { plan?: unknown } | null; + return typeof parsed?.plan === 'string' && parsed.plan.length > 0 + ? parsed.plan + : null; + } catch { + return null; + } +} + +/** Scans one root's changes for those whose `plan:` matches. */ +async function collectMatchingChanges( + root: string, + matches: (ref: string) => boolean, + store: string | undefined +): Promise { + const changesDir = path.join(root, 'openspec', 'changes'); + let entries; + try { + entries = await fs.readdir(changesDir, { withFileTypes: true }); + } catch { + return []; + } + + const found: PlanChangeStatus[] = []; + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === 'archive') continue; + const ref = await readPlanRef(path.join(changesDir, entry.name)); + if (ref === null || !matches(ref)) continue; + + const progress = await getTaskProgressForChange(changesDir, entry.name); + found.push({ + id: entry.name, + ...(store ? { store } : {}), + completedTasks: progress.completed, + totalTasks: progress.total, + state: + progress.total === 0 + ? 'no-tasks' + : progress.completed === progress.total + ? 'complete' + : 'in-progress', + }); + } + found.sort((a, b) => a.id.localeCompare(b.id)); + return found; +} + +/** + * Rolls up a root's plan: its stages plus every change on this machine that + * points at it. Local changes match `plan: local` (or the root's own store + * id); when the root is a registered store, other registered roots are + * scanned for `plan: `. Returns null when the root has no plan. + */ +export async function rollupPlan( + root: string, + options: { globalDataDir?: string } = {} +): Promise { + const shape = await readPlanStages(root); + if (shape === null) { + return null; + } + + const registry = await readStoreRegistryState( + options.globalDataDir ? { globalDataDir: options.globalDataDir } : {} + ).catch(() => null); + const registered = registry ? listStoreRegistryEntries(registry) : []; + + // Which registered store, if any, is this root? + const resolvedRoot = path.resolve(root); + let ownStoreId: string | undefined; + const storeRoots: Array<{ id: string; root: string }> = []; + for (const entry of registered) { + try { + const entryRoot = path.resolve(getStoreRootForBackend(entry.backend)); + storeRoots.push({ id: entry.id, root: entryRoot }); + if (entryRoot === resolvedRoot) { + ownStoreId = entry.id; + } + } catch { + // Unusable backend — skipped; doctor reports it. + } + } + + const changes = await collectMatchingChanges( + root, + (ref) => ref === 'local' || (ownStoreId !== undefined && ref === ownStoreId), + undefined + ); + + if (ownStoreId !== undefined) { + for (const store of storeRoots) { + if (store.root === resolvedRoot) continue; + changes.push( + ...(await collectMatchingChanges( + store.root, + (ref) => ref === ownStoreId, + store.id + )) + ); + } + } + + let changesComplete = 0; + let tasksComplete = 0; + let tasksTotal = 0; + for (const change of changes) { + tasksComplete += change.completedTasks; + tasksTotal += change.totalTasks; + if (change.state === 'complete') changesComplete += 1; + } + + return { + path: planDir(root), + stages: shape.stages, + context: shape.context, + changes, + changesComplete, + changesTotal: changes.length, + tasksComplete, + tasksTotal, + }; +} diff --git a/src/core/profile-sync-drift.ts b/src/core/profile-sync-drift.ts index 782bdcc9fa..f7a87f1f6b 100644 --- a/src/core/profile-sync-drift.ts +++ b/src/core/profile-sync-drift.ts @@ -23,6 +23,7 @@ export const WORKFLOW_TO_SKILL_DIR: Record = { 'verify': 'openspec-verify-change', 'onboard': 'openspec-onboard', 'propose': 'openspec-propose', + 'plan': 'openspec-plan', }; function toKnownWorkflows(workflows: readonly string[]): WorkflowId[] { diff --git a/src/core/profiles.ts b/src/core/profiles.ts index 29d4927468..b738b9b015 100644 --- a/src/core/profiles.ts +++ b/src/core/profiles.ts @@ -28,6 +28,7 @@ export const ALL_WORKFLOWS = [ 'bulk-archive', 'verify', 'onboard', + 'plan', ] as const; export type WorkflowId = (typeof ALL_WORKFLOWS)[number]; diff --git a/src/core/references.ts b/src/core/references.ts index 5e1ad60f9d..6d9bd0d49a 100644 --- a/src/core/references.ts +++ b/src/core/references.ts @@ -24,7 +24,7 @@ import { getSpecIds } from '../utils/item-discovery.js'; import { FileSystemUtils } from '../utils/file-system.js'; import { MAX_CONTEXT_SIZE, type DeclarationEntry } from './project-config.js'; import { listSchemasWithInfo } from './artifact-graph/index.js'; -import { listInitiativeSummaries } from './initiatives.js'; +import { readPlanStages } from './plan.js'; export interface ReferenceSpecEntry { id: string; @@ -38,18 +38,13 @@ export interface ReferenceSchemaEntry { artifacts: string[]; } -/** A store's initiative, for the reference index. */ -export interface ReferenceInitiativeEntry { - id: string; - summary: string; -} - export interface ReferenceIndexEntry { store_id: string; root?: string; specs?: ReferenceSpecEntry[]; schemas?: ReferenceSchemaEntry[]; - initiatives?: ReferenceInitiativeEntry[]; + /** Stage names of the store's plan folder, in order (when it has one). */ + plan?: string[]; fetch?: string; status: StoreDiagnostic[]; } @@ -178,19 +173,14 @@ function collectSchemaEntries(referencedRoot: string): ReferenceSchemaEntry[] { })); } -async function collectInitiativeEntries( - referencedRoot: string -): Promise { - let summaries; +/** Stage names of a store's plan folder, sanitized for the index. */ +async function collectPlanStages(referencedRoot: string): Promise { try { - summaries = await listInitiativeSummaries(referencedRoot); + const plan = await readPlanStages(referencedRoot); + return (plan?.stages ?? []).map((stage) => sanitizeInline(stage.name, 60)); } catch { return []; } - return summaries.map((initiative) => ({ - id: sanitizeInline(initiative.id, 100), - summary: sanitizeInline(initiative.title), - })); } export function fetchRecipe(storeId: string): string { @@ -201,8 +191,8 @@ export function schemasFetchRecipe(storeId: string): string { return `openspec schemas --store ${storeId}`; } -export function initiativesFetchRecipe(storeId: string): string { - return `openspec list --initiatives --store ${storeId}`; +export function planFetchRecipe(storeId: string): string { + return `openspec list --plan --store ${storeId}`; } function specLine(spec: ReferenceSpecEntry): string { @@ -279,15 +269,10 @@ function renderEntryLines(entry: ReferenceIndexEntry): string[] { ); } } - if (entry.initiatives && entry.initiatives.length > 0) { - lines.push(` Initiatives (${initiativesFetchRecipe(entry.store_id)}):`); - for (const initiative of entry.initiatives) { - lines.push( - initiative.summary - ? ` - ${initiative.id}: ${initiative.summary}` - : ` - ${initiative.id}` - ); - } + if (entry.plan && entry.plan.length > 0) { + lines.push( + ` Plan: ${entry.plan.join(' → ')} (${planFetchRecipe(entry.store_id)})` + ); } if (entry.fetch) { lines.push(` Fetch: ${entry.fetch}`); @@ -453,13 +438,13 @@ export async function assembleReferenceIndex( const specs = await collectSpecEntries(inspection.canonicalRoot); const schemas = collectSchemaEntries(inspection.canonicalRoot); - const initiatives = await collectInitiativeEntries(inspection.canonicalRoot); + const plan = await collectPlanStages(inspection.canonicalRoot); const entry: ReferenceIndexEntry = { store_id: id, root: inspection.canonicalRoot, specs, ...(schemas.length > 0 ? { schemas } : {}), - ...(initiatives.length > 0 ? { initiatives } : {}), + ...(plan.length > 0 ? { plan } : {}), fetch: fetchRecipe(id), status: [], }; diff --git a/src/core/shared/skill-generation.ts b/src/core/shared/skill-generation.ts index 898e7a25e8..8429b7f492 100644 --- a/src/core/shared/skill-generation.ts +++ b/src/core/shared/skill-generation.ts @@ -16,6 +16,8 @@ import { getVerifyChangeSkillTemplate, getOnboardSkillTemplate, getOpsxProposeSkillTemplate, + getPlanSkillTemplate, + getOpsxPlanCommandTemplate, getOpsxExploreCommandTemplate, getOpsxNewCommandTemplate, getOpsxContinueCommandTemplate, @@ -66,6 +68,7 @@ export function getSkillTemplates(workflowFilter?: readonly string[]): SkillTemp { template: getVerifyChangeSkillTemplate(), dirName: 'openspec-verify-change', workflowId: 'verify' }, { template: getOnboardSkillTemplate(), dirName: 'openspec-onboard', workflowId: 'onboard' }, { template: getOpsxProposeSkillTemplate(), dirName: 'openspec-propose', workflowId: 'propose' }, + { template: getPlanSkillTemplate(), dirName: 'openspec-plan', workflowId: 'plan' }, ]; if (!workflowFilter) return all; @@ -92,6 +95,7 @@ export function getCommandTemplates(workflowFilter?: readonly string[]): Command { template: getOpsxVerifyCommandTemplate(), id: 'verify' }, { template: getOpsxOnboardCommandTemplate(), id: 'onboard' }, { template: getOpsxProposeCommandTemplate(), id: 'propose' }, + { template: getOpsxPlanCommandTemplate(), id: 'plan' }, ]; if (!workflowFilter) return all; diff --git a/src/core/shared/tool-detection.ts b/src/core/shared/tool-detection.ts index 72a0ebc8a3..f1e1869552 100644 --- a/src/core/shared/tool-detection.ts +++ b/src/core/shared/tool-detection.ts @@ -42,6 +42,7 @@ export const COMMAND_IDS = [ 'verify', 'onboard', 'propose', + 'plan', ] as const; export type CommandId = (typeof COMMAND_IDS)[number]; diff --git a/src/core/templates/skill-templates.ts b/src/core/templates/skill-templates.ts index ff687d900a..a0957da753 100644 --- a/src/core/templates/skill-templates.ts +++ b/src/core/templates/skill-templates.ts @@ -17,4 +17,5 @@ export { getBulkArchiveChangeSkillTemplate, getOpsxBulkArchiveCommandTemplate } export { getVerifyChangeSkillTemplate, getOpsxVerifyCommandTemplate } from './workflows/verify-change.js'; export { getOnboardSkillTemplate, getOpsxOnboardCommandTemplate } from './workflows/onboard.js'; export { getOpsxProposeSkillTemplate, getOpsxProposeCommandTemplate } from './workflows/propose.js'; +export { getPlanSkillTemplate, getOpsxPlanCommandTemplate } from './workflows/plan.js'; export { getFeedbackSkillTemplate } from './workflows/feedback.js'; diff --git a/src/core/templates/workflows/plan.ts b/src/core/templates/workflows/plan.ts new file mode 100644 index 0000000000..a00502aeaf --- /dev/null +++ b/src/core/templates/workflows/plan.ts @@ -0,0 +1,86 @@ +/** + * Skill Template Workflow Modules + * + * The plan skill: work above a single change. A stance, not a workflow — + * modeled on explore mode. + */ +import type { SkillTemplate, CommandTemplate } from '../types.js'; +import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; + +const PLAN_BODY = `Enter plan mode. Work above a single change: help turn high-level intent into changes, and show where the whole effort stands. + +**This is a stance, not a workflow.** No fixed steps, no required sequence, no mandatory outputs. Follow what the conversation needs. + +--- + +## The shape you work with + +A plan lives in one folder: \`openspec/plan/\` — in this repo, or in a store the team shares. + +- **Numbered folders are stages, in order.** \`00_goal/\`, \`01_requirements/\`, \`02_changes/\` — the numbers are the sequence; the names and contents are the user's own. Any artifact goes: a roadmap, a PRD, personas, decisions, a feature table. +- **Unnumbered files and folders are ambient context.** Vision, meeting notes, glossary — always worth reading, never a stage. +- **Changes point up at the plan**, with one line in their \`.openspec.yaml\`: \`plan: local\` (this repo's plan) or \`plan: \` (a store's plan). There is no list to maintain anywhere. + +## Orient first + +Read the folders, not job titles — anyone can pick up from what is on disk. + +- \`ls openspec/plan/\` — which stages exist and which are still empty tells you where the effort stands. +- \`openspec list --plan --json\` (add \`--store \` for a shared plan) — every change pointing at the plan, with live task status. + +## Ways of moving + +**Translate down.** Take the most complete stage and help produce the next one — one altitude at a time. When you decompose into work items, make each one self-contained: someone could pick any single item up without reading the others. Surface merge/split judgment calls to the user; don't decide silently. + +**Bridge to changes.** The last stage is where the plan meets execution. When a work item is ready, make it real: + +\`\`\`bash +openspec new change --plan local # plan lives in this repo +openspec new change --plan # plan lives in a store +\`\`\` + +The \`--plan\` line is the whole link. Status flows back with no bookkeeping. + +**Sync up.** Compare \`openspec list --plan\` against the upstream stages. Update the high level to match reality; name drift plainly instead of papering over it. + +**Filter input.** If the plan has a vision or goal file, weigh new input against it. Park contradicting input in a cut file (e.g. \`notes/cut.md\`) with one line of why — preserved, not lost. + +## Write less + +Plans die of verbosity. + +- One page per artifact. Longer means cut or split. +- Prefer a table to prose, a line to a paragraph. +- No file paths or code snippets in upstream artifacts — they rot. +- Never restate another stage; point to it. + +## Stay above the code + +Reading code to ground the plan is fine. Writing code is not plan work — that belongs to a change (\`/opsx:apply\`). + +--- + +${STORE_SELECTION_GUIDANCE}`; + +export function getPlanSkillTemplate(): SkillTemplate { + return { + name: 'openspec-plan', + description: + 'Enter plan mode - work above a single change. Use when turning high-level intent (a roadmap, PRD, vision) into changes, or when asked where the whole effort stands.', + instructions: PLAN_BODY, + license: 'MIT', + compatibility: 'Requires openspec CLI.', + metadata: { author: 'openspec', version: '1.0' }, + }; +} + +export function getOpsxPlanCommandTemplate(): CommandTemplate { + return { + name: 'OPSX: Plan', + description: + 'Enter plan mode - turn high-level intent into changes and see where the effort stands', + category: 'Workflow', + tags: ['workflow', 'planning', 'experimental'], + content: PLAN_BODY, + }; +} diff --git a/src/core/templates/workflows/store-selection.ts b/src/core/templates/workflows/store-selection.ts index cbdfa66512..1c39b38323 100644 --- a/src/core/templates/workflows/store-selection.ts +++ b/src/core/templates/workflows/store-selection.ts @@ -4,4 +4,4 @@ * Interpolated into every workflow's instructions so generated skills * consistently teach how to target a registered store with `--store `. */ -export const STORE_SELECTION_GUIDANCE = `**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run \`openspec store list --json\` to discover registered store ids, then pass \`--store \` on the commands that act on a selected root (\`new change\`, \`new initiative\`, \`status\`, \`instructions\`, \`list\`, \`show\`, \`validate\`, \`archive\`, \`doctor\`, \`context\`, \`schemas\`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local \`openspec/\` root.`; +export const STORE_SELECTION_GUIDANCE = `**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run \`openspec store list --json\` to discover registered store ids, then pass \`--store \` on the commands that act on a selected root (\`new change\`, \`status\`, \`instructions\`, \`list\`, \`show\`, \`validate\`, \`archive\`, \`doctor\`, \`context\`, \`schemas\`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local \`openspec/\` root.`; diff --git a/src/core/working-set.ts b/src/core/working-set.ts index 333e9de8e9..ebe77d85c7 100644 --- a/src/core/working-set.ts +++ b/src/core/working-set.ts @@ -21,8 +21,8 @@ export interface WorkingSetMember { /** A referenced store's own custom artifact types (project schema names). * Present only when the store defines some and it is available. */ artifactTypes?: string[]; - /** A referenced store's initiative ids. Present only when it has some. */ - initiatives?: string[]; + /** Stage names of a referenced store's plan folder, in order. */ + plan?: string[]; status: StoreDiagnostic[]; } diff --git a/src/utils/change-utils.ts b/src/utils/change-utils.ts index c47a4a3efe..0a3b29340e 100644 --- a/src/utils/change-utils.ts +++ b/src/utils/change-utils.ts @@ -17,7 +17,7 @@ export interface CreateChangeOptions { /** Directory that should contain the change directories */ changesDir?: string; /** Additional metadata to persist in the change's .openspec.yaml */ - metadata?: Partial>; + metadata?: Partial>; } /** diff --git a/test/commands/context.test.ts b/test/commands/context.test.ts index e736faa43e..ae9b8af585 100644 --- a/test/commands/context.test.ts +++ b/test/commands/context.test.ts @@ -103,8 +103,8 @@ describe('openspec context (4.1)', () => { expect(parseJson(declared).members).toHaveLength(2); }); - it("surfaces a referenced store's own artifact types and initiatives", async () => { - // The upstream store defines a custom artifact type and an initiative. + it("surfaces a referenced store's own artifact types and plan stages", async () => { + // The upstream store defines a custom artifact type and a plan folder. const schemaDir = path.join(upstream, 'openspec', 'schemas', 'team-brief'); fs.mkdirSync(path.join(schemaDir, 'templates'), { recursive: true }); fs.writeFileSync( @@ -114,12 +114,12 @@ describe('openspec context (4.1)', () => { ' template: brief.md\n requires: []\n instruction: y\n' ); fs.writeFileSync(path.join(schemaDir, 'templates', 'brief.md'), '# Brief\n'); - const initiativeDir = path.join(upstream, 'openspec', 'initiatives', 'roadmap'); - fs.mkdirSync(initiativeDir, { recursive: true }); - fs.writeFileSync( - path.join(initiativeDir, 'initiative.yaml'), - 'title: Roadmap\nchanges: []\n' - ); + fs.mkdirSync(path.join(upstream, 'openspec', 'plan', '00_goal'), { + recursive: true, + }); + fs.mkdirSync(path.join(upstream, 'openspec', 'plan', '01_changes'), { + recursive: true, + }); const json = await runCLI(['context', '--json', '--store', 'team-context'], { cwd: tempDir, @@ -129,7 +129,7 @@ describe('openspec context (4.1)', () => { (member: any) => member.id === 'upstream-context' ); expect(upstreamMember.artifactTypes).toEqual(['team-brief']); - expect(upstreamMember.initiatives).toEqual(['roadmap']); + expect(upstreamMember.plan).toEqual(['00_goal', '01_changes']); const human = await runCLI(['context', '--store', 'team-context'], { cwd: tempDir, @@ -139,7 +139,7 @@ describe('openspec context (4.1)', () => { 'Artifact types: team-brief (openspec schemas --store upstream-context)' ); expect(human.stdout).toContain( - 'Initiatives: roadmap (openspec list --initiatives --store upstream-context)' + 'Plan: 00_goal → 01_changes (openspec list --plan --store upstream-context)' ); }); diff --git a/test/commands/new-initiative.test.ts b/test/commands/new-initiative.test.ts deleted file mode 100644 index 1521e320ac..0000000000 --- a/test/commands/new-initiative.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; - -import { runCLI } from '../helpers/run-cli.js'; -import { createOpenSpecRoot } from '../helpers/openspec-fixtures.js'; - -describe('openspec new initiative', () => { - let tempDir: string; - let env: NodeJS.ProcessEnv; - - beforeEach(() => { - tempDir = fs.realpathSync.native( - fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-new-initiative-')) - ); - createOpenSpecRoot(tempDir); - env = { - XDG_DATA_HOME: path.join(tempDir, 'data'), - OPEN_SPEC_INTERACTIVE: '0', - OPENSPEC_TELEMETRY: '0', - }; - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - function initiativePath(id: string, ...rest: string[]): string { - return path.join(tempDir, 'openspec', 'initiatives', id, ...rest); - } - - it('scaffolds a manifest and brief, derives a title, and lists it', async () => { - const created = await runCLI(['new', 'initiative', 'smoother-setup'], { - cwd: tempDir, - env, - }); - expect(created.exitCode).toBe(0); - expect(fs.existsSync(initiativePath('smoother-setup', 'initiative.yaml'))).toBe(true); - expect(fs.existsSync(initiativePath('smoother-setup', 'brief.md'))).toBe(true); - expect( - fs.readFileSync(initiativePath('smoother-setup', 'initiative.yaml'), 'utf-8') - ).toContain('title: Smoother setup'); - - const list = await runCLI(['list', '--initiatives'], { cwd: tempDir, env }); - expect(list.stdout).toContain('smoother-setup'); - }); - - it('honors an explicit --title and emits JSON', async () => { - const created = await runCLI( - ['new', 'initiative', 'payments', '--title', 'Payments Revamp', '--json'], - { cwd: tempDir, env } - ); - expect(created.exitCode).toBe(0); - const payload = JSON.parse(created.stdout); - expect(payload.initiative.id).toBe('payments'); - expect(payload.initiative.title).toBe('Payments Revamp'); - expect( - fs.readFileSync(initiativePath('payments', 'initiative.yaml'), 'utf-8') - ).toContain('title: Payments Revamp'); - }); - - it('refuses to overwrite an existing initiative', async () => { - await runCLI(['new', 'initiative', 'dup'], { cwd: tempDir, env }); - const second = await runCLI(['new', 'initiative', 'dup'], { cwd: tempDir, env }); - expect(second.exitCode).toBe(1); - expect(second.stderr + second.stdout).toContain('already exists'); - }); -}); diff --git a/test/core/completions/command-registry.test.ts b/test/core/completions/command-registry.test.ts index 55b3ebbeb4..9a740f421c 100644 --- a/test/core/completions/command-registry.test.ts +++ b/test/core/completions/command-registry.test.ts @@ -171,7 +171,6 @@ describe('command completion registry', () => { 'instructions', 'list', 'new change', - 'new initiative', 'schemas', 'show', 'status', @@ -208,6 +207,7 @@ describe('command completion registry', () => { 'description', 'goal', 'schema', + 'plan', 'json', 'store', ]); diff --git a/test/core/initiatives.test.ts b/test/core/initiatives.test.ts deleted file mode 100644 index 4196a46f8f..0000000000 --- a/test/core/initiatives.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; - -import { listInitiatives } from '../../src/core/initiatives.js'; -import { - readStoreRegistryState, - writeStoreRegistryState, -} from '../../src/core/store/foundation.js'; - -describe('listInitiatives', () => { - let tempDir: string; - let globalDataDir: string; - let savedXdgDataHome: string | undefined; - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-initiatives-')); - globalDataDir = path.join(tempDir, 'data', 'openspec'); - savedXdgDataHome = process.env.XDG_DATA_HOME; - process.env.XDG_DATA_HOME = path.join(tempDir, 'xdg'); - }); - - afterEach(() => { - if (savedXdgDataHome === undefined) { - delete process.env.XDG_DATA_HOME; - } else { - process.env.XDG_DATA_HOME = savedXdgDataHome; - } - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - function write(relativePath: string, content: string): void { - const full = path.join(tempDir, relativePath); - fs.mkdirSync(path.dirname(full), { recursive: true }); - fs.writeFileSync(full, content); - } - - function initiative(root: string, id: string, manifest: string): void { - write(`${root}/openspec/initiatives/${id}/initiative.yaml`, manifest); - } - - function change(root: string, id: string, tasks: string): void { - write(`${root}/openspec/changes/${id}/tasks.md`, tasks); - } - - async function registerStore(id: string): Promise { - const storeRoot = path.join(tempDir, 'stores', id); - fs.mkdirSync(storeRoot, { recursive: true }); - const existing = await readStoreRegistryState({ globalDataDir }).catch( - () => null - ); - await writeStoreRegistryState( - { - version: 1, - stores: { - ...(existing?.stores ?? {}), - [id]: { backend: { type: 'git', local_path: storeRoot } }, - }, - }, - { globalDataDir } - ); - return storeRoot; - } - - it('rolls up the live status of the changes an initiative groups', async () => { - initiative( - 'app', - 'smoother-setup', - 'title: Smoother setup\nchanges:\n - done-change\n - wip-change\n' - ); - change('app', 'done-change', '- [x] 1.1 a\n- [x] 1.2 b\n'); - change('app', 'wip-change', '- [x] 1.1 a\n- [ ] 1.2 b\n'); - - const [result] = await listInitiatives(path.join(tempDir, 'app'), { - globalDataDir, - }); - - expect(result.id).toBe('smoother-setup'); - expect(result.title).toBe('Smoother setup'); - expect(result.changesTotal).toBe(2); - expect(result.changesComplete).toBe(1); // only done-change is fully complete - expect(result.tasksTotal).toBe(4); - expect(result.tasksComplete).toBe(3); - expect(result.shadowsStore).toBeUndefined(); - // Solo/local: no external stores, changes resolve in the initiative's root. - expect(result.stores).toEqual([]); - expect(result.changeStatuses.map((c) => c.store)).toEqual([ - undefined, - undefined, - ]); - }); - - it('rolls up change status across stores named per change (cross-repo)', async () => { - await registerStore('api-server'); - await registerStore('web-app'); - change('stores/api-server', 'add-payments-api', '- [x] a\n- [x] b\n'); // complete - change('stores/api-server', 'add-search', '- [x] a\n- [ ] b\n'); // in-progress - change('stores/web-app', 'add-payments-ui', '- [ ] a\n'); // in-progress - change('app', 'update-docs', '- [x] a\n'); // complete, local to the initiative - - initiative( - 'app', - 'payments-launch', - 'title: Payments launch\n' + - 'changes:\n' + - ' - { id: add-payments-api, store: api-server }\n' + - ' - { id: add-search, store: api-server }\n' + - ' - { id: add-payments-ui, store: web-app }\n' + - ' - update-docs\n' + - ' - { id: add-refunds, store: web-app }\n' // never created -> not-found - ); - - const [info] = await listInitiatives(path.join(tempDir, 'app'), { - globalDataDir, - }); - - expect(info.changesTotal).toBe(5); - expect(info.changesComplete).toBe(2); // add-payments-api + update-docs - expect(info.tasksComplete).toBe(4); // 2 + 1 + 0 + 1 + 0 - expect(info.tasksTotal).toBe(6); // 2 + 2 + 1 + 1 + 0 - expect(info.stores).toEqual(['api-server', 'web-app']); - - const byId = Object.fromEntries( - info.changeStatuses.map((c) => [c.id, c]) - ); - expect(byId['add-payments-api'].state).toBe('complete'); - expect(byId['add-payments-api'].store).toBe('api-server'); - expect(byId['add-search'].state).toBe('in-progress'); - expect(byId['update-docs'].store).toBeUndefined(); // local - expect(byId['update-docs'].state).toBe('complete'); - expect(byId['add-refunds'].state).toBe('not-found'); // named store lacks it - }); - - it('marks a change as not-found when its named store is not registered', async () => { - change('app', 'here-change', '- [x] a\n'); - initiative( - 'app', - 'effort', - 'title: Effort\nchanges:\n - here-change\n - { id: elsewhere, store: ghost-store }\n' - ); - - const [info] = await listInitiatives(path.join(tempDir, 'app'), { - globalDataDir, - }); - - const byId = Object.fromEntries(info.changeStatuses.map((c) => [c.id, c])); - expect(byId['here-change'].state).toBe('complete'); - expect(byId['elsewhere'].state).toBe('not-found'); - expect(info.stores).toEqual(['ghost-store']); // declared, even if unresolved - }); - - it('flags a local initiative that shadows a canonical one in a referenced store', async () => { - const storeRoot = await registerStore('acme-plans'); - fs.mkdirSync( - path.join(storeRoot, 'openspec', 'initiatives', 'smoother-setup'), - { recursive: true } - ); - fs.writeFileSync( - path.join( - storeRoot, - 'openspec', - 'initiatives', - 'smoother-setup', - 'initiative.yaml' - ), - 'title: Canonical\nchanges: []\n' - ); - - write('consumer/openspec/config.yaml', 'references:\n - acme-plans\n'); - initiative('consumer', 'smoother-setup', 'title: Local draft\nchanges: []\n'); - initiative('consumer', 'unrelated', 'title: Unrelated\nchanges: []\n'); - - const results = await listInitiatives(path.join(tempDir, 'consumer'), { - globalDataDir, - }); - const byId = Object.fromEntries(results.map((i) => [i.id, i])); - - expect(byId['smoother-setup'].shadowsStore).toBe('acme-plans'); - expect(byId['unrelated'].shadowsStore).toBeUndefined(); - }); - - it('ignores plain folders without an initiative.yaml manifest', async () => { - fs.mkdirSync( - path.join(tempDir, 'app', 'openspec', 'initiatives', 'just-a-folder'), - { recursive: true } - ); - initiative('app', 'tracked', 'title: Tracked\nchanges: []\n'); - - const results = await listInitiatives(path.join(tempDir, 'app'), { - globalDataDir, - }); - - expect(results.map((i) => i.id)).toEqual(['tracked']); - }); -}); diff --git a/test/core/plan.test.ts b/test/core/plan.test.ts new file mode 100644 index 0000000000..fb360ac1b1 --- /dev/null +++ b/test/core/plan.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { readPlanStages, rollupPlan } from '../../src/core/plan.js'; +import { + readStoreRegistryState, + writeStoreRegistryState, +} from '../../src/core/store/foundation.js'; + +describe('plan folder', () => { + let tempDir: string; + let globalDataDir: string; + let savedXdgDataHome: string | undefined; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-plan-')); + globalDataDir = path.join(tempDir, 'data', 'openspec'); + savedXdgDataHome = process.env.XDG_DATA_HOME; + process.env.XDG_DATA_HOME = path.join(tempDir, 'xdg'); + }); + + afterEach(() => { + if (savedXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = savedXdgDataHome; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function write(relativePath: string, content: string): void { + const full = path.join(tempDir, relativePath); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + + function change(root: string, id: string, planRef: string | null, tasks: string): void { + const metadata = + planRef === null + ? 'schema: spec-driven\n' + : `schema: spec-driven\nplan: ${planRef}\n`; + write(`${root}/openspec/changes/${id}/.openspec.yaml`, metadata); + write(`${root}/openspec/changes/${id}/tasks.md`, tasks); + } + + async function registerStore(id: string, root: string): Promise { + const existing = await readStoreRegistryState({ globalDataDir }).catch(() => null); + await writeStoreRegistryState( + { + version: 1, + stores: { + ...(existing?.stores ?? {}), + [id]: { backend: { type: 'git', local_path: path.join(tempDir, root) } }, + }, + }, + { globalDataDir } + ); + } + + it('splits numbered stages from unnumbered context', async () => { + write('app/openspec/plan/00_goal/goal.md', '# goal\n'); + write('app/openspec/plan/01_requirements/personas.md', '# who\n'); + write('app/openspec/plan/notes/cut.md', '# parked\n'); + write('app/openspec/plan/vision.md', '# vision\n'); + + const shape = await readPlanStages(path.join(tempDir, 'app')); + + expect(shape?.stages.map((s) => s.name)).toEqual(['00_goal', '01_requirements']); + expect(shape?.stages.map((s) => s.files)).toEqual([1, 1]); + expect(shape?.context).toEqual(['notes', 'vision.md']); + }); + + it('returns null when there is no plan folder', async () => { + fs.mkdirSync(path.join(tempDir, 'app', 'openspec'), { recursive: true }); + expect(await readPlanStages(path.join(tempDir, 'app'))).toBeNull(); + expect(await rollupPlan(path.join(tempDir, 'app'), { globalDataDir })).toBeNull(); + }); + + it('rolls up local changes that point at the plan with plan: local', async () => { + write('app/openspec/plan/00_goal/goal.md', '# goal\n'); + change('app', 'done-change', 'local', '- [x] a\n- [x] b\n'); + change('app', 'wip-change', 'local', '- [x] a\n- [ ] b\n'); + change('app', 'unrelated', null, '- [ ] a\n'); // no plan line -> not included + + const plan = await rollupPlan(path.join(tempDir, 'app'), { globalDataDir }); + + expect(plan?.changes.map((c) => c.id)).toEqual(['done-change', 'wip-change']); + expect(plan?.changesComplete).toBe(1); + expect(plan?.changesTotal).toBe(2); + expect(plan?.tasksComplete).toBe(3); + expect(plan?.tasksTotal).toBe(4); + expect(plan?.changes.every((c) => c.store === undefined)).toBe(true); + }); + + it('finds changes across registered repos that point at a store plan', async () => { + // The plan lives in the team-plans store. + write('team-plans/openspec/plan/00_goal/goal.md', '# goal\n'); + await registerStore('team-plans', 'team-plans'); + // Two code repos, registered, each with a change pointing at team-plans. + await registerStore('api-server', 'api-server'); + await registerStore('web-app', 'web-app'); + change('api-server', 'add-payments-api', 'team-plans', '- [x] a\n- [x] b\n'); + change('web-app', 'add-payments-ui', 'team-plans', '- [x] a\n- [ ] b\n- [ ] c\n'); + change('web-app', 'other-work', null, '- [ ] a\n'); + // The store's own change can use 'local' or its own id. + change('team-plans', 'update-docs', 'local', '- [x] a\n'); + + const plan = await rollupPlan(path.join(tempDir, 'team-plans'), { globalDataDir }); + + const byId = Object.fromEntries((plan?.changes ?? []).map((c) => [c.id, c])); + expect(Object.keys(byId).sort()).toEqual([ + 'add-payments-api', + 'add-payments-ui', + 'update-docs', + ]); + expect(byId['add-payments-api'].store).toBe('api-server'); + expect(byId['add-payments-api'].state).toBe('complete'); + expect(byId['add-payments-ui'].store).toBe('web-app'); + expect(byId['add-payments-ui'].state).toBe('in-progress'); + expect(byId['update-docs'].store).toBeUndefined(); + expect(plan?.changesComplete).toBe(2); + expect(plan?.tasksTotal).toBe(6); + }); +}); diff --git a/test/core/profiles.test.ts b/test/core/profiles.test.ts index b46901fa2e..f56a0ac4ef 100644 --- a/test/core/profiles.test.ts +++ b/test/core/profiles.test.ts @@ -20,14 +20,14 @@ describe('profiles', () => { }); describe('ALL_WORKFLOWS', () => { - it('should contain all 11 workflows', () => { - expect(ALL_WORKFLOWS).toHaveLength(11); + it('should contain all 12 workflows', () => { + expect(ALL_WORKFLOWS).toHaveLength(12); }); it('should contain expected workflow IDs', () => { const expected = [ 'propose', 'explore', 'new', 'continue', 'apply', - 'ff', 'sync', 'archive', 'bulk-archive', 'verify', 'onboard', + 'ff', 'sync', 'archive', 'bulk-archive', 'verify', 'onboard', 'plan', ]; expect([...ALL_WORKFLOWS]).toEqual(expected); }); diff --git a/test/core/references.test.ts b/test/core/references.test.ts index fd61e0c1c7..66ff1bdfd2 100644 --- a/test/core/references.test.ts +++ b/test/core/references.test.ts @@ -130,7 +130,7 @@ describe('reference index assembly', () => { expect(entries[0].status).toEqual([]); }); - it("indexes a store's own artifact types and initiatives with fetch commands", async () => { + it("indexes a store's own artifact types and plan stages with fetch commands", async () => { const storeRoot = await registerStore('team-context'); // A project-local schema (custom artifact types) in the store. const schemaDir = path.join(storeRoot, 'openspec', 'schemas', 'team-brief'); @@ -142,18 +142,11 @@ describe('reference index assembly', () => { ' template: brief.md\n requires: []\n instruction: Write it.\n' ); fs.writeFileSync(path.join(schemaDir, 'templates', 'brief.md'), '# Brief\n'); - // An initiative in the store. - const initiativeDir = path.join( - storeRoot, - 'openspec', - 'initiatives', - 'smoother-setup' - ); - fs.mkdirSync(initiativeDir, { recursive: true }); - fs.writeFileSync( - path.join(initiativeDir, 'initiative.yaml'), - 'title: Smoother setup\nchanges: []\n' - ); + // A plan folder in the store: numbered stages. + const planDir = path.join(storeRoot, 'openspec', 'plan'); + fs.mkdirSync(path.join(planDir, '00_goal'), { recursive: true }); + fs.mkdirSync(path.join(planDir, '01_requirements'), { recursive: true }); + fs.writeFileSync(path.join(planDir, '00_goal', 'goal.md'), '# goal\n'); const [entry] = await assemble(['team-context']); @@ -164,9 +157,7 @@ describe('reference index assembly', () => { artifacts: ['brief'], }, ]); - expect(entry.initiatives).toEqual([ - { id: 'smoother-setup', summary: 'Smoother setup' }, - ]); + expect(entry.plan).toEqual(['00_goal', '01_requirements']); const block = renderReferencedStoresBlock([entry]); expect(block).toContain( @@ -174,18 +165,17 @@ describe('reference index assembly', () => { ); expect(block).toContain('- team-brief: Our own planning artifacts. [brief]'); expect(block).toContain( - 'Initiatives (openspec list --initiatives --store team-context):' + 'Plan: 00_goal → 01_requirements (openspec list --plan --store team-context)' ); - expect(block).toContain('- smoother-setup: Smoother setup'); }); - it('omits schemas/initiatives keys for a store that has none', async () => { + it('omits schemas/plan keys for a store that has none', async () => { await registerStore('bare-context'); const [entry] = await assemble(['bare-context']); expect(entry.schemas).toBeUndefined(); - expect(entry.initiatives).toBeUndefined(); + expect(entry.plan).toBeUndefined(); }); it('degrades an unregistered reference to reference_unresolved with a pasteable fix', async () => { diff --git a/test/core/shared/skill-generation.test.ts b/test/core/shared/skill-generation.test.ts index 6c755f51d2..6fcce6665d 100644 --- a/test/core/shared/skill-generation.test.ts +++ b/test/core/shared/skill-generation.test.ts @@ -8,9 +8,9 @@ import { describe('skill-generation', () => { describe('getSkillTemplates', () => { - it('should return all 11 skill templates', () => { + it('should return all 12 skill templates', () => { const templates = getSkillTemplates(); - expect(templates).toHaveLength(11); + expect(templates).toHaveLength(12); }); it('should have unique directory names', () => { @@ -88,9 +88,9 @@ describe('skill-generation', () => { }); describe('getCommandTemplates', () => { - it('should return all 11 command templates', () => { + it('should return all 12 command templates', () => { const templates = getCommandTemplates(); - expect(templates).toHaveLength(11); + expect(templates).toHaveLength(12); }); it('should have unique IDs', () => { @@ -142,9 +142,9 @@ describe('skill-generation', () => { }); describe('getCommandContents', () => { - it('should return all 11 command contents', () => { + it('should return all 12 command contents', () => { const contents = getCommandContents(); - expect(contents).toHaveLength(11); + expect(contents).toHaveLength(12); }); it('should have valid content structure', () => { diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index c2cae91e32..9722c7e681 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -24,6 +24,8 @@ import { getOpsxProposeCommandTemplate, getOpsxProposeSkillTemplate, getOpsxVerifyCommandTemplate, + getPlanSkillTemplate, + getOpsxPlanCommandTemplate, getSyncSpecsSkillTemplate, getVerifyChangeSkillTemplate, } from '../../../src/core/templates/skill-templates.js'; @@ -35,43 +37,46 @@ import { import { STORE_SELECTION_GUIDANCE } from '../../../src/core/templates/workflows/store-selection.js'; const EXPECTED_FUNCTION_HASHES: Record = { - getExploreSkillTemplate: '48cbd02b12009c0e573aeca55dd6100b8b6ce455bd8bdd066bbccd4c729b03f7', - getNewChangeSkillTemplate: 'e0d3a0772dfea90c04ae5e8d4208f8e7b930e6e8b13750ce451494688ec15217', - getContinueChangeSkillTemplate: '11c19058472441295ddf608ded7a4b5fb2ad61ecba40e23b34d307fe59e55919', - getApplyChangeSkillTemplate: '8fba9936ca420c6fd58e193426b96090e7c9fb17fe3f61a634f0961528e59b19', - getFfChangeSkillTemplate: 'cf2488c33e0e0887ef7796e14071f18dccdd7c3660589d909108fdeabd5a2658', - getSyncSpecsSkillTemplate: 'd6c1318bf4be083854cc1614f3a8173305f882ad3ad9568e6e716de94217648a', - getOnboardSkillTemplate: '70a35eea8d6307cd6f7ab5846d5ad332b84d4b14027270c9c424c3b017676e6b', - getOpsxExploreCommandTemplate: 'b747835aab935e6de79ffb1ec57600b70b007281ea516e0cae3c8b0e17fb3fa9', - getOpsxNewCommandTemplate: 'be163b04b8af25cf33a17bb5f3e4c36febec9b74a381670e57b403dd0529cacf', - getOpsxContinueCommandTemplate: '571de5710023b5a17f4e3a37e03d9a7af90fedce88552bfc43c9dff82e6bbe28', - getOpsxApplyCommandTemplate: 'c46308f178f29d8ce66c86f2848cd182c1ee884c421a67d3e9cf644762f9f8cc', - getOpsxFfCommandTemplate: 'd2e94a55f1597959cf2f65e3fc5a4c587676e9de2c7d7a84edf46a01d86b292e', - getArchiveChangeSkillTemplate: 'ba42fde5ef6ee385edcdb4c25d66cee1aa7f701b6c4ad6fbcb0d846d80711393', - getBulkArchiveChangeSkillTemplate: '25698132be5b11118896029828d9d85645653354383dc2e9dea0fd334c49767a', - getOpsxSyncCommandTemplate: '558ad519502a0774447fab1322424754474bcfcb03bf53292577d129cf8d14fe', - getVerifyChangeSkillTemplate: 'cbc5dcb9c7814f95740f502a7d8fdf3063ee4935762a71502efefc36f107639d', - getOpsxArchiveCommandTemplate: 'dfe3ce852ac3b3af2119d99fa6fe0c2f0b543bca783c4b19c5ddd7c0cdb68319', - getOpsxOnboardCommandTemplate: '83edd627153a5280356abfeba111d947b9c13ffd34f8ff9446931f041584df1f', - getOpsxBulkArchiveCommandTemplate: '01dc410f437c49f606778c58493023676d432353698113c501c5cb910f95ec16', - getOpsxVerifyCommandTemplate: '979bac81bf9c3ae054d7b7dea2c0d8b5d6d55d2eee51709281effb806ee280cd', - getOpsxProposeSkillTemplate: '1a46a9da3ed6ce1b8c0733d52e63f267a7dad6907adda4f7a329b47b27807a00', - getOpsxProposeCommandTemplate: '9218180a6752692398eac69dc960099960680bb9dbd363ca6c9295f9c5428b7c', + getExploreSkillTemplate: '26675478b220715bafe3749311db95677043afc85da4dd01c53726a83f198704', + getNewChangeSkillTemplate: 'a647a6602e361cda6a5277ee8195c76cc59c6105c155731bb67ae61bd14c627f', + getContinueChangeSkillTemplate: '03d2a37c9a703a379d4df38633009e63f30f26df78c13e5e259001a4e0391c76', + getApplyChangeSkillTemplate: 'dfbf04bd25ffedba1f8b764623a3c91e0e6f43ed14de97d7642421944ac57ac1', + getFfChangeSkillTemplate: 'bf6f4918e96f681922b715338b56dba7a60a46051e3fbaf2ef2d026e51eed07c', + getSyncSpecsSkillTemplate: '8af60d91a626e4751d6a2aef7e47b1522daa1db01c213386fc206a54ce176ab1', + getOnboardSkillTemplate: 'eb746e0f6e720794f2565e487a8bb7e50273e4b8494308b00e7afeed2c31a5e2', + getOpsxExploreCommandTemplate: '1a99984ace5e8ae76a05d357c04a2c6e4b13407451f4545534d4ce95d507bb54', + getOpsxNewCommandTemplate: 'd761624274af2856e60096847dc9fab4beaec0ee55f49ee1e885d3af0496570f', + getOpsxContinueCommandTemplate: 'bd7776b401467c98b07ead76a6965802e99dc150d59804c1014e7ef5f7f89fb3', + getOpsxApplyCommandTemplate: '0a65c9b1fa9b00953fa8c68bf3ba03e69b7d8167376b1cbf656d151c23a067b4', + getOpsxFfCommandTemplate: '610a96f70073eb6643f1387723dec01ffbfa75a2e48ddd471845d47fc1183378', + getArchiveChangeSkillTemplate: '6457b47ef91bbb964e434725ca845851ed69497a5f770c4ef9713019fd1378ae', + getBulkArchiveChangeSkillTemplate: '0ce6933682e5f74b8d8b3fd49270957d6c14572eb0490eb65ad63628152e865e', + getOpsxSyncCommandTemplate: 'f50af3e913e32269b5b17bfef71f4f2175c327b3546f848cff9e7f8e017df3bd', + getVerifyChangeSkillTemplate: '9ec56494eac8d7970f985c00938987f784ee3402e407e239004e6da7d0af1896', + getOpsxArchiveCommandTemplate: 'eaa988fb7eacfd8d27c89b0dbac75bdf34ae45f11dccedf5a30caf16dfe0ac80', + getOpsxOnboardCommandTemplate: 'f5d6072c4c6d0c9e2704c469d06f2d56f638c1450088df644f88f013af4074d4', + getOpsxBulkArchiveCommandTemplate: 'be07bc0d7ea413aebaad181ee22dc856bbfc6210fe394310b6c8b552ee891bc0', + getOpsxVerifyCommandTemplate: 'b4a5717c25883ca74dc8da091aef2432492e2a377c8cd9c1a0369b6b854dc32c', + getOpsxProposeSkillTemplate: 'ad11a374aa8f3978a93af3a2d5b4cea736d6a5f7b3f913b38a2b34aeeb2bec21', + getOpsxProposeCommandTemplate: '8aa4d2e0ca201ad8e913e8d490223063cef9c2a7e2b7a464d5aa0452540ccc09', + getPlanSkillTemplate: 'e22e8552e40bd777f3d57d2e1e84095eab61af9492ea916698294ca2d4028326', + getOpsxPlanCommandTemplate: 'ef85bab989ee49f93491fc54772df1ab02b42d5058860b9c04b002e9c8f3d2a7', getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d', }; const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { - 'openspec-explore': 'a453c7d19485ca751186c1373ad598b0077e7c208355e9b386dd8c8494a63553', - 'openspec-new-change': '2e6c2f1adfc2f1c717055f3032cc76c1403436c57572fc8be64cd3125bd2903f', - 'openspec-continue-change': '3e2df28eb3f8988dcc75b9a96125f36aa8558058e17b39e363d929a6217a70ae', - 'openspec-apply-change': '4ff8ddf628866be3b79cf116d3a29ac9d1d87961387de130220498c9fab0e74d', - 'openspec-ff-change': '3a35d39f2e925df1d91921e1c78fd3b67288b51888f61dff299deabcbba2cc61', - 'openspec-sync-specs': '536408e97bf275a3a20fea3bfe34bbf9e6e184dac08812064f90071bff13dcd7', - 'openspec-archive-change': '427ac51eb00a4572178b8924db7f986b24b1f0bcd9a59bdc687ef5bc69ac7392', - 'openspec-bulk-archive-change': '89add53ae210265287b661ca64f1c4f536457d1873cfa68c727e13d85651d8e5', - 'openspec-verify-change': '8ebcd05be99c3742d0f7eec6779dd211f7318c3af22a519521a3d9e6b1f74f1d', - 'openspec-onboard': 'dd51c8620d4813c4dab1dda7d7a0d889b1240cee31b2cdc03fcdd667e974ea6e', - 'openspec-propose': 'adf8d619738ffec65cf9588f44bccf8c9c89446069a46e6f82f9ec88213c9c95', + 'openspec-explore': '0b7f81479edf27f85eb8a7deffb37aa6b9782adb1203ac1b541ce7e85b03fc64', + 'openspec-new-change': '81f414bfce1e11931b93d87ccadd0674ec460789bfe4079c73f483358a960859', + 'openspec-continue-change': '987043d62f6703f01258b0d09c0b9fd0481d845d6d738049d62dde5dfdd5160c', + 'openspec-apply-change': '3ad97a4a515021299002eb48279a2e5ee9ea77a9dd251a7156193f5263b0de3f', + 'openspec-ff-change': '30e8e5cc2e73f58699ccefad6f941bc8d8b9c5d0d77b12ef5d3140e220aa6079', + 'openspec-sync-specs': 'defce99383e7269a691cccf1577139bded63b6d3136055eb55b27847aa915602', + 'openspec-archive-change': 'fc9ecf88d9855e36a157f42518e9533b9df35b5370c20413931cef74e5bd353e', + 'openspec-bulk-archive-change': 'bfb25fdacb3bb2959535b79e6d321b0319d43cbde52614337f1318a065929b6f', + 'openspec-verify-change': '2aa862e0f32bf85d1a073629a1dce44dac7c005bf76ba2f63fd19b0953228f30', + 'openspec-onboard': 'abd8b125dd67edb482a6fcba2304ea4327cc3ac3689eea68ee805e5913065523', + 'openspec-propose': 'd4a35ab16a2ca89f65c6aa1cc931cba21c3f01d5de356855a027d114b92047ec', + 'openspec-plan': '40d626533f2e0737c4de5d34d4af612c9c0d7eac68bd1edfe78f96a6bf4fad9f', }; // Intentionally excludes getFeedbackSkillTemplate: this list only models templates @@ -88,6 +93,7 @@ const GENERATED_SKILL_FACTORIES: Array<[string, () => SkillTemplate]> = [ ['openspec-verify-change', getVerifyChangeSkillTemplate], ['openspec-onboard', getOnboardSkillTemplate], ['openspec-propose', getOpsxProposeSkillTemplate], + ['openspec-plan', getPlanSkillTemplate], ]; function stableStringify(value: unknown): string { @@ -135,6 +141,8 @@ describe('skill templates split parity', () => { getOpsxVerifyCommandTemplate, getOpsxProposeSkillTemplate, getOpsxProposeCommandTemplate, + getPlanSkillTemplate, + getOpsxPlanCommandTemplate, getFeedbackSkillTemplate, }; From f6fe4c60c2b9c475f48fed9843284c682d376393 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Thu, 2 Jul 2026 18:03:35 -0500 Subject: [PATCH 15/45] feat(stores): make the plan destination-first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lead with the destination artifact (product.md, roadmap — any name or convention; one file is a valid plan) instead of the stage pipeline. list --plan now shows destination/context files first; numbered stages remain the opt-in convention for visible order and "where am I" navigation. The skill gains an explicit map-in-flight-changes move (propose plan: lines for unlinked work, with confirmation) and a where-you-are table keyed to cwd. Drop the dogfood's changes-named inventory stage (redundant with list --plan). Co-Authored-By: Claude Fable 5 --- docs/stores-beta/plan.md | 99 ++++++++++--------- openspec/changes/add-plan-folder/design.md | 12 ++- openspec/changes/add-plan-folder/proposal.md | 12 ++- openspec/plan/03_changes/inventory.md | 11 --- src/cli/index.ts | 15 ++- src/core/templates/workflows/plan.ts | 25 ++--- .../templates/skill-templates-parity.test.ts | 6 +- 7 files changed, 98 insertions(+), 82 deletions(-) delete mode 100644 openspec/plan/03_changes/inventory.md diff --git a/docs/stores-beta/plan.md b/docs/stores-beta/plan.md index f80d5bbf51..40d3885ed3 100644 --- a/docs/stores-beta/plan.md +++ b/docs/stores-beta/plan.md @@ -2,56 +2,70 @@ > **Beta.** Builds on [stores](user-guide.md). Names and shapes may change. -Big work is bigger than one change. A roadmap, a PRD, a vision — today that -lives outside OpenSpec, and there is no good way in. The plan folder is the -way in. +Big work starts from a destination — a roadmap, a vision, a `product.md` — +and today that artifact has no home in OpenSpec, and no connection to the +changes carrying it out. The plan folder is that home, and that connection. ## The whole idea in one picture ```text -openspec/plan/ the plan: one folder - 00_goal/ numbered folders are stages, in order - 01_requirements/ names and contents are YOURS — any artifact - 02_changes/ the last stage meets execution - vision.md unnumbered = context, not a stage +openspec/plan/ + product.md your destination — any artifact, any name openspec/changes/ add-search/ .openspec.yaml plan: local ← one line points the change UP ``` -That is everything. No manifest, no new file format, no required artifact -types. Rename the folders and you have changed the workflow. - -## Solo: one repo, one machine - -The plan sits in your repo, next to your changes. +Put in your destination. Point your in-flight changes at it. Then one command +maps the work against it, live: ```bash -mkdir -p openspec/plan/00_goal # a plan starts as an empty folder -openspec new change add-search --plan local openspec list --plan ``` ```text Plan: openspec/plan -Stages: - 00_goal 1 file -Changes pointing here: 0/1 complete - · add-search here 0/4 tasks + product.md +Changes pointing here: 1/3 complete + ✓ add-payments-api here 5/5 tasks + · add-search here 2/4 tasks + · update-docs here 0/2 tasks +``` + +No manifest, no required artifact types, no new format. Status comes from the +changes' own task lists — nothing to keep in sync by hand. + +## Growing the plan: any files, any folders + +The plan is a folder, so an org can keep whatever it already uses in there — +an `architecture.md`, personas, decisions, meeting notes. All of it is +context the agent reads. + +**Want visible order?** Number the folders, and they become stages: + +```text +openspec/plan/ + 00_goal/ numbered folders are stages, in order + 01_requirements/ names and contents are still YOURS + vision.md unnumbered = context, read everywhere ``` -Status is live from the changes' own task lists. Nothing to keep in sync. +The numbering is how "where am I?" carries meaning: standing in +`01_requirements/`, an agent (or a new teammate) knows everything +lower-numbered is upstream — read it — and this folder's artifact is what you +produce. Location carries the workflow, not roles or configuration. And it is +opt-in: a plan with no numbers at all works exactly the same. ## Team: the plan is the parent, repos are children Put the same folder in a [store](user-guide.md) — a planning repo the whole team registers by name. The store is the global level; each code repo is a -child that points up at it. +child pointing up at it: ```bash openspec store setup team-plans --path ~/openspec/team-plans -mkdir -p ~/openspec/team-plans/openspec/plan/00_goal +# put your destination in team-plans/openspec/plan/ # in any code repo: openspec new change add-search --plan team-plans @@ -63,41 +77,38 @@ openspec list --plan --store team-plans ```text Plan: ~/openspec/team-plans/openspec/plan -Stages: - 00_goal 1 file -Changes pointing here: 1/3 complete - ✓ add-payments-api api-server 5/5 tasks - · add-search web-app 2/4 tasks - · update-docs here 1/2 tasks + product.md +Changes pointing here: 1/2 complete + ✓ add-payments-api api-server 2/2 tasks + · add-search web-app 1/3 tasks ``` One command answers "where does the whole effort stand?" — across every repo -on your machine that points at the plan. +on your machine that points at the plan. And repos that add +`references: [team-plans]` to their `openspec/config.yaml` get the plan into +their agent's context automatically. -Repos that add `references: [team-plans]` to their `openspec/config.yaml` -also get the plan in their agent's context automatically: - -```text -Store team-plans (…): - Plan: 00_goal → 01_requirements → 02_changes (openspec list --plan --store team-plans) -``` +Going from solo to team is moving one folder into a store. Nothing else +changes. ## The skill -One skill drives it: `openspec-plan` (`/opsx:plan`). It is a stance, not a -procedure — it reads the folders to see where the effort stands, helps -translate each stage into the next (a PRD into a feature list, a feature into -changes), and syncs the high level against live change status. It is also -told to write *less*: one page per artifact, tables over prose. +One skill drives it: `openspec-plan` (`/opsx:plan`). A stance, not a +procedure — it reads the folder to see where the effort stands, **maps +in-flight changes against the destination** (proposing `plan:` lines for +unlinked work), translates each artifact into the next (a PRD into a feature +list, a feature into changes), and syncs the high level against live status. +It is also told to write *less*: one page per artifact, tables over prose. ## What the pieces are | Piece | What it is | |---|---| -| `openspec/plan/` | one folder; numbered subfolders are ordered stages | +| `openspec/plan/` | one folder; your destination + whatever artifacts you use | +| numbered folders | optional ordered stages — "where am I" navigation | | `plan: local` / `plan: ` | one line in a change's `.openspec.yaml` | | `openspec new change --plan ` | create a change already pointing up | -| `openspec list --plan [--store ]` | stages + every linked change, live | +| `openspec list --plan [--store ]` | the plan + every linked change, live | | `openspec-plan` skill | the guide through all of it | ## Honest limits diff --git a/openspec/changes/add-plan-folder/design.md b/openspec/changes/add-plan-folder/design.md index 788638091e..6c41318e33 100644 --- a/openspec/changes/add-plan-folder/design.md +++ b/openspec/changes/add-plan-folder/design.md @@ -14,11 +14,13 @@ machinery, auto-discovery of unregistered repos. ## Decisions -**1. Ordered folders are the workflow.** Numbered folders under -`openspec/plan/` define a linear sequence by convention alone. Reconfiguring -the workflow is renaming folders. Rejected: a schema for the plan (types the -artifacts — the thing users want to keep freeform). If checking is ever -wanted, stages can later map onto the existing schema machinery. +**1. Destination first; ordered folders when wanted.** The plan is whatever +destination artifact the user already has — one file is valid. Numbered +folders opt into a linear sequence by convention alone ("where am I" carries +the workflow); reconfiguring is renaming folders. Rejected: a schema for the +plan (types the artifacts — the thing users want to keep freeform). If +checking is ever wanted, stages can later map onto the existing schema +machinery. **2. Changes reference upward.** `plan: local | ` in change metadata; rollup scans for it. Rejected: a downward manifest — a central file diff --git a/openspec/changes/add-plan-folder/proposal.md b/openspec/changes/add-plan-folder/proposal.md index 83c7e75615..a55d44491c 100644 --- a/openspec/changes/add-plan-folder/proposal.md +++ b/openspec/changes/add-plan-folder/proposal.md @@ -9,17 +9,19 @@ convention, one metadata line, one rollup, one skill. ## What Changes -- **A convention, not a format.** A plan is one folder, `openspec/plan/`. - Numbered subfolders are ordered stages; names and contents are the user's - own. Unnumbered entries are context. +- **A convention, not a format.** A plan is one folder, `openspec/plan/`, + holding the user's destination — `product.md`, a roadmap, any artifact name + or convention; one file is a valid plan. Numbered subfolders add ordered + stages when visible order is wanted; unnumbered entries are context. - **Changes point up.** One line in a change's `.openspec.yaml` — `plan: local` or `plan: `. No manifest anywhere. `new change` gains `--plan`. - **One rollup.** `openspec list --plan [--store ]` shows the stages and every change on this machine pointing at the plan, with live task status — including changes in other registered repos pointing at a store's plan. - **One skill.** `openspec-plan` (`/opsx:plan`): explore-style stance that - reads the folders, translates stage to stage, bridges into changes, and - syncs status back up. It is instructed to write less, not more. + reads the folder, maps in-flight changes against the destination, translates + artifact to artifact, bridges into changes, and syncs status back up. It is + instructed to write less, not more. - Referenced stores' plan stages appear in `openspec context` and the agent instruction block. diff --git a/openspec/plan/03_changes/inventory.md b/openspec/plan/03_changes/inventory.md deleted file mode 100644 index 6f21290d1b..0000000000 --- a/openspec/plan/03_changes/inventory.md +++ /dev/null @@ -1,11 +0,0 @@ -# The changes - -One row per change. Each carries `plan: local` in its `.openspec.yaml`, so -status is live — run `openspec list --plan`, don't update this table. - -| Change | What it does | -|---|---| -| simplify-skill-installation | Fewer default skills, quicker first run | -| fix-opencode-commands-directory | Files where OpenCode expects them | -| add-global-install-scope | Choose where tools install | -| schema-alias-support | Friendlier schema names, nothing breaks | diff --git a/src/cli/index.ts b/src/cli/index.ts index 8764adf32c..5f2da8af48 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -67,11 +67,18 @@ async function renderPlan( if (plan === null) { console.log('No plan folder found at openspec/plan/.'); - console.log('Start one: mkdir -p openspec/plan/00_goal'); + console.log( + 'Start one: mkdir -p openspec/plan, then add your destination (product.md, roadmap.md — any name).' + ); return; } console.log(`Plan: ${plan.path}`); + // Destination and context artifacts (unnumbered) come first: the plan is + // the destination; stages are optional structure on the way there. + for (const entry of plan.context) { + console.log(` ${entry}`); + } if (plan.stages.length > 0) { console.log('Stages:'); const stageWidth = Math.max(...plan.stages.map((s) => s.name.length)); @@ -79,8 +86,10 @@ async function renderPlan( const files = stage.files === 1 ? '1 file' : `${stage.files} files`; console.log(` ${stage.name.padEnd(stageWidth)} ${stage.files === 0 ? 'empty' : files}`); } - } else { - console.log('Stages: none yet (numbered folders become stages, e.g. 00_goal/)'); + } else if (plan.context.length === 0) { + console.log( + ' (empty — add your destination artifact, or numbered folders for ordered stages)' + ); } if (plan.changes.length === 0) { diff --git a/src/core/templates/workflows/plan.ts b/src/core/templates/workflows/plan.ts index a00502aeaf..8c4cdc144b 100644 --- a/src/core/templates/workflows/plan.ts +++ b/src/core/templates/workflows/plan.ts @@ -7,7 +7,7 @@ import type { SkillTemplate, CommandTemplate } from '../types.js'; import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; -const PLAN_BODY = `Enter plan mode. Work above a single change: help turn high-level intent into changes, and show where the whole effort stands. +const PLAN_BODY = `Enter plan mode. Work above a single change: start from the user's end destination, map the in-flight changes against it, and keep the two in sync. **This is a stance, not a workflow.** No fixed steps, no required sequence, no mandatory outputs. Follow what the conversation needs. @@ -17,22 +17,25 @@ const PLAN_BODY = `Enter plan mode. Work above a single change: help turn high-l A plan lives in one folder: \`openspec/plan/\` — in this repo, or in a store the team shares. -- **Numbered folders are stages, in order.** \`00_goal/\`, \`01_requirements/\`, \`02_changes/\` — the numbers are the sequence; the names and contents are the user's own. Any artifact goes: a roadmap, a PRD, personas, decisions, a feature table. -- **Unnumbered files and folders are ambient context.** Vision, meeting notes, glossary — always worth reading, never a stage. +- **The destination comes first.** A \`product.md\`, \`roadmap.md\`, \`architecture.md\`, a collection of folders — whatever artifact name or convention the user already has. One file is a valid plan. +- **Numbered folders are stages, when the user wants visible order.** \`00_goal/\`, \`01_requirements/\` — the numbers are the sequence; the names and contents are theirs. Optional, never required. - **Changes point up at the plan**, with one line in their \`.openspec.yaml\`: \`plan: local\` (this repo's plan) or \`plan: \` (a store's plan). There is no list to maintain anywhere. -## Orient first +## Where you are decides what you do -Read the folders, not job titles — anyone can pick up from what is on disk. +Check your cwd and what exists — the folders carry the workflow, not job titles. Anyone can pick up from what is on disk. -- \`ls openspec/plan/\` — which stages exist and which are still empty tells you where the effort stands. -- \`openspec list --plan --json\` (add \`--store \` for a shared plan) — every change pointing at the plan, with live task status. +- **At the plan folder (or repo root):** show where the effort stands. \`ls openspec/plan/\` for what exists; \`openspec list --plan --json\` (add \`--store \` for a shared plan) for every change pointing at it, with live task status. +- **Inside a stage or artifact folder:** everything lower-numbered (and every unnumbered file) is upstream — read it; this folder's artifact is what you produce. +- **In \`openspec/changes/\` or a change:** that is change work — use the change skills (\`/opsx:apply\`, \`/opsx:continue\`), pulling the plan in as upstream context. ## Ways of moving -**Translate down.** Take the most complete stage and help produce the next one — one altitude at a time. When you decompose into work items, make each one self-contained: someone could pick any single item up without reading the others. Surface merge/split judgment calls to the user; don't decide silently. +**Map in-flight changes.** When a destination exists but changes are not linked yet: run \`openspec list --changes\`, read the destination, propose which changes serve this plan, and — with the user's confirmation — add the \`plan:\` line to each one's \`.openspec.yaml\`. This mapping is the point: destination in, in-flight work mapped against it. -**Bridge to changes.** The last stage is where the plan meets execution. When a work item is ready, make it real: +**Translate down.** Take the most complete artifact and help produce the next one — one altitude at a time. When you decompose into work items, make each one self-contained: someone could pick any single item up without reading the others. Surface merge/split judgment calls to the user; don't decide silently. + +**Bridge to changes.** When a work item is ready, make it real: \`\`\`bash openspec new change --plan local # plan lives in this repo @@ -41,7 +44,7 @@ openspec new change --plan # plan lives in a store The \`--plan\` line is the whole link. Status flows back with no bookkeeping. -**Sync up.** Compare \`openspec list --plan\` against the upstream stages. Update the high level to match reality; name drift plainly instead of papering over it. +**Sync up.** Compare \`openspec list --plan\` against the destination. Update the high level to match reality; name drift plainly instead of papering over it. **Filter input.** If the plan has a vision or goal file, weigh new input against it. Park contradicting input in a cut file (e.g. \`notes/cut.md\`) with one line of why — preserved, not lost. @@ -52,7 +55,7 @@ Plans die of verbosity. - One page per artifact. Longer means cut or split. - Prefer a table to prose, a line to a paragraph. - No file paths or code snippets in upstream artifacts — they rot. -- Never restate another stage; point to it. +- Never restate another artifact; point to it. ## Stay above the code diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index 9722c7e681..d4a6a5b743 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -59,8 +59,8 @@ const EXPECTED_FUNCTION_HASHES: Record = { getOpsxVerifyCommandTemplate: 'b4a5717c25883ca74dc8da091aef2432492e2a377c8cd9c1a0369b6b854dc32c', getOpsxProposeSkillTemplate: 'ad11a374aa8f3978a93af3a2d5b4cea736d6a5f7b3f913b38a2b34aeeb2bec21', getOpsxProposeCommandTemplate: '8aa4d2e0ca201ad8e913e8d490223063cef9c2a7e2b7a464d5aa0452540ccc09', - getPlanSkillTemplate: 'e22e8552e40bd777f3d57d2e1e84095eab61af9492ea916698294ca2d4028326', - getOpsxPlanCommandTemplate: 'ef85bab989ee49f93491fc54772df1ab02b42d5058860b9c04b002e9c8f3d2a7', + getPlanSkillTemplate: 'dba1cbe5d5fa23bccb2ab4a1424ccb3aaf8aac6701dc9b935719a662cdc7aca6', + getOpsxPlanCommandTemplate: '0d6af8a03361bca8a4860dde5bb7f4fa7ee214eae25ee13db729bfe3b20ef6f7', getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d', }; @@ -76,7 +76,7 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-verify-change': '2aa862e0f32bf85d1a073629a1dce44dac7c005bf76ba2f63fd19b0953228f30', 'openspec-onboard': 'abd8b125dd67edb482a6fcba2304ea4327cc3ac3689eea68ee805e5913065523', 'openspec-propose': 'd4a35ab16a2ca89f65c6aa1cc931cba21c3f01d5de356855a027d114b92047ec', - 'openspec-plan': '40d626533f2e0737c4de5d34d4af612c9c0d7eac68bd1edfe78f96a6bf4fad9f', + 'openspec-plan': '24558e5b03fdef237df2293aaa9856cc6c9f62b14e8198b2a367629721c9e8b8', }; // Intentionally excludes getFeedbackSkillTemplate: this list only models templates From 66f6fed29edd6ab97b6b99cb4ba45bdc9d528bc8 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Thu, 2 Jul 2026 18:13:10 -0500 Subject: [PATCH 16/45] fix(stores): surface destination-only plans in agent context A plan with no numbered stages (just product.md) now appears in the referenced-store index and openspec context via its artifact names, so destination-first plans reach the agent the same way staged ones do. Co-Authored-By: Claude Fable 5 --- docs/agent-contract.md | 4 ++-- src/commands/context.ts | 13 ++++++++++--- src/core/references.ts | 12 ++++++++++-- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/docs/agent-contract.md b/docs/agent-contract.md index 8e7568440f..f3d4d5f3c9 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -59,7 +59,7 @@ Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id ### 4.5 `instructions --json` `{ "changeName", "artifactId", "schemaName", "changeDir", "planningHome"?, "outputPath", "resolvedOutputPath", "existingOutputPaths", "description", "instruction"?, "context"?, "rules"?, "references"?: ReferenceIndexEntry[], "template", "dependencies": [{id,done,path,description}], "unlocks", "root" }`. -`ReferenceIndexEntry`: `{ "store_id", "root"?, "specs"?: [{id,summary}], "schemas"?: [{id,summary,artifacts}], "plan"?: [string], "fetch"?, "status": [] }` — resolved entries carry root/specs/fetch; `schemas` (the store's own project-local artifact types) and `plan` (the store's plan stage names, in order) are present only when the store defines them; unresolved carry store_id + warning status. Index capped at 50KB (`reference_index_truncated`). +`ReferenceIndexEntry`: `{ "store_id", "root"?, "specs"?: [{id,summary}], "schemas"?: [{id,summary,artifacts}], "plan"?: [string], "fetch"?, "status": [] }` — resolved entries carry root/specs/fetch; `schemas` (the store's own project-local artifact types) and `plan` (the store's plan stage names in order, or its artifact names for a stage-less plan) are present only when the store defines them; unresolved carry store_id + warning status. Index capped at 50KB (`reference_index_truncated`). ### 4.6 `instructions apply --json` `{ "changeName", "changeDir", "schemaName", "contextFiles": { "": ["/abs", ...] }, "progress": {total,complete,remaining}, "tasks": [{id,description,done}], "state": "blocked"|"all_done"|"ready", "missingArtifacts"?, "instruction", "references"?, "root" }`. @@ -74,7 +74,7 @@ Success: `{ "archive": { "change", "archivedAs": "YYYY-MM-DD-name", "path", "spe `{ "root": { "path", "source", "store_id"?, "healthy", "status": [] }, "store": { "id", "metadata": {present,valid,remote?}, "origin_url"?, "status": [] } | null, "references": [...], "status": [] }`. Health findings of any severity exit 0. Failure payload: `{ "root": null, "store": null, "references": [], "status": [d] }`, exit 1. ### 4.10 `context --json` -`{ "root": { "path", "source", "store_id"?, "role": "openspec_root" }, "members": [ { "role": "referenced_store", "id", "path"?, "remote"?, "fetch"?, "artifactTypes"?: [string], "plan"?: [string], "status": [] } ], "status": [] }`. AVAILABLE = path present AND status empty. `artifactTypes` (the store's own project-local schema names) and `plan` (the store's plan stage names, in order) are present only on available members whose store defines them. `--code-workspace ` writes `{folders:[{name,path}]}` (available referenced stores only, `ref:` prefixes); in JSON mode the write runs before printing so stdout holds exactly one document even on write failure. Failure: `{ "root": null, "members": [], "status": [d] }`, exit 1. +`{ "root": { "path", "source", "store_id"?, "role": "openspec_root" }, "members": [ { "role": "referenced_store", "id", "path"?, "remote"?, "fetch"?, "artifactTypes"?: [string], "plan"?: [string], "status": [] } ], "status": [] }`. AVAILABLE = path present AND status empty. `artifactTypes` (the store's own project-local schema names) and `plan` (the store's plan stage names in order, or its artifact names for a stage-less plan) are present only on available members whose store defines them. `--code-workspace ` writes `{folders:[{name,path}]}` (available referenced stores only, `ref:` prefixes); in JSON mode the write runs before printing so stdout holds exactly one document even on write failure. Failure: `{ "root": null, "members": [], "status": [d] }`, exit 1. ### 4.11 `store ... --json` setup/register: `{ "store": {id, root, metadata_path?}, "registry": {path, registered, already_registered}, "git": {is_repository, initialized, committed}, "created_files": [], "status": [] }`. unregister/remove: `{ "store", "registry": {path, removed}, "files": {deleted, deleted_path, left_on_disk}, "status": [] }`. list: `{ "stores": [{id, root}], "status": [] }`. doctor: `{ "stores": [ { id, root, metadata_path?, openspec_root: {...healthy, status}, metadata: {present, valid, id?, remote}, git: {is_repository, has_commits, has_uncommitted_changes, has_remote, origin_url}, status } ], "status": [] }` (`null` = unknown/not probed). Health findings exit 0; failures exit 1 with the matching null-shape. Prompt cancellation exits 130. diff --git a/src/commands/context.ts b/src/commands/context.ts index a8013b2245..22988bd51c 100644 --- a/src/commands/context.ts +++ b/src/commands/context.ts @@ -87,9 +87,16 @@ async function enrichMembersWithStoreArtifacts( } try { const plan = await readPlanStages(storeRoot); - const stages = (plan?.stages ?? []).map((stage) => stage.name); - if (stages.length > 0) { - member.plan = stages; + if (plan !== null) { + // Stage names in order — or, for a destination-only plan with no + // stages, its artifact names. Either way the agent sees the plan. + const names = + plan.stages.length > 0 + ? plan.stages.map((stage) => stage.name) + : plan.context; + if (names.length > 0) { + member.plan = names; + } } } catch { // Unreadable plan dir: leave the member unenriched. diff --git a/src/core/references.ts b/src/core/references.ts index 6d9bd0d49a..f36d4943d2 100644 --- a/src/core/references.ts +++ b/src/core/references.ts @@ -173,11 +173,19 @@ function collectSchemaEntries(referencedRoot: string): ReferenceSchemaEntry[] { })); } -/** Stage names of a store's plan folder, sanitized for the index. */ +/** + * A store's plan for the index: stage names in order, or — for a + * destination-only plan with no stages — its artifact names. + */ async function collectPlanStages(referencedRoot: string): Promise { try { const plan = await readPlanStages(referencedRoot); - return (plan?.stages ?? []).map((stage) => sanitizeInline(stage.name, 60)); + if (plan === null) return []; + const names = + plan.stages.length > 0 + ? plan.stages.map((stage) => stage.name) + : plan.context; + return names.map((name) => sanitizeInline(name, 60)); } catch { return []; } From 9cda62735dc898d2063e28b3e010f0ab7277742b Mon Sep 17 00:00:00 2001 From: Clay Good Date: Thu, 2 Jul 2026 18:25:29 -0500 Subject: [PATCH 17/45] =?UTF-8?q?feat(stores):=20the=20handoff=20=E2=80=94?= =?UTF-8?q?=20capture,=20decompose,=20hand=20off=20through=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan skill gains the two moves that complete the interpretation layer, borrowed from mattpocock/skills' to-prd / to-issues discipline: capture the conversation into the destination artifact (synthesize, don't re-interview) and decompose into tracer-bullet slices (end-to-end, demoable, grabbable without reading the rest). The change is named as the handoff artifact: born linked, self-contained, status flowing back with no bookkeeping. Dogfood follows the destination-first convention (goal.md at plan root, two optional stages) and the repo's config context: now points agents at the plan — wiring the local plan into every artifact instruction with zero code. Guide gains "The handoff" (where planning starts, where building starts). Co-Authored-By: Claude Fable 5 --- docs/stores-beta/plan.md | 27 ++++++++++++++----- openspec/changes/add-plan-folder/proposal.md | 7 ++--- openspec/config.yaml | 3 +++ openspec/plan/{00_goal => }/goal.md | 0 src/core/templates/workflows/plan.ts | 6 +++-- .../templates/skill-templates-parity.test.ts | 6 ++--- 6 files changed, 35 insertions(+), 14 deletions(-) rename openspec/plan/{00_goal => }/goal.md (100%) diff --git a/docs/stores-beta/plan.md b/docs/stores-beta/plan.md index 40d3885ed3..cefeca18c1 100644 --- a/docs/stores-beta/plan.md +++ b/docs/stores-beta/plan.md @@ -94,11 +94,26 @@ changes. ## The skill One skill drives it: `openspec-plan` (`/opsx:plan`). A stance, not a -procedure — it reads the folder to see where the effort stands, **maps -in-flight changes against the destination** (proposing `plan:` lines for -unlinked work), translates each artifact into the next (a PRD into a feature -list, a feature into changes), and syncs the high level against live status. -It is also told to write *less*: one page per artifact, tables over prose. +procedure — it captures a planning conversation into the destination artifact +(synthesizing what it already knows instead of re-interviewing you), **maps +in-flight changes against the destination**, decomposes into self-contained +tracer-bullet slices, and syncs the high level against live status. It is +also told to write *less*: one page per artifact, tables over prose. + +## The handoff + +**Whoever plans** (a PM, a lead, you) starts in `/opsx:plan`: talk the idea +through and the skill writes the destination artifact — or drop an existing +doc into `openspec/plan/`. Decomposing ends in changes created with +`--plan`, so each is born linked. + +**Whoever builds** picks up any change — each is self-contained — and works +it with the normal change skills. The plan travels along: the repo's +`context:` config can point at it locally, and referenced stores surface it +to agents automatically. + +**The handoff artifact is the change itself.** Status flows back to +`openspec list --plan` with no meetings and no bookkeeping. ## What the pieces are @@ -116,4 +131,4 @@ It is also told to write *less*: one page per artifact, tables over prose. - Rollup scans repos registered on this machine — it never clones or syncs. - Stage order is a naming convention, not a gate. Nothing blocks working out of order; the skill just knows what comes next. -- This repo dogfoods it: see [openspec/plan/](../../openspec/plan/00_goal/goal.md). +- This repo dogfoods it: see [openspec/plan/](../../openspec/plan/goal.md) — a destination file plus two optional stages. diff --git a/openspec/changes/add-plan-folder/proposal.md b/openspec/changes/add-plan-folder/proposal.md index a55d44491c..51843265d8 100644 --- a/openspec/changes/add-plan-folder/proposal.md +++ b/openspec/changes/add-plan-folder/proposal.md @@ -19,9 +19,10 @@ convention, one metadata line, one rollup, one skill. every change on this machine pointing at the plan, with live task status — including changes in other registered repos pointing at a store's plan. - **One skill.** `openspec-plan` (`/opsx:plan`): explore-style stance that - reads the folder, maps in-flight changes against the destination, translates - artifact to artifact, bridges into changes, and syncs status back up. It is - instructed to write less, not more. + captures a planning conversation into the destination artifact, maps + in-flight changes against it, decomposes into self-contained slices, + bridges into changes, and syncs status back up. It is instructed to write + less, not more. - Referenced stores' plan stages appear in `openspec context` and the agent instruction block. diff --git a/openspec/config.yaml b/openspec/config.yaml index 0b7ad5176a..62d5006e84 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -1,6 +1,9 @@ schema: spec-driven context: | + The plan for this repo lives in openspec/plan/ — read goal.md first. + Changes whose .openspec.yaml says `plan: local` serve that plan. + Tech stack: TypeScript, Node.js (≥20.19.0), ESM modules Package manager: pnpm CLI framework: Commander.js diff --git a/openspec/plan/00_goal/goal.md b/openspec/plan/goal.md similarity index 100% rename from openspec/plan/00_goal/goal.md rename to openspec/plan/goal.md diff --git a/src/core/templates/workflows/plan.ts b/src/core/templates/workflows/plan.ts index 8c4cdc144b..689cb42941 100644 --- a/src/core/templates/workflows/plan.ts +++ b/src/core/templates/workflows/plan.ts @@ -31,9 +31,11 @@ Check your cwd and what exists — the folders carry the workflow, not job title ## Ways of moving +**Capture the conversation.** When the intent lives in the chat and no destination artifact exists yet, synthesize what you already know into one — do NOT re-interview the user. One page, their words, into \`openspec/plan/\`. + **Map in-flight changes.** When a destination exists but changes are not linked yet: run \`openspec list --changes\`, read the destination, propose which changes serve this plan, and — with the user's confirmation — add the \`plan:\` line to each one's \`.openspec.yaml\`. This mapping is the point: destination in, in-flight work mapped against it. -**Translate down.** Take the most complete artifact and help produce the next one — one altitude at a time. When you decompose into work items, make each one self-contained: someone could pick any single item up without reading the others. Surface merge/split judgment calls to the user; don't decide silently. +**Translate down.** Take the most complete artifact and help produce the next one — one altitude at a time. Decompose into tracer-bullet slices: each cuts end-to-end and is demoable on its own, so someone could pick any single item up without reading the others. Surface merge/split judgment calls to the user; don't decide silently. **Bridge to changes.** When a work item is ready, make it real: @@ -42,7 +44,7 @@ openspec new change --plan local # plan lives in this repo openspec new change --plan # plan lives in a store \`\`\` -The \`--plan\` line is the whole link. Status flows back with no bookkeeping. +The change IS the handoff: born linked, self-contained, ready for whoever — or whatever agent — picks it up next. Status flows back with no bookkeeping. **Sync up.** Compare \`openspec list --plan\` against the destination. Update the high level to match reality; name drift plainly instead of papering over it. diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index d4a6a5b743..fc8e8c9e1c 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -59,8 +59,8 @@ const EXPECTED_FUNCTION_HASHES: Record = { getOpsxVerifyCommandTemplate: 'b4a5717c25883ca74dc8da091aef2432492e2a377c8cd9c1a0369b6b854dc32c', getOpsxProposeSkillTemplate: 'ad11a374aa8f3978a93af3a2d5b4cea736d6a5f7b3f913b38a2b34aeeb2bec21', getOpsxProposeCommandTemplate: '8aa4d2e0ca201ad8e913e8d490223063cef9c2a7e2b7a464d5aa0452540ccc09', - getPlanSkillTemplate: 'dba1cbe5d5fa23bccb2ab4a1424ccb3aaf8aac6701dc9b935719a662cdc7aca6', - getOpsxPlanCommandTemplate: '0d6af8a03361bca8a4860dde5bb7f4fa7ee214eae25ee13db729bfe3b20ef6f7', + getPlanSkillTemplate: 'e396573d1a9493623bc1f8b92b90b5b8ff771268760bb9f4cdd579e01d7de522', + getOpsxPlanCommandTemplate: '603d4ccb5d390fed47ef28fbb8dd8c821c3dcfc2b7f5b1db0bd56232c0f7140f', getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d', }; @@ -76,7 +76,7 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-verify-change': '2aa862e0f32bf85d1a073629a1dce44dac7c005bf76ba2f63fd19b0953228f30', 'openspec-onboard': 'abd8b125dd67edb482a6fcba2304ea4327cc3ac3689eea68ee805e5913065523', 'openspec-propose': 'd4a35ab16a2ca89f65c6aa1cc931cba21c3f01d5de356855a027d114b92047ec', - 'openspec-plan': '24558e5b03fdef237df2293aaa9856cc6c9f62b14e8198b2a367629721c9e8b8', + 'openspec-plan': '864d54aa1ac8b5601b2760ab9236ff3428286d7d37c17683025e71fb72c18569', }; // Intentionally excludes getFeedbackSkillTemplate: this list only models templates From f90fb62e55a2b6070ff863c865f0acdeb510ee21 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Thu, 2 Jul 2026 18:33:59 -0500 Subject: [PATCH 18/45] docs(stores): point the plan guide at worksets and schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two existing features complete the story: worksets open the plan and code repos together; custom schemas are the path to typed artifacts if ever wanted. One line each — OpenSpec already had both. Co-Authored-By: Claude Fable 5 --- docs/stores-beta/plan.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/stores-beta/plan.md b/docs/stores-beta/plan.md index cefeca18c1..78bdbe2bbe 100644 --- a/docs/stores-beta/plan.md +++ b/docs/stores-beta/plan.md @@ -89,7 +89,8 @@ on your machine that points at the plan. And repos that add their agent's context automatically. Going from solo to team is moving one folder into a store. Nothing else -changes. +changes. (Want the plan and your code repos open together? That is what +[worksets](user-guide.md) already do.) ## The skill @@ -131,4 +132,6 @@ to agents automatically. - Rollup scans repos registered on this machine — it never clones or syncs. - Stage order is a naming convention, not a gate. Nothing blocks working out of order; the skill just knows what comes next. +- Want typed, checked artifacts someday? [Custom schemas](../customization.md) + already exist — plan artifacts stay freeform until you want that. - This repo dogfoods it: see [openspec/plan/](../../openspec/plan/goal.md) — a destination file plus two optional stages. From 8c7c25e3184248af76cd793c92124b3e7fcec15b Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 6 Jul 2026 16:10:40 -0500 Subject: [PATCH 19/45] =?UTF-8?q?feat(stores):=20initiatives=20=E2=80=94?= =?UTF-8?q?=20a=20portfolio=20of=20finite=20work=20above=20evergreen=20tru?= =?UTF-8?q?ths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the plan folder experiment to initiatives and reshape it plural: openspec/initiatives/ holds evergreen artifacts (standing truths — roadmap, product, architecture) plus one folder per finite initiative. Changes point up with 'initiative: ' or '/' (the legacy object {store, id} still reads, normalized to /). 'list --initiatives' renders the portfolio — grouped per initiative, cross-repo via the store registry, with an outside-a-root fallback to registered store portfolios. The skill becomes state-routed (/opsx:initiatives): capture, ideate, bounded pushback, decompose into changes born linked, sync the evergreen layer — every move ending in a short numbered menu of real next actions. --initiative returns as a real flag on 'new change' (validated up front so a bad ref writes nothing); the vocabulary guard now polices only the deleted workspace tokens and says why. The beta-history folder that sat at openspec/initiatives/ moves to openspec/explorations/ with its class. Proved end-to-end: solo → graduation into a store → second repo → agent context → outside-a-root portfolio → skill install. Change record add-initiatives validates --strict and records each decision with its why. Co-Authored-By: Claude Fable 5 --- docs/README.md | 2 +- docs/agent-contract.md | 6 +- docs/stores-beta/initiatives.md | 156 +++++++ docs/stores-beta/plan.md | 137 ------ docs/stores-beta/user-guide.md | 2 +- .../add-global-install-scope/.openspec.yaml | 2 +- openspec/changes/add-initiatives/design.md | 89 ++++ openspec/changes/add-initiatives/proposal.md | 55 +++ .../add-initiatives/specs/initiatives/spec.md | 79 ++++ openspec/changes/add-initiatives/tasks.md | 27 ++ openspec/changes/add-plan-folder/design.md | 52 --- openspec/changes/add-plan-folder/proposal.md | 43 -- .../add-plan-folder/specs/plan-folder/spec.md | 60 --- openspec/changes/add-plan-folder/tasks.md | 24 -- .../.openspec.yaml | 2 +- .../schema-alias-support/.openspec.yaml | 2 +- .../.openspec.yaml | 2 +- openspec/config.yaml | 5 +- .../.initiative.yaml | 0 .../context-store-and-initiatives/README.md | 0 .../decisions.md | 0 .../direction-git-native-work.md | 0 .../direction.md | 0 .../questions.md | 0 .../context-store-and-initiatives/roadmap.md | 0 .../context-store-and-initiatives/tasks.md | 0 .../01-lock-the-direction/evidence.md | 0 .../work-items/01-lock-the-direction/plan.md | 0 .../work-items/01-lock-the-direction/tasks.md | 0 .../evidence.md | 0 .../plan.md | 0 .../tasks.md | 0 .../evidence.md | 0 .../03-add-context-store-foundation/plan.md | 0 .../03-add-context-store-foundation/tasks.md | 0 .../04-add-collection-foundation/evidence.md | 0 .../04-add-collection-foundation/plan.md | 0 .../04-add-collection-foundation/tasks.md | 0 .../05-ship-initiative-mvp/evidence.md | 0 .../work-items/05-ship-initiative-mvp/plan.md | 0 .../05-ship-initiative-mvp/tasks.md | 0 .../evidence.md | 0 .../06-add-minimal-context-store-ux/plan.md | 0 .../06-add-minimal-context-store-ux/tasks.md | 0 .../evidence.md | 0 .../plan.md | 0 .../tasks.md | 0 .../evidence.md | 0 .../plan.md | 0 .../tasks.md | 0 .../decision-review.md | 0 .../09-add-initiative-resolve/evidence.md | 0 .../09-add-initiative-resolve/plan.md | 0 .../09-add-initiative-resolve/tasks.md | 0 .../plan.md | 0 .../tasks.md | 0 .../11-manual-beta-reality-pass/notes.md | 0 .../11-manual-beta-reality-pass/plan.md | 0 .../11-manual-beta-reality-pass/tasks.md | 0 .../evidence.md | 0 .../plan.md | 0 .../tasks.md | 0 .../evidence.md | 0 .../plan.md | 0 .../tasks.md | 0 .../14-workspaces-beta-guide-split/plan.md | 0 .../14-workspaces-beta-guide-split/tasks.md | 0 .../evidence.md | 0 .../plan.md | 0 .../tasks.md | 0 .../work-items/16-add-escalation-ux/plan.md | 0 .../work-items/16-add-escalation-ux/tasks.md | 0 .../plan.md | 0 .../tasks.md | 0 .../evidence.md | 0 .../plan.md | 0 .../tasks.md | 0 .../plan.md | 0 .../tasks.md | 0 .../evidence.md | 0 .../plan.md | 0 .../tasks.md | 0 .../smoother-setup}/01_who/personas.md | 0 .../smoother-setup}/02_decisions/decisions.md | 0 .../smoother-setup}/goal.md | 0 src/cli/index.ts | 174 +++++--- src/commands/context.ts | 28 +- src/commands/workflow/new-change.ts | 31 +- src/core/change-metadata/schema.ts | 22 +- src/core/completions/command-registry.ts | 8 +- src/core/init.ts | 2 +- src/core/initiatives.ts | 404 ++++++++++++++++++ src/core/plan.ts | 240 ----------- src/core/profile-sync-drift.ts | 2 +- src/core/profiles.ts | 2 +- src/core/references.ts | 32 +- src/core/shared/skill-generation.ts | 8 +- src/core/shared/tool-detection.ts | 2 +- src/core/templates/skill-templates.ts | 2 +- src/core/templates/workflows/initiatives.ts | 100 +++++ src/core/templates/workflows/plan.ts | 91 ---- src/core/working-set.ts | 5 +- src/utils/change-utils.ts | 2 +- test/commands/artifact-workflow.test.ts | 15 +- test/commands/change-initiative-link.test.ts | 39 +- test/commands/context.test.ts | 12 +- test/commands/store-root-selection.test.ts | 16 +- .../core/completions/command-registry.test.ts | 3 +- test/core/initiatives.test.ts | 211 +++++++++ test/core/plan.test.ts | 126 ------ test/core/profiles.test.ts | 2 +- test/core/references.test.ts | 23 +- .../templates/skill-templates-parity.test.ts | 16 +- test/vocabulary-sweep.test.ts | 15 +- 114 files changed, 1413 insertions(+), 965 deletions(-) create mode 100644 docs/stores-beta/initiatives.md delete mode 100644 docs/stores-beta/plan.md create mode 100644 openspec/changes/add-initiatives/design.md create mode 100644 openspec/changes/add-initiatives/proposal.md create mode 100644 openspec/changes/add-initiatives/specs/initiatives/spec.md create mode 100644 openspec/changes/add-initiatives/tasks.md delete mode 100644 openspec/changes/add-plan-folder/design.md delete mode 100644 openspec/changes/add-plan-folder/proposal.md delete mode 100644 openspec/changes/add-plan-folder/specs/plan-folder/spec.md delete mode 100644 openspec/changes/add-plan-folder/tasks.md rename openspec/{initiatives => explorations}/context-store-and-initiatives/.initiative.yaml (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/README.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/decisions.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/direction-git-native-work.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/direction.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/questions.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/roadmap.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/tasks.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/01-lock-the-direction/evidence.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/01-lock-the-direction/plan.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/01-lock-the-direction/tasks.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/evidence.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/plan.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/tasks.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/03-add-context-store-foundation/evidence.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/03-add-context-store-foundation/plan.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/03-add-context-store-foundation/tasks.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/04-add-collection-foundation/evidence.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/04-add-collection-foundation/plan.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/04-add-collection-foundation/tasks.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/05-ship-initiative-mvp/evidence.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/05-ship-initiative-mvp/plan.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/05-ship-initiative-mvp/tasks.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/evidence.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/plan.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/tasks.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/evidence.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/plan.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/tasks.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/evidence.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/plan.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/tasks.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/09-add-initiative-resolve/decision-review.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/09-add-initiative-resolve/evidence.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/09-add-initiative-resolve/plan.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/09-add-initiative-resolve/tasks.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/plan.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/tasks.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/notes.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/plan.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/tasks.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/evidence.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/plan.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/tasks.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/evidence.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/plan.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/tasks.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/14-workspaces-beta-guide-split/plan.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/14-workspaces-beta-guide-split/tasks.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/evidence.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/plan.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/tasks.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/16-add-escalation-ux/plan.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/16-add-escalation-ux/tasks.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/17-harden-team-shared-coordination/plan.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/17-harden-team-shared-coordination/tasks.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/evidence.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/plan.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/tasks.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/19-review-workspace-beta-compatibility-before-public-release/plan.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/19-review-workspace-beta-compatibility-before-public-release/tasks.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/evidence.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/plan.md (100%) rename openspec/{initiatives => explorations}/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/tasks.md (100%) rename openspec/{plan => initiatives/smoother-setup}/01_who/personas.md (100%) rename openspec/{plan => initiatives/smoother-setup}/02_decisions/decisions.md (100%) rename openspec/{plan => initiatives/smoother-setup}/goal.md (100%) create mode 100644 src/core/initiatives.ts delete mode 100644 src/core/plan.ts create mode 100644 src/core/templates/workflows/initiatives.ts delete mode 100644 src/core/templates/workflows/plan.ts create mode 100644 test/core/initiatives.test.ts delete mode 100644 test/core/plan.test.ts diff --git a/docs/README.md b/docs/README.md index e00fe3110c..208249fe8f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -83,7 +83,7 @@ That second one matters more than it looks. OpenSpec has two halves: a command l | Doc | What it gives you | |-----|-------------------| | [Stores: User Guide](stores-beta/user-guide.md) | Plan in its own repo when your work spans repos or teams | -| [The plan folder](stores-beta/plan.md) | The way in above a single change: your stages, your artifacts, live status | +| [Initiatives](stores-beta/initiatives.md) | The way in above a single change: your initiatives, your artifacts, live status | | [Agent Contract](agent-contract.md) | The machine-readable CLI surfaces agents drive | ## The thirty-second version diff --git a/docs/agent-contract.md b/docs/agent-contract.md index f3d4d5f3c9..5a6164492e 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -45,7 +45,7 @@ Successful JSON payloads embed the root: ## 4. Command JSON shapes ### 4.1 `list --json` -`{ "changes": [ { "name", "completedTasks", "totalTasks", "lastModified", "status": "no-tasks"|"complete"|"in-progress" } ], "root": RootOutput }` — note the per-change `status` is a string enum here. `--specs`: `{ "specs": [ { "id", "requirementCount" } ], "root" }`. `--plan`: `{ "plan": { "path", "stages": [{name,files}], "context": [string], "changes": [ { "id", "store"?, "completedTasks", "totalTasks", "state": "complete"|"in-progress"|"no-tasks" } ], "changesComplete", "changesTotal", "tasksComplete", "tasksTotal" } | null, "root" }` — `plan` is null when the root has no `openspec/plan/` folder. `changes` are discovered by scanning `.openspec.yaml` files for `plan: local` (the root's own changes) and `plan: ` (changes in other registered roots pointing at this store's plan); `changes[].store` names where a change lives, absent = the plan's own root. +`{ "changes": [ { "name", "completedTasks", "totalTasks", "lastModified", "status": "no-tasks"|"complete"|"in-progress" } ], "root": RootOutput }` — note the per-change `status` is a string enum here. `--specs`: `{ "specs": [ { "id", "requirementCount" } ], "root" }`. `--initiatives`: `{ "initiatives": { "path", "evergreen": [string], "initiatives": [ { "name", "exists", "stages": [{name,files}], "artifacts": [string], "changes": [ { "id", "store"?, "completedTasks", "totalTasks", "state": "complete"|"in-progress"|"no-tasks" } ], "changesComplete", "changesTotal", "tasksComplete", "tasksTotal" } ] } | null, "root" }` — `initiatives` is null when the root has no `openspec/initiatives/` folder; an entry with `"exists": false` is a name changes reference but no folder provides. Changes are discovered by scanning `.openspec.yaml` files for `initiative: ` (the root's own changes) and `initiative: /` (changes in other registered roots pointing at this store's initiative; the legacy object `{store, id}` reads as `/`); `changes[].store` names where a change lives, absent = the portfolio's own root. Outside any root (and without `--store`), the command answers with registered store portfolios instead: `{ "initiatives": null, "stores": [ { "store", "portfolio": } ], "root": null }`. ### 4.2 `show --json` Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id", "title", "overview", "requirementCount", "requirements": [...], "metadata": { "version", "format", "sourcePath"? }, "root" }`. @@ -59,7 +59,7 @@ Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id ### 4.5 `instructions --json` `{ "changeName", "artifactId", "schemaName", "changeDir", "planningHome"?, "outputPath", "resolvedOutputPath", "existingOutputPaths", "description", "instruction"?, "context"?, "rules"?, "references"?: ReferenceIndexEntry[], "template", "dependencies": [{id,done,path,description}], "unlocks", "root" }`. -`ReferenceIndexEntry`: `{ "store_id", "root"?, "specs"?: [{id,summary}], "schemas"?: [{id,summary,artifacts}], "plan"?: [string], "fetch"?, "status": [] }` — resolved entries carry root/specs/fetch; `schemas` (the store's own project-local artifact types) and `plan` (the store's plan stage names in order, or its artifact names for a stage-less plan) are present only when the store defines them; unresolved carry store_id + warning status. Index capped at 50KB (`reference_index_truncated`). +`ReferenceIndexEntry`: `{ "store_id", "root"?, "specs"?: [{id,summary}], "schemas"?: [{id,summary,artifacts}], "initiatives"?: [string], "fetch"?, "status": [] }` — resolved entries carry root/specs/fetch; `schemas` (the store's own project-local artifact types) and `initiatives` (the store's initiative names — or, with none yet, its evergreen artifact names) are present only when the store defines them; unresolved carry store_id + warning status. Index capped at 50KB (`reference_index_truncated`). ### 4.6 `instructions apply --json` `{ "changeName", "changeDir", "schemaName", "contextFiles": { "": ["/abs", ...] }, "progress": {total,complete,remaining}, "tasks": [{id,description,done}], "state": "blocked"|"all_done"|"ready", "missingArtifacts"?, "instruction", "references"?, "root" }`. @@ -74,7 +74,7 @@ Success: `{ "archive": { "change", "archivedAs": "YYYY-MM-DD-name", "path", "spe `{ "root": { "path", "source", "store_id"?, "healthy", "status": [] }, "store": { "id", "metadata": {present,valid,remote?}, "origin_url"?, "status": [] } | null, "references": [...], "status": [] }`. Health findings of any severity exit 0. Failure payload: `{ "root": null, "store": null, "references": [], "status": [d] }`, exit 1. ### 4.10 `context --json` -`{ "root": { "path", "source", "store_id"?, "role": "openspec_root" }, "members": [ { "role": "referenced_store", "id", "path"?, "remote"?, "fetch"?, "artifactTypes"?: [string], "plan"?: [string], "status": [] } ], "status": [] }`. AVAILABLE = path present AND status empty. `artifactTypes` (the store's own project-local schema names) and `plan` (the store's plan stage names in order, or its artifact names for a stage-less plan) are present only on available members whose store defines them. `--code-workspace ` writes `{folders:[{name,path}]}` (available referenced stores only, `ref:` prefixes); in JSON mode the write runs before printing so stdout holds exactly one document even on write failure. Failure: `{ "root": null, "members": [], "status": [d] }`, exit 1. +`{ "root": { "path", "source", "store_id"?, "role": "openspec_root" }, "members": [ { "role": "referenced_store", "id", "path"?, "remote"?, "fetch"?, "artifactTypes"?: [string], "initiatives"?: [string], "status": [] } ], "status": [] }`. AVAILABLE = path present AND status empty. `artifactTypes` (the store's own project-local schema names) and `initiatives` (the store's initiative names — or, with none yet, its evergreen artifact names) are present only on available members whose store defines them. `--code-workspace ` writes `{folders:[{name,path}]}` (available referenced stores only, `ref:` prefixes); in JSON mode the write runs before printing so stdout holds exactly one document even on write failure. Failure: `{ "root": null, "members": [], "status": [d] }`, exit 1. ### 4.11 `store ... --json` setup/register: `{ "store": {id, root, metadata_path?}, "registry": {path, registered, already_registered}, "git": {is_repository, initialized, committed}, "created_files": [], "status": [] }`. unregister/remove: `{ "store", "registry": {path, removed}, "files": {deleted, deleted_path, left_on_disk}, "status": [] }`. list: `{ "stores": [{id, root}], "status": [] }`. doctor: `{ "stores": [ { id, root, metadata_path?, openspec_root: {...healthy, status}, metadata: {present, valid, id?, remote}, git: {is_repository, has_commits, has_uncommitted_changes, has_remote, origin_url}, status } ], "status": [] }` (`null` = unknown/not probed). Health findings exit 0; failures exit 1 with the matching null-shape. Prompt cancellation exits 130. diff --git a/docs/stores-beta/initiatives.md b/docs/stores-beta/initiatives.md new file mode 100644 index 0000000000..26816080b2 --- /dev/null +++ b/docs/stores-beta/initiatives.md @@ -0,0 +1,156 @@ +# Initiatives + +> **Beta.** Builds on [stores](user-guide.md). Names and shapes may change. + +Big work starts above a single change — a roadmap, a product vision, a +quarter's effort — and today those artifacts have no home in OpenSpec, and no +connection to the changes carrying them out. `openspec/initiatives/` is that +home, and that connection. + +## The whole idea in one picture + +```text +openspec/initiatives/ + roadmap.md evergreen — truths every initiative serves + smoother-setup/ one initiative = one folder; contents freeform + notes.md + +openspec/changes/ + add-search/ + .openspec.yaml initiative: smoother-setup ← one line points UP +``` + +Two kinds of things live in the folder: + +- **Evergreen artifacts** (unnumbered top-level files) are your standing + truths — the roadmap, the product, the architecture. Maintained forever, + the way specs are. +- **Initiatives** (subfolders) are finite: a piece of work above a single + change that runs to completion. + +Point your in-flight changes at an initiative, and one command maps the +portfolio, live: + +```bash +openspec list --initiatives +``` + +```text +Initiatives: /…/my-app/openspec/initiatives +Evergreen: roadmap.md + +smoother-setup 1/2 changes complete + · add-search here 1/2 tasks + ✓ fix-onboarding here 2/2 tasks +``` + +No manifest, no required artifact types, no new format. Status comes from +the changes' own task lists — nothing to keep in sync by hand. + +## The same loop, one level up + +```text +planning evergreen artifacts what is true initiatives what is in motion +code specs what is true changes what is in motion +``` + +Work flows down: an initiative decomposes into changes. Truth flows up: +finishing an initiative updates the evergreen artifacts, the way archiving a +change updates specs. Status flows back live — driven by structured facts on +disk (a task checked off, a change archived), never by prose. + +**Want visible order inside an initiative?** Number its folders +(`00_goal/`, `01_requirements/`) and they become stages: standing in +`01_requirements/`, an agent (or a new teammate) knows everything +lower-numbered is upstream. Opt-in — an initiative with no numbers works +exactly the same. + +## Team: the portfolio lives in a store + +Put the same folder in a [store](user-guide.md) — a planning repo the whole +team registers by name. The store is the parent; each code repo is a child +pointing up: + +```bash +openspec store setup team-plans --path ~/openspec/team-plans +# put initiatives in team-plans/openspec/initiatives/ + +# in any code repo: +openspec new change add-search --initiative team-plans/smoother-setup +``` + +```bash +openspec list --initiatives --store team-plans +``` + +```text +Initiatives: /…/team-plans/openspec/initiatives +Evergreen: roadmap.md + +smoother-setup 2/3 changes complete + ✓ add-payments-api api-server 2/2 tasks + · add-search my-app 1/2 tasks + ✓ fix-onboarding my-app 2/2 tasks +``` + +One command answers "where does everything stand?" — across every repo on +your machine that points at the store's initiatives. It even works outside +any repo: with no local root, `list --initiatives` shows the portfolios of +your registered stores. And repos that add `references: [team-plans]` to +their `openspec/config.yaml` get the initiatives into their agent's context +automatically. + +Going from solo to team is moving one folder into a store. The initiative +is untouched; its ref changes from `smoother-setup` to +`team-plans/smoother-setup`. + +## The skill + +One skill drives it: `openspec-initiatives` (`/opsx:initiatives`). It routes +by what is on disk — nothing there yet → it captures the conversation into a +first artifact (synthesizing what it already knows instead of +re-interviewing you); a portfolio exists → it opens with where everything +stands; inside an initiative → it ideates from what exists, decomposes into +self-contained changes born linked, and syncs the evergreen layer as work +completes. Every move ends in a short numbered menu of real next actions — +one marked recommended — and it is told to write *less*: one page per +artifact, tables over prose. + +## The handoff + +**Whoever plans** (a PM, a lead, you) starts in `/opsx:initiatives`: talk the +idea through and the skill writes the artifacts — or drop existing docs into +`openspec/initiatives/`. Decomposing ends in changes created with +`--initiative`, so each is born linked. + +**Whoever builds** picks up any change — each is self-contained — and works +it with the normal change skills. The initiative travels along: the repo's +`context:` config can point at it locally, and referenced stores surface it +to agents automatically. + +**The handoff artifact is the change itself.** Status flows back to +`openspec list --initiatives` with no meetings and no bookkeeping. + +## What the pieces are + +| Piece | What it is | +|---|---| +| `openspec/initiatives/` | evergreen truths + one folder per initiative | +| numbered folders inside an initiative | optional ordered stages | +| `initiative: ` / `/` | one line in a change's `.openspec.yaml` | +| `openspec new change --initiative ` | create a change already pointing up | +| `openspec list --initiatives [--store ]` | the portfolio + every linked change, live | +| `openspec-initiatives` skill | the guide through all of it | + +## Honest limits + +- Rollup scans repos registered on this machine — it never clones or syncs. +- Stage order is a naming convention, not a gate. Nothing blocks working out + of order; the skill just knows what comes next. +- Reactions fire when the skill looks — reactive triggers beyond that (git + hooks, CI) are a natural next experiment, deliberately not this one. +- Want typed, checked artifacts someday? [Custom schemas](../customization.md) + already exist — initiative artifacts stay freeform until you want that. +- This repo dogfoods it: see + [openspec/initiatives/](../../openspec/initiatives/smoother-setup/goal.md) + — one initiative with two optional stages, grouping four real changes. diff --git a/docs/stores-beta/plan.md b/docs/stores-beta/plan.md deleted file mode 100644 index 78bdbe2bbe..0000000000 --- a/docs/stores-beta/plan.md +++ /dev/null @@ -1,137 +0,0 @@ -# The plan folder - -> **Beta.** Builds on [stores](user-guide.md). Names and shapes may change. - -Big work starts from a destination — a roadmap, a vision, a `product.md` — -and today that artifact has no home in OpenSpec, and no connection to the -changes carrying it out. The plan folder is that home, and that connection. - -## The whole idea in one picture - -```text -openspec/plan/ - product.md your destination — any artifact, any name - -openspec/changes/ - add-search/ - .openspec.yaml plan: local ← one line points the change UP -``` - -Put in your destination. Point your in-flight changes at it. Then one command -maps the work against it, live: - -```bash -openspec list --plan -``` - -```text -Plan: openspec/plan - product.md -Changes pointing here: 1/3 complete - ✓ add-payments-api here 5/5 tasks - · add-search here 2/4 tasks - · update-docs here 0/2 tasks -``` - -No manifest, no required artifact types, no new format. Status comes from the -changes' own task lists — nothing to keep in sync by hand. - -## Growing the plan: any files, any folders - -The plan is a folder, so an org can keep whatever it already uses in there — -an `architecture.md`, personas, decisions, meeting notes. All of it is -context the agent reads. - -**Want visible order?** Number the folders, and they become stages: - -```text -openspec/plan/ - 00_goal/ numbered folders are stages, in order - 01_requirements/ names and contents are still YOURS - vision.md unnumbered = context, read everywhere -``` - -The numbering is how "where am I?" carries meaning: standing in -`01_requirements/`, an agent (or a new teammate) knows everything -lower-numbered is upstream — read it — and this folder's artifact is what you -produce. Location carries the workflow, not roles or configuration. And it is -opt-in: a plan with no numbers at all works exactly the same. - -## Team: the plan is the parent, repos are children - -Put the same folder in a [store](user-guide.md) — a planning repo the whole -team registers by name. The store is the global level; each code repo is a -child pointing up at it: - -```bash -openspec store setup team-plans --path ~/openspec/team-plans -# put your destination in team-plans/openspec/plan/ - -# in any code repo: -openspec new change add-search --plan team-plans -``` - -```bash -openspec list --plan --store team-plans -``` - -```text -Plan: ~/openspec/team-plans/openspec/plan - product.md -Changes pointing here: 1/2 complete - ✓ add-payments-api api-server 2/2 tasks - · add-search web-app 1/3 tasks -``` - -One command answers "where does the whole effort stand?" — across every repo -on your machine that points at the plan. And repos that add -`references: [team-plans]` to their `openspec/config.yaml` get the plan into -their agent's context automatically. - -Going from solo to team is moving one folder into a store. Nothing else -changes. (Want the plan and your code repos open together? That is what -[worksets](user-guide.md) already do.) - -## The skill - -One skill drives it: `openspec-plan` (`/opsx:plan`). A stance, not a -procedure — it captures a planning conversation into the destination artifact -(synthesizing what it already knows instead of re-interviewing you), **maps -in-flight changes against the destination**, decomposes into self-contained -tracer-bullet slices, and syncs the high level against live status. It is -also told to write *less*: one page per artifact, tables over prose. - -## The handoff - -**Whoever plans** (a PM, a lead, you) starts in `/opsx:plan`: talk the idea -through and the skill writes the destination artifact — or drop an existing -doc into `openspec/plan/`. Decomposing ends in changes created with -`--plan`, so each is born linked. - -**Whoever builds** picks up any change — each is self-contained — and works -it with the normal change skills. The plan travels along: the repo's -`context:` config can point at it locally, and referenced stores surface it -to agents automatically. - -**The handoff artifact is the change itself.** Status flows back to -`openspec list --plan` with no meetings and no bookkeeping. - -## What the pieces are - -| Piece | What it is | -|---|---| -| `openspec/plan/` | one folder; your destination + whatever artifacts you use | -| numbered folders | optional ordered stages — "where am I" navigation | -| `plan: local` / `plan: ` | one line in a change's `.openspec.yaml` | -| `openspec new change --plan ` | create a change already pointing up | -| `openspec list --plan [--store ]` | the plan + every linked change, live | -| `openspec-plan` skill | the guide through all of it | - -## Honest limits - -- Rollup scans repos registered on this machine — it never clones or syncs. -- Stage order is a naming convention, not a gate. Nothing blocks working out - of order; the skill just knows what comes next. -- Want typed, checked artifacts someday? [Custom schemas](../customization.md) - already exist — plan artifacts stay freeform until you want that. -- This repo dogfoods it: see [openspec/plan/](../../openspec/plan/goal.md) — a destination file plus two optional stages. diff --git a/docs/stores-beta/user-guide.md b/docs/stores-beta/user-guide.md index 08f6f547fe..c8b906c8ae 100644 --- a/docs/stores-beta/user-guide.md +++ b/docs/stores-beta/user-guide.md @@ -311,7 +311,7 @@ tells you which case you're in. - **Some commands stay where they are.** `view`, `templates`, and the deprecated noun forms (`openspec change show`, ...) act on the current directory only — no `--store`. (`schemas` now accepts `--store` — see the - [plan folder guide](plan.md).) + [initiatives guide](initiatives.md).) - **Per-machine state is per-machine.** The store registry and worksets are local settings. Nothing about your machine's layout is ever committed to shared planning. diff --git a/openspec/changes/add-global-install-scope/.openspec.yaml b/openspec/changes/add-global-install-scope/.openspec.yaml index 5f9a35144b..3d698c574e 100644 --- a/openspec/changes/add-global-install-scope/.openspec.yaml +++ b/openspec/changes/add-global-install-scope/.openspec.yaml @@ -1,3 +1,3 @@ schema: spec-driven created: 2026-02-21 -plan: local +initiative: smoother-setup diff --git a/openspec/changes/add-initiatives/design.md b/openspec/changes/add-initiatives/design.md new file mode 100644 index 0000000000..dadb337837 --- /dev/null +++ b/openspec/changes/add-initiatives/design.md @@ -0,0 +1,89 @@ +## Context + +The upstream layer (roadmap → requirements → changes) has no home in +OpenSpec. The design constraint: give it one without OpenSpec modeling the +team's workflow — every team's is different. + +## Goals / Non-Goals + +**Goals:** the thinnest possible wrapper; any artifact shape; one repo or +many; one skill; value on first contact for a solo dev AND for an org. + +**Non-Goals:** typed upstream artifacts, manifests, file watchers or hook +machinery, sync engines, auto-discovery of unregistered repos. + +## Decisions + +**1. A portfolio of initiatives, plus an evergreen layer.** +`openspec/initiatives//` — each folder is one finite initiative; +unnumbered top-level files are evergreen artifacts (product, roadmap, +architecture) that outlive every initiative. Why: a store holding one plan +is a demo; a store holding the org's portfolio is an operational surface — +and the two-layer shape mirrors what OpenSpec already is. Specs are what is +true and changes are what is in motion at the code altitude; evergreen +artifacts are what is true and initiatives are what is in motion at the +planning altitude. Work flows down, truth flows up, at both altitudes: when +an initiative completes, syncing the evergreen artifacts is the planning +equivalent of archiving a change into specs. Rejected: keeping the singular +"one plan per root" (too small for orgs) and typed artifacts (freeform is +the point; custom schemas already exist if a team ever wants types). + +**2. Changes reference upward: `initiative: | /`.** +One line in change metadata; rollup scans for it. One syntax covers solo and +team — no `local` keyword to learn, no precedence question (the store is the +parent; repos are children pointing up). The legacy `initiative:` object +(`{store, id}`) carries the same data and normalizes to `/` on +read, so old metadata keeps working. Rejected: a downward manifest — a +central file invites merge conflicts and goes stale; a folder existing is +what makes an initiative exist. + +**3. Deterministic triggers, agent reactions.** Anything that drives a +workflow must be a structured fact derivable from disk — a task flipped, a +change archived, a new change pointing at an initiative — never prose. +`list --initiatives` is the one deterministic rendering of that state; the +skill reads it on entry and proposes what to do about it. Prose stays a +freeform projection; if a workflow ever needs to subscribe to something +inside an artifact, that thing graduates to structure. Progress is always +derived, never recorded in planning artifacts. Why: judgment-per-event is +expensive and unrepeatable; facts-per-event with judgment-per-response is +cheap, auditable, and repeatable — and OpenSpec's task/archive lifecycle +already supplies the facts. + +**4. One skill, routed by state.** `openspec-initiatives` looks at what +exists before speaking: no folder → offer to capture the conversation; +folder, no target → summarize the portfolio in a few lines; inside an +initiative → work it. Its moves (ideate from what is on disk, capture, +bounded pushback, decompose into changes born linked, sync the evergreen +layer) end in a short numbered menu computed from state — one option marked +recommended, each wired to a real command. Why: the workflow lives on disk, +so anyone (or any agent) can pick it up; menus beat paragraphs for fatigue. +Rejected: per-stage or per-role skills, and a `new initiative` CLI command — +creating an initiative is `mkdir`, and the skill does it in flow. + +**5. Works anywhere.** `--store ` resolves through the global registry, +so the rollup answers from any directory, repo or not. With no local root +and no `--store`, `list --initiatives` falls back to the portfolios of +registered stores instead of erroring. Why: the planning layer is the level +above repos; asking it "where does everything stand" should not require +standing in a repo. + +## Risks / Trade-offs + +- [Scan cost across registered repos] → registries are small; scan is + per-invocation and read-only. +- [`initiative:` values are unvalidated against real folders] → rollup + simply finds nothing for a bad value; the skill and `list --initiatives` + make that visible. +- [Plural shape re-grows toward the deleted initiative machinery] → guarded + by decision 2: no manifest, no registration, no ids beyond the folder + name; discovery stays a metadata scan. + +## Migration Plan + +Additive only. Legacy `initiative:` objects normalize on read; the retired +`plan:` field from this experiment's earlier iteration was never released. + +## Open Questions + +None blocking. Reactive triggers beyond skill-entry (git hooks, CI) are a +natural next experiment, deliberately out of scope. diff --git a/openspec/changes/add-initiatives/proposal.md b/openspec/changes/add-initiatives/proposal.md new file mode 100644 index 0000000000..2f05f67d2b --- /dev/null +++ b/openspec/changes/add-initiatives/proposal.md @@ -0,0 +1,55 @@ +## Why + +Big work is bigger than one change, and OpenSpec has no way in above +`propose`. Roadmaps, PRDs, and visions live outside the tool, and the +translation from them into changes is hand-rolled by every serious team. + +Initiatives are that way in. OpenSpec owns almost nothing: a folder +convention, one metadata line, one rollup, one skill. + +## What Changes + +- **A portfolio, not a single plan.** `openspec/initiatives/` holds the + planning layer for a root — this repo, or a store the team shares. + - Each subfolder is one **initiative**: a finite piece of work above a + single change (`smoother-setup/`, `q3-payments/`). Contents are freeform; + numbered folders inside an initiative are ordered stages when visible + order is wanted. + - Unnumbered files at the top level are **evergreen artifacts** — the + standing truths every initiative serves (`product.md`, `roadmap.md`, + `architecture.md`). They are maintained forever, the way specs are. +- **Changes point up.** One line in a change's `.openspec.yaml` — + `initiative: ` (this root) or `initiative: /` (a + store's initiative). No manifest anywhere. `new change` gains + `--initiative`. Legacy `initiative:` objects (`{store, id}`) stay readable. +- **One rollup.** `openspec list --initiatives [--store ]` shows the + portfolio: every initiative, every change on this machine pointing at it, + live task status — including changes in other registered repos. Run + outside any root, it falls back to the portfolios of registered stores. +- **One skill.** `openspec-initiatives` (`/opsx:initiatives`): routes by + what is on disk, captures planning conversations into artifacts, ideates + from what exists, decomposes into changes born linked, and syncs the + evergreen layer as work completes. It ends every move in a short numbered + menu wired to real commands, and it is instructed to write less, not more. +- Referenced stores' initiatives appear in `openspec context` and the agent + instruction block. + +## Capabilities + +### New Capabilities +- `initiatives`: the initiatives convention (portfolio + evergreen layer), + the upward `initiative:` link, the `list --initiatives` rollup, and + surfacing in context/instructions. + +### Modified Capabilities +None. + +## Impact + +- Commands: `list` (adds `--initiatives`), `new change` (adds + `--initiative`), `context` and `instructions` (surface a store's + initiatives). All additive. +- Skill set: one new optional workflow, `initiatives` (not in the core + profile). +- No breaking changes. Legacy `initiative:` metadata objects normalize to + the new reference form when read. diff --git a/openspec/changes/add-initiatives/specs/initiatives/spec.md b/openspec/changes/add-initiatives/specs/initiatives/spec.md new file mode 100644 index 0000000000..7649c67d34 --- /dev/null +++ b/openspec/changes/add-initiatives/specs/initiatives/spec.md @@ -0,0 +1,79 @@ +## ADDED Requirements + +### Requirement: Initiatives folder convention + +The system SHALL treat each subfolder of `openspec/initiatives/` as one +initiative and unnumbered top-level files as evergreen artifacts, without +requiring any manifest or configuration. Numbered folders inside an +initiative SHALL be treated as ordered stages. + +#### Scenario: Initiatives and evergreen artifacts are read from the folder + +- **WHEN** `openspec/initiatives/` contains `smoother-setup/`, `q3-payments/`, and `roadmap.md` +- **THEN** the system reports `smoother-setup` and `q3-payments` as initiatives +- **AND** reports `roadmap.md` as an evergreen artifact, not an initiative + +#### Scenario: Stages are read inside an initiative + +- **WHEN** `openspec/initiatives/smoother-setup/` contains `00_goal/`, `01_requirements/`, and `notes.md` +- **THEN** the system reports `00_goal` and `01_requirements` as that initiative's stages, in order + +### Requirement: Changes link upward to an initiative + +The system SHALL let a change declare the initiative it serves with an +`initiative` value in its `.openspec.yaml` — `` for an initiative in +its own root, or `/` for one in that registered store. +Legacy object values (`{store, id}`) SHALL remain readable and normalize to +`/`. + +#### Scenario: Creating a change linked to an initiative + +- **WHEN** a user runs `openspec new change add-search --initiative smoother-setup` +- **THEN** the created change's metadata contains `initiative: smoother-setup` + +#### Scenario: Legacy object metadata still resolves + +- **WHEN** a change's `.openspec.yaml` contains `initiative: { store: team-plans, id: q3-payments }` +- **THEN** the system reads it as `initiative: team-plans/q3-payments` + +### Requirement: Portfolio rollup + +The system SHALL show the portfolio — every initiative with every change on +this machine that points at it, with live task status — via +`openspec list --initiatives`. + +#### Scenario: Rolling up local changes + +- **WHEN** a repo has initiatives and changes whose metadata names one of them +- **AND** the user runs `openspec list --initiatives` +- **THEN** the output groups each linked change under its initiative with task status + +#### Scenario: Rolling up across repos toward a store initiative + +- **WHEN** a store has an initiative and changes in other registered repos say + `initiative: /` +- **AND** the user runs `openspec list --initiatives --store ` +- **THEN** the output includes those changes with the repo each lives in + +#### Scenario: Running outside any root + +- **WHEN** the user runs `openspec list --initiatives` in a directory with no `openspec/` root +- **THEN** the output shows the initiatives of registered stores that have them, instead of an error + +#### Scenario: No initiatives folder + +- **WHEN** the resolved root has no `openspec/initiatives/` folder +- **THEN** `openspec list --initiatives` says so and suggests how to start one + +### Requirement: Initiatives surface in agent context + +The system SHALL include a referenced store's initiative names in +`openspec context` and in the agent instruction block, with the command to +fetch the full rollup. + +#### Scenario: A referenced store's initiatives appear in context + +- **WHEN** a repo references a store whose initiatives folder is non-empty +- **AND** the user runs `openspec context` +- **THEN** the store's member entry lists the initiative names (and evergreen artifacts when there are no initiatives) +- **AND** shows the `openspec list --initiatives --store ` fetch command diff --git a/openspec/changes/add-initiatives/tasks.md b/openspec/changes/add-initiatives/tasks.md new file mode 100644 index 0000000000..ec93bf7bae --- /dev/null +++ b/openspec/changes/add-initiatives/tasks.md @@ -0,0 +1,27 @@ +## 1. Core + +- [x] 1.1 Initiatives module: portfolio read (initiatives / evergreen / + stages), upward-ref scan, cross-repo rollup, legacy normalization +- [x] 1.2 `initiative` field in change metadata: string ref union with the + legacy object shape + +## 2. Surfaces + +- [x] 2.1 `openspec list --initiatives [--store ]` (human + JSON), + including the outside-a-root fallback to registered stores +- [x] 2.2 `openspec new change --initiative ` +- [x] 2.3 Initiatives in `openspec context` and the instruction block + +## 3. The skill + +- [x] 3.1 `openspec-initiatives` skill + `/opsx:initiatives` command + (state-routed moves, numbered handoff menus), registered as an + optional workflow (not in the core profile) + +## 4. Proof + +- [x] 4.1 Dogfood: this repo's `openspec/initiatives/smoother-setup/` groups + real changes; `openspec list --initiatives` rolls them up live +- [x] 4.2 Tests: initiatives module (solo + cross-repo + legacy + fallback), + context/instructions surfacing, registries, skill parity +- [x] 4.3 `openspec validate add-initiatives --strict` passes diff --git a/openspec/changes/add-plan-folder/design.md b/openspec/changes/add-plan-folder/design.md deleted file mode 100644 index 6c41318e33..0000000000 --- a/openspec/changes/add-plan-folder/design.md +++ /dev/null @@ -1,52 +0,0 @@ -## Context - -The upstream layer (roadmap → requirements → changes) has no home in -OpenSpec. The design constraint: give it one without OpenSpec modeling the -team's workflow — every team's is different. - -## Goals / Non-Goals - -**Goals:** the thinnest possible wrapper; any artifact shape; one repo or -many; one skill. - -**Non-Goals:** typed upstream artifacts, relationship taxonomies, sync -machinery, auto-discovery of unregistered repos. - -## Decisions - -**1. Destination first; ordered folders when wanted.** The plan is whatever -destination artifact the user already has — one file is valid. Numbered -folders opt into a linear sequence by convention alone ("where am I" carries -the workflow); reconfiguring is renaming folders. Rejected: a schema for the -plan (types the artifacts — the thing users want to keep freeform). If -checking is ever wanted, stages can later map onto the existing schema -machinery. - -**2. Changes reference upward.** `plan: local | ` in change -metadata; rollup scans for it. Rejected: a downward manifest — a central file -invites merge conflicts and goes stale when someone forgets it. The store is -the parent/global level; repos are children pointing up, so there is no -local-vs-store precedence question left to arbitrate. - -**3. One explore-style skill.** The translation judgment (decompose into -self-contained items, flag merge/split calls, filter input against the -vision, write one-page artifacts) lives in the `openspec-plan` skill prompt, -not in code. Rejected: per-stage commands or role-based skills — navigation -derives from what is on disk, so anyone can pick the work up. - -## Risks / Trade-offs - -- [Scan cost across registered repos] → registries are small; scan is - per-invocation and read-only. -- [`plan:` values are unvalidated against real stores] → rollup simply finds - nothing for a bad value; the skill and `list --plan` make that visible. - -## Migration Plan - -Additive only. Legacy `initiative:` metadata remains readable; nothing else -existed to migrate. - -## Open Questions - -None blocking. Naming ("plan") is the simplest word we had; cheap to change -while beta. diff --git a/openspec/changes/add-plan-folder/proposal.md b/openspec/changes/add-plan-folder/proposal.md deleted file mode 100644 index 51843265d8..0000000000 --- a/openspec/changes/add-plan-folder/proposal.md +++ /dev/null @@ -1,43 +0,0 @@ -## Why - -Big work is bigger than one change, and OpenSpec has no way in above -`propose`. Roadmaps, PRDs, and visions live outside the tool, and the -translation from them into changes is hand-rolled by every serious team. - -The plan folder is that way in. OpenSpec owns almost nothing: a folder -convention, one metadata line, one rollup, one skill. - -## What Changes - -- **A convention, not a format.** A plan is one folder, `openspec/plan/`, - holding the user's destination — `product.md`, a roadmap, any artifact name - or convention; one file is a valid plan. Numbered subfolders add ordered - stages when visible order is wanted; unnumbered entries are context. -- **Changes point up.** One line in a change's `.openspec.yaml` — `plan: local` - or `plan: `. No manifest anywhere. `new change` gains `--plan`. -- **One rollup.** `openspec list --plan [--store ]` shows the stages and - every change on this machine pointing at the plan, with live task status — - including changes in other registered repos pointing at a store's plan. -- **One skill.** `openspec-plan` (`/opsx:plan`): explore-style stance that - captures a planning conversation into the destination artifact, maps - in-flight changes against it, decomposes into self-contained slices, - bridges into changes, and syncs status back up. It is instructed to write - less, not more. -- Referenced stores' plan stages appear in `openspec context` and the agent - instruction block. - -## Capabilities - -### New Capabilities -- `plan-folder`: the plan convention, the upward `plan:` link, the - `list --plan` rollup, and plan surfacing in context/instructions. - -### Modified Capabilities -None. - -## Impact - -- Commands: `list` (adds `--plan`), `new change` (adds `--plan`), `context` - and `instructions` (surface a store's plan stages). All additive. -- Skill set: one new optional workflow, `plan` (not in the core profile). -- No breaking changes. diff --git a/openspec/changes/add-plan-folder/specs/plan-folder/spec.md b/openspec/changes/add-plan-folder/specs/plan-folder/spec.md deleted file mode 100644 index db9b3c6af9..0000000000 --- a/openspec/changes/add-plan-folder/specs/plan-folder/spec.md +++ /dev/null @@ -1,60 +0,0 @@ -## ADDED Requirements - -### Requirement: Plan folder convention - -The system SHALL treat numbered folders under `openspec/plan/` as ordered -stages and unnumbered entries as ambient context, without requiring any -manifest or configuration. - -#### Scenario: Stages and context are read from folder names - -- **WHEN** `openspec/plan/` contains `00_goal/`, `01_requirements/`, and `vision.md` -- **THEN** the system reports `00_goal` and `01_requirements` as stages, in order -- **AND** reports `vision.md` as context, not a stage - -### Requirement: Changes link upward to a plan - -The system SHALL let a change declare the plan it serves with a `plan` value -in its `.openspec.yaml` — `local` for the plan in its own root, or a -registered store id for that store's plan. - -#### Scenario: Creating a change linked to a plan - -- **WHEN** a user runs `openspec new change add-search --plan local` -- **THEN** the created change's metadata contains `plan: local` - -### Requirement: Plan rollup - -The system SHALL show a plan's stages and every change on this machine that -points at it, with live task status, via `openspec list --plan`. - -#### Scenario: Rolling up local changes - -- **WHEN** a repo has a plan folder and changes whose metadata says `plan: local` -- **AND** the user runs `openspec list --plan` -- **THEN** the output lists the stages and each linked change with its task status - -#### Scenario: Rolling up across repos toward a store plan - -- **WHEN** a store has a plan folder and changes in other registered repos say - `plan: ` -- **AND** the user runs `openspec list --plan --store ` -- **THEN** the output includes those changes with the repo each lives in - -#### Scenario: No plan folder - -- **WHEN** the resolved root has no `openspec/plan/` folder -- **THEN** `openspec list --plan` says so and suggests how to start one - -### Requirement: Plan stages surface in agent context - -The system SHALL include a referenced store's plan stage names in -`openspec context` and in the agent instruction block, with the command to -fetch the full rollup. - -#### Scenario: A referenced store's plan appears in context - -- **WHEN** a repo references a store whose plan folder has stages -- **AND** the user runs `openspec context` -- **THEN** the store's member entry lists the stage names in order -- **AND** shows the `openspec list --plan --store ` fetch command diff --git a/openspec/changes/add-plan-folder/tasks.md b/openspec/changes/add-plan-folder/tasks.md deleted file mode 100644 index 6468d8c1b7..0000000000 --- a/openspec/changes/add-plan-folder/tasks.md +++ /dev/null @@ -1,24 +0,0 @@ -## 1. Core - -- [x] 1.1 Plan module: stage/context split, upward-ref scan, cross-repo rollup -- [x] 1.2 `plan` field in change metadata - -## 2. Surfaces - -- [x] 2.1 `openspec list --plan [--store ]` (human + JSON) -- [x] 2.2 `openspec new change --plan ` -- [x] 2.3 Plan stages in `openspec context` and the instruction block -- [x] 2.4 Retire superseded prototype surfaces - -## 3. The skill - -- [x] 3.1 `openspec-plan` skill + `/opsx:plan` command (explore-style stance), - registered as an optional workflow (not in the core profile) - -## 4. Proof - -- [x] 4.1 Dogfood: this repo's `openspec/plan/` groups four real changes via - `plan: local`; `openspec list --plan` rolls them up live -- [x] 4.2 Tests: plan module (solo + cross-repo), context/instructions - surfacing, registries, skill parity -- [x] 4.3 `openspec validate add-plan-folder --strict` passes diff --git a/openspec/changes/fix-opencode-commands-directory/.openspec.yaml b/openspec/changes/fix-opencode-commands-directory/.openspec.yaml index 29888c80b8..5fcdfb2ae1 100644 --- a/openspec/changes/fix-opencode-commands-directory/.openspec.yaml +++ b/openspec/changes/fix-opencode-commands-directory/.openspec.yaml @@ -1,3 +1,3 @@ schema: spec-driven created: 2026-02-25 -plan: local +initiative: smoother-setup diff --git a/openspec/changes/schema-alias-support/.openspec.yaml b/openspec/changes/schema-alias-support/.openspec.yaml index bad31aea74..c9c46f60b9 100644 --- a/openspec/changes/schema-alias-support/.openspec.yaml +++ b/openspec/changes/schema-alias-support/.openspec.yaml @@ -1,3 +1,3 @@ schema: spec-driven created: 2026-01-20 -plan: local +initiative: smoother-setup diff --git a/openspec/changes/simplify-skill-installation/.openspec.yaml b/openspec/changes/simplify-skill-installation/.openspec.yaml index fa26e9a3f0..42e8161e7c 100644 --- a/openspec/changes/simplify-skill-installation/.openspec.yaml +++ b/openspec/changes/simplify-skill-installation/.openspec.yaml @@ -1,3 +1,3 @@ schema: spec-driven created: 2026-02-17 -plan: local +initiative: smoother-setup diff --git a/openspec/config.yaml b/openspec/config.yaml index 62d5006e84..138c6501d9 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -1,8 +1,9 @@ schema: spec-driven context: | - The plan for this repo lives in openspec/plan/ — read goal.md first. - Changes whose .openspec.yaml says `plan: local` serve that plan. + This repo's initiatives live in openspec/initiatives/ — read + smoother-setup/goal.md first. Changes whose .openspec.yaml says + `initiative: smoother-setup` serve that initiative. Tech stack: TypeScript, Node.js (≥20.19.0), ESM modules Package manager: pnpm diff --git a/openspec/initiatives/context-store-and-initiatives/.initiative.yaml b/openspec/explorations/context-store-and-initiatives/.initiative.yaml similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/.initiative.yaml rename to openspec/explorations/context-store-and-initiatives/.initiative.yaml diff --git a/openspec/initiatives/context-store-and-initiatives/README.md b/openspec/explorations/context-store-and-initiatives/README.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/README.md rename to openspec/explorations/context-store-and-initiatives/README.md diff --git a/openspec/initiatives/context-store-and-initiatives/decisions.md b/openspec/explorations/context-store-and-initiatives/decisions.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/decisions.md rename to openspec/explorations/context-store-and-initiatives/decisions.md diff --git a/openspec/initiatives/context-store-and-initiatives/direction-git-native-work.md b/openspec/explorations/context-store-and-initiatives/direction-git-native-work.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/direction-git-native-work.md rename to openspec/explorations/context-store-and-initiatives/direction-git-native-work.md diff --git a/openspec/initiatives/context-store-and-initiatives/direction.md b/openspec/explorations/context-store-and-initiatives/direction.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/direction.md rename to openspec/explorations/context-store-and-initiatives/direction.md diff --git a/openspec/initiatives/context-store-and-initiatives/questions.md b/openspec/explorations/context-store-and-initiatives/questions.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/questions.md rename to openspec/explorations/context-store-and-initiatives/questions.md diff --git a/openspec/initiatives/context-store-and-initiatives/roadmap.md b/openspec/explorations/context-store-and-initiatives/roadmap.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/roadmap.md rename to openspec/explorations/context-store-and-initiatives/roadmap.md diff --git a/openspec/initiatives/context-store-and-initiatives/tasks.md b/openspec/explorations/context-store-and-initiatives/tasks.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/tasks.md rename to openspec/explorations/context-store-and-initiatives/tasks.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/01-lock-the-direction/evidence.md b/openspec/explorations/context-store-and-initiatives/work-items/01-lock-the-direction/evidence.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/01-lock-the-direction/evidence.md rename to openspec/explorations/context-store-and-initiatives/work-items/01-lock-the-direction/evidence.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/01-lock-the-direction/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/01-lock-the-direction/plan.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/01-lock-the-direction/plan.md rename to openspec/explorations/context-store-and-initiatives/work-items/01-lock-the-direction/plan.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/01-lock-the-direction/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/01-lock-the-direction/tasks.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/01-lock-the-direction/tasks.md rename to openspec/explorations/context-store-and-initiatives/work-items/01-lock-the-direction/tasks.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/evidence.md b/openspec/explorations/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/evidence.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/evidence.md rename to openspec/explorations/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/evidence.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/plan.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/plan.md rename to openspec/explorations/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/plan.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/tasks.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/tasks.md rename to openspec/explorations/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/tasks.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/03-add-context-store-foundation/evidence.md b/openspec/explorations/context-store-and-initiatives/work-items/03-add-context-store-foundation/evidence.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/03-add-context-store-foundation/evidence.md rename to openspec/explorations/context-store-and-initiatives/work-items/03-add-context-store-foundation/evidence.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/03-add-context-store-foundation/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/03-add-context-store-foundation/plan.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/03-add-context-store-foundation/plan.md rename to openspec/explorations/context-store-and-initiatives/work-items/03-add-context-store-foundation/plan.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/03-add-context-store-foundation/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/03-add-context-store-foundation/tasks.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/03-add-context-store-foundation/tasks.md rename to openspec/explorations/context-store-and-initiatives/work-items/03-add-context-store-foundation/tasks.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/04-add-collection-foundation/evidence.md b/openspec/explorations/context-store-and-initiatives/work-items/04-add-collection-foundation/evidence.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/04-add-collection-foundation/evidence.md rename to openspec/explorations/context-store-and-initiatives/work-items/04-add-collection-foundation/evidence.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/04-add-collection-foundation/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/04-add-collection-foundation/plan.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/04-add-collection-foundation/plan.md rename to openspec/explorations/context-store-and-initiatives/work-items/04-add-collection-foundation/plan.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/04-add-collection-foundation/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/04-add-collection-foundation/tasks.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/04-add-collection-foundation/tasks.md rename to openspec/explorations/context-store-and-initiatives/work-items/04-add-collection-foundation/tasks.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/05-ship-initiative-mvp/evidence.md b/openspec/explorations/context-store-and-initiatives/work-items/05-ship-initiative-mvp/evidence.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/05-ship-initiative-mvp/evidence.md rename to openspec/explorations/context-store-and-initiatives/work-items/05-ship-initiative-mvp/evidence.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/05-ship-initiative-mvp/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/05-ship-initiative-mvp/plan.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/05-ship-initiative-mvp/plan.md rename to openspec/explorations/context-store-and-initiatives/work-items/05-ship-initiative-mvp/plan.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/05-ship-initiative-mvp/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/05-ship-initiative-mvp/tasks.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/05-ship-initiative-mvp/tasks.md rename to openspec/explorations/context-store-and-initiatives/work-items/05-ship-initiative-mvp/tasks.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/evidence.md b/openspec/explorations/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/evidence.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/evidence.md rename to openspec/explorations/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/evidence.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/plan.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/plan.md rename to openspec/explorations/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/plan.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/tasks.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/tasks.md rename to openspec/explorations/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/tasks.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/evidence.md b/openspec/explorations/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/evidence.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/evidence.md rename to openspec/explorations/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/evidence.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/plan.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/plan.md rename to openspec/explorations/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/plan.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/tasks.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/tasks.md rename to openspec/explorations/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/tasks.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/evidence.md b/openspec/explorations/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/evidence.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/evidence.md rename to openspec/explorations/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/evidence.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/plan.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/plan.md rename to openspec/explorations/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/plan.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/tasks.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/tasks.md rename to openspec/explorations/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/tasks.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/decision-review.md b/openspec/explorations/context-store-and-initiatives/work-items/09-add-initiative-resolve/decision-review.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/decision-review.md rename to openspec/explorations/context-store-and-initiatives/work-items/09-add-initiative-resolve/decision-review.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/evidence.md b/openspec/explorations/context-store-and-initiatives/work-items/09-add-initiative-resolve/evidence.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/evidence.md rename to openspec/explorations/context-store-and-initiatives/work-items/09-add-initiative-resolve/evidence.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/09-add-initiative-resolve/plan.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/plan.md rename to openspec/explorations/context-store-and-initiatives/work-items/09-add-initiative-resolve/plan.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/09-add-initiative-resolve/tasks.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/09-add-initiative-resolve/tasks.md rename to openspec/explorations/context-store-and-initiatives/work-items/09-add-initiative-resolve/tasks.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/plan.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/plan.md rename to openspec/explorations/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/plan.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/tasks.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/tasks.md rename to openspec/explorations/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/tasks.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/notes.md b/openspec/explorations/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/notes.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/notes.md rename to openspec/explorations/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/notes.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/plan.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/plan.md rename to openspec/explorations/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/plan.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/tasks.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/tasks.md rename to openspec/explorations/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/tasks.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/evidence.md b/openspec/explorations/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/evidence.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/evidence.md rename to openspec/explorations/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/evidence.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/plan.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/plan.md rename to openspec/explorations/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/plan.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/tasks.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/tasks.md rename to openspec/explorations/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/tasks.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/evidence.md b/openspec/explorations/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/evidence.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/evidence.md rename to openspec/explorations/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/evidence.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/plan.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/plan.md rename to openspec/explorations/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/plan.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/tasks.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/tasks.md rename to openspec/explorations/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/tasks.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/14-workspaces-beta-guide-split/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/14-workspaces-beta-guide-split/plan.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/14-workspaces-beta-guide-split/plan.md rename to openspec/explorations/context-store-and-initiatives/work-items/14-workspaces-beta-guide-split/plan.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/14-workspaces-beta-guide-split/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/14-workspaces-beta-guide-split/tasks.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/14-workspaces-beta-guide-split/tasks.md rename to openspec/explorations/context-store-and-initiatives/work-items/14-workspaces-beta-guide-split/tasks.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/evidence.md b/openspec/explorations/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/evidence.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/evidence.md rename to openspec/explorations/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/evidence.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/plan.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/plan.md rename to openspec/explorations/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/plan.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/tasks.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/tasks.md rename to openspec/explorations/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/tasks.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/16-add-escalation-ux/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/16-add-escalation-ux/plan.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/16-add-escalation-ux/plan.md rename to openspec/explorations/context-store-and-initiatives/work-items/16-add-escalation-ux/plan.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/16-add-escalation-ux/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/16-add-escalation-ux/tasks.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/16-add-escalation-ux/tasks.md rename to openspec/explorations/context-store-and-initiatives/work-items/16-add-escalation-ux/tasks.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/17-harden-team-shared-coordination/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/17-harden-team-shared-coordination/plan.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/17-harden-team-shared-coordination/plan.md rename to openspec/explorations/context-store-and-initiatives/work-items/17-harden-team-shared-coordination/plan.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/17-harden-team-shared-coordination/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/17-harden-team-shared-coordination/tasks.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/17-harden-team-shared-coordination/tasks.md rename to openspec/explorations/context-store-and-initiatives/work-items/17-harden-team-shared-coordination/tasks.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/evidence.md b/openspec/explorations/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/evidence.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/evidence.md rename to openspec/explorations/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/evidence.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/plan.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/plan.md rename to openspec/explorations/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/plan.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/tasks.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/tasks.md rename to openspec/explorations/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/tasks.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/19-review-workspace-beta-compatibility-before-public-release/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/19-review-workspace-beta-compatibility-before-public-release/plan.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/19-review-workspace-beta-compatibility-before-public-release/plan.md rename to openspec/explorations/context-store-and-initiatives/work-items/19-review-workspace-beta-compatibility-before-public-release/plan.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/19-review-workspace-beta-compatibility-before-public-release/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/19-review-workspace-beta-compatibility-before-public-release/tasks.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/19-review-workspace-beta-compatibility-before-public-release/tasks.md rename to openspec/explorations/context-store-and-initiatives/work-items/19-review-workspace-beta-compatibility-before-public-release/tasks.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/evidence.md b/openspec/explorations/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/evidence.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/evidence.md rename to openspec/explorations/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/evidence.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/plan.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/plan.md rename to openspec/explorations/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/plan.md diff --git a/openspec/initiatives/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/tasks.md similarity index 100% rename from openspec/initiatives/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/tasks.md rename to openspec/explorations/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/tasks.md diff --git a/openspec/plan/01_who/personas.md b/openspec/initiatives/smoother-setup/01_who/personas.md similarity index 100% rename from openspec/plan/01_who/personas.md rename to openspec/initiatives/smoother-setup/01_who/personas.md diff --git a/openspec/plan/02_decisions/decisions.md b/openspec/initiatives/smoother-setup/02_decisions/decisions.md similarity index 100% rename from openspec/plan/02_decisions/decisions.md rename to openspec/initiatives/smoother-setup/02_decisions/decisions.md diff --git a/openspec/plan/goal.md b/openspec/initiatives/smoother-setup/goal.md similarity index 100% rename from openspec/plan/goal.md rename to openspec/initiatives/smoother-setup/goal.md diff --git a/src/cli/index.ts b/src/cli/index.ts index 5f2da8af48..6ec4f10bec 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -8,7 +8,11 @@ import { promises as fs } from 'fs'; import { AI_TOOLS } from '../core/config.js'; import { UpdateCommand } from '../core/update.js'; import { ListCommand } from '../core/list.js'; -import { rollupPlan } from '../core/plan.js'; +import { + rollupInitiatives, + rollupRegisteredStorePortfolios, + type PortfolioInfo, +} from '../core/initiatives.js'; import { ArchiveCommand, type ArchiveOptions } from '../core/archive.js'; import { ViewCommand } from '../core/view.js'; import { resolveRootForCommand, toRootOutput, type ResolvedOpenSpecRoot } from '../core/root-selection.js'; @@ -54,65 +58,105 @@ function hiddenStorePathOption(): Option { ).hideHelp(); } -async function renderPlan( +function printPortfolioBody(portfolio: PortfolioInfo, indent: string): void { + if (portfolio.evergreen.length > 0) { + console.log(`${indent}Evergreen: ${portfolio.evergreen.join(', ')}`); + } + if (portfolio.initiatives.length === 0) { + if (portfolio.evergreen.length === 0) { + console.log( + `${indent}(empty — add an evergreen artifact like product.md, or a folder per initiative)` + ); + } + return; + } + + let anyChanges = false; + for (const initiative of portfolio.initiatives) { + const summary = + initiative.changesTotal === 0 + ? 'no changes yet' + : `${initiative.changesComplete}/${initiative.changesTotal} changes complete`; + const missing = initiative.exists ? '' : ' (no folder)'; + console.log(''); + console.log(`${indent}${initiative.name} ${summary}${missing}`); + if (initiative.stages.length > 0) { + console.log( + `${indent} stages: ${initiative.stages.map((stage) => stage.name).join(' → ')}` + ); + } + if (initiative.changes.length === 0) continue; + anyChanges = true; + const changeWidth = Math.max(...initiative.changes.map((c) => c.id.length)); + for (const change of initiative.changes) { + const mark = + change.state === 'complete' ? '✓' : change.state === 'no-tasks' ? '–' : '·'; + const where = change.store ?? 'here'; + const tasks = + change.totalTasks === 0 + ? 'no tasks' + : `${change.completedTasks}/${change.totalTasks} tasks`; + console.log( + `${indent} ${mark} ${change.id.padEnd(changeWidth)} ${where.padEnd(14)} ${tasks}` + ); + } + } + if (!anyChanges) { + console.log(''); + console.log( + `${indent}Link a change: openspec new change --initiative ` + ); + } +} + +async function renderInitiatives( root: ResolvedOpenSpecRoot, options: { json?: boolean } ): Promise { - const plan = await rollupPlan(root.path); + const initiatives = await rollupInitiatives(root.path); if (options.json) { - console.log(JSON.stringify({ plan, root: toRootOutput(root) }, null, 2)); + console.log( + JSON.stringify({ initiatives, root: toRootOutput(root) }, null, 2) + ); return; } - if (plan === null) { - console.log('No plan folder found at openspec/plan/.'); + if (initiatives === null) { + console.log('No initiatives folder found at openspec/initiatives/.'); console.log( - 'Start one: mkdir -p openspec/plan, then add your destination (product.md, roadmap.md — any name).' + 'Start one: mkdir -p openspec/initiatives/, then add any artifact (notes.md, roadmap.md — any name).' ); return; } - console.log(`Plan: ${plan.path}`); - // Destination and context artifacts (unnumbered) come first: the plan is - // the destination; stages are optional structure on the way there. - for (const entry of plan.context) { - console.log(` ${entry}`); - } - if (plan.stages.length > 0) { - console.log('Stages:'); - const stageWidth = Math.max(...plan.stages.map((s) => s.name.length)); - for (const stage of plan.stages) { - const files = stage.files === 1 ? '1 file' : `${stage.files} files`; - console.log(` ${stage.name.padEnd(stageWidth)} ${stage.files === 0 ? 'empty' : files}`); - } - } else if (plan.context.length === 0) { - console.log( - ' (empty — add your destination artifact, or numbered folders for ordered stages)' - ); + console.log(`Initiatives: ${initiatives.path}`); + printPortfolioBody(initiatives, ''); +} + +/** + * Outside any root, the portfolio question is still answerable: show the + * initiatives of every registered store that has them. + */ +async function renderStorePortfolios(options: { json?: boolean }): Promise { + const stores = await rollupRegisteredStorePortfolios(); + + if (options.json) { + console.log(JSON.stringify({ initiatives: null, stores, root: null }, null, 2)); + return; } - if (plan.changes.length === 0) { - console.log('Changes pointing here: none yet'); - console.log('Link one: openspec new change --plan local'); + if (stores.length === 0) { + console.log('No local OpenSpec root, and no registered store has initiatives.'); + console.log('Run inside a repo, or pass --store .'); return; } - console.log( - `Changes pointing here: ${plan.changesComplete}/${plan.changesTotal} complete` - ); - const changeWidth = Math.max(...plan.changes.map((c) => c.id.length)); - for (const change of plan.changes) { - const mark = - change.state === 'complete' ? '✓' : change.state === 'no-tasks' ? '–' : '·'; - const where = change.store ?? 'here'; - const tasks = - change.totalTasks === 0 - ? 'no tasks' - : `${change.completedTasks}/${change.totalTasks} tasks`; - console.log( - ` ${mark} ${change.id.padEnd(changeWidth)} ${where.padEnd(14)} ${tasks}` - ); + console.log('No local OpenSpec root — showing registered store portfolios.'); + for (const { store, portfolio } of stores) { + console.log(''); + console.log(`${store} (${portfolio.path})`); + printPortfolioBody(portfolio, ' '); } } @@ -277,21 +321,42 @@ program program .command('list') - .description('List items (changes by default). Use --specs to list specs, --plan to see the plan rollup.') + .description('List items (changes by default). Use --specs to list specs, --initiatives to see the portfolio rollup.') .option('--specs', 'List specs instead of changes') .option('--changes', 'List changes explicitly (default)') - .option('--plan', "Show the root's plan: its stages, and every change pointing at it") + .option('--initiatives', "Show the root's portfolio: every initiative, and every change pointing at it") .option('--sort ', 'Sort order: "recent" (default) or "name"', 'recent') .option('--json', 'Output as JSON (for programmatic use)') .option('--store ', STORE_OPTION_DESCRIPTION) .addOption(hiddenStorePathOption()) - .action(async (options?: { specs?: boolean; changes?: boolean; plan?: boolean; sort?: string; json?: boolean; store?: string; storePath?: string }) => { + .action(async (options?: { specs?: boolean; changes?: boolean; initiatives?: boolean; sort?: string; json?: boolean; store?: string; storePath?: string }) => { try { - const failurePayload = options?.plan - ? { plan: null, root: null } + const failurePayload = options?.initiatives + ? { initiatives: null, root: null } : options?.specs ? { specs: [], root: null } : { changes: [], root: null }; + // The portfolio question is the one `list` answer that still makes + // sense outside any root: fall back to registered store portfolios + // instead of failing root resolution. + if ( + options?.initiatives && + options.store === undefined && + options.storePath === undefined + ) { + try { + const root = await resolveRootForCommand(options ?? {}); + if (root) { + await renderInitiatives(root, { json: options?.json }); + } + return; + } catch (error) { + const code = (error as { diagnostic?: { code?: string } }).diagnostic?.code; + if (code !== 'no_root_with_registered_stores') throw error; + await renderStorePortfolios({ json: options?.json }); + return; + } + } const root = await resolveRootForCommand(options ?? {}, { json: options?.json, failurePayload, @@ -299,8 +364,8 @@ program if (!root) { return; } - if (options?.plan) { - await renderPlan(root, { json: options?.json }); + if (options?.initiatives) { + await renderInitiatives(root, { json: options?.json }); return; } const listCommand = new ListCommand(); @@ -314,7 +379,11 @@ program } catch (error) { failWithError(error, { enabled: options?.json, - payload: options?.specs ? { specs: [], root: null } : { changes: [], root: null }, + payload: options?.initiatives + ? { initiatives: null, root: null } + : options?.specs + ? { specs: [], root: null } + : { changes: [], root: null }, fallbackCode: 'list_error', }); process.exit(1); @@ -637,13 +706,12 @@ newCmd .option('--description ', 'Description to add to README.md') .option('--goal ', 'Optional goal metadata to store with the change') .option('--schema ', `Workflow schema to use (default: ${DEFAULT_SCHEMA})`) - .option('--plan ', "Link this change to a plan: 'local' or a store id") + .option('--initiative ', 'Link this change to an initiative: or /') .option('--json', 'Output as JSON') .option('--store ', STORE_OPTION_DESCRIPTION) .addOption(hiddenStorePathOption()) - // Removed options kept registered (hidden) so users get a deliberate + // Removed option kept registered (hidden) so users get a deliberate // explanation instead of a generic unknown-option error. - .addOption(new Option('--initiative ', 'No longer supported').hideHelp()) .addOption(new Option('--areas ', 'No longer supported').hideHelp()) .action(async (name: string, options: NewChangeOptions) => { try { diff --git a/src/commands/context.ts b/src/commands/context.ts index 22988bd51c..d1c0734c76 100644 --- a/src/commands/context.ts +++ b/src/commands/context.ts @@ -27,8 +27,8 @@ import { COMMON_FLAGS } from '../core/completions/shared-flags.js'; import { emitFailure, printJson } from './shared-output.js'; import { gatherRelationshipData } from './shared-gather.js'; import { listSchemasWithInfo } from '../core/artifact-graph/index.js'; -import { readPlanStages } from '../core/plan.js'; -import { schemasFetchRecipe, planFetchRecipe } from '../core/references.js'; +import { listInitiativeNames } from '../core/initiatives.js'; +import { schemasFetchRecipe, initiativesFetchRecipe } from '../core/references.js'; const FAILURE_PAYLOAD = { root: null, members: [] }; @@ -64,7 +64,7 @@ function memberLine(member: WorkingSetMember): string { /** * Enrich available referenced-store members with the store's own custom - * artifact types and plan stages, so `context` reports what a repo draws on + * artifact types and initiatives, so `context` reports what a repo draws on * beyond specs. Read-only; failures degrade to an unenriched member. */ async function enrichMembersWithStoreArtifacts( @@ -86,20 +86,14 @@ async function enrichMembersWithStoreArtifacts( // Unreadable schemas dir: leave the member unenriched. } try { - const plan = await readPlanStages(storeRoot); - if (plan !== null) { - // Stage names in order — or, for a destination-only plan with no - // stages, its artifact names. Either way the agent sees the plan. - const names = - plan.stages.length > 0 - ? plan.stages.map((stage) => stage.name) - : plan.context; - if (names.length > 0) { - member.plan = names; - } + // Initiative names — or, with none yet, the evergreen artifact + // names. Either way the agent sees the planning layer exists. + const names = await listInitiativeNames(storeRoot); + if (names.length > 0) { + member.initiatives = names; } } catch { - // Unreadable plan dir: leave the member unenriched. + // Unreadable initiatives dir: leave the member unenriched. } } } @@ -129,9 +123,9 @@ function printHumanWorkingSet(workingSet: WorkingSet, declaredReferenceCount: nu ` Artifact types: ${member.artifactTypes.join(', ')} (${schemasFetchRecipe(member.id)})` ); } - if (member.plan && member.plan.length > 0) { + if (member.initiatives && member.initiatives.length > 0) { console.log( - ` Plan: ${member.plan.join(' → ')} (${planFetchRecipe(member.id)})` + ` Initiatives: ${member.initiatives.join(', ')} (${initiativesFetchRecipe(member.id)})` ); } } diff --git a/src/commands/workflow/new-change.ts b/src/commands/workflow/new-change.ts index e14386caae..73e759d085 100644 --- a/src/commands/workflow/new-change.ts +++ b/src/commands/workflow/new-change.ts @@ -2,13 +2,14 @@ * New Change Command * * Creates a new change directory with optional description and schema in the - * resolved OpenSpec root. `--store ` selects a registered store's - * root; initiative linking and workspace affected areas are no longer part of - * this command. + * resolved OpenSpec root. `--store ` selects a registered store's root; + * `--initiative ` links the change upward to an initiative. Workspace + * affected areas are no longer part of this command. */ import ora from 'ora'; import path from 'path'; +import { InitiativeRefSchema } from '../../core/change-metadata/schema.js'; import { createChange, validateChangeName } from '../../utils/change-utils.js'; import { formatChangeLocation } from '../../core/planning-home.js'; import { @@ -31,11 +32,10 @@ export interface NewChangeOptions { description?: string; goal?: string; schema?: string; - /** Link the change to a plan: 'local' or a registered store id. */ - plan?: string; + /** Link the change to an initiative: `` or `/`. */ + initiative?: string; store?: string; storePath?: string; - initiative?: string; areas?: string; json?: boolean; } @@ -55,14 +55,6 @@ interface NewChangeOutput { // ----------------------------------------------------------------------------- function assertRemovedOptionsAbsent(options: NewChangeOptions): void { - if (options.initiative !== undefined) { - throw new RootSelectionError( - '--initiative is no longer supported. Normal changes no longer attach to initiatives; --store selects the OpenSpec root.', - 'initiative_option_removed', - { target: 'change.options' } - ); - } - if (options.areas !== undefined) { throw new RootSelectionError( '--areas is no longer supported. Workspace affected areas are not part of the normal OpenSpec root path.', @@ -102,6 +94,15 @@ export async function newChangeCommand(name: string | undefined, options: NewCha assertRemovedOptionsAbsent(options); + // Validate the ref before anything is created: a bad value must not + // leave a half-written change behind. + if (options.initiative !== undefined) { + const ref = InitiativeRefSchema.safeParse(options.initiative); + if (!ref.success) { + throw new Error(ref.error.issues[0].message); + } + } + const root = await resolveRootForCommand(options, { json: options.json, failurePayload: { change: null }, @@ -128,7 +129,7 @@ export async function newChangeCommand(name: string | undefined, options: NewCha changesDir: root.changesDir, metadata: { ...(options.goal ? { goal: options.goal } : {}), - ...(options.plan ? { plan: options.plan } : {}), + ...(options.initiative ? { initiative: options.initiative } : {}), }, }); diff --git a/src/core/change-metadata/schema.ts b/src/core/change-metadata/schema.ts index dedc9509b5..7e8879ee1c 100644 --- a/src/core/change-metadata/schema.ts +++ b/src/core/change-metadata/schema.ts @@ -13,6 +13,7 @@ const KebabIdentifierSchema = (label: string): z.ZodString => } }); +/** Legacy shape; carries the same data as the string ref `/`. */ export const InitiativeLinkSchema = z.object({ store: KebabIdentifierSchema('Store id'), id: KebabIdentifierSchema('Initiative id'), @@ -20,6 +21,22 @@ export const InitiativeLinkSchema = z.object({ export type InitiativeLink = z.infer; +// The upward link to an initiative: `` for one in this change's own +// root, or `/` for one in that registered store. +export const InitiativeRefSchema = z.string().superRefine((value, ctx) => { + const parts = value.split('/'); + const valid = + (parts.length === 1 || parts.length === 2) && + parts.every((part) => isKebabId(part)); + if (!valid) { + ctx.addIssue({ + code: 'custom', + message: + 'initiative must be a kebab-case name, optionally prefixed with a store id: or /', + }); + } +}); + // Per-change metadata schema. The schema field is validated against available // workflow schemas when metadata is read or written. export const ChangeMetadataSchema = z.object({ @@ -32,10 +49,7 @@ export const ChangeMetadataSchema = z.object({ .optional(), goal: z.string().min(1).optional(), affected_areas: z.array(z.string().min(1)).optional(), - initiative: InitiativeLinkSchema.optional(), - // The upward link to a plan folder: 'local' for the plan in this change's - // own repo, or a registered store id for that store's plan. - plan: KebabIdentifierSchema('plan').optional(), + initiative: z.union([InitiativeRefSchema, InitiativeLinkSchema]).optional(), }); export type ChangeMetadata = z.infer; diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index c4045ea56e..407f455a93 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -51,8 +51,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'List changes explicitly (default)', }, { - name: 'plan', - description: "Show the root's plan: its stages, and every change pointing at it", + name: 'initiatives', + description: "Show the root's portfolio: every initiative, and every change pointing at it", }, { name: 'sort', @@ -247,8 +247,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ takesValue: true, }, { - name: 'plan', - description: "Link this change to a plan: 'local' or a store id", + name: 'initiative', + description: 'Link this change to an initiative: or /', takesValue: true, }, COMMON_FLAGS.json, diff --git a/src/core/init.ts b/src/core/init.ts index 46393e031e..bda7870761 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -74,7 +74,7 @@ const WORKFLOW_TO_SKILL_DIR: Record = { 'verify': 'openspec-verify-change', 'onboard': 'openspec-onboard', 'propose': 'openspec-propose', - 'plan': 'openspec-plan', + 'initiatives': 'openspec-initiatives', }; // ----------------------------------------------------------------------------- diff --git a/src/core/initiatives.ts b/src/core/initiatives.ts new file mode 100644 index 0000000000..c16121885e --- /dev/null +++ b/src/core/initiatives.ts @@ -0,0 +1,404 @@ +/** + * Initiatives (stores beta). + * + * `openspec/initiatives/` is the planning layer of a root — this repo, or a + * store the team shares. It holds two kinds of things: + * + * - Each subfolder is one **initiative**: a finite piece of work above a + * single change. Contents are freeform; numbered folders inside an + * initiative (`00_goal/`, `01_requirements/`, ...) are ordered stages. + * - Unnumbered top-level files are **evergreen artifacts** — the standing + * truths every initiative serves (product, roadmap, architecture). + * + * Changes point UP at an initiative with one metadata line in + * `.openspec.yaml`: `initiative: ` (an initiative in the change's own + * root) or `initiative: /` (one in that registered store). + * There is no manifest — rollup discovers changes by scanning for that line, + * so teams tag their own work and nothing central needs maintaining. + */ + +import { promises as fs } from 'fs'; +import path from 'path'; +import { parse as parseYaml } from 'yaml'; + +import { getTaskProgressForChange } from '../utils/task-progress.js'; +import { + listStoreRegistryEntries, + readStoreRegistryState, +} from './store/foundation.js'; +import { getStoreRootForBackend } from './store/registry.js'; + +export const INITIATIVES_DIRNAME = 'initiatives'; + +/** Numbered entries are stages: 00_goal, 01-requirements, 2_design ... */ +const STAGE_PATTERN = /^\d+[-_]/; + +export interface InitiativeStage { + name: string; + files: number; +} + +export interface InitiativeChangeStatus { + id: string; + /** Registered store the change lives in; absent = the portfolio's own root. */ + store?: string; + completedTasks: number; + totalTasks: number; + state: 'complete' | 'in-progress' | 'no-tasks'; +} + +export interface InitiativeInfo { + name: string; + /** False when changes reference this name but no folder exists on disk. */ + exists: boolean; + stages: InitiativeStage[]; + /** Unnumbered entries inside the initiative: freeform artifacts. */ + artifacts: string[]; + changes: InitiativeChangeStatus[]; + changesComplete: number; + changesTotal: number; + tasksComplete: number; + tasksTotal: number; +} + +export interface PortfolioInfo { + path: string; + /** Unnumbered top-level files: standing truths every initiative serves. */ + evergreen: string[]; + initiatives: InitiativeInfo[]; +} + +function initiativesDir(root: string): string { + return path.join(root, 'openspec', INITIATIVES_DIRNAME); +} + +async function countFiles(dir: string): Promise { + let count = 0; + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return 0; + } + for (const entry of entries) { + if (entry.isDirectory()) { + count += await countFiles(path.join(dir, entry.name)); + } else { + count += 1; + } + } + return count; +} + +/** + * Normalizes an `initiative:` metadata value to its reference form: + * `` or `/`. The legacy object shape + * `{ store, id }` carries the same data and maps to `/`. + */ +export function normalizeInitiativeRef(value: unknown): string | null { + if (typeof value === 'string') { + return value.length > 0 ? value : null; + } + if (value !== null && typeof value === 'object') { + const { store, id } = value as { store?: unknown; id?: unknown }; + if (typeof store === 'string' && store.length > 0 && typeof id === 'string' && id.length > 0) { + return `${store}/${id}`; + } + } + return null; +} + +interface InitiativeShape { + name: string; + stages: InitiativeStage[]; + artifacts: string[]; +} + +/** + * Reads a root's initiatives folder: subfolders are initiatives (with their + * stages and freeform artifacts), unnumbered top-level files are evergreen + * artifacts. Returns null when there is no initiatives folder. + */ +export async function readInitiativesShape( + root: string +): Promise<{ evergreen: string[]; initiatives: InitiativeShape[] } | null> { + const dir = initiativesDir(root); + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return null; + } + + const evergreen: string[] = []; + const initiatives: InitiativeShape[] = []; + for (const entry of entries) { + if (entry.name.startsWith('.')) continue; + if (!entry.isDirectory()) { + evergreen.push(entry.name); + continue; + } + const inner = await fs.readdir(path.join(dir, entry.name), { + withFileTypes: true, + }); + const stages: InitiativeStage[] = []; + const artifacts: string[] = []; + for (const item of inner) { + if (item.name.startsWith('.')) continue; + if (item.isDirectory() && STAGE_PATTERN.test(item.name)) { + stages.push({ + name: item.name, + files: await countFiles(path.join(dir, entry.name, item.name)), + }); + } else { + artifacts.push(item.name); + } + } + stages.sort((a, b) => a.name.localeCompare(b.name)); + artifacts.sort(); + initiatives.push({ name: entry.name, stages, artifacts }); + } + evergreen.sort(); + initiatives.sort((a, b) => a.name.localeCompare(b.name)); + return { evergreen, initiatives }; +} + +/** + * Reads the `initiative:` line from a change's `.openspec.yaml`, normalized + * to reference form. Tolerant: any unreadable or unlinked metadata simply + * means "not part of an initiative". + */ +async function readInitiativeRef(changeDir: string): Promise { + try { + const raw = await fs.readFile(path.join(changeDir, '.openspec.yaml'), 'utf-8'); + const parsed = parseYaml(raw) as { initiative?: unknown } | null; + return normalizeInitiativeRef(parsed?.initiative); + } catch { + return null; + } +} + +/** + * Scans one root's changes and returns, per matching initiative name, the + * changes that point at it. `toName` maps a normalized ref to the initiative + * name it addresses in the portfolio being rolled up — or null when the ref + * points elsewhere. + */ +async function collectMatchingChanges( + root: string, + toName: (ref: string) => string | null, + store: string | undefined +): Promise> { + const changesDir = path.join(root, 'openspec', 'changes'); + const found = new Map(); + let entries; + try { + entries = await fs.readdir(changesDir, { withFileTypes: true }); + } catch { + return found; + } + + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === 'archive') continue; + const ref = await readInitiativeRef(path.join(changesDir, entry.name)); + if (ref === null) continue; + const name = toName(ref); + if (name === null) continue; + + const progress = await getTaskProgressForChange(changesDir, entry.name); + const status: InitiativeChangeStatus = { + id: entry.name, + ...(store ? { store } : {}), + completedTasks: progress.completed, + totalTasks: progress.total, + state: + progress.total === 0 + ? 'no-tasks' + : progress.completed === progress.total + ? 'complete' + : 'in-progress', + }; + const bucket = found.get(name); + if (bucket) { + bucket.push(status); + } else { + found.set(name, [status]); + } + } + return found; +} + +function mergeChanges( + target: Map, + source: Map +): void { + for (const [name, changes] of source) { + const bucket = target.get(name); + if (bucket) { + bucket.push(...changes); + } else { + target.set(name, changes); + } + } +} + +/** + * Rolls up a root's portfolio: its evergreen artifacts and initiatives, plus + * every change on this machine that points at them. Local changes match + * `initiative: ` (or `/`); when the root is a + * registered store, other registered roots are scanned for + * `/`. Names referenced by changes but missing on disk are + * included with `exists: false` — a bad reference should be visible, not + * silently dropped. Returns null when the root has no initiatives folder. + */ +export async function rollupInitiatives( + root: string, + options: { globalDataDir?: string } = {} +): Promise { + const shape = await readInitiativesShape(root); + if (shape === null) { + return null; + } + + const registry = await readStoreRegistryState( + options.globalDataDir ? { globalDataDir: options.globalDataDir } : {} + ).catch(() => null); + const registered = registry ? listStoreRegistryEntries(registry) : []; + + // Which registered store, if any, is this root? + const resolvedRoot = path.resolve(root); + let ownStoreId: string | undefined; + const storeRoots: Array<{ id: string; root: string }> = []; + for (const entry of registered) { + try { + const entryRoot = path.resolve(getStoreRootForBackend(entry.backend)); + storeRoots.push({ id: entry.id, root: entryRoot }); + if (entryRoot === resolvedRoot) { + ownStoreId = entry.id; + } + } catch { + // Unusable backend — skipped; doctor reports it. + } + } + + // A ref addresses this portfolio as `` from its own root, or as + // `/` from anywhere. + const ownPrefix = ownStoreId !== undefined ? `${ownStoreId}/` : null; + const byName = await collectMatchingChanges( + root, + (ref) => { + if (!ref.includes('/')) return ref; + if (ownPrefix !== null && ref.startsWith(ownPrefix)) { + return ref.slice(ownPrefix.length); + } + return null; + }, + undefined + ); + + if (ownStoreId !== undefined && ownPrefix !== null) { + for (const store of storeRoots) { + if (store.root === resolvedRoot) continue; + mergeChanges( + byName, + await collectMatchingChanges( + store.root, + (ref) => (ref.startsWith(ownPrefix) ? ref.slice(ownPrefix.length) : null), + store.id + ) + ); + } + } + + const initiatives: InitiativeInfo[] = []; + const addInitiative = ( + name: string, + exists: boolean, + stages: InitiativeStage[], + artifacts: string[] + ) => { + const changes = (byName.get(name) ?? []).sort((a, b) => + a.id.localeCompare(b.id) + ); + let changesComplete = 0; + let tasksComplete = 0; + let tasksTotal = 0; + for (const change of changes) { + tasksComplete += change.completedTasks; + tasksTotal += change.totalTasks; + if (change.state === 'complete') changesComplete += 1; + } + initiatives.push({ + name, + exists, + stages, + artifacts, + changes, + changesComplete, + changesTotal: changes.length, + tasksComplete, + tasksTotal, + }); + }; + + for (const initiative of shape.initiatives) { + addInitiative(initiative.name, true, initiative.stages, initiative.artifacts); + } + const known = new Set(shape.initiatives.map((initiative) => initiative.name)); + for (const name of [...byName.keys()].sort()) { + if (!known.has(name)) { + addInitiative(name, false, [], []); + } + } + + return { + path: initiativesDir(root), + evergreen: shape.evergreen, + initiatives, + }; +} + +/** + * The portfolios of every registered store that has one. This is the + * outside-a-root answer to "where does everything stand": the planning layer + * sits above repos, so asking it should not require standing in one. + */ +export async function rollupRegisteredStorePortfolios( + options: { globalDataDir?: string } = {} +): Promise> { + const registry = await readStoreRegistryState( + options.globalDataDir ? { globalDataDir: options.globalDataDir } : {} + ).catch(() => null); + const registered = registry ? listStoreRegistryEntries(registry) : []; + + const portfolios: Array<{ store: string; portfolio: PortfolioInfo }> = []; + for (const entry of registered) { + let root; + try { + root = getStoreRootForBackend(entry.backend); + } catch { + continue; // Unusable backend — skipped; doctor reports it. + } + const portfolio = await rollupInitiatives(root, options); + if ( + portfolio !== null && + (portfolio.initiatives.length > 0 || portfolio.evergreen.length > 0) + ) { + portfolios.push({ store: entry.id, portfolio }); + } + } + return portfolios; +} + +/** + * The names an agent surface shows for a root's planning layer: initiative + * names in order — or, when there are no initiatives yet, the evergreen + * artifact names. Either way the agent sees that the layer exists. + */ +export async function listInitiativeNames(root: string): Promise { + const shape = await readInitiativesShape(root); + if (shape === null) return []; + return shape.initiatives.length > 0 + ? shape.initiatives.map((initiative) => initiative.name) + : shape.evergreen; +} diff --git a/src/core/plan.ts b/src/core/plan.ts deleted file mode 100644 index 34c319bcdb..0000000000 --- a/src/core/plan.ts +++ /dev/null @@ -1,240 +0,0 @@ -/** - * The plan folder (stores beta). - * - * A plan is the home for work above a single change: `openspec/plan/`. - * Numbered folders inside it are stages, in order (`00_goal/`, - * `01_requirements/`, ...) — the numbering is the order; the names and - * contents are the user's own. Unnumbered entries are ambient context - * (vision, notes, meetings). - * - * Changes point UP at a plan with one metadata line in `.openspec.yaml`: - * `plan: local` (the plan in the change's own repo) or `plan: ` - * (the plan in that registered store). There is no manifest — rollup - * discovers changes by scanning for that line, so teams tag their own work - * and nothing central needs maintaining. - */ - -import { promises as fs } from 'fs'; -import path from 'path'; -import { parse as parseYaml } from 'yaml'; - -import { getTaskProgressForChange } from '../utils/task-progress.js'; -import { - listStoreRegistryEntries, - readStoreRegistryState, -} from './store/foundation.js'; -import { getStoreRootForBackend } from './store/registry.js'; - -export const PLAN_DIRNAME = 'plan'; - -/** Numbered entries are stages: 00_goal, 01-requirements, 2_design ... */ -const STAGE_PATTERN = /^\d+[-_]/; - -export interface PlanStage { - name: string; - files: number; -} - -export interface PlanChangeStatus { - id: string; - /** Registered store the change lives in; absent = the plan's own root. */ - store?: string; - completedTasks: number; - totalTasks: number; - state: 'complete' | 'in-progress' | 'no-tasks'; -} - -export interface PlanInfo { - path: string; - stages: PlanStage[]; - /** Unnumbered entries: ambient context, always worth reading. */ - context: string[]; - changes: PlanChangeStatus[]; - changesComplete: number; - changesTotal: number; - tasksComplete: number; - tasksTotal: number; -} - -function planDir(root: string): string { - return path.join(root, 'openspec', PLAN_DIRNAME); -} - -async function countFiles(dir: string): Promise { - let count = 0; - let entries; - try { - entries = await fs.readdir(dir, { withFileTypes: true }); - } catch { - return 0; - } - for (const entry of entries) { - if (entry.isDirectory()) { - count += await countFiles(path.join(dir, entry.name)); - } else { - count += 1; - } - } - return count; -} - -/** - * Reads a root's plan folder: numbered stages (sorted) and unnumbered - * context entries. Returns null when there is no plan folder. - */ -export async function readPlanStages( - root: string -): Promise<{ stages: PlanStage[]; context: string[] } | null> { - let entries; - try { - entries = await fs.readdir(planDir(root), { withFileTypes: true }); - } catch { - return null; - } - - const stages: PlanStage[] = []; - const context: string[] = []; - for (const entry of entries) { - if (entry.isDirectory() && STAGE_PATTERN.test(entry.name)) { - stages.push({ - name: entry.name, - files: await countFiles(path.join(planDir(root), entry.name)), - }); - } else if (!entry.name.startsWith('.')) { - context.push(entry.name); - } - } - stages.sort((a, b) => a.name.localeCompare(b.name)); - context.sort(); - return { stages, context }; -} - -/** - * Reads the `plan:` line from a change's `.openspec.yaml`. Tolerant: any - * unreadable or planless metadata simply means "not part of a plan". - */ -async function readPlanRef(changeDir: string): Promise { - try { - const raw = await fs.readFile(path.join(changeDir, '.openspec.yaml'), 'utf-8'); - const parsed = parseYaml(raw) as { plan?: unknown } | null; - return typeof parsed?.plan === 'string' && parsed.plan.length > 0 - ? parsed.plan - : null; - } catch { - return null; - } -} - -/** Scans one root's changes for those whose `plan:` matches. */ -async function collectMatchingChanges( - root: string, - matches: (ref: string) => boolean, - store: string | undefined -): Promise { - const changesDir = path.join(root, 'openspec', 'changes'); - let entries; - try { - entries = await fs.readdir(changesDir, { withFileTypes: true }); - } catch { - return []; - } - - const found: PlanChangeStatus[] = []; - for (const entry of entries) { - if (!entry.isDirectory() || entry.name === 'archive') continue; - const ref = await readPlanRef(path.join(changesDir, entry.name)); - if (ref === null || !matches(ref)) continue; - - const progress = await getTaskProgressForChange(changesDir, entry.name); - found.push({ - id: entry.name, - ...(store ? { store } : {}), - completedTasks: progress.completed, - totalTasks: progress.total, - state: - progress.total === 0 - ? 'no-tasks' - : progress.completed === progress.total - ? 'complete' - : 'in-progress', - }); - } - found.sort((a, b) => a.id.localeCompare(b.id)); - return found; -} - -/** - * Rolls up a root's plan: its stages plus every change on this machine that - * points at it. Local changes match `plan: local` (or the root's own store - * id); when the root is a registered store, other registered roots are - * scanned for `plan: `. Returns null when the root has no plan. - */ -export async function rollupPlan( - root: string, - options: { globalDataDir?: string } = {} -): Promise { - const shape = await readPlanStages(root); - if (shape === null) { - return null; - } - - const registry = await readStoreRegistryState( - options.globalDataDir ? { globalDataDir: options.globalDataDir } : {} - ).catch(() => null); - const registered = registry ? listStoreRegistryEntries(registry) : []; - - // Which registered store, if any, is this root? - const resolvedRoot = path.resolve(root); - let ownStoreId: string | undefined; - const storeRoots: Array<{ id: string; root: string }> = []; - for (const entry of registered) { - try { - const entryRoot = path.resolve(getStoreRootForBackend(entry.backend)); - storeRoots.push({ id: entry.id, root: entryRoot }); - if (entryRoot === resolvedRoot) { - ownStoreId = entry.id; - } - } catch { - // Unusable backend — skipped; doctor reports it. - } - } - - const changes = await collectMatchingChanges( - root, - (ref) => ref === 'local' || (ownStoreId !== undefined && ref === ownStoreId), - undefined - ); - - if (ownStoreId !== undefined) { - for (const store of storeRoots) { - if (store.root === resolvedRoot) continue; - changes.push( - ...(await collectMatchingChanges( - store.root, - (ref) => ref === ownStoreId, - store.id - )) - ); - } - } - - let changesComplete = 0; - let tasksComplete = 0; - let tasksTotal = 0; - for (const change of changes) { - tasksComplete += change.completedTasks; - tasksTotal += change.totalTasks; - if (change.state === 'complete') changesComplete += 1; - } - - return { - path: planDir(root), - stages: shape.stages, - context: shape.context, - changes, - changesComplete, - changesTotal: changes.length, - tasksComplete, - tasksTotal, - }; -} diff --git a/src/core/profile-sync-drift.ts b/src/core/profile-sync-drift.ts index f7a87f1f6b..698e672fa4 100644 --- a/src/core/profile-sync-drift.ts +++ b/src/core/profile-sync-drift.ts @@ -23,7 +23,7 @@ export const WORKFLOW_TO_SKILL_DIR: Record = { 'verify': 'openspec-verify-change', 'onboard': 'openspec-onboard', 'propose': 'openspec-propose', - 'plan': 'openspec-plan', + 'initiatives': 'openspec-initiatives', }; function toKnownWorkflows(workflows: readonly string[]): WorkflowId[] { diff --git a/src/core/profiles.ts b/src/core/profiles.ts index b738b9b015..7dd574ead0 100644 --- a/src/core/profiles.ts +++ b/src/core/profiles.ts @@ -28,7 +28,7 @@ export const ALL_WORKFLOWS = [ 'bulk-archive', 'verify', 'onboard', - 'plan', + 'initiatives', ] as const; export type WorkflowId = (typeof ALL_WORKFLOWS)[number]; diff --git a/src/core/references.ts b/src/core/references.ts index f36d4943d2..249d85c510 100644 --- a/src/core/references.ts +++ b/src/core/references.ts @@ -24,7 +24,7 @@ import { getSpecIds } from '../utils/item-discovery.js'; import { FileSystemUtils } from '../utils/file-system.js'; import { MAX_CONTEXT_SIZE, type DeclarationEntry } from './project-config.js'; import { listSchemasWithInfo } from './artifact-graph/index.js'; -import { readPlanStages } from './plan.js'; +import { listInitiativeNames } from './initiatives.js'; export interface ReferenceSpecEntry { id: string; @@ -43,8 +43,9 @@ export interface ReferenceIndexEntry { root?: string; specs?: ReferenceSpecEntry[]; schemas?: ReferenceSchemaEntry[]; - /** Stage names of the store's plan folder, in order (when it has one). */ - plan?: string[]; + /** The store's initiative names — or, with none yet, its evergreen + * artifact names (when it has an initiatives folder). */ + initiatives?: string[]; fetch?: string; status: StoreDiagnostic[]; } @@ -174,17 +175,12 @@ function collectSchemaEntries(referencedRoot: string): ReferenceSchemaEntry[] { } /** - * A store's plan for the index: stage names in order, or — for a - * destination-only plan with no stages — its artifact names. + * A store's planning layer for the index: initiative names in order, or — + * with no initiatives yet — its evergreen artifact names. */ -async function collectPlanStages(referencedRoot: string): Promise { +async function collectInitiativeNames(referencedRoot: string): Promise { try { - const plan = await readPlanStages(referencedRoot); - if (plan === null) return []; - const names = - plan.stages.length > 0 - ? plan.stages.map((stage) => stage.name) - : plan.context; + const names = await listInitiativeNames(referencedRoot); return names.map((name) => sanitizeInline(name, 60)); } catch { return []; @@ -199,8 +195,8 @@ export function schemasFetchRecipe(storeId: string): string { return `openspec schemas --store ${storeId}`; } -export function planFetchRecipe(storeId: string): string { - return `openspec list --plan --store ${storeId}`; +export function initiativesFetchRecipe(storeId: string): string { + return `openspec list --initiatives --store ${storeId}`; } function specLine(spec: ReferenceSpecEntry): string { @@ -277,9 +273,9 @@ function renderEntryLines(entry: ReferenceIndexEntry): string[] { ); } } - if (entry.plan && entry.plan.length > 0) { + if (entry.initiatives && entry.initiatives.length > 0) { lines.push( - ` Plan: ${entry.plan.join(' → ')} (${planFetchRecipe(entry.store_id)})` + ` Initiatives: ${entry.initiatives.join(', ')} (${initiativesFetchRecipe(entry.store_id)})` ); } if (entry.fetch) { @@ -446,13 +442,13 @@ export async function assembleReferenceIndex( const specs = await collectSpecEntries(inspection.canonicalRoot); const schemas = collectSchemaEntries(inspection.canonicalRoot); - const plan = await collectPlanStages(inspection.canonicalRoot); + const initiatives = await collectInitiativeNames(inspection.canonicalRoot); const entry: ReferenceIndexEntry = { store_id: id, root: inspection.canonicalRoot, specs, ...(schemas.length > 0 ? { schemas } : {}), - ...(plan.length > 0 ? { plan } : {}), + ...(initiatives.length > 0 ? { initiatives } : {}), fetch: fetchRecipe(id), status: [], }; diff --git a/src/core/shared/skill-generation.ts b/src/core/shared/skill-generation.ts index 8429b7f492..34b19a1586 100644 --- a/src/core/shared/skill-generation.ts +++ b/src/core/shared/skill-generation.ts @@ -16,8 +16,8 @@ import { getVerifyChangeSkillTemplate, getOnboardSkillTemplate, getOpsxProposeSkillTemplate, - getPlanSkillTemplate, - getOpsxPlanCommandTemplate, + getInitiativesSkillTemplate, + getOpsxInitiativesCommandTemplate, getOpsxExploreCommandTemplate, getOpsxNewCommandTemplate, getOpsxContinueCommandTemplate, @@ -68,7 +68,7 @@ export function getSkillTemplates(workflowFilter?: readonly string[]): SkillTemp { template: getVerifyChangeSkillTemplate(), dirName: 'openspec-verify-change', workflowId: 'verify' }, { template: getOnboardSkillTemplate(), dirName: 'openspec-onboard', workflowId: 'onboard' }, { template: getOpsxProposeSkillTemplate(), dirName: 'openspec-propose', workflowId: 'propose' }, - { template: getPlanSkillTemplate(), dirName: 'openspec-plan', workflowId: 'plan' }, + { template: getInitiativesSkillTemplate(), dirName: 'openspec-initiatives', workflowId: 'initiatives' }, ]; if (!workflowFilter) return all; @@ -95,7 +95,7 @@ export function getCommandTemplates(workflowFilter?: readonly string[]): Command { template: getOpsxVerifyCommandTemplate(), id: 'verify' }, { template: getOpsxOnboardCommandTemplate(), id: 'onboard' }, { template: getOpsxProposeCommandTemplate(), id: 'propose' }, - { template: getOpsxPlanCommandTemplate(), id: 'plan' }, + { template: getOpsxInitiativesCommandTemplate(), id: 'initiatives' }, ]; if (!workflowFilter) return all; diff --git a/src/core/shared/tool-detection.ts b/src/core/shared/tool-detection.ts index f1e1869552..fea287d11b 100644 --- a/src/core/shared/tool-detection.ts +++ b/src/core/shared/tool-detection.ts @@ -42,7 +42,7 @@ export const COMMAND_IDS = [ 'verify', 'onboard', 'propose', - 'plan', + 'initiatives', ] as const; export type CommandId = (typeof COMMAND_IDS)[number]; diff --git a/src/core/templates/skill-templates.ts b/src/core/templates/skill-templates.ts index a0957da753..670cc255cc 100644 --- a/src/core/templates/skill-templates.ts +++ b/src/core/templates/skill-templates.ts @@ -17,5 +17,5 @@ export { getBulkArchiveChangeSkillTemplate, getOpsxBulkArchiveCommandTemplate } export { getVerifyChangeSkillTemplate, getOpsxVerifyCommandTemplate } from './workflows/verify-change.js'; export { getOnboardSkillTemplate, getOpsxOnboardCommandTemplate } from './workflows/onboard.js'; export { getOpsxProposeSkillTemplate, getOpsxProposeCommandTemplate } from './workflows/propose.js'; -export { getPlanSkillTemplate, getOpsxPlanCommandTemplate } from './workflows/plan.js'; +export { getInitiativesSkillTemplate, getOpsxInitiativesCommandTemplate } from './workflows/initiatives.js'; export { getFeedbackSkillTemplate } from './workflows/feedback.js'; diff --git a/src/core/templates/workflows/initiatives.ts b/src/core/templates/workflows/initiatives.ts new file mode 100644 index 0000000000..aa804c57e3 --- /dev/null +++ b/src/core/templates/workflows/initiatives.ts @@ -0,0 +1,100 @@ +/** + * Skill Template Workflow Modules + * + * The initiatives skill: work above a single change. Routed by what is on + * disk; every move ends in a short numbered menu of real next actions. + */ +import type { SkillTemplate, CommandTemplate } from '../types.js'; +import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; + +const INITIATIVES_BODY = `Work above a single change: keep the evergreen truths current, run initiatives to completion, and keep the in-flight changes mapped against both. + +**Route by what is on disk — look before asking.** The folders carry the workflow, not job titles; anyone can pick up from what exists. + +--- + +## The shape you work with + +The planning layer lives in one folder: \`openspec/initiatives/\` — in this repo, or in a store the team shares. + +- **Unnumbered top-level files are evergreen artifacts** — the standing truths every initiative serves: \`product.md\`, \`roadmap.md\`, \`architecture.md\`, whatever names the user already uses. They are maintained forever, the way specs are. +- **Each subfolder is one initiative** — a finite piece of work above a single change. Contents are freeform. Numbered folders inside an initiative (\`00_goal/\`, \`01_requirements/\`) are ordered stages, when the user wants visible order; everything lower-numbered is upstream. +- **Changes point up** with one line in their \`.openspec.yaml\`: \`initiative: \` (this root) or \`initiative: /\` (a store's initiative). There is no list to maintain anywhere. + +The same loop runs at both altitudes: work flows down (an initiative decomposes into changes), truth flows up (finishing work updates the evergreen artifacts, the way archiving a change updates specs), and status flows back live through \`openspec list --initiatives\`. + +## Start from state + +Run \`openspec list --initiatives --json\` (add \`--store \` for a shared portfolio) before saying anything, then route: + +- **No initiatives folder yet** → offer to capture the conversation (below). Do not lecture about the feature first. +- **A portfolio exists, no initiative named** → open with where everything stands, in a few lines, from live status — then the menu. +- **Working one initiative** → the moves below, with everything upstream (evergreen files, lower-numbered stages) read first. +- **In \`openspec/changes/\` or a change** → that is change work — use the change skills (\`/opsx:apply\`, \`/opsx:continue\`), pulling the initiative in as upstream context. + +What drives your reactions must be structured facts from disk — a task checked off, a change archived, a change pointing at an initiative — never your memory of prose. Read state fresh; propose what to do about it. + +## The moves + +**Capture the conversation.** When the intent lives in the chat and no artifact exists yet, synthesize what you already know into one — do NOT re-interview the user. One page, their words. Standing truths go in an evergreen file; a finite effort becomes \`openspec/initiatives//\` (create the folder yourself — \`mkdir\` is the whole ceremony). + +**Ideate from what exists.** Read the evergreen artifacts, the initiative's files, and live change status; propose directions grounded in them — each with a one-line basis pointing at what it serves. Name what you are NOT proposing and why, in one line each. + +**Push back — twice, then move on.** When a goal is vague ("improve UX"), a metric can only go up, or a problem is stated as a feature, ask one sharper question using the user's own words. One question per message; offer numbered options when they scaffold the answer. After two rounds, capture what you have and note what is worth revisiting — planning is iterative. + +**Decompose and bridge.** Cut the initiative into tracer-bullet slices: each end-to-end and demoable alone, so anyone could pick one up without reading the others. Surface merge/split judgment calls; don't decide silently. When a slice is ready, make it real: + +\`\`\`bash +openspec new change --initiative # initiative in this root +openspec new change --initiative / # initiative in a store +\`\`\` + +The change IS the handoff: born linked, self-contained, ready for whoever — or whatever agent — picks it up next. Status flows back with no bookkeeping. + +**Sync up.** Compare \`openspec list --initiatives\` against the evergreen artifacts. When an initiative's changes are complete, that is the trigger to update the truths it served — and to say plainly what drifted instead of papering over it. + +**Filter input.** Weigh new input against the evergreen artifacts. Park what contradicts them in a cut file (e.g. \`notes/cut.md\`) with one line of why — preserved, not lost. + +## End every move in a menu + +Close with 2–4 numbered options computed from what is actually on disk — never a paragraph of suggestions. Exactly one option is marked **(recommended)**. Every option is a real next action: a command to run, a change to start, an artifact to write. Examples of state-driven options: no changes linked yet → "map the in-flight changes"; all changes complete → "sync the evergreen artifacts"; a slice is ready → "start the change". The user should mostly answer with a number. + +## Write less + +Plans die of verbosity. + +- One page per artifact. Longer means cut or split. +- Prefer a table to prose, a line to a paragraph. +- No file paths or code snippets in upstream artifacts — they rot. +- Never restate another artifact; point to it. + +## Stay above the code + +Reading code to ground the work is fine. Writing code is not initiative work — that belongs to a change (\`/opsx:apply\`). + +--- + +${STORE_SELECTION_GUIDANCE}`; + +export function getInitiativesSkillTemplate(): SkillTemplate { + return { + name: 'openspec-initiatives', + description: + 'Work above a single change. Use when turning high-level intent (a roadmap, PRD, vision) into changes, when asked where the whole effort stands, or when finished work should update the roadmap.', + instructions: INITIATIVES_BODY, + license: 'MIT', + compatibility: 'Requires openspec CLI.', + metadata: { author: 'openspec', version: '1.0' }, + }; +} + +export function getOpsxInitiativesCommandTemplate(): CommandTemplate { + return { + name: 'OPSX: Initiatives', + description: + 'Work above a single change - turn high-level intent into changes and see where the effort stands', + category: 'Workflow', + tags: ['workflow', 'planning', 'experimental'], + content: INITIATIVES_BODY, + }; +} diff --git a/src/core/templates/workflows/plan.ts b/src/core/templates/workflows/plan.ts deleted file mode 100644 index 689cb42941..0000000000 --- a/src/core/templates/workflows/plan.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Skill Template Workflow Modules - * - * The plan skill: work above a single change. A stance, not a workflow — - * modeled on explore mode. - */ -import type { SkillTemplate, CommandTemplate } from '../types.js'; -import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; - -const PLAN_BODY = `Enter plan mode. Work above a single change: start from the user's end destination, map the in-flight changes against it, and keep the two in sync. - -**This is a stance, not a workflow.** No fixed steps, no required sequence, no mandatory outputs. Follow what the conversation needs. - ---- - -## The shape you work with - -A plan lives in one folder: \`openspec/plan/\` — in this repo, or in a store the team shares. - -- **The destination comes first.** A \`product.md\`, \`roadmap.md\`, \`architecture.md\`, a collection of folders — whatever artifact name or convention the user already has. One file is a valid plan. -- **Numbered folders are stages, when the user wants visible order.** \`00_goal/\`, \`01_requirements/\` — the numbers are the sequence; the names and contents are theirs. Optional, never required. -- **Changes point up at the plan**, with one line in their \`.openspec.yaml\`: \`plan: local\` (this repo's plan) or \`plan: \` (a store's plan). There is no list to maintain anywhere. - -## Where you are decides what you do - -Check your cwd and what exists — the folders carry the workflow, not job titles. Anyone can pick up from what is on disk. - -- **At the plan folder (or repo root):** show where the effort stands. \`ls openspec/plan/\` for what exists; \`openspec list --plan --json\` (add \`--store \` for a shared plan) for every change pointing at it, with live task status. -- **Inside a stage or artifact folder:** everything lower-numbered (and every unnumbered file) is upstream — read it; this folder's artifact is what you produce. -- **In \`openspec/changes/\` or a change:** that is change work — use the change skills (\`/opsx:apply\`, \`/opsx:continue\`), pulling the plan in as upstream context. - -## Ways of moving - -**Capture the conversation.** When the intent lives in the chat and no destination artifact exists yet, synthesize what you already know into one — do NOT re-interview the user. One page, their words, into \`openspec/plan/\`. - -**Map in-flight changes.** When a destination exists but changes are not linked yet: run \`openspec list --changes\`, read the destination, propose which changes serve this plan, and — with the user's confirmation — add the \`plan:\` line to each one's \`.openspec.yaml\`. This mapping is the point: destination in, in-flight work mapped against it. - -**Translate down.** Take the most complete artifact and help produce the next one — one altitude at a time. Decompose into tracer-bullet slices: each cuts end-to-end and is demoable on its own, so someone could pick any single item up without reading the others. Surface merge/split judgment calls to the user; don't decide silently. - -**Bridge to changes.** When a work item is ready, make it real: - -\`\`\`bash -openspec new change --plan local # plan lives in this repo -openspec new change --plan # plan lives in a store -\`\`\` - -The change IS the handoff: born linked, self-contained, ready for whoever — or whatever agent — picks it up next. Status flows back with no bookkeeping. - -**Sync up.** Compare \`openspec list --plan\` against the destination. Update the high level to match reality; name drift plainly instead of papering over it. - -**Filter input.** If the plan has a vision or goal file, weigh new input against it. Park contradicting input in a cut file (e.g. \`notes/cut.md\`) with one line of why — preserved, not lost. - -## Write less - -Plans die of verbosity. - -- One page per artifact. Longer means cut or split. -- Prefer a table to prose, a line to a paragraph. -- No file paths or code snippets in upstream artifacts — they rot. -- Never restate another artifact; point to it. - -## Stay above the code - -Reading code to ground the plan is fine. Writing code is not plan work — that belongs to a change (\`/opsx:apply\`). - ---- - -${STORE_SELECTION_GUIDANCE}`; - -export function getPlanSkillTemplate(): SkillTemplate { - return { - name: 'openspec-plan', - description: - 'Enter plan mode - work above a single change. Use when turning high-level intent (a roadmap, PRD, vision) into changes, or when asked where the whole effort stands.', - instructions: PLAN_BODY, - license: 'MIT', - compatibility: 'Requires openspec CLI.', - metadata: { author: 'openspec', version: '1.0' }, - }; -} - -export function getOpsxPlanCommandTemplate(): CommandTemplate { - return { - name: 'OPSX: Plan', - description: - 'Enter plan mode - turn high-level intent into changes and see where the effort stands', - category: 'Workflow', - tags: ['workflow', 'planning', 'experimental'], - content: PLAN_BODY, - }; -} diff --git a/src/core/working-set.ts b/src/core/working-set.ts index ebe77d85c7..e025835fac 100644 --- a/src/core/working-set.ts +++ b/src/core/working-set.ts @@ -21,8 +21,9 @@ export interface WorkingSetMember { /** A referenced store's own custom artifact types (project schema names). * Present only when the store defines some and it is available. */ artifactTypes?: string[]; - /** Stage names of a referenced store's plan folder, in order. */ - plan?: string[]; + /** A referenced store's initiative names — or, with none yet, its + * evergreen artifact names. */ + initiatives?: string[]; status: StoreDiagnostic[]; } diff --git a/src/utils/change-utils.ts b/src/utils/change-utils.ts index 0a3b29340e..c47a4a3efe 100644 --- a/src/utils/change-utils.ts +++ b/src/utils/change-utils.ts @@ -17,7 +17,7 @@ export interface CreateChangeOptions { /** Directory that should contain the change directories */ changesDir?: string; /** Additional metadata to persist in the change's .openspec.yaml */ - metadata?: Partial>; + metadata?: Partial>; } /** diff --git a/test/commands/artifact-workflow.test.ts b/test/commands/artifact-workflow.test.ts index a286422a82..117694fbbe 100644 --- a/test/commands/artifact-workflow.test.ts +++ b/test/commands/artifact-workflow.test.ts @@ -342,17 +342,18 @@ describe('artifact-workflow CLI commands', () => { expect(stat.isDirectory()).toBe(true); }); - it('rejects --initiative and writes no change', async () => { + it('writes the upward initiative link for --initiative', async () => { const result = await runCLI( ['new', 'change', 'linked-change', '--initiative', 'billing-launch'], { cwd: tempDir } ); - expect(result.exitCode).toBe(1); - const output = getOutput(result); - expect(output).toContain('--initiative is no longer supported'); - await expect(fs.stat(path.join(changesDir, 'linked-change'))).rejects.toMatchObject({ - code: 'ENOENT', - }); + expect(result.exitCode).toBe(0); + + const metadata = await fs.readFile( + path.join(changesDir, 'linked-change', '.openspec.yaml'), + 'utf-8' + ); + expect(metadata).toContain('initiative: billing-launch'); }); it('rejects --areas and writes no affected-area metadata', async () => { diff --git a/test/commands/change-initiative-link.test.ts b/test/commands/change-initiative-link.test.ts index c1a7797b57..3226cb2811 100644 --- a/test/commands/change-initiative-link.test.ts +++ b/test/commands/change-initiative-link.test.ts @@ -7,11 +7,11 @@ import { readChangeMetadata } from '../../src/utils/change-metadata.js'; import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; /** - * Initiative-link creation was removed from normal change flows in the - * store-root-selection slice: `new change` no longer accepts `--initiative` - * and `openspec set change` is gone. Existing initiative metadata from the - * beta remains readable and untouched; this suite covers that legacy - * behavior. + * The `initiative:` metadata field has two readable shapes: the current + * string ref (`` or `/`, written by + * `new change --initiative`) and the legacy object (`{store, id}`) from the + * earlier beta, which stays readable and untouched. `openspec set change` + * remains gone. This suite covers both shapes. */ describe('legacy repo-local change initiative metadata', () => { let tempDir: string; @@ -101,16 +101,35 @@ describe('legacy repo-local change initiative metadata', () => { expect(metadata?.initiative).toBeUndefined(); }); - it('rejects new change --initiative without writing files', async () => { + it('writes the string ref for new change --initiative', async () => { const result = await runCLI( ['new', 'change', 'linked-change', '--initiative', 'billing-launch', '--json'], { cwd: tempDir, env } ); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(0); const json = parseJson(result); - expect(json.change).toBeNull(); - expect(json.status[0].code).toBe('initiative_option_removed'); - expect(fs.existsSync(changeDir('linked-change'))).toBe(false); + expect(json.change.id).toBe('linked-change'); + expect(readChangeMetadata(changeDir('linked-change'), tempDir)?.initiative).toBe( + 'billing-launch' + ); + }); + + it('accepts a store-prefixed ref and rejects a malformed one', async () => { + const ok = await runCLI( + ['new', 'change', 'store-linked', '--initiative', 'team-plans/billing-launch', '--json'], + { cwd: tempDir, env } + ); + expect(ok.exitCode).toBe(0); + expect(readChangeMetadata(changeDir('store-linked'), tempDir)?.initiative).toBe( + 'team-plans/billing-launch' + ); + + const bad = await runCLI( + ['new', 'change', 'bad-linked', '--initiative', 'Not A Ref', '--json'], + { cwd: tempDir, env } + ); + expect(bad.exitCode).toBe(1); + expect(fs.existsSync(changeDir('bad-linked'))).toBe(false); }); it('no longer provides openspec set change', async () => { diff --git a/test/commands/context.test.ts b/test/commands/context.test.ts index ae9b8af585..d5a5e361c6 100644 --- a/test/commands/context.test.ts +++ b/test/commands/context.test.ts @@ -103,8 +103,8 @@ describe('openspec context (4.1)', () => { expect(parseJson(declared).members).toHaveLength(2); }); - it("surfaces a referenced store's own artifact types and plan stages", async () => { - // The upstream store defines a custom artifact type and a plan folder. + it("surfaces a referenced store's own artifact types and initiatives", async () => { + // The upstream store defines a custom artifact type and initiatives. const schemaDir = path.join(upstream, 'openspec', 'schemas', 'team-brief'); fs.mkdirSync(path.join(schemaDir, 'templates'), { recursive: true }); fs.writeFileSync( @@ -114,10 +114,10 @@ describe('openspec context (4.1)', () => { ' template: brief.md\n requires: []\n instruction: y\n' ); fs.writeFileSync(path.join(schemaDir, 'templates', 'brief.md'), '# Brief\n'); - fs.mkdirSync(path.join(upstream, 'openspec', 'plan', '00_goal'), { + fs.mkdirSync(path.join(upstream, 'openspec', 'initiatives', 'smoother-setup'), { recursive: true, }); - fs.mkdirSync(path.join(upstream, 'openspec', 'plan', '01_changes'), { + fs.mkdirSync(path.join(upstream, 'openspec', 'initiatives', 'q3-payments'), { recursive: true, }); @@ -129,7 +129,7 @@ describe('openspec context (4.1)', () => { (member: any) => member.id === 'upstream-context' ); expect(upstreamMember.artifactTypes).toEqual(['team-brief']); - expect(upstreamMember.plan).toEqual(['00_goal', '01_changes']); + expect(upstreamMember.initiatives).toEqual(['q3-payments', 'smoother-setup']); const human = await runCLI(['context', '--store', 'team-context'], { cwd: tempDir, @@ -139,7 +139,7 @@ describe('openspec context (4.1)', () => { 'Artifact types: team-brief (openspec schemas --store upstream-context)' ); expect(human.stdout).toContain( - 'Plan: 00_goal → 01_changes (openspec list --plan --store upstream-context)' + 'Initiatives: q3-payments, smoother-setup (openspec list --initiatives --store upstream-context)' ); }); diff --git a/test/commands/store-root-selection.test.ts b/test/commands/store-root-selection.test.ts index 2079887d48..0e1d6e5af7 100644 --- a/test/commands/store-root-selection.test.ts +++ b/test/commands/store-root-selection.test.ts @@ -611,8 +611,8 @@ describe('store root selection for normal commands', () => { }); }); - describe('initiative links are retired from normal change flows', () => { - it('rejects --initiative and creates no files', async () => { + describe('initiative links in normal change flows', () => { + it('accepts --initiative and writes the upward link', async () => { const localRepo = path.join(tempDir, 'initiative-repo'); createOpenSpecRoot(localRepo); @@ -620,12 +620,12 @@ describe('store root selection for normal commands', () => { ['new', 'change', 'linked-change', '--initiative', 'billing-launch'], { cwd: localRepo, env } ); - expect(result.exitCode).toBe(1); - const output = result.stdout + result.stderr; - expect(output).toContain('--initiative is no longer supported'); - expect( - fs.existsSync(path.join(localRepo, 'openspec', 'changes', 'linked-change')) - ).toBe(false); + expect(result.exitCode).toBe(0); + const metadata = fs.readFileSync( + path.join(localRepo, 'openspec', 'changes', 'linked-change', '.openspec.yaml'), + 'utf-8' + ); + expect(metadata).toContain('initiative: billing-launch'); }); it('removes openspec set change entirely', async () => { diff --git a/test/core/completions/command-registry.test.ts b/test/core/completions/command-registry.test.ts index 9a740f421c..cb6e573d52 100644 --- a/test/core/completions/command-registry.test.ts +++ b/test/core/completions/command-registry.test.ts @@ -207,14 +207,13 @@ describe('command completion registry', () => { 'description', 'goal', 'schema', - 'plan', + 'initiative', 'json', 'store', ]); const storeFlag = newChange?.flags.find((flag) => flag.name === 'store'); expect(storeFlag?.description).toContain('OpenSpec root'); - expect(newChange?.flags.map((flag) => flag.name)).not.toContain('initiative'); expect(newChange?.flags.map((flag) => flag.name)).not.toContain('areas'); expect(newChange?.flags.map((flag) => flag.name)).not.toContain('store-path'); }); diff --git a/test/core/initiatives.test.ts b/test/core/initiatives.test.ts new file mode 100644 index 0000000000..7d6334c9d9 --- /dev/null +++ b/test/core/initiatives.test.ts @@ -0,0 +1,211 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + listInitiativeNames, + normalizeInitiativeRef, + readInitiativesShape, + rollupInitiatives, + rollupRegisteredStorePortfolios, +} from '../../src/core/initiatives.js'; +import { + readStoreRegistryState, + writeStoreRegistryState, +} from '../../src/core/store/foundation.js'; + +describe('initiatives', () => { + let tempDir: string; + let globalDataDir: string; + let savedXdgDataHome: string | undefined; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-initiatives-')); + globalDataDir = path.join(tempDir, 'data', 'openspec'); + savedXdgDataHome = process.env.XDG_DATA_HOME; + process.env.XDG_DATA_HOME = path.join(tempDir, 'xdg'); + }); + + afterEach(() => { + if (savedXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = savedXdgDataHome; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function write(relativePath: string, content: string): void { + const full = path.join(tempDir, relativePath); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + + function change(root: string, id: string, ref: string | null, tasks: string): void { + const metadata = + ref === null + ? 'schema: spec-driven\n' + : `schema: spec-driven\ninitiative: ${ref}\n`; + write(`${root}/openspec/changes/${id}/.openspec.yaml`, metadata); + write(`${root}/openspec/changes/${id}/tasks.md`, tasks); + } + + async function registerStore(id: string, root: string): Promise { + const existing = await readStoreRegistryState({ globalDataDir }).catch(() => null); + await writeStoreRegistryState( + { + version: 1, + stores: { + ...(existing?.stores ?? {}), + [id]: { backend: { type: 'git', local_path: path.join(tempDir, root) } }, + }, + }, + { globalDataDir } + ); + } + + it('splits initiatives from evergreen artifacts, and stages inside an initiative', async () => { + write('app/openspec/initiatives/roadmap.md', '# roadmap\n'); + write('app/openspec/initiatives/smoother-setup/00_goal/goal.md', '# goal\n'); + write('app/openspec/initiatives/smoother-setup/01_who/personas.md', '# who\n'); + write('app/openspec/initiatives/smoother-setup/notes.md', '# notes\n'); + write('app/openspec/initiatives/q3-payments/brief.md', '# brief\n'); + + const shape = await readInitiativesShape(path.join(tempDir, 'app')); + + expect(shape?.evergreen).toEqual(['roadmap.md']); + expect(shape?.initiatives.map((i) => i.name)).toEqual([ + 'q3-payments', + 'smoother-setup', + ]); + const smoother = shape?.initiatives.find((i) => i.name === 'smoother-setup'); + expect(smoother?.stages.map((s) => s.name)).toEqual(['00_goal', '01_who']); + expect(smoother?.stages.map((s) => s.files)).toEqual([1, 1]); + expect(smoother?.artifacts).toEqual(['notes.md']); + }); + + it('returns null when there is no initiatives folder', async () => { + fs.mkdirSync(path.join(tempDir, 'app', 'openspec'), { recursive: true }); + expect(await readInitiativesShape(path.join(tempDir, 'app'))).toBeNull(); + expect( + await rollupInitiatives(path.join(tempDir, 'app'), { globalDataDir }) + ).toBeNull(); + }); + + it('normalizes the legacy object ref to /', () => { + expect(normalizeInitiativeRef('smoother-setup')).toBe('smoother-setup'); + expect(normalizeInitiativeRef('team-plans/q3')).toBe('team-plans/q3'); + expect(normalizeInitiativeRef({ store: 'team-plans', id: 'q3' })).toBe( + 'team-plans/q3' + ); + expect(normalizeInitiativeRef(undefined)).toBeNull(); + expect(normalizeInitiativeRef('')).toBeNull(); + expect(normalizeInitiativeRef({ store: 'team-plans' })).toBeNull(); + }); + + it('groups local changes under the initiative they name', async () => { + write('app/openspec/initiatives/smoother-setup/goal.md', '# goal\n'); + change('app', 'done-change', 'smoother-setup', '- [x] a\n- [x] b\n'); + change('app', 'wip-change', 'smoother-setup', '- [x] a\n- [ ] b\n'); + change('app', 'unrelated', null, '- [ ] a\n'); // no initiative line -> not included + + const portfolio = await rollupInitiatives(path.join(tempDir, 'app'), { + globalDataDir, + }); + + expect(portfolio?.initiatives.map((i) => i.name)).toEqual(['smoother-setup']); + const initiative = portfolio?.initiatives[0]; + expect(initiative?.exists).toBe(true); + expect(initiative?.changes.map((c) => c.id)).toEqual(['done-change', 'wip-change']); + expect(initiative?.changesComplete).toBe(1); + expect(initiative?.changesTotal).toBe(2); + expect(initiative?.tasksComplete).toBe(3); + expect(initiative?.tasksTotal).toBe(4); + expect(initiative?.changes.every((c) => c.store === undefined)).toBe(true); + }); + + it('surfaces a referenced name with no folder as exists: false', async () => { + write('app/openspec/initiatives/roadmap.md', '# roadmap\n'); + change('app', 'orphan-change', 'renamed-away', '- [ ] a\n'); + + const portfolio = await rollupInitiatives(path.join(tempDir, 'app'), { + globalDataDir, + }); + + const orphan = portfolio?.initiatives.find((i) => i.name === 'renamed-away'); + expect(orphan?.exists).toBe(false); + expect(orphan?.changes.map((c) => c.id)).toEqual(['orphan-change']); + }); + + it('finds changes across registered repos that point at a store initiative', async () => { + // The initiative lives in the team-plans store. + write('team-plans/openspec/initiatives/smoother-setup/goal.md', '# goal\n'); + await registerStore('team-plans', 'team-plans'); + // Two code repos, registered, each with a change pointing at it. + await registerStore('api-server', 'api-server'); + await registerStore('web-app', 'web-app'); + change('api-server', 'add-payments-api', 'team-plans/smoother-setup', '- [x] a\n- [x] b\n'); + change('web-app', 'add-payments-ui', 'team-plans/smoother-setup', '- [x] a\n- [ ] b\n- [ ] c\n'); + change('web-app', 'other-work', null, '- [ ] a\n'); + // Legacy object metadata still resolves to the same initiative. + write( + 'web-app/openspec/changes/legacy-change/.openspec.yaml', + 'schema: spec-driven\ninitiative:\n store: team-plans\n id: smoother-setup\n' + ); + write('web-app/openspec/changes/legacy-change/tasks.md', '- [x] a\n'); + // The store's own change can use the bare name or its own prefix. + change('team-plans', 'update-docs', 'smoother-setup', '- [x] a\n'); + change('team-plans', 'update-brand', 'team-plans/smoother-setup', '- [ ] a\n'); + + const portfolio = await rollupInitiatives(path.join(tempDir, 'team-plans'), { + globalDataDir, + }); + + const initiative = portfolio?.initiatives.find((i) => i.name === 'smoother-setup'); + const byId = Object.fromEntries((initiative?.changes ?? []).map((c) => [c.id, c])); + expect(Object.keys(byId).sort()).toEqual([ + 'add-payments-api', + 'add-payments-ui', + 'legacy-change', + 'update-brand', + 'update-docs', + ]); + expect(byId['add-payments-api'].store).toBe('api-server'); + expect(byId['add-payments-api'].state).toBe('complete'); + expect(byId['add-payments-ui'].store).toBe('web-app'); + expect(byId['add-payments-ui'].state).toBe('in-progress'); + expect(byId['legacy-change'].store).toBe('web-app'); + expect(byId['update-docs'].store).toBeUndefined(); + expect(initiative?.changesComplete).toBe(3); + expect(initiative?.tasksTotal).toBe(8); + }); + + it('rolls up every registered store portfolio for the outside-a-root answer', async () => { + write('team-plans/openspec/initiatives/smoother-setup/goal.md', '# goal\n'); + write('other-store/openspec/specs/.gitkeep', ''); // registered, no initiatives + await registerStore('team-plans', 'team-plans'); + await registerStore('other-store', 'other-store'); + change('team-plans', 'update-docs', 'smoother-setup', '- [x] a\n'); + + const portfolios = await rollupRegisteredStorePortfolios({ globalDataDir }); + + expect(portfolios.map((p) => p.store)).toEqual(['team-plans']); + expect(portfolios[0].portfolio.initiatives[0].name).toBe('smoother-setup'); + expect(portfolios[0].portfolio.initiatives[0].changes.map((c) => c.id)).toEqual([ + 'update-docs', + ]); + }); + + it('lists initiative names for agent surfaces, falling back to evergreen names', async () => { + write('app/openspec/initiatives/roadmap.md', '# roadmap\n'); + expect(await listInitiativeNames(path.join(tempDir, 'app'))).toEqual([ + 'roadmap.md', + ]); + + write('app/openspec/initiatives/smoother-setup/goal.md', '# goal\n'); + expect(await listInitiativeNames(path.join(tempDir, 'app'))).toEqual([ + 'smoother-setup', + ]); + }); +}); diff --git a/test/core/plan.test.ts b/test/core/plan.test.ts deleted file mode 100644 index fb360ac1b1..0000000000 --- a/test/core/plan.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; - -import { readPlanStages, rollupPlan } from '../../src/core/plan.js'; -import { - readStoreRegistryState, - writeStoreRegistryState, -} from '../../src/core/store/foundation.js'; - -describe('plan folder', () => { - let tempDir: string; - let globalDataDir: string; - let savedXdgDataHome: string | undefined; - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-plan-')); - globalDataDir = path.join(tempDir, 'data', 'openspec'); - savedXdgDataHome = process.env.XDG_DATA_HOME; - process.env.XDG_DATA_HOME = path.join(tempDir, 'xdg'); - }); - - afterEach(() => { - if (savedXdgDataHome === undefined) { - delete process.env.XDG_DATA_HOME; - } else { - process.env.XDG_DATA_HOME = savedXdgDataHome; - } - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - function write(relativePath: string, content: string): void { - const full = path.join(tempDir, relativePath); - fs.mkdirSync(path.dirname(full), { recursive: true }); - fs.writeFileSync(full, content); - } - - function change(root: string, id: string, planRef: string | null, tasks: string): void { - const metadata = - planRef === null - ? 'schema: spec-driven\n' - : `schema: spec-driven\nplan: ${planRef}\n`; - write(`${root}/openspec/changes/${id}/.openspec.yaml`, metadata); - write(`${root}/openspec/changes/${id}/tasks.md`, tasks); - } - - async function registerStore(id: string, root: string): Promise { - const existing = await readStoreRegistryState({ globalDataDir }).catch(() => null); - await writeStoreRegistryState( - { - version: 1, - stores: { - ...(existing?.stores ?? {}), - [id]: { backend: { type: 'git', local_path: path.join(tempDir, root) } }, - }, - }, - { globalDataDir } - ); - } - - it('splits numbered stages from unnumbered context', async () => { - write('app/openspec/plan/00_goal/goal.md', '# goal\n'); - write('app/openspec/plan/01_requirements/personas.md', '# who\n'); - write('app/openspec/plan/notes/cut.md', '# parked\n'); - write('app/openspec/plan/vision.md', '# vision\n'); - - const shape = await readPlanStages(path.join(tempDir, 'app')); - - expect(shape?.stages.map((s) => s.name)).toEqual(['00_goal', '01_requirements']); - expect(shape?.stages.map((s) => s.files)).toEqual([1, 1]); - expect(shape?.context).toEqual(['notes', 'vision.md']); - }); - - it('returns null when there is no plan folder', async () => { - fs.mkdirSync(path.join(tempDir, 'app', 'openspec'), { recursive: true }); - expect(await readPlanStages(path.join(tempDir, 'app'))).toBeNull(); - expect(await rollupPlan(path.join(tempDir, 'app'), { globalDataDir })).toBeNull(); - }); - - it('rolls up local changes that point at the plan with plan: local', async () => { - write('app/openspec/plan/00_goal/goal.md', '# goal\n'); - change('app', 'done-change', 'local', '- [x] a\n- [x] b\n'); - change('app', 'wip-change', 'local', '- [x] a\n- [ ] b\n'); - change('app', 'unrelated', null, '- [ ] a\n'); // no plan line -> not included - - const plan = await rollupPlan(path.join(tempDir, 'app'), { globalDataDir }); - - expect(plan?.changes.map((c) => c.id)).toEqual(['done-change', 'wip-change']); - expect(plan?.changesComplete).toBe(1); - expect(plan?.changesTotal).toBe(2); - expect(plan?.tasksComplete).toBe(3); - expect(plan?.tasksTotal).toBe(4); - expect(plan?.changes.every((c) => c.store === undefined)).toBe(true); - }); - - it('finds changes across registered repos that point at a store plan', async () => { - // The plan lives in the team-plans store. - write('team-plans/openspec/plan/00_goal/goal.md', '# goal\n'); - await registerStore('team-plans', 'team-plans'); - // Two code repos, registered, each with a change pointing at team-plans. - await registerStore('api-server', 'api-server'); - await registerStore('web-app', 'web-app'); - change('api-server', 'add-payments-api', 'team-plans', '- [x] a\n- [x] b\n'); - change('web-app', 'add-payments-ui', 'team-plans', '- [x] a\n- [ ] b\n- [ ] c\n'); - change('web-app', 'other-work', null, '- [ ] a\n'); - // The store's own change can use 'local' or its own id. - change('team-plans', 'update-docs', 'local', '- [x] a\n'); - - const plan = await rollupPlan(path.join(tempDir, 'team-plans'), { globalDataDir }); - - const byId = Object.fromEntries((plan?.changes ?? []).map((c) => [c.id, c])); - expect(Object.keys(byId).sort()).toEqual([ - 'add-payments-api', - 'add-payments-ui', - 'update-docs', - ]); - expect(byId['add-payments-api'].store).toBe('api-server'); - expect(byId['add-payments-api'].state).toBe('complete'); - expect(byId['add-payments-ui'].store).toBe('web-app'); - expect(byId['add-payments-ui'].state).toBe('in-progress'); - expect(byId['update-docs'].store).toBeUndefined(); - expect(plan?.changesComplete).toBe(2); - expect(plan?.tasksTotal).toBe(6); - }); -}); diff --git a/test/core/profiles.test.ts b/test/core/profiles.test.ts index f56a0ac4ef..734de7e666 100644 --- a/test/core/profiles.test.ts +++ b/test/core/profiles.test.ts @@ -27,7 +27,7 @@ describe('profiles', () => { it('should contain expected workflow IDs', () => { const expected = [ 'propose', 'explore', 'new', 'continue', 'apply', - 'ff', 'sync', 'archive', 'bulk-archive', 'verify', 'onboard', 'plan', + 'ff', 'sync', 'archive', 'bulk-archive', 'verify', 'onboard', 'initiatives', ]; expect([...ALL_WORKFLOWS]).toEqual(expected); }); diff --git a/test/core/references.test.ts b/test/core/references.test.ts index 66ff1bdfd2..bbdc119df7 100644 --- a/test/core/references.test.ts +++ b/test/core/references.test.ts @@ -130,7 +130,7 @@ describe('reference index assembly', () => { expect(entries[0].status).toEqual([]); }); - it("indexes a store's own artifact types and plan stages with fetch commands", async () => { + it("indexes a store's own artifact types and initiatives with fetch commands", async () => { const storeRoot = await registerStore('team-context'); // A project-local schema (custom artifact types) in the store. const schemaDir = path.join(storeRoot, 'openspec', 'schemas', 'team-brief'); @@ -142,11 +142,14 @@ describe('reference index assembly', () => { ' template: brief.md\n requires: []\n instruction: Write it.\n' ); fs.writeFileSync(path.join(schemaDir, 'templates', 'brief.md'), '# Brief\n'); - // A plan folder in the store: numbered stages. - const planDir = path.join(storeRoot, 'openspec', 'plan'); - fs.mkdirSync(path.join(planDir, '00_goal'), { recursive: true }); - fs.mkdirSync(path.join(planDir, '01_requirements'), { recursive: true }); - fs.writeFileSync(path.join(planDir, '00_goal', 'goal.md'), '# goal\n'); + // An initiatives folder in the store: one folder per initiative. + const initiativesDir = path.join(storeRoot, 'openspec', 'initiatives'); + fs.mkdirSync(path.join(initiativesDir, 'smoother-setup'), { recursive: true }); + fs.mkdirSync(path.join(initiativesDir, 'q3-payments'), { recursive: true }); + fs.writeFileSync( + path.join(initiativesDir, 'smoother-setup', 'goal.md'), + '# goal\n' + ); const [entry] = await assemble(['team-context']); @@ -157,7 +160,7 @@ describe('reference index assembly', () => { artifacts: ['brief'], }, ]); - expect(entry.plan).toEqual(['00_goal', '01_requirements']); + expect(entry.initiatives).toEqual(['q3-payments', 'smoother-setup']); const block = renderReferencedStoresBlock([entry]); expect(block).toContain( @@ -165,17 +168,17 @@ describe('reference index assembly', () => { ); expect(block).toContain('- team-brief: Our own planning artifacts. [brief]'); expect(block).toContain( - 'Plan: 00_goal → 01_requirements (openspec list --plan --store team-context)' + 'Initiatives: q3-payments, smoother-setup (openspec list --initiatives --store team-context)' ); }); - it('omits schemas/plan keys for a store that has none', async () => { + it('omits schemas/initiatives keys for a store that has none', async () => { await registerStore('bare-context'); const [entry] = await assemble(['bare-context']); expect(entry.schemas).toBeUndefined(); - expect(entry.plan).toBeUndefined(); + expect(entry.initiatives).toBeUndefined(); }); it('degrades an unregistered reference to reference_unresolved with a pasteable fix', async () => { diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index fc8e8c9e1c..ee1c8e9f49 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -24,8 +24,8 @@ import { getOpsxProposeCommandTemplate, getOpsxProposeSkillTemplate, getOpsxVerifyCommandTemplate, - getPlanSkillTemplate, - getOpsxPlanCommandTemplate, + getInitiativesSkillTemplate, + getOpsxInitiativesCommandTemplate, getSyncSpecsSkillTemplate, getVerifyChangeSkillTemplate, } from '../../../src/core/templates/skill-templates.js'; @@ -59,8 +59,8 @@ const EXPECTED_FUNCTION_HASHES: Record = { getOpsxVerifyCommandTemplate: 'b4a5717c25883ca74dc8da091aef2432492e2a377c8cd9c1a0369b6b854dc32c', getOpsxProposeSkillTemplate: 'ad11a374aa8f3978a93af3a2d5b4cea736d6a5f7b3f913b38a2b34aeeb2bec21', getOpsxProposeCommandTemplate: '8aa4d2e0ca201ad8e913e8d490223063cef9c2a7e2b7a464d5aa0452540ccc09', - getPlanSkillTemplate: 'e396573d1a9493623bc1f8b92b90b5b8ff771268760bb9f4cdd579e01d7de522', - getOpsxPlanCommandTemplate: '603d4ccb5d390fed47ef28fbb8dd8c821c3dcfc2b7f5b1db0bd56232c0f7140f', + getInitiativesSkillTemplate: '9b3261e6a319223fa8a3795ace0f59197b0a15dc0bd2a1dfe0eabc5312ac0956', + getOpsxInitiativesCommandTemplate: 'b2c38557ccf42569531bfbac19146cc83e48292663d5b8f432d352feb0910b97', getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d', }; @@ -76,7 +76,7 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-verify-change': '2aa862e0f32bf85d1a073629a1dce44dac7c005bf76ba2f63fd19b0953228f30', 'openspec-onboard': 'abd8b125dd67edb482a6fcba2304ea4327cc3ac3689eea68ee805e5913065523', 'openspec-propose': 'd4a35ab16a2ca89f65c6aa1cc931cba21c3f01d5de356855a027d114b92047ec', - 'openspec-plan': '864d54aa1ac8b5601b2760ab9236ff3428286d7d37c17683025e71fb72c18569', + 'openspec-initiatives': 'f7ca1580fb8fbe1244e35ef6c733865a5cd5ab4010767914456d75e6503de175', }; // Intentionally excludes getFeedbackSkillTemplate: this list only models templates @@ -93,7 +93,7 @@ const GENERATED_SKILL_FACTORIES: Array<[string, () => SkillTemplate]> = [ ['openspec-verify-change', getVerifyChangeSkillTemplate], ['openspec-onboard', getOnboardSkillTemplate], ['openspec-propose', getOpsxProposeSkillTemplate], - ['openspec-plan', getPlanSkillTemplate], + ['openspec-initiatives', getInitiativesSkillTemplate], ]; function stableStringify(value: unknown): string { @@ -141,8 +141,8 @@ describe('skill templates split parity', () => { getOpsxVerifyCommandTemplate, getOpsxProposeSkillTemplate, getOpsxProposeCommandTemplate, - getPlanSkillTemplate, - getOpsxPlanCommandTemplate, + getInitiativesSkillTemplate, + getOpsxInitiativesCommandTemplate, getFeedbackSkillTemplate, }; diff --git a/test/vocabulary-sweep.test.ts b/test/vocabulary-sweep.test.ts index ab5bf157d9..e24c4d2fd5 100644 --- a/test/vocabulary-sweep.test.ts +++ b/test/vocabulary-sweep.test.ts @@ -71,13 +71,16 @@ describe('vocabulary sweep', () => { expect(offenders, `retired vocabulary found:\n${offenders.join('\n')}`).toEqual([]); }); - it('keeps the deleted workspace/initiative token surface from regrowing', () => { - // The command-group deletion slice's ledger records exactly these - // survivors; a new (workspace|initiative)_ token in src/ must be a - // deliberate decision recorded in the ledger, not drift. - const allowed = new Set(['initiative_option_removed']); + it('keeps the deleted workspace token surface from regrowing', () => { + // The command-group deletion slice retired the workspace machinery; a + // new workspace_ token in src/ must be a deliberate decision recorded + // in the ledger, not drift. Initiatives returned as a designed feature + // (change add-initiatives) — manifest-free, folder-per-initiative — so + // initiative_ tokens are no longer policed; the guarded surface there + // is the old manifest/collection machinery, which stays deleted. + const allowed = new Set([]); const found = new Set(); - const pattern = /(workspace|initiative)_[a-z_]+/g; + const pattern = /workspace_[a-z_]+/g; for (const filePath of walkFiles(path.join(REPO_ROOT, 'src'))) { const content = fs.readFileSync(filePath, 'utf-8'); From 72c437b92069ee1c3d3b6c04e56fb41d6d10601c Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 6 Jul 2026 16:13:51 -0500 Subject: [PATCH 20/45] fix(stores): pass projectRoot to task progress after merging main Main's tracked-tasks change (#1311) added a third parameter to getTaskProgressForChange; the initiatives rollup now passes the scanned root so schema-tracked task files count correctly. Co-Authored-By: Claude Fable 5 --- src/core/initiatives.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/initiatives.ts b/src/core/initiatives.ts index c16121885e..b7f34d556b 100644 --- a/src/core/initiatives.ts +++ b/src/core/initiatives.ts @@ -205,7 +205,7 @@ async function collectMatchingChanges( const name = toName(ref); if (name === null) continue; - const progress = await getTaskProgressForChange(changesDir, entry.name); + const progress = await getTaskProgressForChange(changesDir, entry.name, root); const status: InitiativeChangeStatus = { id: entry.name, ...(store ? { store } : {}), From a81ef23ab79fba2270a78a0137216d3e57faea29 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 8 Jul 2026 11:34:26 -0500 Subject: [PATCH 21/45] fix(tests): reconcile workflow list and golden hashes after merging main Co-Authored-By: Claude Fable 5 --- .../templates/skill-templates-parity.test.ts | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index 55082e04e1..39285110f9 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -64,24 +64,24 @@ const EXPECTED_FUNCTION_HASHES: Record = { getInitiativesSkillTemplate: '9b3261e6a319223fa8a3795ace0f59197b0a15dc0bd2a1dfe0eabc5312ac0956', getOpsxInitiativesCommandTemplate: 'b2c38557ccf42569531bfbac19146cc83e48292663d5b8f432d352feb0910b97', getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d', - getUpdateChangeSkillTemplate: 'fe2e8edaf973d42dc7fc7dfd846105c4c3cfec0437606e582ec644985cd4e81d', - getOpsxUpdateCommandTemplate: 'e55ac5774203a7d9037d2d588889c97c53f3f930da49497cc79e865375920da7', + getUpdateChangeSkillTemplate: 'cb95d9c5c450087b5adf862986a6a81a65d57cce535162e693fbf9aea127c1c3', + getOpsxUpdateCommandTemplate: '1657481ccf1f60c44cba192d51b20f5d7035b3c3a84e237b7323b703496fc149', }; const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { - 'openspec-explore': '0b7f81479edf27f85eb8a7deffb37aa6b9782adb1203ac1b541ce7e85b03fc64', - 'openspec-new-change': '81f414bfce1e11931b93d87ccadd0674ec460789bfe4079c73f483358a960859', - 'openspec-continue-change': '987043d62f6703f01258b0d09c0b9fd0481d845d6d738049d62dde5dfdd5160c', - 'openspec-apply-change': '3ad97a4a515021299002eb48279a2e5ee9ea77a9dd251a7156193f5263b0de3f', - 'openspec-ff-change': '30e8e5cc2e73f58699ccefad6f941bc8d8b9c5d0d77b12ef5d3140e220aa6079', - 'openspec-sync-specs': 'defce99383e7269a691cccf1577139bded63b6d3136055eb55b27847aa915602', - 'openspec-archive-change': 'fc9ecf88d9855e36a157f42518e9533b9df35b5370c20413931cef74e5bd353e', - 'openspec-bulk-archive-change': 'bfb25fdacb3bb2959535b79e6d321b0319d43cbde52614337f1318a065929b6f', - 'openspec-verify-change': '2aa862e0f32bf85d1a073629a1dce44dac7c005bf76ba2f63fd19b0953228f30', - 'openspec-onboard': 'abd8b125dd67edb482a6fcba2304ea4327cc3ac3689eea68ee805e5913065523', - 'openspec-propose': 'd4a35ab16a2ca89f65c6aa1cc931cba21c3f01d5de356855a027d114b92047ec', - 'openspec-initiatives': 'f7ca1580fb8fbe1244e35ef6c733865a5cd5ab4010767914456d75e6503de175', - 'openspec-update-change': '77ff4d1f1cd08a57649cce1f25e0ebc4f55d6d032dfde5c301d1b479561b72fa', + 'openspec-explore': 'e5365b5136fd85b0f3be3e65a834148b32ab05a88df575d8c582a2d048204ba7', + 'openspec-new-change': '491b175943bffded8ec33c1ec2f62dfc83cd078970c0469ceefe21c668192a54', + 'openspec-continue-change': 'ffc8d80dbf2bf39ba1cefbdd7e24aa004f4ff399ec2ff5aa764aa5cf68e6535c', + 'openspec-apply-change': '9a800c59af2e60121953afb43f63255d75136af44af57d083ed44617d34cb167', + 'openspec-ff-change': '20197740266c5fa521eaf4d74a24cb9e5ce270ac8a4aafa92bc47ad56e11b91e', + 'openspec-sync-specs': 'ea1e8d016953775d0f61b4c1813fc0b77ee310dac1bd085722ac99cba7b6f1d1', + 'openspec-archive-change': '51c003b7fc44cab1ddefdd16db4030e971b4504eb9dce99b70a79f691ba2e8b0', + 'openspec-bulk-archive-change': '3fa3a1b31791f74fa8fbc28acfb54626cecca19d9f7434d772873767e6a94f83', + 'openspec-verify-change': 'ba6d6bc6e78d2ab9cf3c41d75961cdd08277ac591ff2454c98b4190a4e43fba6', + 'openspec-onboard': 'd8baa141849b099671376d6928d29e4ed7dfb4be9675dbe3e1ff282548d883a9', + 'openspec-propose': '273e432fd268546de6316520b2d116c1e6d01d5a8d57bd03ad71a47fb5cec112', + 'openspec-initiatives': '861cfac98de8bdec719d1a841d027fe6b5b2ff43035f8110e3c034ce7f6f9e41', + 'openspec-update-change': '29c95eefe0df8efb4681dcaa6a90ae965535edda1832501f637a368608e33684', }; // Intentionally excludes getFeedbackSkillTemplate: this list only models templates From e9dff7d851be2dd84c68fcd270238b9bfb247a0d Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 8 Jul 2026 11:49:30 -0500 Subject: [PATCH 22/45] =?UTF-8?q?feat(stores):=20encode=20PM=20=E2=86=92?= =?UTF-8?q?=20engineering=20handoffs=20as=20pull-based=20stage=20transitio?= =?UTF-8?q?ns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage folder names are the team's workflow (00_product/ 01_engineering/, or any longer chain); the transition rule is one sentence — read what is lower-numbered, produce what your stage owes the next. Stage documents can be any format (a PRD, an RFC, or any other doc format); position carries the meaning. - skill: adds the 'Advance the workflow' move + upstream-tracing decompose - docs: stages-as-workflow section + lifecycle-by-persona table - change record: new design decision, proposal line, spec scenario - tests: stage-chain module test; golden hashes regenerated Co-Authored-By: Claude Fable 5 --- docs/stores-beta/initiatives.md | 58 +++++++++++++------ openspec/changes/add-initiatives/design.md | 15 ++++- openspec/changes/add-initiatives/proposal.md | 7 ++- .../add-initiatives/specs/initiatives/spec.md | 5 ++ src/core/templates/workflows/initiatives.ts | 6 +- test/core/initiatives.test.ts | 17 ++++++ .../templates/skill-templates-parity.test.ts | 6 +- 7 files changed, 88 insertions(+), 26 deletions(-) diff --git a/docs/stores-beta/initiatives.md b/docs/stores-beta/initiatives.md index 26816080b2..87ec0d5804 100644 --- a/docs/stores-beta/initiatives.md +++ b/docs/stores-beta/initiatives.md @@ -59,11 +59,32 @@ finishing an initiative updates the evergreen artifacts, the way archiving a change updates specs. Status flows back live — driven by structured facts on disk (a task checked off, a change archived), never by prose. -**Want visible order inside an initiative?** Number its folders -(`00_goal/`, `01_requirements/`) and they become stages: standing in -`01_requirements/`, an agent (or a new teammate) knows everything -lower-numbered is upstream. Opt-in — an initiative with no numbers works -exactly the same. +## Stages: your workflow, in folder names + +Number the folders inside an initiative and they become ordered stages — +and the names are your workflow: + +```text +openspec/initiatives/checkout-revamp/ + 00_product/ the what & why — a ProductSpec doc, a PRD, anything + 01_engineering/ the how — decomposes into changes +``` + +That is the PM → Engineering base case. Want design in between? Rename to +`00_product/ 01_design/ 02_engineering/`. A bigger org? +`00_analysis/ 01_product/ 02_architecture/ 03_design/ 04_engineering/`. +Reconfiguring the workflow is renaming folders — no settings, no new skills, +and OpenSpec handles every chain the same way. + +The handoff between stages is **pull-based, one rule**: whoever opens the +initiative reads everything lower-numbered first, then produces what their +stage owes the next one. Nothing pushes work downstream; the folder position +already says what is upstream and what comes next. + +The documents themselves can be any format — a +[ProductSpec](https://github.com/gokulrajaram/ProductSpec) file, a PRD, an +RFC, exported design notes. Position carries the meaning, not the format. +And stages are opt-in: an initiative with no numbers works exactly the same. ## Team: the portfolio lives in a store @@ -116,27 +137,30 @@ completes. Every move ends in a short numbered menu of real next actions — one marked recommended — and it is told to write *less*: one page per artifact, tables over prose. -## The handoff +## The lifecycle, by persona -**Whoever plans** (a PM, a lead, you) starts in `/opsx:initiatives`: talk the -idea through and the skill writes the artifacts — or drop existing docs into -`openspec/initiatives/`. Decomposing ends in changes created with -`--initiative`, so each is born linked. +Everyone talks to the same skill; the folder position decides what happens. +Nobody needs to know the other personas' tools: -**Whoever builds** picks up any change — each is self-contained — and works -it with the normal change skills. The initiative travels along: the repo's -`context:` config can point at it locally, and referenced stores surface it -to agents automatically. +| Who | Job to be done | What they actually do | +|---|---|---| +| PM (or anyone with an idea) | capture the what & why | ask the agent — `/opsx:initiatives` turns the conversation into the first stage's artifact, or they drop an existing doc (ProductSpec, PRD) into the folder | +| Designer / architect / analyst | add their stage | open the initiative; the skill reads everything upstream and drafts their stage — same move, different position | +| Engineer | turn the last stage into work | the skill decomposes it into changes born linked (`openspec new change --initiative `), each tracing to something upstream | +| Anyone | know where it stands | `openspec list --initiatives` — live, from task lists, no bookkeeping | -**The handoff artifact is the change itself.** Status flows back to -`openspec list --initiatives` with no meetings and no bookkeeping. +**The handoff artifact between planning and building is the change itself** — +self-contained, worked with the normal change skills. The initiative travels +along: the repo's `context:` config can point at it locally, and referenced +stores surface it to agents automatically. Status flows back with no +meetings and no status updates written by hand. ## What the pieces are | Piece | What it is | |---|---| | `openspec/initiatives/` | evergreen truths + one folder per initiative | -| numbered folders inside an initiative | optional ordered stages | +| numbered folders inside an initiative | your workflow, as optional ordered stages | | `initiative: ` / `/` | one line in a change's `.openspec.yaml` | | `openspec new change --initiative ` | create a change already pointing up | | `openspec list --initiatives [--store ]` | the portfolio + every linked change, live | diff --git a/openspec/changes/add-initiatives/design.md b/openspec/changes/add-initiatives/design.md index dadb337837..5b809ce00f 100644 --- a/openspec/changes/add-initiatives/design.md +++ b/openspec/changes/add-initiatives/design.md @@ -49,7 +49,18 @@ expensive and unrepeatable; facts-per-event with judgment-per-response is cheap, auditable, and repeatable — and OpenSpec's task/archive lifecycle already supplies the facts. -**4. One skill, routed by state.** `openspec-initiatives` looks at what +**4. Transitions between roles are pull-based, encoded in folder order.** +The PM → Engineering handoff (or PM → Design → Engineering, or any longer +chain) is not a workflow OpenSpec models — it is the numbered stage folders +themselves. The rule is one sentence: read everything lower-numbered, produce +what your stage owes the next one. Upstream documents can be any format (a +ProductSpec file, a PRD, an RFC); position carries the meaning. Why pull: +push requires the upstream author to know the downstream tooling and fires +before anyone has implementation context; pull happens when someone opens +the work, with everything on disk. Rejected: per-role skills (the rule is +the same move from different positions) and typed transition events. + +**5. One skill, routed by state.** `openspec-initiatives` looks at what exists before speaking: no folder → offer to capture the conversation; folder, no target → summarize the portfolio in a few lines; inside an initiative → work it. Its moves (ideate from what is on disk, capture, @@ -60,7 +71,7 @@ so anyone (or any agent) can pick it up; menus beat paragraphs for fatigue. Rejected: per-stage or per-role skills, and a `new initiative` CLI command — creating an initiative is `mkdir`, and the skill does it in flow. -**5. Works anywhere.** `--store ` resolves through the global registry, +**6. Works anywhere.** `--store ` resolves through the global registry, so the rollup answers from any directory, repo or not. With no local root and no `--store`, `list --initiatives` falls back to the portfolios of registered stores instead of erroring. Why: the planning layer is the level diff --git a/openspec/changes/add-initiatives/proposal.md b/openspec/changes/add-initiatives/proposal.md index 2f05f67d2b..45b7c86f3b 100644 --- a/openspec/changes/add-initiatives/proposal.md +++ b/openspec/changes/add-initiatives/proposal.md @@ -13,8 +13,11 @@ convention, one metadata line, one rollup, one skill. planning layer for a root — this repo, or a store the team shares. - Each subfolder is one **initiative**: a finite piece of work above a single change (`smoother-setup/`, `q3-payments/`). Contents are freeform; - numbered folders inside an initiative are ordered stages when visible - order is wanted. + numbered folders inside an initiative are ordered stages, and their + names are the team's workflow (`00_product/ 01_engineering/`, or any + longer chain). Stage documents can be any format — a ProductSpec file, + a PRD, an RFC. Handoffs are pull-based: read what is lower-numbered, + produce what your stage owes the next. - Unnumbered files at the top level are **evergreen artifacts** — the standing truths every initiative serves (`product.md`, `roadmap.md`, `architecture.md`). They are maintained forever, the way specs are. diff --git a/openspec/changes/add-initiatives/specs/initiatives/spec.md b/openspec/changes/add-initiatives/specs/initiatives/spec.md index 7649c67d34..5d0a8cafe2 100644 --- a/openspec/changes/add-initiatives/specs/initiatives/spec.md +++ b/openspec/changes/add-initiatives/specs/initiatives/spec.md @@ -18,6 +18,11 @@ initiative SHALL be treated as ordered stages. - **WHEN** `openspec/initiatives/smoother-setup/` contains `00_goal/`, `01_requirements/`, and `notes.md` - **THEN** the system reports `00_goal` and `01_requirements` as that initiative's stages, in order +#### Scenario: Stage names encode the team's workflow, any chain length + +- **WHEN** `openspec/initiatives/checkout-revamp/` contains `00_analysis/`, `01_product/`, `02_design/`, and `03_engineering/` +- **THEN** the system reports all four as that initiative's stages, in order, with no configuration naming the workflow + ### Requirement: Changes link upward to an initiative The system SHALL let a change declare the initiative it serves with an diff --git a/src/core/templates/workflows/initiatives.ts b/src/core/templates/workflows/initiatives.ts index aa804c57e3..7cb9c10576 100644 --- a/src/core/templates/workflows/initiatives.ts +++ b/src/core/templates/workflows/initiatives.ts @@ -18,7 +18,7 @@ const INITIATIVES_BODY = `Work above a single change: keep the evergreen truths The planning layer lives in one folder: \`openspec/initiatives/\` — in this repo, or in a store the team shares. - **Unnumbered top-level files are evergreen artifacts** — the standing truths every initiative serves: \`product.md\`, \`roadmap.md\`, \`architecture.md\`, whatever names the user already uses. They are maintained forever, the way specs are. -- **Each subfolder is one initiative** — a finite piece of work above a single change. Contents are freeform. Numbered folders inside an initiative (\`00_goal/\`, \`01_requirements/\`) are ordered stages, when the user wants visible order; everything lower-numbered is upstream. +- **Each subfolder is one initiative** — a finite piece of work above a single change. Contents are freeform. Numbered folders inside an initiative are ordered stages, and their names are the team's own workflow: \`00_product/ 01_engineering/\` for one team, \`00_analysis/ 01_product/ 02_design/ 03_engineering/\` for another. Everything lower-numbered is upstream. Any document works at any stage — a ProductSpec file, a PRD, an RFC, design notes; position carries the meaning, not the format. - **Changes point up** with one line in their \`.openspec.yaml\`: \`initiative: \` (this root) or \`initiative: /\` (a store's initiative). There is no list to maintain anywhere. The same loop runs at both altitudes: work flows down (an initiative decomposes into changes), truth flows up (finishing work updates the evergreen artifacts, the way archiving a change updates specs), and status flows back live through \`openspec list --initiatives\`. @@ -38,11 +38,13 @@ What drives your reactions must be structured facts from disk — a task checked **Capture the conversation.** When the intent lives in the chat and no artifact exists yet, synthesize what you already know into one — do NOT re-interview the user. One page, their words. Standing truths go in an evergreen file; a finite effort becomes \`openspec/initiatives//\` (create the folder yourself — \`mkdir\` is the whole ceremony). +**Advance the workflow.** The transition rule is one sentence: read everything upstream of where you stand (evergreen files, lower-numbered stages), then produce what your stage owes the next one. Never re-interview for what an upstream artifact already answers — cite it; when upstream is silent on something you need, say so and ask once. This one rule is why no persona needs its own skill: a PM filling \`00_product/\`, a designer filling \`01_design/\`, and an engineer decomposing the last stage into changes are all making the same move from different positions. + **Ideate from what exists.** Read the evergreen artifacts, the initiative's files, and live change status; propose directions grounded in them — each with a one-line basis pointing at what it serves. Name what you are NOT proposing and why, in one line each. **Push back — twice, then move on.** When a goal is vague ("improve UX"), a metric can only go up, or a problem is stated as a feature, ask one sharper question using the user's own words. One question per message; offer numbered options when they scaffold the answer. After two rounds, capture what you have and note what is worth revisiting — planning is iterative. -**Decompose and bridge.** Cut the initiative into tracer-bullet slices: each end-to-end and demoable alone, so anyone could pick one up without reading the others. Surface merge/split judgment calls; don't decide silently. When a slice is ready, make it real: +**Decompose and bridge.** Cut the initiative into tracer-bullet slices: each end-to-end and demoable alone, so anyone could pick one up without reading the others. Each slice should trace to something upstream — an acceptance criterion, a requirement, a decision; a slice that serves nothing upstream gets questioned out loud. Surface merge/split judgment calls; don't decide silently. When a slice is ready, make it real: \`\`\`bash openspec new change --initiative # initiative in this root diff --git a/test/core/initiatives.test.ts b/test/core/initiatives.test.ts index 7d6334c9d9..cd6eae6186 100644 --- a/test/core/initiatives.test.ts +++ b/test/core/initiatives.test.ts @@ -85,6 +85,23 @@ describe('initiatives', () => { expect(smoother?.artifacts).toEqual(['notes.md']); }); + it('reads stage chains of any length as the workflow, in order', async () => { + write('app/openspec/initiatives/checkout-revamp/00_analysis/brief.md', '# brief\n'); + write('app/openspec/initiatives/checkout-revamp/01_product/product-spec.md', '# spec\n'); + write('app/openspec/initiatives/checkout-revamp/02_design/flows.md', '# flows\n'); + write('app/openspec/initiatives/checkout-revamp/03_engineering/plan.md', '# plan\n'); + + const shape = await readInitiativesShape(path.join(tempDir, 'app')); + const initiative = shape?.initiatives.find((i) => i.name === 'checkout-revamp'); + + expect(initiative?.stages.map((s) => s.name)).toEqual([ + '00_analysis', + '01_product', + '02_design', + '03_engineering', + ]); + }); + it('returns null when there is no initiatives folder', async () => { fs.mkdirSync(path.join(tempDir, 'app', 'openspec'), { recursive: true }); expect(await readInitiativesShape(path.join(tempDir, 'app'))).toBeNull(); diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index 39285110f9..ba9f49ba0b 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -61,8 +61,8 @@ const EXPECTED_FUNCTION_HASHES: Record = { getOpsxVerifyCommandTemplate: 'b4a5717c25883ca74dc8da091aef2432492e2a377c8cd9c1a0369b6b854dc32c', getOpsxProposeSkillTemplate: 'ad11a374aa8f3978a93af3a2d5b4cea736d6a5f7b3f913b38a2b34aeeb2bec21', getOpsxProposeCommandTemplate: '8aa4d2e0ca201ad8e913e8d490223063cef9c2a7e2b7a464d5aa0452540ccc09', - getInitiativesSkillTemplate: '9b3261e6a319223fa8a3795ace0f59197b0a15dc0bd2a1dfe0eabc5312ac0956', - getOpsxInitiativesCommandTemplate: 'b2c38557ccf42569531bfbac19146cc83e48292663d5b8f432d352feb0910b97', + getInitiativesSkillTemplate: '626effaa5f2e8454409c7cf96b17e5cafa9413b6374375f8be8140ac165125b8', + getOpsxInitiativesCommandTemplate: '4443646ad2c28fea0865aba3e3b2e10fe5458182171af24991b3cd2574ccf3fe', getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d', getUpdateChangeSkillTemplate: 'cb95d9c5c450087b5adf862986a6a81a65d57cce535162e693fbf9aea127c1c3', getOpsxUpdateCommandTemplate: '1657481ccf1f60c44cba192d51b20f5d7035b3c3a84e237b7323b703496fc149', @@ -80,7 +80,7 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-verify-change': 'ba6d6bc6e78d2ab9cf3c41d75961cdd08277ac591ff2454c98b4190a4e43fba6', 'openspec-onboard': 'd8baa141849b099671376d6928d29e4ed7dfb4be9675dbe3e1ff282548d883a9', 'openspec-propose': '273e432fd268546de6316520b2d116c1e6d01d5a8d57bd03ad71a47fb5cec112', - 'openspec-initiatives': '861cfac98de8bdec719d1a841d027fe6b5b2ff43035f8110e3c034ce7f6f9e41', + 'openspec-initiatives': '87e593d1c3d82e786488450ab2ea637a24537f756c586b6404753c3695f139b2', 'openspec-update-change': '29c95eefe0df8efb4681dcaa6a90ae965535edda1832501f637a368608e33684', }; From ba07f5acd81789741cb3c055f78acedbf2fccd04 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 8 Jul 2026 11:59:03 -0500 Subject: [PATCH 23/45] fix(tests): bump skill-generation template counts to 13 after update-workflow merge Co-Authored-By: Claude Fable 5 --- test/core/shared/skill-generation.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/core/shared/skill-generation.test.ts b/test/core/shared/skill-generation.test.ts index 5f4bba9d55..c806c906b3 100644 --- a/test/core/shared/skill-generation.test.ts +++ b/test/core/shared/skill-generation.test.ts @@ -8,9 +8,9 @@ import { describe('skill-generation', () => { describe('getSkillTemplates', () => { - it('should return all 12 skill templates', () => { + it('should return all 13 skill templates', () => { const templates = getSkillTemplates(); - expect(templates).toHaveLength(12); + expect(templates).toHaveLength(13); }); it('should have unique directory names', () => { @@ -89,9 +89,9 @@ describe('skill-generation', () => { }); describe('getCommandTemplates', () => { - it('should return all 12 command templates', () => { + it('should return all 13 command templates', () => { const templates = getCommandTemplates(); - expect(templates).toHaveLength(12); + expect(templates).toHaveLength(13); }); it('should have unique IDs', () => { @@ -144,9 +144,9 @@ describe('skill-generation', () => { }); describe('getCommandContents', () => { - it('should return all 12 command contents', () => { + it('should return all 13 command contents', () => { const contents = getCommandContents(); - expect(contents).toHaveLength(12); + expect(contents).toHaveLength(13); }); it('should have valid content structure', () => { From 2f682ed75e2953ef885a89198e411550de006ad7 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Wed, 8 Jul 2026 12:38:16 -0500 Subject: [PATCH 24/45] refactor(stores): make stage workflows fully persona-agnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No chain is the base case: the skill, docs, and design now frame stages as any workflow in folder names — two-stage, five-stage, or none — with the lifecycle described by position (first stage, middle, last) instead of job titles. Golden hashes regenerated. Co-Authored-By: Claude Fable 5 --- docs/stores-beta/initiatives.md | 34 +++++++++---------- openspec/changes/add-initiatives/design.md | 19 ++++++----- src/core/templates/workflows/initiatives.ts | 2 +- .../templates/skill-templates-parity.test.ts | 6 ++-- 4 files changed, 31 insertions(+), 30 deletions(-) diff --git a/docs/stores-beta/initiatives.md b/docs/stores-beta/initiatives.md index 87ec0d5804..37848ae091 100644 --- a/docs/stores-beta/initiatives.md +++ b/docs/stores-beta/initiatives.md @@ -62,19 +62,19 @@ disk (a task checked off, a change archived), never by prose. ## Stages: your workflow, in folder names Number the folders inside an initiative and they become ordered stages — -and the names are your workflow: +and the names are your workflow. Any chain works, because none of them is +special: ```text -openspec/initiatives/checkout-revamp/ - 00_product/ the what & why — a ProductSpec doc, a PRD, anything - 01_engineering/ the how — decomposes into changes +00_product/ 01_engineering/ a two-person team +00_product/ 01_design/ 02_engineering/ a product team +00_analysis/ 01_product/ 02_architecture/ 03_design/ 04_engineering/ a bigger org +00_idea/ 01_build/ a solo dev switching hats ``` -That is the PM → Engineering base case. Want design in between? Rename to -`00_product/ 01_design/ 02_engineering/`. A bigger org? -`00_analysis/ 01_product/ 02_architecture/ 03_design/ 04_engineering/`. -Reconfiguring the workflow is renaming folders — no settings, no new skills, -and OpenSpec handles every chain the same way. +Reconfiguring the workflow is renaming folders — no settings, no manifest, +no new skills. OpenSpec never learns your chain; it only knows *lower-numbered +is upstream*, and that is enough. The handoff between stages is **pull-based, one rule**: whoever opens the initiative reads everything lower-numbered first, then produces what their @@ -137,17 +137,17 @@ completes. Every move ends in a short numbered menu of real next actions — one marked recommended — and it is told to write *less*: one page per artifact, tables over prose. -## The lifecycle, by persona +## The lifecycle, by position -Everyone talks to the same skill; the folder position decides what happens. -Nobody needs to know the other personas' tools: +Everyone talks to the same skill; the folder position — not the job title — +decides what happens. Whether your chain has two stages or five: -| Who | Job to be done | What they actually do | +| Where you stand | Job to be done | What actually happens | |---|---|---| -| PM (or anyone with an idea) | capture the what & why | ask the agent — `/opsx:initiatives` turns the conversation into the first stage's artifact, or they drop an existing doc (ProductSpec, PRD) into the folder | -| Designer / architect / analyst | add their stage | open the initiative; the skill reads everything upstream and drafts their stage — same move, different position | -| Engineer | turn the last stage into work | the skill decomposes it into changes born linked (`openspec new change --initiative `), each tracing to something upstream | -| Anyone | know where it stands | `openspec list --initiatives` — live, from task lists, no bookkeeping | +| nothing exists yet | capture the intent | ask the agent — `/opsx:initiatives` turns the conversation into the first stage's artifact, or drop in a doc you already have (ProductSpec, PRD, RFC) | +| any middle stage | add your piece | open the initiative; the skill reads everything lower-numbered and drafts your stage — same move at `01_design/` as at `02_architecture/` | +| the last stage | turn plans into work | the skill decomposes it into changes born linked (`openspec new change --initiative `), each tracing to something upstream | +| anywhere, anytime | know where it stands | `openspec list --initiatives` — live, from task lists, no bookkeeping | **The handoff artifact between planning and building is the change itself** — self-contained, worked with the normal change skills. The initiative travels diff --git a/openspec/changes/add-initiatives/design.md b/openspec/changes/add-initiatives/design.md index 5b809ce00f..bb88cab226 100644 --- a/openspec/changes/add-initiatives/design.md +++ b/openspec/changes/add-initiatives/design.md @@ -50,15 +50,16 @@ cheap, auditable, and repeatable — and OpenSpec's task/archive lifecycle already supplies the facts. **4. Transitions between roles are pull-based, encoded in folder order.** -The PM → Engineering handoff (or PM → Design → Engineering, or any longer -chain) is not a workflow OpenSpec models — it is the numbered stage folders -themselves. The rule is one sentence: read everything lower-numbered, produce -what your stage owes the next one. Upstream documents can be any format (a -ProductSpec file, a PRD, an RFC); position carries the meaning. Why pull: -push requires the upstream author to know the downstream tooling and fires -before anyone has implementation context; pull happens when someone opens -the work, with everything on disk. Rejected: per-role skills (the rule is -the same move from different positions) and typed transition events. +Handoffs between roles — PM → Engineering, PM → Design → Engineering, or +any longer chain — are not workflows OpenSpec models; they are the numbered +stage folders themselves, and every chain is handled identically. The rule +is one sentence: read everything lower-numbered, produce what your stage +owes the next one. Upstream documents can be any format (a ProductSpec +file, a PRD, an RFC); position carries the meaning. Why pull: push requires +the upstream author to know the downstream tooling and fires before anyone +has implementation context; pull happens when someone opens the work, with +everything on disk. Rejected: per-role skills (the rule is the same move +from different positions) and typed transition events. **5. One skill, routed by state.** `openspec-initiatives` looks at what exists before speaking: no folder → offer to capture the conversation; diff --git a/src/core/templates/workflows/initiatives.ts b/src/core/templates/workflows/initiatives.ts index 7cb9c10576..8b84193153 100644 --- a/src/core/templates/workflows/initiatives.ts +++ b/src/core/templates/workflows/initiatives.ts @@ -38,7 +38,7 @@ What drives your reactions must be structured facts from disk — a task checked **Capture the conversation.** When the intent lives in the chat and no artifact exists yet, synthesize what you already know into one — do NOT re-interview the user. One page, their words. Standing truths go in an evergreen file; a finite effort becomes \`openspec/initiatives//\` (create the folder yourself — \`mkdir\` is the whole ceremony). -**Advance the workflow.** The transition rule is one sentence: read everything upstream of where you stand (evergreen files, lower-numbered stages), then produce what your stage owes the next one. Never re-interview for what an upstream artifact already answers — cite it; when upstream is silent on something you need, say so and ask once. This one rule is why no persona needs its own skill: a PM filling \`00_product/\`, a designer filling \`01_design/\`, and an engineer decomposing the last stage into changes are all making the same move from different positions. +**Advance the workflow.** The transition rule is one sentence: read everything upstream of where you stand (evergreen files, lower-numbered stages), then produce what your stage owes the next one. Never re-interview for what an upstream artifact already answers — cite it; when upstream is silent on something you need, say so and ask once. This one rule is why no persona needs its own skill: whoever owns a stage — analyst, PM, architect, designer, engineer, or a hat-switching solo dev — is making the same move from a different position, and decomposing the last stage into changes is just the final instance of it. **Ideate from what exists.** Read the evergreen artifacts, the initiative's files, and live change status; propose directions grounded in them — each with a one-line basis pointing at what it serves. Name what you are NOT proposing and why, in one line each. diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index ba9f49ba0b..f805d639e4 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -61,8 +61,8 @@ const EXPECTED_FUNCTION_HASHES: Record = { getOpsxVerifyCommandTemplate: 'b4a5717c25883ca74dc8da091aef2432492e2a377c8cd9c1a0369b6b854dc32c', getOpsxProposeSkillTemplate: 'ad11a374aa8f3978a93af3a2d5b4cea736d6a5f7b3f913b38a2b34aeeb2bec21', getOpsxProposeCommandTemplate: '8aa4d2e0ca201ad8e913e8d490223063cef9c2a7e2b7a464d5aa0452540ccc09', - getInitiativesSkillTemplate: '626effaa5f2e8454409c7cf96b17e5cafa9413b6374375f8be8140ac165125b8', - getOpsxInitiativesCommandTemplate: '4443646ad2c28fea0865aba3e3b2e10fe5458182171af24991b3cd2574ccf3fe', + getInitiativesSkillTemplate: '6e1c37daefb93825b4806ea059047c930eb55b7d06658826b89d0573acd691bb', + getOpsxInitiativesCommandTemplate: '3b888b37f53f33719eedd38c109ff1759c47c852e365739bd5b87ab0ae9518f9', getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d', getUpdateChangeSkillTemplate: 'cb95d9c5c450087b5adf862986a6a81a65d57cce535162e693fbf9aea127c1c3', getOpsxUpdateCommandTemplate: '1657481ccf1f60c44cba192d51b20f5d7035b3c3a84e237b7323b703496fc149', @@ -80,7 +80,7 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-verify-change': 'ba6d6bc6e78d2ab9cf3c41d75961cdd08277ac591ff2454c98b4190a4e43fba6', 'openspec-onboard': 'd8baa141849b099671376d6928d29e4ed7dfb4be9675dbe3e1ff282548d883a9', 'openspec-propose': '273e432fd268546de6316520b2d116c1e6d01d5a8d57bd03ad71a47fb5cec112', - 'openspec-initiatives': '87e593d1c3d82e786488450ab2ea637a24537f756c586b6404753c3695f139b2', + 'openspec-initiatives': 'cb156f032b133c23e0f8dccf7a1a7db20af0a7a9f564a9321a8133b08a2701d1', 'openspec-update-change': '29c95eefe0df8efb4681dcaa6a90ae965535edda1832501f637a368608e33684', }; From 74f2c17a09c26d02be700a02bd5f80cc9455b0f4 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Thu, 9 Jul 2026 16:53:25 -0500 Subject: [PATCH 25/45] =?UTF-8?q?feat(stores):=20close=20the=20dogfooded?= =?UTF-8?q?=20gaps=20=E2=80=94=20discovery=20on=20link,=20the=20upstream?= =?UTF-8?q?=20join,=20core=20skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A real dogfood on a multi-repo product found the two load-bearing joints half-wired. This closes them, plus the friction it logged: - Linking is the registration: `new change --initiative /` records the checkout machine-locally (stores/linked-roots.yaml), so the rollup finds plain code repos without registering them as stores and with nothing written into the repo. - The join: `instructions` and `instructions apply` for a linked change open with an block — the ref, the on-disk upstream path, and the read-upstream-first instruction. Stale refs stay visible. - `initiatives` joins the core profile so `openspec init` actually generates the documented skill. - Rollup nudges the evergreen sync when an initiative's changes are all complete; link hints are store-qualified; store setup seeds openspec/initiatives/; no-root errors lead with `openspec init`; `--remote` help names .openspec-store/store.yaml. - Product names removed from templates, docs, and the change record. Tests: linked-root discovery + resolveInitiativeLink units; pins updated (profiles, store scaffold shape, parity hashes regenerated from dist). Full suite green locally except the 17 known env-only zsh-installer failures. `openspec validate add-initiatives --strict` passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/stores-beta/initiatives.md | 31 ++-- openspec/changes/add-initiatives/design.md | 7 +- openspec/changes/add-initiatives/proposal.md | 19 ++- .../add-initiatives/specs/initiatives/spec.md | 28 +++- openspec/changes/add-initiatives/tasks.md | 16 +- src/cli/index.ts | 25 ++- src/commands/store.ts | 2 +- src/commands/workflow/instructions.ts | 84 +++++++++- src/commands/workflow/new-change.ts | 30 +++- src/core/initiatives.ts | 143 ++++++++++++++++-- src/core/openspec-root.ts | 8 +- src/core/profiles.ts | 10 +- src/core/root-selection.ts | 7 +- src/core/templates/workflows/initiatives.ts | 2 +- test/cli-e2e/store-lifecycle.test.ts | 7 +- test/commands/config-profile.test.ts | 7 +- test/commands/config.test.ts | 2 +- test/commands/store-git.test.ts | 2 + test/commands/store.test.ts | 9 +- test/core/initiatives.test.ts | 78 ++++++++++ test/core/openspec-root.test.ts | 5 +- test/core/profiles.test.ts | 10 +- .../templates/skill-templates-parity.test.ts | 6 +- 23 files changed, 471 insertions(+), 67 deletions(-) diff --git a/docs/stores-beta/initiatives.md b/docs/stores-beta/initiatives.md index 37848ae091..ff676635f2 100644 --- a/docs/stores-beta/initiatives.md +++ b/docs/stores-beta/initiatives.md @@ -81,9 +81,8 @@ initiative reads everything lower-numbered first, then produces what their stage owes the next one. Nothing pushes work downstream; the folder position already says what is upstream and what comes next. -The documents themselves can be any format — a -[ProductSpec](https://github.com/gokulrajaram/ProductSpec) file, a PRD, an -RFC, exported design notes. Position carries the meaning, not the format. +The documents themselves can be any format — a PRD, an RFC, a one-pager, +exported design notes. Position carries the meaning, not the format. And stages are opt-in: an initiative with no numbers works exactly the same. ## Team: the portfolio lives in a store @@ -115,11 +114,13 @@ smoother-setup 2/3 changes complete ``` One command answers "where does everything stand?" — across every repo on -your machine that points at the store's initiatives. It even works outside -any repo: with no local root, `list --initiatives` shows the portfolios of -your registered stores. And repos that add `references: [team-plans]` to -their `openspec/config.yaml` get the initiatives into their agent's context -automatically. +your machine that points at the store's initiatives. Linking is the +registration: `new change --initiative team-plans/` records the +checkout so rollups scan it, with nothing written into the repo itself. It +even works outside any repo: with no local root, `list --initiatives` shows +the portfolios of your registered stores. And repos that add +`references: [team-plans]` to their `openspec/config.yaml` get the +initiatives into their agent's context automatically. Going from solo to team is moving one folder into a store. The initiative is untouched; its ref changes from `smoother-setup` to @@ -144,16 +145,18 @@ decides what happens. Whether your chain has two stages or five: | Where you stand | Job to be done | What actually happens | |---|---|---| -| nothing exists yet | capture the intent | ask the agent — `/opsx:initiatives` turns the conversation into the first stage's artifact, or drop in a doc you already have (ProductSpec, PRD, RFC) | +| nothing exists yet | capture the intent | ask the agent — `/opsx:initiatives` turns the conversation into the first stage's artifact, or drop in a doc you already have (a PRD, an RFC, a one-pager) | | any middle stage | add your piece | open the initiative; the skill reads everything lower-numbered and drafts your stage — same move at `01_design/` as at `02_architecture/` | | the last stage | turn plans into work | the skill decomposes it into changes born linked (`openspec new change --initiative `), each tracing to something upstream | | anywhere, anytime | know where it stands | `openspec list --initiatives` — live, from task lists, no bookkeeping | **The handoff artifact between planning and building is the change itself** — self-contained, worked with the normal change skills. The initiative travels -along: the repo's `context:` config can point at it locally, and referenced -stores surface it to agents automatically. Status flows back with no -meetings and no status updates written by hand. +along: `openspec instructions` and `openspec instructions apply` for a linked +change open with the initiative it serves and where its upstream context +lives on disk, so intent reaches the working agent without anyone pasting +it. Status flows back with no meetings and no status updates written by +hand. ## What the pieces are @@ -168,7 +171,9 @@ meetings and no status updates written by hand. ## Honest limits -- Rollup scans repos registered on this machine — it never clones or syncs. +- Rollup scans checkouts this machine knows: registered stores, plus any + repo that has linked a change to a store's initiative. It never clones or + syncs. - Stage order is a naming convention, not a gate. Nothing blocks working out of order; the skill just knows what comes next. - Reactions fire when the skill looks — reactive triggers beyond that (git diff --git a/openspec/changes/add-initiatives/design.md b/openspec/changes/add-initiatives/design.md index bb88cab226..e6ff9118c5 100644 --- a/openspec/changes/add-initiatives/design.md +++ b/openspec/changes/add-initiatives/design.md @@ -10,7 +10,8 @@ team's workflow — every team's is different. many; one skill; value on first contact for a solo dev AND for an org. **Non-Goals:** typed upstream artifacts, manifests, file watchers or hook -machinery, sync engines, auto-discovery of unregistered repos. +machinery, sync engines, filesystem-crawling discovery of unknown repos +(a repo becomes known the moment it links a change — never by scanning). ## Decisions @@ -54,8 +55,8 @@ Handoffs between roles — PM → Engineering, PM → Design → Engineering, or any longer chain — are not workflows OpenSpec models; they are the numbered stage folders themselves, and every chain is handled identically. The rule is one sentence: read everything lower-numbered, produce what your stage -owes the next one. Upstream documents can be any format (a ProductSpec -file, a PRD, an RFC); position carries the meaning. Why pull: push requires +owes the next one. Upstream documents can be any format (a PRD, an RFC, a +one-pager); position carries the meaning. Why pull: push requires the upstream author to know the downstream tooling and fires before anyone has implementation context; pull happens when someone opens the work, with everything on disk. Rejected: per-role skills (the rule is the same move diff --git a/openspec/changes/add-initiatives/proposal.md b/openspec/changes/add-initiatives/proposal.md index 45b7c86f3b..02d40c2c16 100644 --- a/openspec/changes/add-initiatives/proposal.md +++ b/openspec/changes/add-initiatives/proposal.md @@ -15,8 +15,8 @@ convention, one metadata line, one rollup, one skill. single change (`smoother-setup/`, `q3-payments/`). Contents are freeform; numbered folders inside an initiative are ordered stages, and their names are the team's workflow (`00_product/ 01_engineering/`, or any - longer chain). Stage documents can be any format — a ProductSpec file, - a PRD, an RFC. Handoffs are pull-based: read what is lower-numbered, + longer chain). Stage documents can be any format — a PRD, an RFC, a + one-pager. Handoffs are pull-based: read what is lower-numbered, produce what your stage owes the next. - Unnumbered files at the top level are **evergreen artifacts** — the standing truths every initiative serves (`product.md`, `roadmap.md`, @@ -27,8 +27,11 @@ convention, one metadata line, one rollup, one skill. `--initiative`. Legacy `initiative:` objects (`{store, id}`) stay readable. - **One rollup.** `openspec list --initiatives [--store ]` shows the portfolio: every initiative, every change on this machine pointing at it, - live task status — including changes in other registered repos. Run - outside any root, it falls back to the portfolios of registered stores. + live task status — including changes in other checkouts. Linking is the + registration: `new change --initiative /` records the repo's + path machine-locally so rollups scan it, with nothing written into the + repo. Run outside any root, it falls back to the portfolios of registered + stores. - **One skill.** `openspec-initiatives` (`/opsx:initiatives`): routes by what is on disk, captures planning conversations into artifacts, ideates from what exists, decomposes into changes born linked, and syncs the @@ -51,8 +54,10 @@ None. - Commands: `list` (adds `--initiatives`), `new change` (adds `--initiative`), `context` and `instructions` (surface a store's - initiatives). All additive. -- Skill set: one new optional workflow, `initiatives` (not in the core - profile). + initiatives; a linked change's instructions open with the initiative it + serves and its upstream path). All additive. +- Skill set: one new workflow, `initiatives`, in the core profile — the + documented happy path (`openspec init` → `/opsx:initiatives`) must work + without extra configuration. - No breaking changes. Legacy `initiative:` metadata objects normalize to the new reference form when read. diff --git a/openspec/changes/add-initiatives/specs/initiatives/spec.md b/openspec/changes/add-initiatives/specs/initiatives/spec.md index 5d0a8cafe2..3346e09aaa 100644 --- a/openspec/changes/add-initiatives/specs/initiatives/spec.md +++ b/openspec/changes/add-initiatives/specs/initiatives/spec.md @@ -55,11 +55,25 @@ this machine that points at it, with live task status — via #### Scenario: Rolling up across repos toward a store initiative -- **WHEN** a store has an initiative and changes in other registered repos say - `initiative: /` +- **WHEN** a store has an initiative and changes in other checkouts on this + machine say `initiative: /` - **AND** the user runs `openspec list --initiatives --store ` - **THEN** the output includes those changes with the repo each lives in +#### Scenario: Linking is the registration + +- **WHEN** a plain code repo (not a registered store) creates a change with + `--initiative /` +- **THEN** the repo's checkout is recorded machine-locally, with nothing + written into the repo itself +- **AND** the store's rollup includes that change without any further setup + +#### Scenario: A complete initiative prompts the evergreen sync + +- **WHEN** every change linked to an initiative is complete +- **THEN** the rollup marks it and suggests syncing the evergreen artifacts + it served + #### Scenario: Running outside any root - **WHEN** the user runs `openspec list --initiatives` in a directory with no `openspec/` root @@ -82,3 +96,13 @@ fetch the full rollup. - **AND** the user runs `openspec context` - **THEN** the store's member entry lists the initiative names (and evergreen artifacts when there are no initiatives) - **AND** shows the `openspec list --initiatives --store ` fetch command + +#### Scenario: A linked change hands its agent the upstream context + +- **WHEN** a change's metadata names an initiative +- **AND** the user runs `openspec instructions --change ` or + `openspec instructions apply --change ` +- **THEN** the output opens with the initiative the change serves, the + on-disk path to its folder, and the instruction to read everything + upstream first +- **AND** a ref whose folder cannot be found says so instead of vanishing diff --git a/openspec/changes/add-initiatives/tasks.md b/openspec/changes/add-initiatives/tasks.md index ec93bf7bae..368aff1f0e 100644 --- a/openspec/changes/add-initiatives/tasks.md +++ b/openspec/changes/add-initiatives/tasks.md @@ -15,8 +15,8 @@ ## 3. The skill - [x] 3.1 `openspec-initiatives` skill + `/opsx:initiatives` command - (state-routed moves, numbered handoff menus), registered as an - optional workflow (not in the core profile) + (state-routed moves, numbered handoff menus), in the core profile so + the documented happy path (`openspec init` → `/opsx:initiatives`) works ## 4. Proof @@ -25,3 +25,15 @@ - [x] 4.2 Tests: initiatives module (solo + cross-repo + legacy + fallback), context/instructions surfacing, registries, skill parity - [x] 4.3 `openspec validate add-initiatives --strict` passes + +## 5. Dogfood round 2 (real multi-repo product, this machine) + +- [x] 5.1 Rollup discovers plain code repos: linking a change records the + checkout (machine-local, nothing written into the repo) +- [x] 5.2 The join: `instructions` / `instructions apply` open a linked + change with the initiative it serves and its upstream path on disk +- [x] 5.3 Completion nudge: a fully-complete initiative prompts an + evergreen sync in the rollup +- [x] 5.4 Friction batch: store setup seeds `initiatives/`, link hints are + store-qualified, `openspec init` leads the no-root fix, `--remote` + help names the real metadata path diff --git a/src/cli/index.ts b/src/cli/index.ts index 6ec4f10bec..f9237160b2 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -15,7 +15,7 @@ import { } from '../core/initiatives.js'; import { ArchiveCommand, type ArchiveOptions } from '../core/archive.js'; import { ViewCommand } from '../core/view.js'; -import { resolveRootForCommand, toRootOutput, type ResolvedOpenSpecRoot } from '../core/root-selection.js'; +import { resolveRootForCommand, toRootOutput, isStoreSelectedRoot, type ResolvedOpenSpecRoot } from '../core/root-selection.js'; import { registerSpecCommand } from '../commands/spec.js'; import { ChangeCommand } from '../commands/change.js'; import { ValidateCommand } from '../commands/validate.js'; @@ -58,7 +58,13 @@ function hiddenStorePathOption(): Option { ).hideHelp(); } -function printPortfolioBody(portfolio: PortfolioInfo, indent: string): void { +function printPortfolioBody( + portfolio: PortfolioInfo, + indent: string, + // Store portfolios need the store-qualified ref in the link hint — a bare + // would not resolve from a consumer repo. + refPrefix = '' +): void { if (portfolio.evergreen.length > 0) { console.log(`${indent}Evergreen: ${portfolio.evergreen.join(', ')}`); } @@ -91,7 +97,7 @@ function printPortfolioBody(portfolio: PortfolioInfo, indent: string): void { for (const change of initiative.changes) { const mark = change.state === 'complete' ? '✓' : change.state === 'no-tasks' ? '–' : '·'; - const where = change.store ?? 'here'; + const where = change.store ?? change.repo ?? 'here'; const tasks = change.totalTasks === 0 ? 'no tasks' @@ -100,11 +106,18 @@ function printPortfolioBody(portfolio: PortfolioInfo, indent: string): void { `${indent} ${mark} ${change.id.padEnd(changeWidth)} ${where.padEnd(14)} ${tasks}` ); } + // Truth flows up: an initiative whose changes are all complete is the + // trigger to update the evergreen artifacts it served. + if (initiative.changesTotal > 0 && initiative.changesComplete === initiative.changesTotal) { + console.log( + `${indent} all changes complete — sync the evergreen artifacts this initiative served` + ); + } } if (!anyChanges) { console.log(''); console.log( - `${indent}Link a change: openspec new change --initiative ` + `${indent}Link a change: openspec new change --initiative ${refPrefix}` ); } } @@ -131,7 +144,7 @@ async function renderInitiatives( } console.log(`Initiatives: ${initiatives.path}`); - printPortfolioBody(initiatives, ''); + printPortfolioBody(initiatives, '', isStoreSelectedRoot(root) ? `${root.storeId}/` : ''); } /** @@ -156,7 +169,7 @@ async function renderStorePortfolios(options: { json?: boolean }): Promise for (const { store, portfolio } of stores) { console.log(''); console.log(`${store} (${portfolio.path})`); - printPortfolioBody(portfolio, ' '); + printPortfolioBody(portfolio, ' ', `${store}/`); } } diff --git a/src/commands/store.ts b/src/commands/store.ts index 1a91d89984..3fcc0a34af 100644 --- a/src/commands/store.ts +++ b/src/commands/store.ts @@ -673,7 +673,7 @@ export function registerStoreCommand(program: Command): void { .option('--path ', 'Folder where the store should live (for example ~/openspec/)') .option('--init-git', 'Initialize a Git repository with an initial commit (default)') .option('--no-init-git', 'Skip every Git action: no init, no initial commit') - .option('--remote ', 'Canonical clone source recorded in store.yaml') + .option('--remote ', 'Canonical clone source recorded in .openspec-store/store.yaml') .option('--json', 'Output as JSON') .action(async (id: string | undefined, options: StoreSetupOptions) => { await storeCommand.setup(id, options); diff --git a/src/commands/workflow/instructions.ts b/src/commands/workflow/instructions.ts index 10a5fac166..37fc75e81e 100644 --- a/src/commands/workflow/instructions.ts +++ b/src/commands/workflow/instructions.ts @@ -35,6 +35,10 @@ import { } from '../../core/references.js'; import { readRegistrySnapshot } from '../../core/store/registry.js'; import { readProjectConfig, type ProjectConfig } from '../../core/project-config.js'; +import { + resolveInitiativeLink, + type ResolvedInitiativeLink, +} from '../../core/initiatives.js'; import { validateChangeExists, validateSchemaExists, @@ -97,6 +101,33 @@ async function loadRootConfigContext(root: ResolvedOpenSpecRoot): Promise<{ }; } +/** + * The upward join: a linked change's instructions hand the agent the + * initiative it serves and where its upstream context lives on disk, so + * intent reaches the working agent without anyone pasting it. + */ +function renderInitiativeBlock(link: ResolvedInitiativeLink): string { + const lines = [ + ``, + `This change serves initiative '${link.name}'${link.store ? ` in store '${link.store}'` : ''}.`, + ]; + if (link.path) { + lines.push(`Upstream context: ${link.path}`); + lines.push( + 'Before working, read the evergreen files beside that initiative and every lower-numbered stage inside it; this change should trace to something upstream.' + ); + } else { + lines.push( + 'The linked initiative folder was not found on disk — the link may be stale, or the store may not be registered on this machine.' + ); + } + lines.push( + `Status rollup: openspec list --initiatives${link.store ? ` --store ${link.store}` : ''}` + ); + lines.push(''); + return lines.join('\n'); +} + export async function instructionsCommand( artifactId: string | undefined, options: InstructionsOptions @@ -154,22 +185,37 @@ export async function instructionsCommand( references, }); const isBlocked = instructions.dependencies.some((d) => !d.done); + const initiative = await resolveInitiativeLink(context.changeDir, projectRoot); spinner?.stop(); if (options.json) { - console.log(JSON.stringify({ ...instructions, root: toRootOutput(root) }, null, 2)); + console.log( + JSON.stringify( + { + ...instructions, + ...(initiative ? { initiative } : {}), + root: toRootOutput(root), + }, + null, + 2 + ) + ); return; } - printInstructionsText(instructions, isBlocked); + printInstructionsText(instructions, isBlocked, initiative); } catch (error) { spinner?.stop(); throw error; } } -export function printInstructionsText(instructions: ArtifactInstructions, isBlocked: boolean): void { +export function printInstructionsText( + instructions: ArtifactInstructions, + isBlocked: boolean, + initiative?: ResolvedInitiativeLink | null +): void { const { artifactId, changeName, @@ -215,6 +261,12 @@ export function printInstructionsText(instructions: ArtifactInstructions, isBloc console.log(); } + // The initiative this change serves (read-only upstream context) + if (initiative) { + console.log(renderInitiativeBlock(initiative)); + console.log(); + } + // Referenced-store index (read-only upstream context) if (instructions.references && instructions.references.length > 0) { console.log(renderReferencedStoresBlock(instructions.references)); @@ -462,28 +514,48 @@ export async function applyInstructionsCommand(options: ApplyInstructionsOptions planningHome, references, }); + const initiative = await resolveInitiativeLink(instructions.changeDir, projectRoot); spinner?.stop(); if (options.json) { - console.log(JSON.stringify({ ...instructions, root: toRootOutput(root) }, null, 2)); + console.log( + JSON.stringify( + { + ...instructions, + ...(initiative ? { initiative } : {}), + root: toRootOutput(root), + }, + null, + 2 + ) + ); return; } - printApplyInstructionsText(instructions); + printApplyInstructionsText(instructions, initiative); } catch (error) { spinner?.stop(); throw error; } } -export function printApplyInstructionsText(instructions: ApplyInstructions): void { +export function printApplyInstructionsText( + instructions: ApplyInstructions, + initiative?: ResolvedInitiativeLink | null +): void { const { changeName, schemaName, contextFiles, progress, tasks, state, missingArtifacts, instruction } = instructions; console.log(`## Apply: ${changeName}`); console.log(`Schema: ${schemaName}`); console.log(); + // The initiative this change serves (read-only upstream context) + if (initiative) { + console.log(renderInitiativeBlock(initiative)); + console.log(); + } + if (instructions.references && instructions.references.length > 0) { console.log(renderReferencedStoresSection(instructions.references)); console.log(); diff --git a/src/commands/workflow/new-change.ts b/src/commands/workflow/new-change.ts index 73e759d085..26d8c3c013 100644 --- a/src/commands/workflow/new-change.ts +++ b/src/commands/workflow/new-change.ts @@ -10,6 +10,8 @@ import ora from 'ora'; import path from 'path'; import { InitiativeRefSchema } from '../../core/change-metadata/schema.js'; +import { recordLinkedRoot } from '../../core/initiatives.js'; +import { readProjectConfig } from '../../core/project-config.js'; import { createChange, validateChangeName } from '../../utils/change-utils.js'; import { formatChangeLocation } from '../../core/planning-home.js'; import { @@ -66,7 +68,8 @@ function assertRemovedOptionsAbsent(options: NewChangeOptions): void { function printCreatedChangeHuman( payload: NewChangeOutput, - root: ResolvedOpenSpecRoot + root: ResolvedOpenSpecRoot, + initiative?: string ): void { // A relative path is only honest when the root is where the user // stands; a distant ancestor root gets the absolute path. @@ -76,6 +79,22 @@ function printCreatedChangeHuman( : payload.change.path; console.log(`Created change '${payload.change.id}' at ${location}/`); console.log(`Schema: ${payload.change.schema}`); + if (initiative) { + const storeId = initiative.includes('/') ? initiative.split('/')[0] : null; + const rollup = storeId + ? `openspec list --initiatives --store ${storeId}` + : 'openspec list --initiatives'; + console.log(`Initiative: ${initiative} (rollup: ${rollup})`); + if (storeId !== null) { + // readProjectConfig never throws: missing/unparseable configs are null. + const references = readProjectConfig(root.path)?.references ?? []; + if (!references.some((entry) => entry.id === storeId)) { + console.log( + `Tip: add 'references: [${storeId}]' to openspec/config.yaml so agents here see that store's context.` + ); + } + } + } console.log(`Next: ${withStoreFlag(root, `openspec status --change ${payload.change.id}`)}`); } @@ -140,6 +159,13 @@ export async function newChangeCommand(name: string | undefined, options: NewCha await fs.writeFile(readmePath, `# ${name}\n\n${options.description}\n`, 'utf-8'); } + // A store-qualified link makes this repo part of that store's portfolio; + // record the checkout so rollups scan it. Linking IS the registration — + // and a recording failure must never fail the created change. + if (options.initiative?.includes('/')) { + await recordLinkedRoot(projectRoot).catch(() => undefined); + } + const payload: NewChangeOutput = { change: { id: name, @@ -156,7 +182,7 @@ export async function newChangeCommand(name: string | undefined, options: NewCha } spinner?.stop(); - printCreatedChangeHuman(payload, root); + printCreatedChangeHuman(payload, root, options.initiative); } catch (error) { spinner?.stop(); if (options.json) { diff --git a/src/core/initiatives.ts b/src/core/initiatives.ts index b7f34d556b..873fbef8d8 100644 --- a/src/core/initiatives.ts +++ b/src/core/initiatives.ts @@ -22,9 +22,12 @@ import path from 'path'; import { parse as parseYaml } from 'yaml'; import { getTaskProgressForChange } from '../utils/task-progress.js'; +import { writeFileAtomically } from './file-state.js'; import { + getStoresDir, listStoreRegistryEntries, readStoreRegistryState, + type StorePathOptions, } from './store/foundation.js'; import { getStoreRootForBackend } from './store/registry.js'; @@ -42,6 +45,8 @@ export interface InitiativeChangeStatus { id: string; /** Registered store the change lives in; absent = the portfolio's own root. */ store?: string; + /** Linked (non-store) repo the change lives in, by directory name. */ + repo?: string; completedTasks: number; totalTasks: number; state: 'complete' | 'in-progress' | 'no-tasks'; @@ -108,6 +113,57 @@ export function normalizeInitiativeRef(value: unknown): string | null { return null; } +// --------------------------------------------------------------------------- +// Linked roots +// +// Rollup can only scan checkouts it knows about. Store roots come from the +// store registry; plain code repos become known the moment they link a change +// to a store's initiative (`new change --initiative /` records +// the repo's path here). Linking IS the registration — no extra command, and +// nothing is written into the repo itself. +// --------------------------------------------------------------------------- + +const LINKED_ROOTS_FILE_NAME = 'linked-roots.yaml'; + +function getLinkedRootsPath(options: StorePathOptions = {}): string { + return path.join(getStoresDir(options), LINKED_ROOTS_FILE_NAME); +} + +/** Known linked roots. Tolerant: unreadable state means "none". */ +export async function readLinkedRoots( + options: StorePathOptions = {} +): Promise { + try { + const raw = await fs.readFile(getLinkedRootsPath(options), 'utf-8'); + const parsed = parseYaml(raw) as { roots?: unknown } | null; + if (!Array.isArray(parsed?.roots)) return []; + return parsed.roots.filter( + (entry): entry is string => typeof entry === 'string' && entry.length > 0 + ); + } catch { + return []; + } +} + +/** + * Records a repo root so rollups scan it. Idempotent; stale entries are + * harmless (a missing directory scans as empty). + */ +export async function recordLinkedRoot( + root: string, + options: StorePathOptions = {} +): Promise { + const resolved = path.resolve(root); + const existing = await readLinkedRoots(options); + if (existing.includes(resolved)) return; + const roots = [...existing, resolved].sort(); + await fs.mkdir(getStoresDir(options), { recursive: true }); + await writeFileAtomically( + getLinkedRootsPath(options), + `version: 1\nroots:\n${roots.map((entry) => ` - ${JSON.stringify(entry)}`).join('\n')}\n` + ); +} + interface InitiativeShape { name: string; stages: InitiativeStage[]; @@ -168,7 +224,7 @@ export async function readInitiativesShape( * to reference form. Tolerant: any unreadable or unlinked metadata simply * means "not part of an initiative". */ -async function readInitiativeRef(changeDir: string): Promise { +export async function readInitiativeRef(changeDir: string): Promise { try { const raw = await fs.readFile(path.join(changeDir, '.openspec.yaml'), 'utf-8'); const parsed = parseYaml(raw) as { initiative?: unknown } | null; @@ -187,7 +243,7 @@ async function readInitiativeRef(changeDir: string): Promise { async function collectMatchingChanges( root: string, toName: (ref: string) => string | null, - store: string | undefined + label: { store?: string; repo?: string } | undefined ): Promise> { const changesDir = path.join(root, 'openspec', 'changes'); const found = new Map(); @@ -208,7 +264,8 @@ async function collectMatchingChanges( const progress = await getTaskProgressForChange(changesDir, entry.name, root); const status: InitiativeChangeStatus = { id: entry.name, - ...(store ? { store } : {}), + ...(label?.store ? { store: label.store } : {}), + ...(label?.repo ? { repo: label.repo } : {}), completedTasks: progress.completed, totalTasks: progress.total, state: @@ -297,15 +354,28 @@ export async function rollupInitiatives( ); if (ownStoreId !== undefined && ownPrefix !== null) { + const toOwnName = (ref: string) => + ref.startsWith(ownPrefix) ? ref.slice(ownPrefix.length) : null; + const scanned = new Set([resolvedRoot]); for (const store of storeRoots) { - if (store.root === resolvedRoot) continue; + if (scanned.has(store.root)) continue; + scanned.add(store.root); + mergeChanges( + byName, + await collectMatchingChanges(store.root, toOwnName, { store: store.id }) + ); + } + // Plain code repos that linked a change here (recorded at link time). + for (const linkedRoot of await readLinkedRoots( + options.globalDataDir ? { globalDataDir: options.globalDataDir } : {} + )) { + if (scanned.has(linkedRoot)) continue; + scanned.add(linkedRoot); mergeChanges( byName, - await collectMatchingChanges( - store.root, - (ref) => (ref.startsWith(ownPrefix) ? ref.slice(ownPrefix.length) : null), - store.id - ) + await collectMatchingChanges(linkedRoot, toOwnName, { + repo: path.basename(linkedRoot), + }) ); } } @@ -402,3 +472,58 @@ export async function listInitiativeNames(root: string): Promise { ? shape.initiatives.map((initiative) => initiative.name) : shape.evergreen; } + +export interface ResolvedInitiativeLink { + /** The reference as written: `` or `/`. */ + ref: string; + name: string; + /** Present when the ref is store-qualified. */ + store?: string; + /** Absolute path to the initiative folder; null when not found on disk. */ + path: string | null; +} + +/** + * Resolves a change's upward link to the initiative folder it points at, so + * instruction surfaces can hand the agent the actual upstream context. A ref + * that resolves to no folder still returns (with `path: null`) — a stale + * link should be visible, not silently dropped. + */ +export async function resolveInitiativeLink( + changeDir: string, + root: string, + options: StorePathOptions = {} +): Promise { + const ref = await readInitiativeRef(changeDir); + if (ref === null) return null; + + const slash = ref.indexOf('/'); + const store = slash === -1 ? undefined : ref.slice(0, slash); + const name = slash === -1 ? ref : ref.slice(slash + 1); + + let initiativeRoot: string | null = slash === -1 ? root : null; + if (store !== undefined) { + const registry = await readStoreRegistryState(options).catch(() => null); + const entry = registry?.stores[store]; + if (entry) { + try { + initiativeRoot = getStoreRootForBackend(entry.backend); + } catch { + // Unusable backend — the link renders with path: null. + } + } + } + + let resolvedPath: string | null = null; + if (initiativeRoot !== null) { + const candidate = path.join(initiativesDir(initiativeRoot), name); + try { + const stat = await fs.stat(candidate); + if (stat.isDirectory()) resolvedPath = candidate; + } catch { + // Missing on disk — keep null. + } + } + + return { ref, name, ...(store !== undefined ? { store } : {}), path: resolvedPath }; +} diff --git a/src/core/openspec-root.ts b/src/core/openspec-root.ts index d65882ee21..ff766beb47 100644 --- a/src/core/openspec-root.ts +++ b/src/core/openspec-root.ts @@ -14,12 +14,17 @@ export const OPENSPEC_CONFIG_YML = 'openspec/config.yml'; export const OPENSPEC_SPECS_DIR = 'openspec/specs'; export const OPENSPEC_CHANGES_DIR = 'openspec/changes'; export const OPENSPEC_ARCHIVE_DIR = 'openspec/changes/archive'; +export const OPENSPEC_INITIATIVES_DIR = 'openspec/initiatives'; export const DEFAULT_OPENSPEC_SCHEMA = 'spec-driven'; export const DIRECTORY_ANCHOR_FILE_NAME = '.gitkeep'; // Git cannot track empty directories, so setup anchors otherwise-empty // conventional store directories for teammates who clone the repo later. -export const ANCHORED_OPENSPEC_DIRS = [OPENSPEC_SPECS_DIR, OPENSPEC_ARCHIVE_DIR] as const; +export const ANCHORED_OPENSPEC_DIRS = [ + OPENSPEC_SPECS_DIR, + OPENSPEC_ARCHIVE_DIR, + OPENSPEC_INITIATIVES_DIR, +] as const; type PathKind = 'missing' | 'directory' | 'file' | 'other'; @@ -310,6 +315,7 @@ export async function ensureOpenSpecRoot( await ensureDirectory(storeRoot, OPENSPEC_SPECS_DIR, ledger); await ensureDirectory(storeRoot, OPENSPEC_CHANGES_DIR, ledger); await ensureDirectory(storeRoot, OPENSPEC_ARCHIVE_DIR, ledger); + await ensureDirectory(storeRoot, OPENSPEC_INITIATIVES_DIR, ledger); await ensureDefaultConfig(storeRoot, ledger); if (options.anchorEmptyDirectories) { diff --git a/src/core/profiles.ts b/src/core/profiles.ts index 6e576abac3..cfb4718ead 100644 --- a/src/core/profiles.ts +++ b/src/core/profiles.ts @@ -11,7 +11,15 @@ import type { Profile } from './global-config.js'; * Core workflows included in the 'core' profile. * These provide the streamlined experience for new users. */ -export const CORE_WORKFLOWS = ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] as const; +export const CORE_WORKFLOWS = [ + 'propose', + 'explore', + 'apply', + 'update', + 'sync', + 'archive', + 'initiatives', +] as const; /** * All available workflows in the system. diff --git a/src/core/root-selection.ts b/src/core/root-selection.ts index aeb4e0a350..6ac64e9ec4 100644 --- a/src/core/root-selection.ts +++ b/src/core/root-selection.ts @@ -383,12 +383,15 @@ export async function resolveOpenSpecRoot( : []; if (registeredIds.length > 0) { + // Lead with init: for work that belongs to THIS repo (like a change + // linking to a store's initiative), --store would land it in the wrong + // place — the store, not the repo. throw new RootSelectionError( - `No OpenSpec root found in the current directory or its ancestors. Registered stores: ${registeredIds.join(', ')}. Pass --store to use one, or run openspec init to create a local root.`, + `No OpenSpec root found in the current directory or its ancestors. Run openspec init to create a local root here, or pass --store to work in a registered store (registered: ${registeredIds.join(', ')}).`, 'no_root_with_registered_stores', { target: 'openspec.root', - fix: `Rerun with --store (registered: ${registeredIds.join(', ')}) or run openspec init.`, + fix: `Run openspec init here, or rerun with --store (registered: ${registeredIds.join(', ')}).`, } ); } diff --git a/src/core/templates/workflows/initiatives.ts b/src/core/templates/workflows/initiatives.ts index 8b84193153..8a94deaefd 100644 --- a/src/core/templates/workflows/initiatives.ts +++ b/src/core/templates/workflows/initiatives.ts @@ -18,7 +18,7 @@ const INITIATIVES_BODY = `Work above a single change: keep the evergreen truths The planning layer lives in one folder: \`openspec/initiatives/\` — in this repo, or in a store the team shares. - **Unnumbered top-level files are evergreen artifacts** — the standing truths every initiative serves: \`product.md\`, \`roadmap.md\`, \`architecture.md\`, whatever names the user already uses. They are maintained forever, the way specs are. -- **Each subfolder is one initiative** — a finite piece of work above a single change. Contents are freeform. Numbered folders inside an initiative are ordered stages, and their names are the team's own workflow: \`00_product/ 01_engineering/\` for one team, \`00_analysis/ 01_product/ 02_design/ 03_engineering/\` for another. Everything lower-numbered is upstream. Any document works at any stage — a ProductSpec file, a PRD, an RFC, design notes; position carries the meaning, not the format. +- **Each subfolder is one initiative** — a finite piece of work above a single change. Contents are freeform. Numbered folders inside an initiative are ordered stages, and their names are the team's own workflow: \`00_product/ 01_engineering/\` for one team, \`00_analysis/ 01_product/ 02_design/ 03_engineering/\` for another. Everything lower-numbered is upstream. Any document works at any stage — a PRD, an RFC, a one-pager, design notes; position carries the meaning, not the format. - **Changes point up** with one line in their \`.openspec.yaml\`: \`initiative: \` (this root) or \`initiative: /\` (a store's initiative). There is no list to maintain anywhere. The same loop runs at both altitudes: work flows down (an initiative decomposes into changes), truth flows up (finishing work updates the evergreen artifacts, the way archiving a change updates specs), and status flows back live through \`openspec list --initiatives\`. diff --git a/test/cli-e2e/store-lifecycle.test.ts b/test/cli-e2e/store-lifecycle.test.ts index 4f0acd99c4..345e0a2f0e 100644 --- a/test/cli-e2e/store-lifecycle.test.ts +++ b/test/cli-e2e/store-lifecycle.test.ts @@ -460,7 +460,12 @@ describe('standalone store lifecycle journey', () => { for (const entry of entries) { expect(entry).toMatch(/^(\.openspec-store(\/|\/store\.yaml)?|openspec(\/.*)?)$/); - expect(entry).not.toMatch(/initiative|workspace/i); + // The old initiatives machinery (manifests, registration files) stays + // gone; the plain openspec/initiatives/ folder convention is expected. + expect(entry).not.toMatch(/workspace/i); + if (/initiative/i.test(entry)) { + expect(entry).toMatch(/^openspec\/initiatives\//); + } } expect(entries).toContain('.openspec-store/store.yaml'); diff --git a/test/commands/config-profile.test.ts b/test/commands/config-profile.test.ts index 679e89a547..5926016601 100644 --- a/test/commands/config-profile.test.ts +++ b/test/commands/config-profile.test.ts @@ -78,7 +78,7 @@ describe('deriveProfileFromWorkflowSelection', () => { it('returns core when selection has exactly core workflows in different order', async () => { const { deriveProfileFromWorkflowSelection } = await import('../../src/commands/config.js'); - expect(deriveProfileFromWorkflowSelection(['archive', 'sync', 'update', 'apply', 'explore', 'propose'])).toBe('core'); + expect(deriveProfileFromWorkflowSelection(['initiatives', 'archive', 'sync', 'update', 'apply', 'explore', 'propose'])).toBe('core'); }); }); @@ -107,6 +107,7 @@ describe('config profile interactive flow', () => { 'openspec-update-change', 'openspec-sync-specs', 'openspec-archive-change', + 'openspec-initiatives', ]; for (const dirName of coreSkillDirs) { const skillPath = path.join(projectDir, '.claude', 'skills', dirName, 'SKILL.md'); @@ -114,7 +115,7 @@ describe('config profile interactive flow', () => { fs.writeFileSync(skillPath, `name: ${dirName}\n`, 'utf-8'); } - const coreCommands = ['propose', 'explore', 'apply', 'update', 'sync', 'archive']; + const coreCommands = ['propose', 'explore', 'apply', 'update', 'sync', 'archive', 'initiatives']; for (const commandId of coreCommands) { const commandPath = path.join(projectDir, '.claude', 'commands', 'opsx', `${commandId}.md`); fs.mkdirSync(path.dirname(commandPath), { recursive: true }); @@ -408,7 +409,7 @@ describe('config profile interactive flow', () => { const config = getGlobalConfig(); expect(config.profile).toBe('core'); expect(config.delivery).toBe('skills'); - expect(config.workflows).toEqual(['propose', 'explore', 'apply', 'update', 'sync', 'archive']); + expect(config.workflows).toEqual(['propose', 'explore', 'apply', 'update', 'sync', 'archive', 'initiatives']); expect(select).not.toHaveBeenCalled(); expect(checkbox).not.toHaveBeenCalled(); expect(confirm).not.toHaveBeenCalled(); diff --git a/test/commands/config.test.ts b/test/commands/config.test.ts index 9d3541b686..89854041ae 100644 --- a/test/commands/config.test.ts +++ b/test/commands/config.test.ts @@ -250,7 +250,7 @@ describe('config profile command', () => { const result = getGlobalConfig(); expect(result.profile).toBe('core'); expect(result.delivery).toBe('skills'); // preserved - expect(result.workflows).toEqual(['propose', 'explore', 'apply', 'update', 'sync', 'archive']); + expect(result.workflows).toEqual(['propose', 'explore', 'apply', 'update', 'sync', 'archive', 'initiatives']); }); it('custom workflow selection should set profile to custom', async () => { diff --git a/test/commands/store-git.test.ts b/test/commands/store-git.test.ts index 2a9ea8a03c..623ae6b35f 100644 --- a/test/commands/store-git.test.ts +++ b/test/commands/store-git.test.ts @@ -176,6 +176,7 @@ describe('store git lifecycle', () => { '.openspec-store/store.yaml', 'openspec/changes/archive/.gitkeep', 'openspec/config.yaml', + 'openspec/initiatives/.gitkeep', 'openspec/specs/keep-me.md', ]); @@ -321,6 +322,7 @@ describe('store git lifecycle', () => { expect(committedFiles).toEqual([ '.openspec-store/store.yaml', 'openspec/changes/archive/.gitkeep', + 'openspec/initiatives/.gitkeep', 'openspec/specs/.gitkeep', ]); diff --git a/test/commands/store.test.ts b/test/commands/store.test.ts index 41a17a6377..237c82b684 100644 --- a/test/commands/store.test.ts +++ b/test/commands/store.test.ts @@ -157,9 +157,11 @@ describe('store command', () => { 'openspec/specs/', 'openspec/changes/', 'openspec/changes/archive/', + 'openspec/initiatives/', 'openspec/config.yaml', 'openspec/specs/.gitkeep', 'openspec/changes/archive/.gitkeep', + 'openspec/initiatives/.gitkeep', '.openspec-store/store.yaml', ]); expect(payload.status).toEqual([]); @@ -291,9 +293,11 @@ describe('store command', () => { 'openspec/specs/', 'openspec/changes/', 'openspec/changes/archive/', + 'openspec/initiatives/', 'openspec/config.yaml', 'openspec/specs/.gitkeep', 'openspec/changes/archive/.gitkeep', + 'openspec/initiatives/.gitkeep', '.openspec-store/store.yaml', ]); expect(fs.existsSync(path.join(storeRoot, '.git'))).toBe(true); @@ -313,9 +317,12 @@ describe('store command', () => { expect(result.exitCode).toBe(0); const payload = parseJson(result); // First-time accept of an existing root anchors its empty directories - // (specs/ has user content here, so only archive/ gets an anchor). + // (specs/ has user content here, so only archive/ gets an anchor) and + // adds the initiatives/ folder a pre-initiatives root lacks. expect(payload.created_files).toEqual([ + 'openspec/initiatives/', 'openspec/changes/archive/.gitkeep', + 'openspec/initiatives/.gitkeep', '.openspec-store/store.yaml', ]); expect(fs.existsSync(path.join(storeRoot, 'openspec', 'config.yaml'))).toBe(false); diff --git a/test/core/initiatives.test.ts b/test/core/initiatives.test.ts index cd6eae6186..478b2a3358 100644 --- a/test/core/initiatives.test.ts +++ b/test/core/initiatives.test.ts @@ -7,6 +7,9 @@ import { listInitiativeNames, normalizeInitiativeRef, readInitiativesShape, + readLinkedRoots, + recordLinkedRoot, + resolveInitiativeLink, rollupInitiatives, rollupRegisteredStorePortfolios, } from '../../src/core/initiatives.js'; @@ -198,6 +201,81 @@ describe('initiatives', () => { expect(initiative?.tasksTotal).toBe(8); }); + it('finds changes in plain linked repos — linking is the registration', async () => { + write('team-plans/openspec/initiatives/smoother-setup/goal.md', '# goal\n'); + await registerStore('team-plans', 'team-plans'); + // A code repo that is NOT registered as a store; it linked a change, + // which recorded its checkout. + change('my-app', 'add-search', 'team-plans/smoother-setup', '- [x] a\n- [ ] b\n'); + await recordLinkedRoot(path.join(tempDir, 'my-app'), { globalDataDir }); + // Recording is idempotent. + await recordLinkedRoot(path.join(tempDir, 'my-app'), { globalDataDir }); + expect(await readLinkedRoots({ globalDataDir })).toEqual([ + path.join(tempDir, 'my-app'), + ]); + // A stale entry (deleted checkout) must not break the rollup. + await recordLinkedRoot(path.join(tempDir, 'gone-repo'), { globalDataDir }); + + const portfolio = await rollupInitiatives(path.join(tempDir, 'team-plans'), { + globalDataDir, + }); + + const initiative = portfolio?.initiatives.find((i) => i.name === 'smoother-setup'); + expect(initiative?.changes.map((c) => c.id)).toEqual(['add-search']); + expect(initiative?.changes[0].repo).toBe('my-app'); + expect(initiative?.changes[0].store).toBeUndefined(); + expect(initiative?.changes[0].state).toBe('in-progress'); + }); + + it('resolves a change link to the initiative folder it points at', async () => { + // Local ref: the initiative lives in the change's own root. + write('app/openspec/initiatives/smoother-setup/goal.md', '# goal\n'); + change('app', 'add-search', 'smoother-setup', '- [ ] a\n'); + const local = await resolveInitiativeLink( + path.join(tempDir, 'app/openspec/changes/add-search'), + path.join(tempDir, 'app'), + { globalDataDir } + ); + expect(local?.ref).toBe('smoother-setup'); + expect(local?.store).toBeUndefined(); + expect(local?.path).toBe(path.join(tempDir, 'app/openspec/initiatives/smoother-setup')); + + // Store-qualified ref: resolves through the registry. + write('team-plans/openspec/initiatives/q3-payments/brief.md', '# brief\n'); + await registerStore('team-plans', 'team-plans'); + change('app', 'add-payments', 'team-plans/q3-payments', '- [ ] a\n'); + const qualified = await resolveInitiativeLink( + path.join(tempDir, 'app/openspec/changes/add-payments'), + path.join(tempDir, 'app'), + { globalDataDir } + ); + expect(qualified?.store).toBe('team-plans'); + expect(qualified?.name).toBe('q3-payments'); + expect(qualified?.path).toBe( + path.join(tempDir, 'team-plans/openspec/initiatives/q3-payments') + ); + + // A stale ref stays visible with path: null instead of vanishing. + change('app', 'add-ghost', 'no-such-initiative', '- [ ] a\n'); + const stale = await resolveInitiativeLink( + path.join(tempDir, 'app/openspec/changes/add-ghost'), + path.join(tempDir, 'app'), + { globalDataDir } + ); + expect(stale?.ref).toBe('no-such-initiative'); + expect(stale?.path).toBeNull(); + + // An unlinked change resolves to nothing. + change('app', 'plain', null, '- [ ] a\n'); + expect( + await resolveInitiativeLink( + path.join(tempDir, 'app/openspec/changes/plain'), + path.join(tempDir, 'app'), + { globalDataDir } + ) + ).toBeNull(); + }); + it('rolls up every registered store portfolio for the outside-a-root answer', async () => { write('team-plans/openspec/initiatives/smoother-setup/goal.md', '# goal\n'); write('other-store/openspec/specs/.gitkeep', ''); // registered, no initiatives diff --git a/test/core/openspec-root.test.ts b/test/core/openspec-root.test.ts index d2059f31e0..5f19fb77f0 100644 --- a/test/core/openspec-root.test.ts +++ b/test/core/openspec-root.test.ts @@ -110,6 +110,7 @@ describe('OpenSpec root helper', () => { 'openspec/specs/', 'openspec/changes/', 'openspec/changes/archive/', + 'openspec/initiatives/', 'openspec/config.yaml', ]); expect(result.inspection.healthy).toBe(true); @@ -125,7 +126,9 @@ describe('OpenSpec root helper', () => { const result = await ensureOpenSpecRoot(root); - expect(result.createdArtifacts).toEqual([]); + // A pre-initiatives root gains only the (empty) initiatives directory; + // everything the user already had is untouched. + expect(result.createdArtifacts).toEqual(['openspec/initiatives/']); expect(fs.existsSync(path.join(root, 'openspec', 'config.yaml'))).toBe(false); expect(fs.readFileSync(path.join(root, 'openspec', 'config.yml'), 'utf-8')).toBe( `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n` diff --git a/test/core/profiles.test.ts b/test/core/profiles.test.ts index 52fd2f72f0..254ddbbf10 100644 --- a/test/core/profiles.test.ts +++ b/test/core/profiles.test.ts @@ -9,7 +9,15 @@ import { describe('profiles', () => { describe('CORE_WORKFLOWS', () => { it('should contain the default core workflows', () => { - expect(CORE_WORKFLOWS).toEqual(['propose', 'explore', 'apply', 'update', 'sync', 'archive']); + expect(CORE_WORKFLOWS).toEqual([ + 'propose', + 'explore', + 'apply', + 'update', + 'sync', + 'archive', + 'initiatives', + ]); }); it('should include update in the core profile (default install, not expanded-only)', () => { diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index f805d639e4..12170007d4 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -61,8 +61,8 @@ const EXPECTED_FUNCTION_HASHES: Record = { getOpsxVerifyCommandTemplate: 'b4a5717c25883ca74dc8da091aef2432492e2a377c8cd9c1a0369b6b854dc32c', getOpsxProposeSkillTemplate: 'ad11a374aa8f3978a93af3a2d5b4cea736d6a5f7b3f913b38a2b34aeeb2bec21', getOpsxProposeCommandTemplate: '8aa4d2e0ca201ad8e913e8d490223063cef9c2a7e2b7a464d5aa0452540ccc09', - getInitiativesSkillTemplate: '6e1c37daefb93825b4806ea059047c930eb55b7d06658826b89d0573acd691bb', - getOpsxInitiativesCommandTemplate: '3b888b37f53f33719eedd38c109ff1759c47c852e365739bd5b87ab0ae9518f9', + getInitiativesSkillTemplate: 'fe25973b8b36e0d72d0250bcbe2c8871f56f1ef7646a48eadd04de32fa2af985', + getOpsxInitiativesCommandTemplate: '903fc4d5dd39a71505098169faea483d594a8df4b86d89292b2fe2338e77fe9c', getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d', getUpdateChangeSkillTemplate: 'cb95d9c5c450087b5adf862986a6a81a65d57cce535162e693fbf9aea127c1c3', getOpsxUpdateCommandTemplate: '1657481ccf1f60c44cba192d51b20f5d7035b3c3a84e237b7323b703496fc149', @@ -80,7 +80,7 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-verify-change': 'ba6d6bc6e78d2ab9cf3c41d75961cdd08277ac591ff2454c98b4190a4e43fba6', 'openspec-onboard': 'd8baa141849b099671376d6928d29e4ed7dfb4be9675dbe3e1ff282548d883a9', 'openspec-propose': '273e432fd268546de6316520b2d116c1e6d01d5a8d57bd03ad71a47fb5cec112', - 'openspec-initiatives': 'cb156f032b133c23e0f8dccf7a1a7db20af0a7a9f564a9321a8133b08a2701d1', + 'openspec-initiatives': 'f45387076aaaa61e91f5c76fef2cf897ffd38c09876ef162d892ec814fbfe2d2', 'openspec-update-change': '29c95eefe0df8efb4681dcaa6a90ae965535edda1832501f637a368608e33684', }; From 5dfdd7840e654413e1e5fa88c4b3500f177d602d Mon Sep 17 00:00:00 2001 From: Clay Good Date: Thu, 9 Jul 2026 17:16:17 -0500 Subject: [PATCH 26/45] feat(stores): teach the initiatives layout in the empty states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dogfood round 2 (fresh repos, CLI-output-only bar) confirmed the round-1 fixes but caught the cold start: a first-time user with a fresh store has no way to learn the folder/stage layout from the CLI. Both empty states now teach it — the seeded-but-empty portfolio and the no-folder hint name evergreen files, one-folder-per-initiative, and numbered stage subfolders. Deliberately not added: a `new initiative` command (creating the folder stays the whole ceremony, and /opsx:initiatives is the guided path). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cli/index.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index f9237160b2..4375180243 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -70,8 +70,13 @@ function printPortfolioBody( } if (portfolio.initiatives.length === 0) { if (portfolio.evergreen.length === 0) { + // The empty state must teach the whole layout: a first-time user with + // a fresh store has no other way to learn it from the CLI. console.log( - `${indent}(empty — add an evergreen artifact like product.md, or a folder per initiative)` + `${indent}(empty — add evergreen files like product.md or roadmap.md, and one folder per initiative;` + ); + console.log( + `${indent} numbered subfolders like 00_intent/ 01_engineering/ become its ordered stages)` ); } return; @@ -138,7 +143,7 @@ async function renderInitiatives( if (initiatives === null) { console.log('No initiatives folder found at openspec/initiatives/.'); console.log( - 'Start one: mkdir -p openspec/initiatives/, then add any artifact (notes.md, roadmap.md — any name).' + 'Start one: mkdir -p openspec/initiatives/, then add any doc (numbered subfolders inside become ordered stages).' ); return; } From f58830983f495418328a6df4435c435efc3e1dc7 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Thu, 9 Jul 2026 17:50:27 -0500 Subject: [PATCH 27/45] =?UTF-8?q?feat(stores):=20dead-simple=20team=20path?= =?UTF-8?q?=20=E2=80=94=20three=20commands,=20all=20wiring=20automatic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dogfood round 3 drove the whole loop through the skill (all five moves passed) and left two instruction fixes; this lands them plus the two automations that collapse the multi-repo flow: - `store setup ` no longer needs --path: non-interactive runs default to ~/openspec/, the same visible location the interactive prompt already prefilled. One command, zero decisions. - Linking wires the agent context too: `new change --initiative /` adds the store to `references:` in openspec/config.yaml (format-preserving edit, announced in output, conservative fallback to the printed one-liner when the config can't be edited with certainty). JSON output carries `initiative.reference_wiring`. - Blocked-state hints stop naming skills that may not be installed: CLI text and the apply skill template now point at the always-available `openspec instructions --change ` path. - Initiatives skill: naming a module, crate, or tool upstream is fine; paths and line-anchored code stay banned. The team path is now: store setup → new change --initiative → list --initiatives. Three commands; discovery, references, and hints all automatic and announced. Tests: addReferenceToProjectConfig unit coverage (add/append/already/skip, comment preservation); store-setup default-path test; parity hashes regenerated from dist. Full suite green except the 17 known env-only zsh-installer failures. Change record validates --strict. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/stores-beta/initiatives.md | 27 +++++----- docs/stores-beta/user-guide.md | 5 +- .../add-initiatives/specs/initiatives/spec.md | 11 +++- src/commands/store.ts | 23 +++----- src/commands/workflow/instructions.ts | 16 ++++-- src/commands/workflow/new-change.ts | 51 +++++++++++++----- src/core/project-config.ts | 54 ++++++++++++++++++- src/core/templates/workflows/apply-change.ts | 4 +- src/core/templates/workflows/initiatives.ts | 2 +- test/commands/store.test.ts | 24 +++++---- test/core/project-config.test.ts | 49 +++++++++++++++++ .../templates/skill-templates-parity.test.ts | 12 ++--- 12 files changed, 205 insertions(+), 73 deletions(-) diff --git a/docs/stores-beta/initiatives.md b/docs/stores-beta/initiatives.md index ff676635f2..e9b427215b 100644 --- a/docs/stores-beta/initiatives.md +++ b/docs/stores-beta/initiatives.md @@ -89,18 +89,16 @@ And stages are opt-in: an initiative with no numbers works exactly the same. Put the same folder in a [store](user-guide.md) — a planning repo the whole team registers by name. The store is the parent; each code repo is a child -pointing up: +pointing up. The whole path is three commands: ```bash -openspec store setup team-plans --path ~/openspec/team-plans -# put initiatives in team-plans/openspec/initiatives/ +openspec store setup team-plans # once — defaults to ~/openspec/team-plans +# put initiatives in team-plans/openspec/initiatives/ (or let /opsx:initiatives capture them) -# in any code repo: +# in any code repo — this one command does ALL the wiring: openspec new change add-search --initiative team-plans/smoother-setup -``` -```bash -openspec list --initiatives --store team-plans +openspec list --initiatives # from anywhere ``` ```text @@ -113,14 +111,13 @@ smoother-setup 2/3 changes complete ✓ fix-onboarding my-app 2/2 tasks ``` -One command answers "where does everything stand?" — across every repo on -your machine that points at the store's initiatives. Linking is the -registration: `new change --initiative team-plans/` records the -checkout so rollups scan it, with nothing written into the repo itself. It -even works outside any repo: with no local root, `list --initiatives` shows -the portfolios of your registered stores. And repos that add -`references: [team-plans]` to their `openspec/config.yaml` get the -initiatives into their agent's context automatically. +Linking does all the wiring, and says so: it records the checkout so +rollups scan it (nothing written into the repo for that), and it adds +`references: [team-plans]` to the repo's `openspec/config.yaml` so agents +there see the store's context — announced in the output, one visible line +in your own config. One command then answers "where does everything +stand?" from anywhere — even outside any repo, where `list --initiatives` +shows the portfolios of your registered stores. Going from solo to team is moving one folder into a store. The initiative is untouched; its ref changes from `smoother-setup` to diff --git a/docs/stores-beta/user-guide.md b/docs/stores-beta/user-guide.md index 5c715a1748..ca2f5c1b29 100644 --- a/docs/stores-beta/user-guide.md +++ b/docs/stores-beta/user-guide.md @@ -55,7 +55,7 @@ Two rules keep this simple: Two commands take you from nothing to a working, store-scoped change: ```bash -openspec store setup team-plans --path ~/openspec/team-plans +openspec store setup team-plans # defaults to ~/openspec/team-plans ``` ``` @@ -93,8 +93,7 @@ them across code repos. **Day one (whoever sets it up):** ```bash -openspec store setup team-plans --path ~/openspec/team-plans \ - --remote git@github.com:acme/team-plans.git +openspec store setup team-plans --remote git@github.com:acme/team-plans.git git -C ~/openspec/team-plans push -u origin main ``` diff --git a/openspec/changes/add-initiatives/specs/initiatives/spec.md b/openspec/changes/add-initiatives/specs/initiatives/spec.md index 3346e09aaa..aa654b14ef 100644 --- a/openspec/changes/add-initiatives/specs/initiatives/spec.md +++ b/openspec/changes/add-initiatives/specs/initiatives/spec.md @@ -65,9 +65,18 @@ this machine that points at it, with live task status — via - **WHEN** a plain code repo (not a registered store) creates a change with `--initiative /` - **THEN** the repo's checkout is recorded machine-locally, with nothing - written into the repo itself + written into the repo itself for discovery - **AND** the store's rollup includes that change without any further setup +#### Scenario: Linking wires the agent context too + +- **WHEN** a change links to a store's initiative and the repo's config does + not yet reference that store +- **THEN** the store is added to `references:` in `openspec/config.yaml`, + announced in the command's output +- **AND** any config the command cannot edit with certainty is left + untouched, with the manual one-liner printed instead + #### Scenario: A complete initiative prompts the evergreen sync - **WHEN** every change linked to an initiative is complete diff --git a/src/commands/store.ts b/src/commands/store.ts index 3fcc0a34af..f7228f1642 100644 --- a/src/commands/store.ts +++ b/src/commands/store.ts @@ -269,25 +269,18 @@ async function resolveSetupInput( ); } - if (options.path === undefined && !interactive) { - throw new StoreError( - 'Pass --path with the folder where this store should live.', - 'store_setup_path_required', - { - target: 'store.root', - fix: `openspec store setup ${id ?? ''} --path ~/openspec/${id ?? ''}`, - } - ); - } - const resolvedId = id ? validateStoreId(id) : await promptStoreId(); - const promptedPath = options.path === undefined - ? await promptStorePath(resolvedId) - : undefined; + // No --path needed: interactive prompts with ~/openspec/ prefilled; + // non-interactive defaults to the same place, so setup is one command. + const resolvedPath = + options.path ?? + (interactive + ? await promptStorePath(resolvedId) + : ['~', 'openspec', resolvedId].join('/')); return { id: resolvedId, - path: options.path ?? promptedPath, + path: resolvedPath, ...(options.remote !== undefined ? { remote: options.remote } : {}), }; } diff --git a/src/commands/workflow/instructions.ts b/src/commands/workflow/instructions.ts index 37fc75e81e..77443a06a7 100644 --- a/src/commands/workflow/instructions.ts +++ b/src/commands/workflow/instructions.ts @@ -445,19 +445,25 @@ export async function generateApplyInstructions( let state: ApplyInstructions['state']; let instruction: string; + // Blocked-state hints must point at the always-available CLI path — a + // named skill may not be generated under every profile. + const tracksArtifactId = tracksFile + ? (schema.artifacts.find((a) => a.generates === tracksFile)?.id ?? + path.parse(path.basename(tracksFile)).name) + : null; if (missingArtifacts.length > 0) { state = 'blocked'; - instruction = `Cannot apply this change yet. Missing artifacts: ${missingArtifacts.join(', ')}.\nUse the openspec-continue-change skill to create the missing artifacts first.`; + instruction = `Cannot apply this change yet. Missing artifacts: ${missingArtifacts.join(', ')}.\nCreate each one first: openspec instructions --change ${changeName}`; } else if (tracksFile && !tracksFileExists) { // Tracking file configured but doesn't exist yet const tracksFilename = path.basename(tracksFile); state = 'blocked'; - instruction = `The ${tracksFilename} file is missing and must be created.\nUse openspec-continue-change to generate the tracking file.`; + instruction = `The ${tracksFilename} file is missing and must be created.\nGenerate it: openspec instructions ${tracksArtifactId} --change ${changeName}`; } else if (tracksFile && tracksFileExists && total === 0) { // Tracking file exists but contains no tasks const tracksFilename = path.basename(tracksFile); state = 'blocked'; - instruction = `The ${tracksFilename} file exists but contains no tasks.\nAdd tasks to ${tracksFilename} or regenerate it with openspec-continue-change.`; + instruction = `The ${tracksFilename} file exists but contains no tasks.\nAdd tasks to ${tracksFilename}, or regenerate it: openspec instructions ${tracksArtifactId} --change ${changeName}`; } else if (tracksFile && remaining === 0 && total > 0) { state = 'all_done'; instruction = 'All tasks are complete! This change is ready to be archived.\nConsider running tests and reviewing the changes before archiving.'; @@ -566,7 +572,9 @@ export function printApplyInstructionsText( console.log('### ⚠️ Blocked'); console.log(); console.log(`Missing artifacts: ${missingArtifacts.join(', ')}`); - console.log('Use the openspec-continue-change skill to create these first.'); + console.log( + `Create each one first: openspec instructions --change ${changeName}` + ); console.log(); } diff --git a/src/commands/workflow/new-change.ts b/src/commands/workflow/new-change.ts index 26d8c3c013..c96294280b 100644 --- a/src/commands/workflow/new-change.ts +++ b/src/commands/workflow/new-change.ts @@ -11,7 +11,7 @@ import ora from 'ora'; import path from 'path'; import { InitiativeRefSchema } from '../../core/change-metadata/schema.js'; import { recordLinkedRoot } from '../../core/initiatives.js'; -import { readProjectConfig } from '../../core/project-config.js'; +import { addReferenceToProjectConfig } from '../../core/project-config.js'; import { createChange, validateChangeName } from '../../utils/change-utils.js'; import { formatChangeLocation } from '../../core/planning-home.js'; import { @@ -49,6 +49,12 @@ interface NewChangeOutput { metadataPath: string; schema: string; }; + /** Present when the change was born linked to an initiative. */ + initiative?: { + ref: string; + /** Whether linking auto-added the store to `references:` in config. */ + reference_wiring?: 'added' | 'already' | 'skipped'; + }; root: RootOutput; } @@ -69,7 +75,8 @@ function assertRemovedOptionsAbsent(options: NewChangeOptions): void { function printCreatedChangeHuman( payload: NewChangeOutput, root: ResolvedOpenSpecRoot, - initiative?: string + initiative?: string, + referenceWiring?: 'added' | 'already' | 'skipped' | null ): void { // A relative path is only honest when the root is where the user // stands; a distant ancestor root gets the absolute path. @@ -85,14 +92,14 @@ function printCreatedChangeHuman( ? `openspec list --initiatives --store ${storeId}` : 'openspec list --initiatives'; console.log(`Initiative: ${initiative} (rollup: ${rollup})`); - if (storeId !== null) { - // readProjectConfig never throws: missing/unparseable configs are null. - const references = readProjectConfig(root.path)?.references ?? []; - if (!references.some((entry) => entry.id === storeId)) { - console.log( - `Tip: add 'references: [${storeId}]' to openspec/config.yaml so agents here see that store's context.` - ); - } + if (referenceWiring === 'added') { + console.log( + `Referenced store '${storeId}' in openspec/config.yaml — agents here now see its context.` + ); + } else if (referenceWiring === 'skipped') { + console.log( + `Tip: add 'references: [${storeId}]' to openspec/config.yaml so agents here see that store's context.` + ); } } console.log(`Next: ${withStoreFlag(root, `openspec status --change ${payload.change.id}`)}`); @@ -159,11 +166,19 @@ export async function newChangeCommand(name: string | undefined, options: NewCha await fs.writeFile(readmePath, `# ${name}\n\n${options.description}\n`, 'utf-8'); } - // A store-qualified link makes this repo part of that store's portfolio; - // record the checkout so rollups scan it. Linking IS the registration — - // and a recording failure must never fail the created change. + // A store-qualified link makes this repo part of that store's portfolio. + // Linking does ALL the wiring: record the checkout so rollups scan it, + // and reference the store so agents here see its context — neither + // failure may fail the created change. + let referenceWiring: 'added' | 'already' | 'skipped' | null = null; if (options.initiative?.includes('/')) { await recordLinkedRoot(projectRoot).catch(() => undefined); + const storeId = options.initiative.split('/')[0]; + try { + referenceWiring = addReferenceToProjectConfig(projectRoot, storeId); + } catch { + referenceWiring = 'skipped'; + } } const payload: NewChangeOutput = { @@ -173,6 +188,14 @@ export async function newChangeCommand(name: string | undefined, options: NewCha metadataPath: path.join(result.changeDir, '.openspec.yaml'), schema: result.schema, }, + ...(options.initiative + ? { + initiative: { + ref: options.initiative, + ...(referenceWiring ? { reference_wiring: referenceWiring } : {}), + }, + } + : {}), root: toRootOutput(root), }; @@ -182,7 +205,7 @@ export async function newChangeCommand(name: string | undefined, options: NewCha } spinner?.stop(); - printCreatedChangeHuman(payload, root, options.initiative); + printCreatedChangeHuman(payload, root, options.initiative, referenceWiring); } catch (error) { spinner?.stop(); if (options.json) { diff --git a/src/core/project-config.ts b/src/core/project-config.ts index 5d1b70e3aa..7bca0ca16e 100644 --- a/src/core/project-config.ts +++ b/src/core/project-config.ts @@ -1,6 +1,6 @@ -import { existsSync, readFileSync, statSync } from 'fs'; +import { existsSync, readFileSync, statSync, writeFileSync } from 'fs'; import path from 'path'; -import { parse as parseYaml } from 'yaml'; +import { parse as parseYaml, parseDocument, isSeq } from 'yaml'; import { z } from 'zod'; /** @@ -417,6 +417,56 @@ export function readStorePointer(projectRoot: string): StorePointerRead { } } +/** + * Adds a store id to the config's `references:` list, preserving the file's + * comments and formatting. Conservative on purpose: any state it cannot + * edit with certainty (missing config, parse errors, a non-list + * `references:`) returns 'skipped' so the caller falls back to telling the + * user instead of guessing. Returns 'already' when the reference exists. + */ +export function addReferenceToProjectConfig( + projectRoot: string, + storeId: string +): 'added' | 'already' | 'skipped' { + const configPath = resolveConfigFilePath(projectRoot); + if (configPath === null) return 'skipped'; + + let doc; + try { + doc = parseDocument(readFileSync(configPath, 'utf-8')); + } catch { + return 'skipped'; + } + if (doc.errors.length > 0) return 'skipped'; + + const existing = doc.get('references'); + if (existing === undefined) { + doc.set('references', [storeId]); + } else if (isSeq(existing)) { + const values = existing.toJSON() as unknown[]; + const present = + Array.isArray(values) && + values.some( + (value) => + value === storeId || + (value !== null && + typeof value === 'object' && + (value as { id?: unknown }).id === storeId) + ); + if (present) return 'already'; + existing.add(doc.createNode(storeId)); + } else { + return 'skipped'; + } + + try { + writeFileSync(configPath, doc.toString(), 'utf-8'); + } catch { + return 'skipped'; + } + return 'added'; +} + /** Shared .yaml/.yml probe used by readProjectConfig and readStorePointer. */ export function resolveConfigFilePath(projectRoot: string): string | null { const yamlPath = path.join(projectRoot, 'openspec', 'config.yaml'); diff --git a/src/core/templates/workflows/apply-change.ts b/src/core/templates/workflows/apply-change.ts index a08b24ddd0..3dd91eb43d 100644 --- a/src/core/templates/workflows/apply-change.ts +++ b/src/core/templates/workflows/apply-change.ts @@ -50,7 +50,7 @@ ${STORE_SELECTION_GUIDANCE} - Dynamic instruction based on current state **Handle states:** - - If \`state: "blocked"\` (missing artifacts): show message, suggest using openspec-continue-change + - If \`state: "blocked"\` (missing artifacts): show message, create each missing artifact via \`openspec instructions --change \` (never point at a skill that may not be installed) - If \`state: "all_done"\`: congratulate, suggest archive - Otherwise: proceed to implementation @@ -210,7 +210,7 @@ ${STORE_SELECTION_GUIDANCE} - Dynamic instruction based on current state **Handle states:** - - If \`state: "blocked"\` (missing artifacts): show message, suggest using \`/opsx:continue\` + - If \`state: "blocked"\` (missing artifacts): show message, create each missing artifact via \`openspec instructions --change \` (never point at a skill that may not be installed) - If \`state: "all_done"\`: congratulate, suggest archive - Otherwise: proceed to implementation diff --git a/src/core/templates/workflows/initiatives.ts b/src/core/templates/workflows/initiatives.ts index 8a94deaefd..e264ac1d56 100644 --- a/src/core/templates/workflows/initiatives.ts +++ b/src/core/templates/workflows/initiatives.ts @@ -67,7 +67,7 @@ Plans die of verbosity. - One page per artifact. Longer means cut or split. - Prefer a table to prose, a line to a paragraph. -- No file paths or code snippets in upstream artifacts — they rot. +- No file paths or line-anchored code in upstream artifacts — they rot. Naming a module, crate, or tool is fine; pointing at a path or line is not. - Never restate another artifact; point to it. ## Stay above the code diff --git a/test/commands/store.test.ts b/test/commands/store.test.ts index 237c82b684..0099636793 100644 --- a/test/commands/store.test.ts +++ b/test/commands/store.test.ts @@ -231,18 +231,22 @@ describe('store command', () => { expect(process.exitCode).toBeUndefined(); }); - it('requires an explicit path for non-interactive JSON setup', async () => { - const result = await runCLI(['store', 'setup', 'team-context', '--json'], { - cwd: tempDir, - env, - }); + it('defaults the path to ~/openspec/ for non-interactive JSON setup', async () => { + const result = await runCLI( + ['store', 'setup', 'team-context', '--no-init-git', '--json'], + { cwd: tempDir, env: { ...env, HOME: tempDir } } + ); - expect(result.exitCode).toBe(1); - expect(parseJson(result).status[0]).toEqual( - expect.objectContaining({ - code: 'store_setup_path_required', - }) + expect(result.exitCode).toBe(0); + const storeRoot = path.join( + expectedExistingPath(tempDir), + 'openspec', + 'team-context' ); + expect(parseJson(result).store.root).toBe(storeRoot); + expect(fs.existsSync(getStoreMetadataPath(storeRoot))).toBe(true); + expectHealthyOpenSpecRoot(storeRoot); + // The default is a visible user-owned location, never the managed data dir. expect( fs.existsSync(path.join(getStoresDir({ globalDataDir }), 'team-context')) ).toBe(false); diff --git a/test/core/project-config.test.ts b/test/core/project-config.test.ts index 02173d5691..73382f25fa 100644 --- a/test/core/project-config.test.ts +++ b/test/core/project-config.test.ts @@ -3,6 +3,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { + addReferenceToProjectConfig, readProjectConfig, validateConfigRules, suggestSchemas, @@ -693,4 +694,52 @@ rules: expect(message).toContain('Available schemas:'); }); }); + + describe('addReferenceToProjectConfig', () => { + function writeConfig(content: string): string { + const configPath = path.join(tempDir, 'openspec', 'config.yaml'); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, content, 'utf-8'); + return configPath; + } + + it('adds a references list when none exists, preserving comments', () => { + const configPath = writeConfig('# my config\nschema: spec-driven\n'); + + expect(addReferenceToProjectConfig(tempDir, 'team-plans')).toBe('added'); + + const content = fs.readFileSync(configPath, 'utf-8'); + expect(content).toContain('# my config'); + expect(readProjectConfig(tempDir)?.references).toEqual([ + { id: 'team-plans' }, + ]); + }); + + it('appends to an existing list and is idempotent', () => { + writeConfig('schema: spec-driven\nreferences:\n - other-store\n'); + + expect(addReferenceToProjectConfig(tempDir, 'team-plans')).toBe('added'); + expect(addReferenceToProjectConfig(tempDir, 'team-plans')).toBe('already'); + + expect(readProjectConfig(tempDir)?.references).toEqual([ + { id: 'other-store' }, + { id: 'team-plans' }, + ]); + }); + + it('recognizes an existing {id, remote} entry', () => { + writeConfig( + 'schema: spec-driven\nreferences:\n - id: team-plans\n remote: git@example.com:plans.git\n' + ); + + expect(addReferenceToProjectConfig(tempDir, 'team-plans')).toBe('already'); + }); + + it('skips instead of guessing: missing config or a non-list references', () => { + expect(addReferenceToProjectConfig(tempDir, 'team-plans')).toBe('skipped'); + + writeConfig('schema: spec-driven\nreferences: not-a-list\n'); + expect(addReferenceToProjectConfig(tempDir, 'team-plans')).toBe('skipped'); + }); + }); }); diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index 12170007d4..083b32412a 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -42,14 +42,14 @@ const EXPECTED_FUNCTION_HASHES: Record = { getExploreSkillTemplate: '26675478b220715bafe3749311db95677043afc85da4dd01c53726a83f198704', getNewChangeSkillTemplate: 'a647a6602e361cda6a5277ee8195c76cc59c6105c155731bb67ae61bd14c627f', getContinueChangeSkillTemplate: '03d2a37c9a703a379d4df38633009e63f30f26df78c13e5e259001a4e0391c76', - getApplyChangeSkillTemplate: 'dfbf04bd25ffedba1f8b764623a3c91e0e6f43ed14de97d7642421944ac57ac1', + getApplyChangeSkillTemplate: '31b8da530a8ad1d06562255a5f3b285fb5591c6ddeca9b68b786c0b022196845', getFfChangeSkillTemplate: 'bf6f4918e96f681922b715338b56dba7a60a46051e3fbaf2ef2d026e51eed07c', getSyncSpecsSkillTemplate: '8af60d91a626e4751d6a2aef7e47b1522daa1db01c213386fc206a54ce176ab1', getOnboardSkillTemplate: 'eb746e0f6e720794f2565e487a8bb7e50273e4b8494308b00e7afeed2c31a5e2', getOpsxExploreCommandTemplate: '1a99984ace5e8ae76a05d357c04a2c6e4b13407451f4545534d4ce95d507bb54', getOpsxNewCommandTemplate: 'd761624274af2856e60096847dc9fab4beaec0ee55f49ee1e885d3af0496570f', getOpsxContinueCommandTemplate: 'bd7776b401467c98b07ead76a6965802e99dc150d59804c1014e7ef5f7f89fb3', - getOpsxApplyCommandTemplate: '0a65c9b1fa9b00953fa8c68bf3ba03e69b7d8167376b1cbf656d151c23a067b4', + getOpsxApplyCommandTemplate: '1382a717a379ba6c6e0b957c59f98df6010a3f10778a73b64af4fc6e0e0b0cc6', getOpsxFfCommandTemplate: '610a96f70073eb6643f1387723dec01ffbfa75a2e48ddd471845d47fc1183378', getArchiveChangeSkillTemplate: '6457b47ef91bbb964e434725ca845851ed69497a5f770c4ef9713019fd1378ae', getBulkArchiveChangeSkillTemplate: '0ce6933682e5f74b8d8b3fd49270957d6c14572eb0490eb65ad63628152e865e', @@ -61,8 +61,8 @@ const EXPECTED_FUNCTION_HASHES: Record = { getOpsxVerifyCommandTemplate: 'b4a5717c25883ca74dc8da091aef2432492e2a377c8cd9c1a0369b6b854dc32c', getOpsxProposeSkillTemplate: 'ad11a374aa8f3978a93af3a2d5b4cea736d6a5f7b3f913b38a2b34aeeb2bec21', getOpsxProposeCommandTemplate: '8aa4d2e0ca201ad8e913e8d490223063cef9c2a7e2b7a464d5aa0452540ccc09', - getInitiativesSkillTemplate: 'fe25973b8b36e0d72d0250bcbe2c8871f56f1ef7646a48eadd04de32fa2af985', - getOpsxInitiativesCommandTemplate: '903fc4d5dd39a71505098169faea483d594a8df4b86d89292b2fe2338e77fe9c', + getInitiativesSkillTemplate: '6a8da2ed910e732a56dc7f280cb26b90b487247af323f8059d3bda707e02d168', + getOpsxInitiativesCommandTemplate: '4297c838493ca508f7a08748be4f48393d220b59981f6276db6b704864c470c8', getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d', getUpdateChangeSkillTemplate: 'cb95d9c5c450087b5adf862986a6a81a65d57cce535162e693fbf9aea127c1c3', getOpsxUpdateCommandTemplate: '1657481ccf1f60c44cba192d51b20f5d7035b3c3a84e237b7323b703496fc149', @@ -72,7 +72,7 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-explore': 'e5365b5136fd85b0f3be3e65a834148b32ab05a88df575d8c582a2d048204ba7', 'openspec-new-change': '491b175943bffded8ec33c1ec2f62dfc83cd078970c0469ceefe21c668192a54', 'openspec-continue-change': 'ffc8d80dbf2bf39ba1cefbdd7e24aa004f4ff399ec2ff5aa764aa5cf68e6535c', - 'openspec-apply-change': '9a800c59af2e60121953afb43f63255d75136af44af57d083ed44617d34cb167', + 'openspec-apply-change': 'af4c1c22e9d1571c41d90933e8dd1763f168e1629595825142978a91b0314033', 'openspec-ff-change': '20197740266c5fa521eaf4d74a24cb9e5ce270ac8a4aafa92bc47ad56e11b91e', 'openspec-sync-specs': 'ea1e8d016953775d0f61b4c1813fc0b77ee310dac1bd085722ac99cba7b6f1d1', 'openspec-archive-change': '51c003b7fc44cab1ddefdd16db4030e971b4504eb9dce99b70a79f691ba2e8b0', @@ -80,7 +80,7 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-verify-change': 'ba6d6bc6e78d2ab9cf3c41d75961cdd08277ac591ff2454c98b4190a4e43fba6', 'openspec-onboard': 'd8baa141849b099671376d6928d29e4ed7dfb4be9675dbe3e1ff282548d883a9', 'openspec-propose': '273e432fd268546de6316520b2d116c1e6d01d5a8d57bd03ad71a47fb5cec112', - 'openspec-initiatives': 'f45387076aaaa61e91f5c76fef2cf897ffd38c09876ef162d892ec814fbfe2d2', + 'openspec-initiatives': '9f40e9c1e485186912e3fd88aedbbe8d4106ff2a4c3b45114047afacaf2a65da', 'openspec-update-change': '29c95eefe0df8efb4681dcaa6a90ae965535edda1832501f637a368608e33684', }; From a6e2e304774ab12baa86319787b15a554dc39c1c Mon Sep 17 00:00:00 2001 From: Clay Good Date: Thu, 9 Jul 2026 17:58:14 -0500 Subject: [PATCH 28/45] fix(tests): set USERPROFILE alongside HOME in the default-path setup test os.homedir() reads HOME on POSIX but USERPROFILE on Windows, so the Windows runner resolved ~/openspec/ into its real home instead of the test sandbox. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/commands/store.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/commands/store.test.ts b/test/commands/store.test.ts index 0099636793..c1a59b2349 100644 --- a/test/commands/store.test.ts +++ b/test/commands/store.test.ts @@ -234,7 +234,8 @@ describe('store command', () => { it('defaults the path to ~/openspec/ for non-interactive JSON setup', async () => { const result = await runCLI( ['store', 'setup', 'team-context', '--no-init-git', '--json'], - { cwd: tempDir, env: { ...env, HOME: tempDir } } + // Both spellings: os.homedir() reads HOME on POSIX, USERPROFILE on Windows. + { cwd: tempDir, env: { ...env, HOME: tempDir, USERPROFILE: tempDir } } ); expect(result.exitCode).toBe(0); From acb792d1a43b09f359ee98b8ee9b1d1a502371ca Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 13 Jul 2026 09:54:08 -0500 Subject: [PATCH 29/45] refactor(stores): dissolve initiatives into typed changes + serves links Upstream work is a change in the store, typed by the store's schema; stages are artifacts; standing truths are the store's specs. The wiring survives retargeted: serves: metadata + new change --serves, linked-root recording, auto-references, the upstream instructions block, and list --downstream for the rollup. The schema system opens up instead: notes: surfaced on every instruction surface, instructions/.md files, schema inheritance through references:, and a declarative structure: map in config. Co-Authored-By: Claude Fable 5 --- docs/README.md | 1 - docs/stores-beta/initiatives.md | 182 ------ .../add-global-install-scope/.openspec.yaml | 1 - openspec/changes/add-initiatives/design.md | 102 ---- openspec/changes/add-initiatives/proposal.md | 63 --- .../add-initiatives/specs/initiatives/spec.md | 117 ---- openspec/changes/add-initiatives/tasks.md | 39 -- .../.openspec.yaml | 1 - .../schema-alias-support/.openspec.yaml | 1 - .../.openspec.yaml | 1 - openspec/config.yaml | 4 - .../smoother-setup/01_who/personas.md | 10 - .../smoother-setup/02_decisions/decisions.md | 7 - openspec/initiatives/smoother-setup/goal.md | 7 - src/cli/index.ts | 154 +++-- src/commands/context.ts | 40 +- src/commands/workflow/instructions.ts | 75 ++- src/commands/workflow/new-change.ts | 80 ++- src/commands/workflow/schemas.ts | 2 + src/commands/workflow/shared.ts | 2 + src/core/artifact-graph/instruction-loader.ts | 6 + src/core/artifact-graph/resolver.ts | 235 +++++--- src/core/artifact-graph/types.ts | 4 + src/core/change-metadata/schema.ts | 13 +- src/core/completions/command-registry.ts | 8 +- src/core/init.ts | 1 - src/core/initiatives.ts | 529 ------------------ src/core/openspec-root.ts | 8 +- src/core/profile-sync-drift.ts | 1 - src/core/profiles.ts | 11 +- src/core/project-config.ts | 32 ++ src/core/references.ts | 62 +- src/core/root-selection.ts | 4 +- src/core/shared/skill-generation.ts | 4 - src/core/shared/tool-detection.ts | 1 - src/core/templates/skill-templates.ts | 1 - src/core/templates/workflows/initiatives.ts | 102 ---- src/core/upstream.ts | 435 ++++++++++++++ src/core/working-set.ts | 7 +- src/utils/change-utils.ts | 2 +- test/commands/change-initiative-link.test.ts | 39 +- test/core/initiatives.test.ts | 306 ---------- test/vocabulary-sweep.test.ts | 15 +- 43 files changed, 926 insertions(+), 1789 deletions(-) delete mode 100644 docs/stores-beta/initiatives.md delete mode 100644 openspec/changes/add-initiatives/design.md delete mode 100644 openspec/changes/add-initiatives/proposal.md delete mode 100644 openspec/changes/add-initiatives/specs/initiatives/spec.md delete mode 100644 openspec/changes/add-initiatives/tasks.md delete mode 100644 openspec/initiatives/smoother-setup/01_who/personas.md delete mode 100644 openspec/initiatives/smoother-setup/02_decisions/decisions.md delete mode 100644 openspec/initiatives/smoother-setup/goal.md delete mode 100644 src/core/initiatives.ts delete mode 100644 src/core/templates/workflows/initiatives.ts create mode 100644 src/core/upstream.ts delete mode 100644 test/core/initiatives.test.ts diff --git a/docs/README.md b/docs/README.md index b64f91cb45..2e3df67b1c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -90,7 +90,6 @@ That second one matters more than it looks. OpenSpec has two halves: a command l | Doc | What it gives you | |-----|-------------------| | [Stores: User Guide](stores-beta/user-guide.md) | Plan in its own repo when your work spans repos or teams | -| [Initiatives](stores-beta/initiatives.md) | The way in above a single change: your initiatives, your artifacts, live status | | [Agent Contract](agent-contract.md) | The machine-readable CLI surfaces agents drive | ## The thirty-second version diff --git a/docs/stores-beta/initiatives.md b/docs/stores-beta/initiatives.md deleted file mode 100644 index e9b427215b..0000000000 --- a/docs/stores-beta/initiatives.md +++ /dev/null @@ -1,182 +0,0 @@ -# Initiatives - -> **Beta.** Builds on [stores](user-guide.md). Names and shapes may change. - -Big work starts above a single change — a roadmap, a product vision, a -quarter's effort — and today those artifacts have no home in OpenSpec, and no -connection to the changes carrying them out. `openspec/initiatives/` is that -home, and that connection. - -## The whole idea in one picture - -```text -openspec/initiatives/ - roadmap.md evergreen — truths every initiative serves - smoother-setup/ one initiative = one folder; contents freeform - notes.md - -openspec/changes/ - add-search/ - .openspec.yaml initiative: smoother-setup ← one line points UP -``` - -Two kinds of things live in the folder: - -- **Evergreen artifacts** (unnumbered top-level files) are your standing - truths — the roadmap, the product, the architecture. Maintained forever, - the way specs are. -- **Initiatives** (subfolders) are finite: a piece of work above a single - change that runs to completion. - -Point your in-flight changes at an initiative, and one command maps the -portfolio, live: - -```bash -openspec list --initiatives -``` - -```text -Initiatives: /…/my-app/openspec/initiatives -Evergreen: roadmap.md - -smoother-setup 1/2 changes complete - · add-search here 1/2 tasks - ✓ fix-onboarding here 2/2 tasks -``` - -No manifest, no required artifact types, no new format. Status comes from -the changes' own task lists — nothing to keep in sync by hand. - -## The same loop, one level up - -```text -planning evergreen artifacts what is true initiatives what is in motion -code specs what is true changes what is in motion -``` - -Work flows down: an initiative decomposes into changes. Truth flows up: -finishing an initiative updates the evergreen artifacts, the way archiving a -change updates specs. Status flows back live — driven by structured facts on -disk (a task checked off, a change archived), never by prose. - -## Stages: your workflow, in folder names - -Number the folders inside an initiative and they become ordered stages — -and the names are your workflow. Any chain works, because none of them is -special: - -```text -00_product/ 01_engineering/ a two-person team -00_product/ 01_design/ 02_engineering/ a product team -00_analysis/ 01_product/ 02_architecture/ 03_design/ 04_engineering/ a bigger org -00_idea/ 01_build/ a solo dev switching hats -``` - -Reconfiguring the workflow is renaming folders — no settings, no manifest, -no new skills. OpenSpec never learns your chain; it only knows *lower-numbered -is upstream*, and that is enough. - -The handoff between stages is **pull-based, one rule**: whoever opens the -initiative reads everything lower-numbered first, then produces what their -stage owes the next one. Nothing pushes work downstream; the folder position -already says what is upstream and what comes next. - -The documents themselves can be any format — a PRD, an RFC, a one-pager, -exported design notes. Position carries the meaning, not the format. -And stages are opt-in: an initiative with no numbers works exactly the same. - -## Team: the portfolio lives in a store - -Put the same folder in a [store](user-guide.md) — a planning repo the whole -team registers by name. The store is the parent; each code repo is a child -pointing up. The whole path is three commands: - -```bash -openspec store setup team-plans # once — defaults to ~/openspec/team-plans -# put initiatives in team-plans/openspec/initiatives/ (or let /opsx:initiatives capture them) - -# in any code repo — this one command does ALL the wiring: -openspec new change add-search --initiative team-plans/smoother-setup - -openspec list --initiatives # from anywhere -``` - -```text -Initiatives: /…/team-plans/openspec/initiatives -Evergreen: roadmap.md - -smoother-setup 2/3 changes complete - ✓ add-payments-api api-server 2/2 tasks - · add-search my-app 1/2 tasks - ✓ fix-onboarding my-app 2/2 tasks -``` - -Linking does all the wiring, and says so: it records the checkout so -rollups scan it (nothing written into the repo for that), and it adds -`references: [team-plans]` to the repo's `openspec/config.yaml` so agents -there see the store's context — announced in the output, one visible line -in your own config. One command then answers "where does everything -stand?" from anywhere — even outside any repo, where `list --initiatives` -shows the portfolios of your registered stores. - -Going from solo to team is moving one folder into a store. The initiative -is untouched; its ref changes from `smoother-setup` to -`team-plans/smoother-setup`. - -## The skill - -One skill drives it: `openspec-initiatives` (`/opsx:initiatives`). It routes -by what is on disk — nothing there yet → it captures the conversation into a -first artifact (synthesizing what it already knows instead of -re-interviewing you); a portfolio exists → it opens with where everything -stands; inside an initiative → it ideates from what exists, decomposes into -self-contained changes born linked, and syncs the evergreen layer as work -completes. Every move ends in a short numbered menu of real next actions — -one marked recommended — and it is told to write *less*: one page per -artifact, tables over prose. - -## The lifecycle, by position - -Everyone talks to the same skill; the folder position — not the job title — -decides what happens. Whether your chain has two stages or five: - -| Where you stand | Job to be done | What actually happens | -|---|---|---| -| nothing exists yet | capture the intent | ask the agent — `/opsx:initiatives` turns the conversation into the first stage's artifact, or drop in a doc you already have (a PRD, an RFC, a one-pager) | -| any middle stage | add your piece | open the initiative; the skill reads everything lower-numbered and drafts your stage — same move at `01_design/` as at `02_architecture/` | -| the last stage | turn plans into work | the skill decomposes it into changes born linked (`openspec new change --initiative `), each tracing to something upstream | -| anywhere, anytime | know where it stands | `openspec list --initiatives` — live, from task lists, no bookkeeping | - -**The handoff artifact between planning and building is the change itself** — -self-contained, worked with the normal change skills. The initiative travels -along: `openspec instructions` and `openspec instructions apply` for a linked -change open with the initiative it serves and where its upstream context -lives on disk, so intent reaches the working agent without anyone pasting -it. Status flows back with no meetings and no status updates written by -hand. - -## What the pieces are - -| Piece | What it is | -|---|---| -| `openspec/initiatives/` | evergreen truths + one folder per initiative | -| numbered folders inside an initiative | your workflow, as optional ordered stages | -| `initiative: ` / `/` | one line in a change's `.openspec.yaml` | -| `openspec new change --initiative ` | create a change already pointing up | -| `openspec list --initiatives [--store ]` | the portfolio + every linked change, live | -| `openspec-initiatives` skill | the guide through all of it | - -## Honest limits - -- Rollup scans checkouts this machine knows: registered stores, plus any - repo that has linked a change to a store's initiative. It never clones or - syncs. -- Stage order is a naming convention, not a gate. Nothing blocks working out - of order; the skill just knows what comes next. -- Reactions fire when the skill looks — reactive triggers beyond that (git - hooks, CI) are a natural next experiment, deliberately not this one. -- Want typed, checked artifacts someday? [Custom schemas](../customization.md) - already exist — initiative artifacts stay freeform until you want that. -- This repo dogfoods it: see - [openspec/initiatives/](../../openspec/initiatives/smoother-setup/goal.md) - — one initiative with two optional stages, grouping four real changes. diff --git a/openspec/changes/add-global-install-scope/.openspec.yaml b/openspec/changes/add-global-install-scope/.openspec.yaml index 3d698c574e..910badd983 100644 --- a/openspec/changes/add-global-install-scope/.openspec.yaml +++ b/openspec/changes/add-global-install-scope/.openspec.yaml @@ -1,3 +1,2 @@ schema: spec-driven created: 2026-02-21 -initiative: smoother-setup diff --git a/openspec/changes/add-initiatives/design.md b/openspec/changes/add-initiatives/design.md deleted file mode 100644 index e6ff9118c5..0000000000 --- a/openspec/changes/add-initiatives/design.md +++ /dev/null @@ -1,102 +0,0 @@ -## Context - -The upstream layer (roadmap → requirements → changes) has no home in -OpenSpec. The design constraint: give it one without OpenSpec modeling the -team's workflow — every team's is different. - -## Goals / Non-Goals - -**Goals:** the thinnest possible wrapper; any artifact shape; one repo or -many; one skill; value on first contact for a solo dev AND for an org. - -**Non-Goals:** typed upstream artifacts, manifests, file watchers or hook -machinery, sync engines, filesystem-crawling discovery of unknown repos -(a repo becomes known the moment it links a change — never by scanning). - -## Decisions - -**1. A portfolio of initiatives, plus an evergreen layer.** -`openspec/initiatives//` — each folder is one finite initiative; -unnumbered top-level files are evergreen artifacts (product, roadmap, -architecture) that outlive every initiative. Why: a store holding one plan -is a demo; a store holding the org's portfolio is an operational surface — -and the two-layer shape mirrors what OpenSpec already is. Specs are what is -true and changes are what is in motion at the code altitude; evergreen -artifacts are what is true and initiatives are what is in motion at the -planning altitude. Work flows down, truth flows up, at both altitudes: when -an initiative completes, syncing the evergreen artifacts is the planning -equivalent of archiving a change into specs. Rejected: keeping the singular -"one plan per root" (too small for orgs) and typed artifacts (freeform is -the point; custom schemas already exist if a team ever wants types). - -**2. Changes reference upward: `initiative: | /`.** -One line in change metadata; rollup scans for it. One syntax covers solo and -team — no `local` keyword to learn, no precedence question (the store is the -parent; repos are children pointing up). The legacy `initiative:` object -(`{store, id}`) carries the same data and normalizes to `/` on -read, so old metadata keeps working. Rejected: a downward manifest — a -central file invites merge conflicts and goes stale; a folder existing is -what makes an initiative exist. - -**3. Deterministic triggers, agent reactions.** Anything that drives a -workflow must be a structured fact derivable from disk — a task flipped, a -change archived, a new change pointing at an initiative — never prose. -`list --initiatives` is the one deterministic rendering of that state; the -skill reads it on entry and proposes what to do about it. Prose stays a -freeform projection; if a workflow ever needs to subscribe to something -inside an artifact, that thing graduates to structure. Progress is always -derived, never recorded in planning artifacts. Why: judgment-per-event is -expensive and unrepeatable; facts-per-event with judgment-per-response is -cheap, auditable, and repeatable — and OpenSpec's task/archive lifecycle -already supplies the facts. - -**4. Transitions between roles are pull-based, encoded in folder order.** -Handoffs between roles — PM → Engineering, PM → Design → Engineering, or -any longer chain — are not workflows OpenSpec models; they are the numbered -stage folders themselves, and every chain is handled identically. The rule -is one sentence: read everything lower-numbered, produce what your stage -owes the next one. Upstream documents can be any format (a PRD, an RFC, a -one-pager); position carries the meaning. Why pull: push requires -the upstream author to know the downstream tooling and fires before anyone -has implementation context; pull happens when someone opens the work, with -everything on disk. Rejected: per-role skills (the rule is the same move -from different positions) and typed transition events. - -**5. One skill, routed by state.** `openspec-initiatives` looks at what -exists before speaking: no folder → offer to capture the conversation; -folder, no target → summarize the portfolio in a few lines; inside an -initiative → work it. Its moves (ideate from what is on disk, capture, -bounded pushback, decompose into changes born linked, sync the evergreen -layer) end in a short numbered menu computed from state — one option marked -recommended, each wired to a real command. Why: the workflow lives on disk, -so anyone (or any agent) can pick it up; menus beat paragraphs for fatigue. -Rejected: per-stage or per-role skills, and a `new initiative` CLI command — -creating an initiative is `mkdir`, and the skill does it in flow. - -**6. Works anywhere.** `--store ` resolves through the global registry, -so the rollup answers from any directory, repo or not. With no local root -and no `--store`, `list --initiatives` falls back to the portfolios of -registered stores instead of erroring. Why: the planning layer is the level -above repos; asking it "where does everything stand" should not require -standing in a repo. - -## Risks / Trade-offs - -- [Scan cost across registered repos] → registries are small; scan is - per-invocation and read-only. -- [`initiative:` values are unvalidated against real folders] → rollup - simply finds nothing for a bad value; the skill and `list --initiatives` - make that visible. -- [Plural shape re-grows toward the deleted initiative machinery] → guarded - by decision 2: no manifest, no registration, no ids beyond the folder - name; discovery stays a metadata scan. - -## Migration Plan - -Additive only. Legacy `initiative:` objects normalize on read; the retired -`plan:` field from this experiment's earlier iteration was never released. - -## Open Questions - -None blocking. Reactive triggers beyond skill-entry (git hooks, CI) are a -natural next experiment, deliberately out of scope. diff --git a/openspec/changes/add-initiatives/proposal.md b/openspec/changes/add-initiatives/proposal.md deleted file mode 100644 index 02d40c2c16..0000000000 --- a/openspec/changes/add-initiatives/proposal.md +++ /dev/null @@ -1,63 +0,0 @@ -## Why - -Big work is bigger than one change, and OpenSpec has no way in above -`propose`. Roadmaps, PRDs, and visions live outside the tool, and the -translation from them into changes is hand-rolled by every serious team. - -Initiatives are that way in. OpenSpec owns almost nothing: a folder -convention, one metadata line, one rollup, one skill. - -## What Changes - -- **A portfolio, not a single plan.** `openspec/initiatives/` holds the - planning layer for a root — this repo, or a store the team shares. - - Each subfolder is one **initiative**: a finite piece of work above a - single change (`smoother-setup/`, `q3-payments/`). Contents are freeform; - numbered folders inside an initiative are ordered stages, and their - names are the team's workflow (`00_product/ 01_engineering/`, or any - longer chain). Stage documents can be any format — a PRD, an RFC, a - one-pager. Handoffs are pull-based: read what is lower-numbered, - produce what your stage owes the next. - - Unnumbered files at the top level are **evergreen artifacts** — the - standing truths every initiative serves (`product.md`, `roadmap.md`, - `architecture.md`). They are maintained forever, the way specs are. -- **Changes point up.** One line in a change's `.openspec.yaml` — - `initiative: ` (this root) or `initiative: /` (a - store's initiative). No manifest anywhere. `new change` gains - `--initiative`. Legacy `initiative:` objects (`{store, id}`) stay readable. -- **One rollup.** `openspec list --initiatives [--store ]` shows the - portfolio: every initiative, every change on this machine pointing at it, - live task status — including changes in other checkouts. Linking is the - registration: `new change --initiative /` records the repo's - path machine-locally so rollups scan it, with nothing written into the - repo. Run outside any root, it falls back to the portfolios of registered - stores. -- **One skill.** `openspec-initiatives` (`/opsx:initiatives`): routes by - what is on disk, captures planning conversations into artifacts, ideates - from what exists, decomposes into changes born linked, and syncs the - evergreen layer as work completes. It ends every move in a short numbered - menu wired to real commands, and it is instructed to write less, not more. -- Referenced stores' initiatives appear in `openspec context` and the agent - instruction block. - -## Capabilities - -### New Capabilities -- `initiatives`: the initiatives convention (portfolio + evergreen layer), - the upward `initiative:` link, the `list --initiatives` rollup, and - surfacing in context/instructions. - -### Modified Capabilities -None. - -## Impact - -- Commands: `list` (adds `--initiatives`), `new change` (adds - `--initiative`), `context` and `instructions` (surface a store's - initiatives; a linked change's instructions open with the initiative it - serves and its upstream path). All additive. -- Skill set: one new workflow, `initiatives`, in the core profile — the - documented happy path (`openspec init` → `/opsx:initiatives`) must work - without extra configuration. -- No breaking changes. Legacy `initiative:` metadata objects normalize to - the new reference form when read. diff --git a/openspec/changes/add-initiatives/specs/initiatives/spec.md b/openspec/changes/add-initiatives/specs/initiatives/spec.md deleted file mode 100644 index aa654b14ef..0000000000 --- a/openspec/changes/add-initiatives/specs/initiatives/spec.md +++ /dev/null @@ -1,117 +0,0 @@ -## ADDED Requirements - -### Requirement: Initiatives folder convention - -The system SHALL treat each subfolder of `openspec/initiatives/` as one -initiative and unnumbered top-level files as evergreen artifacts, without -requiring any manifest or configuration. Numbered folders inside an -initiative SHALL be treated as ordered stages. - -#### Scenario: Initiatives and evergreen artifacts are read from the folder - -- **WHEN** `openspec/initiatives/` contains `smoother-setup/`, `q3-payments/`, and `roadmap.md` -- **THEN** the system reports `smoother-setup` and `q3-payments` as initiatives -- **AND** reports `roadmap.md` as an evergreen artifact, not an initiative - -#### Scenario: Stages are read inside an initiative - -- **WHEN** `openspec/initiatives/smoother-setup/` contains `00_goal/`, `01_requirements/`, and `notes.md` -- **THEN** the system reports `00_goal` and `01_requirements` as that initiative's stages, in order - -#### Scenario: Stage names encode the team's workflow, any chain length - -- **WHEN** `openspec/initiatives/checkout-revamp/` contains `00_analysis/`, `01_product/`, `02_design/`, and `03_engineering/` -- **THEN** the system reports all four as that initiative's stages, in order, with no configuration naming the workflow - -### Requirement: Changes link upward to an initiative - -The system SHALL let a change declare the initiative it serves with an -`initiative` value in its `.openspec.yaml` — `` for an initiative in -its own root, or `/` for one in that registered store. -Legacy object values (`{store, id}`) SHALL remain readable and normalize to -`/`. - -#### Scenario: Creating a change linked to an initiative - -- **WHEN** a user runs `openspec new change add-search --initiative smoother-setup` -- **THEN** the created change's metadata contains `initiative: smoother-setup` - -#### Scenario: Legacy object metadata still resolves - -- **WHEN** a change's `.openspec.yaml` contains `initiative: { store: team-plans, id: q3-payments }` -- **THEN** the system reads it as `initiative: team-plans/q3-payments` - -### Requirement: Portfolio rollup - -The system SHALL show the portfolio — every initiative with every change on -this machine that points at it, with live task status — via -`openspec list --initiatives`. - -#### Scenario: Rolling up local changes - -- **WHEN** a repo has initiatives and changes whose metadata names one of them -- **AND** the user runs `openspec list --initiatives` -- **THEN** the output groups each linked change under its initiative with task status - -#### Scenario: Rolling up across repos toward a store initiative - -- **WHEN** a store has an initiative and changes in other checkouts on this - machine say `initiative: /` -- **AND** the user runs `openspec list --initiatives --store ` -- **THEN** the output includes those changes with the repo each lives in - -#### Scenario: Linking is the registration - -- **WHEN** a plain code repo (not a registered store) creates a change with - `--initiative /` -- **THEN** the repo's checkout is recorded machine-locally, with nothing - written into the repo itself for discovery -- **AND** the store's rollup includes that change without any further setup - -#### Scenario: Linking wires the agent context too - -- **WHEN** a change links to a store's initiative and the repo's config does - not yet reference that store -- **THEN** the store is added to `references:` in `openspec/config.yaml`, - announced in the command's output -- **AND** any config the command cannot edit with certainty is left - untouched, with the manual one-liner printed instead - -#### Scenario: A complete initiative prompts the evergreen sync - -- **WHEN** every change linked to an initiative is complete -- **THEN** the rollup marks it and suggests syncing the evergreen artifacts - it served - -#### Scenario: Running outside any root - -- **WHEN** the user runs `openspec list --initiatives` in a directory with no `openspec/` root -- **THEN** the output shows the initiatives of registered stores that have them, instead of an error - -#### Scenario: No initiatives folder - -- **WHEN** the resolved root has no `openspec/initiatives/` folder -- **THEN** `openspec list --initiatives` says so and suggests how to start one - -### Requirement: Initiatives surface in agent context - -The system SHALL include a referenced store's initiative names in -`openspec context` and in the agent instruction block, with the command to -fetch the full rollup. - -#### Scenario: A referenced store's initiatives appear in context - -- **WHEN** a repo references a store whose initiatives folder is non-empty -- **AND** the user runs `openspec context` -- **THEN** the store's member entry lists the initiative names (and evergreen artifacts when there are no initiatives) -- **AND** shows the `openspec list --initiatives --store ` fetch command - -#### Scenario: A linked change hands its agent the upstream context - -- **WHEN** a change's metadata names an initiative -- **AND** the user runs `openspec instructions --change ` or - `openspec instructions apply --change ` -- **THEN** the output opens with the initiative the change serves, the - on-disk path to its folder, and the instruction to read everything - upstream first -- **AND** a ref whose folder cannot be found says so instead of vanishing diff --git a/openspec/changes/add-initiatives/tasks.md b/openspec/changes/add-initiatives/tasks.md deleted file mode 100644 index 368aff1f0e..0000000000 --- a/openspec/changes/add-initiatives/tasks.md +++ /dev/null @@ -1,39 +0,0 @@ -## 1. Core - -- [x] 1.1 Initiatives module: portfolio read (initiatives / evergreen / - stages), upward-ref scan, cross-repo rollup, legacy normalization -- [x] 1.2 `initiative` field in change metadata: string ref union with the - legacy object shape - -## 2. Surfaces - -- [x] 2.1 `openspec list --initiatives [--store ]` (human + JSON), - including the outside-a-root fallback to registered stores -- [x] 2.2 `openspec new change --initiative ` -- [x] 2.3 Initiatives in `openspec context` and the instruction block - -## 3. The skill - -- [x] 3.1 `openspec-initiatives` skill + `/opsx:initiatives` command - (state-routed moves, numbered handoff menus), in the core profile so - the documented happy path (`openspec init` → `/opsx:initiatives`) works - -## 4. Proof - -- [x] 4.1 Dogfood: this repo's `openspec/initiatives/smoother-setup/` groups - real changes; `openspec list --initiatives` rolls them up live -- [x] 4.2 Tests: initiatives module (solo + cross-repo + legacy + fallback), - context/instructions surfacing, registries, skill parity -- [x] 4.3 `openspec validate add-initiatives --strict` passes - -## 5. Dogfood round 2 (real multi-repo product, this machine) - -- [x] 5.1 Rollup discovers plain code repos: linking a change records the - checkout (machine-local, nothing written into the repo) -- [x] 5.2 The join: `instructions` / `instructions apply` open a linked - change with the initiative it serves and its upstream path on disk -- [x] 5.3 Completion nudge: a fully-complete initiative prompts an - evergreen sync in the rollup -- [x] 5.4 Friction batch: store setup seeds `initiatives/`, link hints are - store-qualified, `openspec init` leads the no-root fix, `--remote` - help names the real metadata path diff --git a/openspec/changes/fix-opencode-commands-directory/.openspec.yaml b/openspec/changes/fix-opencode-commands-directory/.openspec.yaml index 5fcdfb2ae1..e331c975d9 100644 --- a/openspec/changes/fix-opencode-commands-directory/.openspec.yaml +++ b/openspec/changes/fix-opencode-commands-directory/.openspec.yaml @@ -1,3 +1,2 @@ schema: spec-driven created: 2026-02-25 -initiative: smoother-setup diff --git a/openspec/changes/schema-alias-support/.openspec.yaml b/openspec/changes/schema-alias-support/.openspec.yaml index c9c46f60b9..749f7518cb 100644 --- a/openspec/changes/schema-alias-support/.openspec.yaml +++ b/openspec/changes/schema-alias-support/.openspec.yaml @@ -1,3 +1,2 @@ schema: spec-driven created: 2026-01-20 -initiative: smoother-setup diff --git a/openspec/changes/simplify-skill-installation/.openspec.yaml b/openspec/changes/simplify-skill-installation/.openspec.yaml index 42e8161e7c..c8d3976aee 100644 --- a/openspec/changes/simplify-skill-installation/.openspec.yaml +++ b/openspec/changes/simplify-skill-installation/.openspec.yaml @@ -1,3 +1,2 @@ schema: spec-driven created: 2026-02-17 -initiative: smoother-setup diff --git a/openspec/config.yaml b/openspec/config.yaml index 138c6501d9..0b7ad5176a 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -1,10 +1,6 @@ schema: spec-driven context: | - This repo's initiatives live in openspec/initiatives/ — read - smoother-setup/goal.md first. Changes whose .openspec.yaml says - `initiative: smoother-setup` serve that initiative. - Tech stack: TypeScript, Node.js (≥20.19.0), ESM modules Package manager: pnpm CLI framework: Commander.js diff --git a/openspec/initiatives/smoother-setup/01_who/personas.md b/openspec/initiatives/smoother-setup/01_who/personas.md deleted file mode 100644 index cdccc50ec9..0000000000 --- a/openspec/initiatives/smoother-setup/01_who/personas.md +++ /dev/null @@ -1,10 +0,0 @@ -# Who this serves - -**A new user.** Wants a working first change in minutes. Blocked by too many -concepts before the first win. - -**A lead across repos.** Wants one plan several repos can read. Blocked by -plans living in wikis their agents can't see. - -**A coding agent.** Wants clear intent and a next action. Blocked by context -scattered across tools. diff --git a/openspec/initiatives/smoother-setup/02_decisions/decisions.md b/openspec/initiatives/smoother-setup/02_decisions/decisions.md deleted file mode 100644 index b3f0572d51..0000000000 --- a/openspec/initiatives/smoother-setup/02_decisions/decisions.md +++ /dev/null @@ -1,7 +0,0 @@ -# Decisions - -**Aliases over rename.** `spec-driven` and `openspec-default` both work. -Easier: nothing breaks. Harder: two names for one thing, for a while. - -**Tool-native paths.** Files go where each tool expects, not where we prefer. -Easier: everything just works per tool. Harder: our layout varies by tool. diff --git a/openspec/initiatives/smoother-setup/goal.md b/openspec/initiatives/smoother-setup/goal.md deleted file mode 100644 index 7383c710cb..0000000000 --- a/openspec/initiatives/smoother-setup/goal.md +++ /dev/null @@ -1,7 +0,0 @@ -# Goal: smoother setup - -New users should reach a first win fast. Today the first run asks too much: -too many skills up front, files landing where tools don't expect them, no say -in where things install, schema names that confuse. - -Done looks like: install, init, first change — with nothing to explain. diff --git a/src/cli/index.ts b/src/cli/index.ts index 4375180243..3fe2971eda 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -9,10 +9,10 @@ import { AI_TOOLS } from '../core/config.js'; import { UpdateCommand } from '../core/update.js'; import { ListCommand } from '../core/list.js'; import { - rollupInitiatives, - rollupRegisteredStorePortfolios, - type PortfolioInfo, -} from '../core/initiatives.js'; + rollupDownstream, + rollupRegisteredStores, + type DownstreamRollup, +} from '../core/upstream.js'; import { ArchiveCommand, type ArchiveOptions } from '../core/archive.js'; import { ViewCommand } from '../core/view.js'; import { resolveRootForCommand, toRootOutput, isStoreSelectedRoot, type ResolvedOpenSpecRoot } from '../core/root-selection.js'; @@ -58,48 +58,43 @@ function hiddenStorePathOption(): Option { ).hideHelp(); } -function printPortfolioBody( - portfolio: PortfolioInfo, +function printRollupBody( + rollup: DownstreamRollup, indent: string, - // Store portfolios need the store-qualified ref in the link hint — a bare - // would not resolve from a consumer repo. + // Store rollups need the store-qualified ref in the link hint — a bare + // would not resolve from a consumer repo. refPrefix = '' ): void { - if (portfolio.evergreen.length > 0) { - console.log(`${indent}Evergreen: ${portfolio.evergreen.join(', ')}`); - } - if (portfolio.initiatives.length === 0) { - if (portfolio.evergreen.length === 0) { - // The empty state must teach the whole layout: a first-time user with - // a fresh store has no other way to learn it from the CLI. - console.log( - `${indent}(empty — add evergreen files like product.md or roadmap.md, and one folder per initiative;` - ); - console.log( - `${indent} numbered subfolders like 00_intent/ 01_engineering/ become its ordered stages)` - ); - } + if (rollup.upstream.length === 0) { + // The empty state must teach the whole move: a first-time user with a + // fresh store has no other way to learn it from the CLI. + console.log(`${indent}(no changes yet — upstream work is just a change here:`); + console.log( + `${indent} openspec new change ${refPrefix ? ` --store ${refPrefix.slice(0, -1)}` : ''}, then link work to it from any repo` + ); + console.log( + `${indent} with: openspec new change --serves ${refPrefix})` + ); return; } - let anyChanges = false; - for (const initiative of portfolio.initiatives) { + let anyServing = false; + for (const entry of rollup.upstream) { const summary = - initiative.changesTotal === 0 - ? 'no changes yet' - : `${initiative.changesComplete}/${initiative.changesTotal} changes complete`; - const missing = initiative.exists ? '' : ' (no folder)'; + entry.changesTotal === 0 + ? 'nothing serves it yet' + : `${entry.changesComplete}/${entry.changesTotal} serving changes complete`; + const marker = !entry.exists + ? ' (no such change)' + : entry.archived + ? ' (archived — its requirements now live in specs/)' + : ''; console.log(''); - console.log(`${indent}${initiative.name} ${summary}${missing}`); - if (initiative.stages.length > 0) { - console.log( - `${indent} stages: ${initiative.stages.map((stage) => stage.name).join(' → ')}` - ); - } - if (initiative.changes.length === 0) continue; - anyChanges = true; - const changeWidth = Math.max(...initiative.changes.map((c) => c.id.length)); - for (const change of initiative.changes) { + console.log(`${indent}${entry.id} ${summary}${marker}`); + if (entry.changes.length === 0) continue; + anyServing = true; + const changeWidth = Math.max(...entry.changes.map((c) => c.id.length)); + for (const change of entry.changes) { const mark = change.state === 'complete' ? '✓' : change.state === 'no-tasks' ? '–' : '·'; const where = change.store ?? change.repo ?? 'here'; @@ -111,70 +106,68 @@ function printPortfolioBody( `${indent} ${mark} ${change.id.padEnd(changeWidth)} ${where.padEnd(14)} ${tasks}` ); } - // Truth flows up: an initiative whose changes are all complete is the - // trigger to update the evergreen artifacts it served. - if (initiative.changesTotal > 0 && initiative.changesComplete === initiative.changesTotal) { + // Truth flows up: upstream work whose serving changes are all complete + // is ready to archive, which syncs its requirements into specs. + if (entry.changesTotal > 0 && entry.changesComplete === entry.changesTotal && !entry.archived) { console.log( - `${indent} all changes complete — sync the evergreen artifacts this initiative served` + `${indent} all serving changes complete — archive it to sync its specs` ); } } - if (!anyChanges) { + if (!anyServing) { console.log(''); console.log( - `${indent}Link a change: openspec new change --initiative ${refPrefix}` + `${indent}Link work to a change here: openspec new change --serves ${refPrefix}` ); } } -async function renderInitiatives( +async function renderDownstream( root: ResolvedOpenSpecRoot, options: { json?: boolean } ): Promise { - const initiatives = await rollupInitiatives(root.path); + const downstream = await rollupDownstream(root.path); if (options.json) { console.log( - JSON.stringify({ initiatives, root: toRootOutput(root) }, null, 2) + JSON.stringify({ downstream, root: toRootOutput(root) }, null, 2) ); return; } - if (initiatives === null) { - console.log('No initiatives folder found at openspec/initiatives/.'); - console.log( - 'Start one: mkdir -p openspec/initiatives/, then add any doc (numbered subfolders inside become ordered stages).' - ); + if (downstream === null) { + console.log('No changes folder found at openspec/changes/.'); + console.log('Create the first change: openspec new change '); return; } - console.log(`Initiatives: ${initiatives.path}`); - printPortfolioBody(initiatives, '', isStoreSelectedRoot(root) ? `${root.storeId}/` : ''); + console.log(`Downstream rollup: ${downstream.path}`); + printRollupBody(downstream, '', isStoreSelectedRoot(root) ? `${root.storeId}/` : ''); } /** - * Outside any root, the portfolio question is still answerable: show the - * initiatives of every registered store that has them. + * Outside any root, the rollup question is still answerable: show the + * downstream work of every registered store that has changes. */ -async function renderStorePortfolios(options: { json?: boolean }): Promise { - const stores = await rollupRegisteredStorePortfolios(); +async function renderStoreRollups(options: { json?: boolean }): Promise { + const stores = await rollupRegisteredStores(); if (options.json) { - console.log(JSON.stringify({ initiatives: null, stores, root: null }, null, 2)); + console.log(JSON.stringify({ downstream: null, stores, root: null }, null, 2)); return; } if (stores.length === 0) { - console.log('No local OpenSpec root, and no registered store has initiatives.'); + console.log('No local OpenSpec root, and no registered store has changes.'); console.log('Run inside a repo, or pass --store .'); return; } - console.log('No local OpenSpec root — showing registered store portfolios.'); - for (const { store, portfolio } of stores) { + console.log('No local OpenSpec root — showing registered store rollups.'); + for (const { store, rollup } of stores) { console.log(''); - console.log(`${store} (${portfolio.path})`); - printPortfolioBody(portfolio, ' ', `${store}/`); + console.log(`${store} (${rollup.path})`); + printRollupBody(rollup, ' ', `${store}/`); } } @@ -339,39 +332,39 @@ program program .command('list') - .description('List items (changes by default). Use --specs to list specs, --initiatives to see the portfolio rollup.') + .description('List items (changes by default). Use --specs to list specs, --downstream to see the upstream rollup.') .option('--specs', 'List specs instead of changes') .option('--changes', 'List changes explicitly (default)') - .option('--initiatives', "Show the root's portfolio: every initiative, and every change pointing at it") + .option('--downstream', "Show the root's changes and every change on this machine that serves them") .option('--sort ', 'Sort order: "recent" (default) or "name"', 'recent') .option('--json', 'Output as JSON (for programmatic use)') .option('--store ', STORE_OPTION_DESCRIPTION) .addOption(hiddenStorePathOption()) - .action(async (options?: { specs?: boolean; changes?: boolean; initiatives?: boolean; sort?: string; json?: boolean; store?: string; storePath?: string }) => { + .action(async (options?: { specs?: boolean; changes?: boolean; downstream?: boolean; sort?: string; json?: boolean; store?: string; storePath?: string }) => { try { - const failurePayload = options?.initiatives - ? { initiatives: null, root: null } + const failurePayload = options?.downstream + ? { downstream: null, root: null } : options?.specs ? { specs: [], root: null } : { changes: [], root: null }; - // The portfolio question is the one `list` answer that still makes - // sense outside any root: fall back to registered store portfolios + // The rollup question is the one `list` answer that still makes + // sense outside any root: fall back to registered store rollups // instead of failing root resolution. if ( - options?.initiatives && + options?.downstream && options.store === undefined && options.storePath === undefined ) { try { const root = await resolveRootForCommand(options ?? {}); if (root) { - await renderInitiatives(root, { json: options?.json }); + await renderDownstream(root, { json: options?.json }); } return; } catch (error) { const code = (error as { diagnostic?: { code?: string } }).diagnostic?.code; if (code !== 'no_root_with_registered_stores') throw error; - await renderStorePortfolios({ json: options?.json }); + await renderStoreRollups({ json: options?.json }); return; } } @@ -382,8 +375,8 @@ program if (!root) { return; } - if (options?.initiatives) { - await renderInitiatives(root, { json: options?.json }); + if (options?.downstream) { + await renderDownstream(root, { json: options?.json }); return; } const listCommand = new ListCommand(); @@ -397,8 +390,8 @@ program } catch (error) { failWithError(error, { enabled: options?.json, - payload: options?.initiatives - ? { initiatives: null, root: null } + payload: options?.downstream + ? { downstream: null, root: null } : options?.specs ? { specs: [], root: null } : { changes: [], root: null }, @@ -724,12 +717,13 @@ newCmd .option('--description ', 'Description to add to README.md') .option('--goal ', 'Optional goal metadata to store with the change') .option('--schema ', `Workflow schema to use (default: ${DEFAULT_SCHEMA})`) - .option('--initiative ', 'Link this change to an initiative: or /') + .option('--serves ', 'Link this change to the work it serves: or /') .option('--json', 'Output as JSON') .option('--store ', STORE_OPTION_DESCRIPTION) .addOption(hiddenStorePathOption()) - // Removed option kept registered (hidden) so users get a deliberate + // Removed options kept registered (hidden) so users get a deliberate // explanation instead of a generic unknown-option error. + .addOption(new Option('--initiative ', 'No longer supported').hideHelp()) .addOption(new Option('--areas ', 'No longer supported').hideHelp()) .action(async (name: string, options: NewChangeOptions) => { try { diff --git a/src/commands/context.ts b/src/commands/context.ts index d1c0734c76..e817d74e36 100644 --- a/src/commands/context.ts +++ b/src/commands/context.ts @@ -27,8 +27,9 @@ import { COMMON_FLAGS } from '../core/completions/shared-flags.js'; import { emitFailure, printJson } from './shared-output.js'; import { gatherRelationshipData } from './shared-gather.js'; import { listSchemasWithInfo } from '../core/artifact-graph/index.js'; -import { listInitiativeNames } from '../core/initiatives.js'; -import { schemasFetchRecipe, initiativesFetchRecipe } from '../core/references.js'; +import { getActiveChangeIds } from '../utils/item-discovery.js'; +import { readProjectConfig } from '../core/project-config.js'; +import { schemasFetchRecipe, changesFetchRecipe } from '../core/references.js'; const FAILURE_PAYLOAD = { root: null, members: [] }; @@ -64,8 +65,8 @@ function memberLine(member: WorkingSetMember): string { /** * Enrich available referenced-store members with the store's own custom - * artifact types and initiatives, so `context` reports what a repo draws on - * beyond specs. Read-only; failures degrade to an unenriched member. + * artifact types and in-motion changes, so `context` reports what a repo + * draws on beyond specs. Read-only; failures degrade to an unenriched member. */ async function enrichMembersWithStoreArtifacts( workingSet: WorkingSet @@ -86,14 +87,24 @@ async function enrichMembersWithStoreArtifacts( // Unreadable schemas dir: leave the member unenriched. } try { - // Initiative names — or, with none yet, the evergreen artifact - // names. Either way the agent sees the planning layer exists. - const names = await listInitiativeNames(storeRoot); - if (names.length > 0) { - member.initiatives = names; + // In-motion change ids: specs say what is true; these say what the + // team there is currently deciding. + const changeIds = await getActiveChangeIds(storeRoot); + if (changeIds.length > 0) { + member.changes = changeIds; } } catch { - // Unreadable initiatives dir: leave the member unenriched. + // Unreadable changes dir: leave the member unenriched. + } + try { + // The store's declared layout (config `structure:`), so agents know + // what its non-reserved folders are for without spelunking. + const structure = readProjectConfig(storeRoot)?.structure; + if (structure && Object.keys(structure).length > 0) { + member.structure = structure; + } + } catch { + // Unreadable config: leave the member unenriched. } } } @@ -123,11 +134,16 @@ function printHumanWorkingSet(workingSet: WorkingSet, declaredReferenceCount: nu ` Artifact types: ${member.artifactTypes.join(', ')} (${schemasFetchRecipe(member.id)})` ); } - if (member.initiatives && member.initiatives.length > 0) { + if (member.changes && member.changes.length > 0) { console.log( - ` Initiatives: ${member.initiatives.join(', ')} (${initiativesFetchRecipe(member.id)})` + ` In motion: ${member.changes.join(', ')} (${changesFetchRecipe(member.id)})` ); } + if (member.structure && Object.keys(member.structure).length > 0) { + for (const [folder, purpose] of Object.entries(member.structure)) { + console.log(` Layout: ${folder} — ${purpose}`); + } + } } } diff --git a/src/commands/workflow/instructions.ts b/src/commands/workflow/instructions.ts index 77443a06a7..1e5d02af02 100644 --- a/src/commands/workflow/instructions.ts +++ b/src/commands/workflow/instructions.ts @@ -36,9 +36,9 @@ import { import { readRegistrySnapshot } from '../../core/store/registry.js'; import { readProjectConfig, type ProjectConfig } from '../../core/project-config.js'; import { - resolveInitiativeLink, - type ResolvedInitiativeLink, -} from '../../core/initiatives.js'; + resolveUpstreamLink, + type ResolvedUpstreamLink, +} from '../../core/upstream.js'; import { validateChangeExists, validateSchemaExists, @@ -103,28 +103,33 @@ async function loadRootConfigContext(root: ResolvedOpenSpecRoot): Promise<{ /** * The upward join: a linked change's instructions hand the agent the - * initiative it serves and where its upstream context lives on disk, so + * upstream change it serves and where that context lives on disk, so * intent reaches the working agent without anyone pasting it. */ -function renderInitiativeBlock(link: ResolvedInitiativeLink): string { +function renderUpstreamBlock(link: ResolvedUpstreamLink): string { const lines = [ - ``, - `This change serves initiative '${link.name}'${link.store ? ` in store '${link.store}'` : ''}.`, + ``, + `This change serves the change '${link.changeId}'${link.store ? ` in store '${link.store}'` : ''}.`, ]; - if (link.path) { + if (link.path && link.archived) { + lines.push(`Upstream context (archived): ${link.path}`); + lines.push( + "The upstream change has been archived — its requirements were synced into that root's openspec/specs/, which is now the standing truth to trace against." + ); + } else if (link.path) { lines.push(`Upstream context: ${link.path}`); lines.push( - 'Before working, read the evergreen files beside that initiative and every lower-numbered stage inside it; this change should trace to something upstream.' + "Before working, read its artifacts (proposal, specs, and any others its schema defines); this change should trace to a requirement there. Standing truths live beside it in that root's openspec/specs/." ); } else { lines.push( - 'The linked initiative folder was not found on disk — the link may be stale, or the store may not be registered on this machine.' + 'The upstream change was not found on disk — the link may be stale, or the store may not be registered on this machine.' ); } lines.push( - `Status rollup: openspec list --initiatives${link.store ? ` --store ${link.store}` : ''}` + `Status rollup: openspec list --downstream${link.store ? ` --store ${link.store}` : ''}` ); - lines.push(''); + lines.push(''); return lines.join('\n'); } @@ -185,7 +190,7 @@ export async function instructionsCommand( references, }); const isBlocked = instructions.dependencies.some((d) => !d.done); - const initiative = await resolveInitiativeLink(context.changeDir, projectRoot); + const upstream = await resolveUpstreamLink(context.changeDir, projectRoot); spinner?.stop(); @@ -194,7 +199,7 @@ export async function instructionsCommand( JSON.stringify( { ...instructions, - ...(initiative ? { initiative } : {}), + ...(upstream ? { upstream } : {}), root: toRootOutput(root), }, null, @@ -204,7 +209,7 @@ export async function instructionsCommand( return; } - printInstructionsText(instructions, isBlocked, initiative); + printInstructionsText(instructions, isBlocked, upstream); } catch (error) { spinner?.stop(); throw error; @@ -214,7 +219,7 @@ export async function instructionsCommand( export function printInstructionsText( instructions: ArtifactInstructions, isBlocked: boolean, - initiative?: ResolvedInitiativeLink | null + upstream?: ResolvedUpstreamLink | null ): void { const { artifactId, @@ -252,6 +257,15 @@ export function printInstructionsText( console.log(''); console.log(); + // Schema-level workflow guidance (the schema author speaking to agents) + if (instructions.schemaNotes) { + console.log(''); + console.log(''); + console.log(instructions.schemaNotes.trim()); + console.log(''); + console.log(); + } + // Project context (AI constraint - do not include in output) if (context) { console.log(''); @@ -261,9 +275,9 @@ export function printInstructionsText( console.log(); } - // The initiative this change serves (read-only upstream context) - if (initiative) { - console.log(renderInitiativeBlock(initiative)); + // The upstream change this change serves (read-only upstream context) + if (upstream) { + console.log(renderUpstreamBlock(upstream)); console.log(); } @@ -486,6 +500,7 @@ export async function generateApplyInstructions( state, missingArtifacts: missingArtifacts.length > 0 ? missingArtifacts : undefined, instruction, + ...(schema.notes ? { schemaNotes: schema.notes } : {}), ...(references !== undefined ? { references } : {}), }; } @@ -520,7 +535,7 @@ export async function applyInstructionsCommand(options: ApplyInstructionsOptions planningHome, references, }); - const initiative = await resolveInitiativeLink(instructions.changeDir, projectRoot); + const upstream = await resolveUpstreamLink(instructions.changeDir, projectRoot); spinner?.stop(); @@ -529,7 +544,7 @@ export async function applyInstructionsCommand(options: ApplyInstructionsOptions JSON.stringify( { ...instructions, - ...(initiative ? { initiative } : {}), + ...(upstream ? { upstream } : {}), root: toRootOutput(root), }, null, @@ -539,7 +554,7 @@ export async function applyInstructionsCommand(options: ApplyInstructionsOptions return; } - printApplyInstructionsText(instructions, initiative); + printApplyInstructionsText(instructions, upstream); } catch (error) { spinner?.stop(); throw error; @@ -548,7 +563,7 @@ export async function applyInstructionsCommand(options: ApplyInstructionsOptions export function printApplyInstructionsText( instructions: ApplyInstructions, - initiative?: ResolvedInitiativeLink | null + upstream?: ResolvedUpstreamLink | null ): void { const { changeName, schemaName, contextFiles, progress, tasks, state, missingArtifacts, instruction } = instructions; @@ -556,9 +571,17 @@ export function printApplyInstructionsText( console.log(`Schema: ${schemaName}`); console.log(); - // The initiative this change serves (read-only upstream context) - if (initiative) { - console.log(renderInitiativeBlock(initiative)); + // Schema-level workflow guidance (the schema author speaking to agents) + if (instructions.schemaNotes) { + console.log('### Schema Notes'); + console.log(); + console.log(instructions.schemaNotes.trim()); + console.log(); + } + + // The upstream change this change serves (read-only upstream context) + if (upstream) { + console.log(renderUpstreamBlock(upstream)); console.log(); } diff --git a/src/commands/workflow/new-change.ts b/src/commands/workflow/new-change.ts index c96294280b..75ef42167e 100644 --- a/src/commands/workflow/new-change.ts +++ b/src/commands/workflow/new-change.ts @@ -3,14 +3,14 @@ * * Creates a new change directory with optional description and schema in the * resolved OpenSpec root. `--store ` selects a registered store's root; - * `--initiative ` links the change upward to an initiative. Workspace + * `--serves ` links the change upward to the work it serves. Workspace * affected areas are no longer part of this command. */ import ora from 'ora'; import path from 'path'; -import { InitiativeRefSchema } from '../../core/change-metadata/schema.js'; -import { recordLinkedRoot } from '../../core/initiatives.js'; +import { ServesRefSchema } from '../../core/change-metadata/schema.js'; +import { recordLinkedRoot, resolveUpstreamLink } from '../../core/upstream.js'; import { addReferenceToProjectConfig } from '../../core/project-config.js'; import { createChange, validateChangeName } from '../../utils/change-utils.js'; import { formatChangeLocation } from '../../core/planning-home.js'; @@ -34,10 +34,11 @@ export interface NewChangeOptions { description?: string; goal?: string; schema?: string; - /** Link the change to an initiative: `` or `/`. */ - initiative?: string; + /** Link the change to the work it serves: `` or `/`. */ + serves?: string; store?: string; storePath?: string; + initiative?: string; areas?: string; json?: boolean; } @@ -49,9 +50,11 @@ interface NewChangeOutput { metadataPath: string; schema: string; }; - /** Present when the change was born linked to an initiative. */ - initiative?: { + /** Present when the change was born serving upstream work. */ + serves?: { ref: string; + /** Whether the upstream change was found on disk at link time. */ + resolved: boolean; /** Whether linking auto-added the store to `references:` in config. */ reference_wiring?: 'added' | 'already' | 'skipped'; }; @@ -63,6 +66,14 @@ interface NewChangeOutput { // ----------------------------------------------------------------------------- function assertRemovedOptionsAbsent(options: NewChangeOptions): void { + if (options.initiative !== undefined) { + throw new RootSelectionError( + '--initiative is no longer supported. Upstream work is a change in its store; link to it with --serves /.', + 'initiative_option_removed', + { target: 'change.options' } + ); + } + if (options.areas !== undefined) { throw new RootSelectionError( '--areas is no longer supported. Workspace affected areas are not part of the normal OpenSpec root path.', @@ -74,9 +85,7 @@ function assertRemovedOptionsAbsent(options: NewChangeOptions): void { function printCreatedChangeHuman( payload: NewChangeOutput, - root: ResolvedOpenSpecRoot, - initiative?: string, - referenceWiring?: 'added' | 'already' | 'skipped' | null + root: ResolvedOpenSpecRoot ): void { // A relative path is only honest when the root is where the user // stands; a distant ancestor root gets the absolute path. @@ -86,17 +95,23 @@ function printCreatedChangeHuman( : payload.change.path; console.log(`Created change '${payload.change.id}' at ${location}/`); console.log(`Schema: ${payload.change.schema}`); - if (initiative) { - const storeId = initiative.includes('/') ? initiative.split('/')[0] : null; + if (payload.serves) { + const { ref, resolved, reference_wiring: wiring } = payload.serves; + const storeId = ref.includes('/') ? ref.split('/')[0] : null; const rollup = storeId - ? `openspec list --initiatives --store ${storeId}` - : 'openspec list --initiatives'; - console.log(`Initiative: ${initiative} (rollup: ${rollup})`); - if (referenceWiring === 'added') { + ? `openspec list --downstream --store ${storeId}` + : 'openspec list --downstream'; + console.log(`Serves: ${ref} (rollup: ${rollup})`); + if (!resolved) { + console.log( + `Warning: '${ref}' was not found on this machine — the link will show as missing in rollups until that change exists.` + ); + } + if (wiring === 'added') { console.log( `Referenced store '${storeId}' in openspec/config.yaml — agents here now see its context.` ); - } else if (referenceWiring === 'skipped') { + } else if (wiring === 'skipped') { console.log( `Tip: add 'references: [${storeId}]' to openspec/config.yaml so agents here see that store's context.` ); @@ -122,8 +137,8 @@ export async function newChangeCommand(name: string | undefined, options: NewCha // Validate the ref before anything is created: a bad value must not // leave a half-written change behind. - if (options.initiative !== undefined) { - const ref = InitiativeRefSchema.safeParse(options.initiative); + if (options.serves !== undefined) { + const ref = ServesRefSchema.safeParse(options.serves); if (!ref.success) { throw new Error(ref.error.issues[0].message); } @@ -155,7 +170,7 @@ export async function newChangeCommand(name: string | undefined, options: NewCha changesDir: root.changesDir, metadata: { ...(options.goal ? { goal: options.goal } : {}), - ...(options.initiative ? { initiative: options.initiative } : {}), + ...(options.serves ? { serves: options.serves } : {}), }, }); @@ -166,14 +181,14 @@ export async function newChangeCommand(name: string | undefined, options: NewCha await fs.writeFile(readmePath, `# ${name}\n\n${options.description}\n`, 'utf-8'); } - // A store-qualified link makes this repo part of that store's portfolio. + // A store-qualified link makes this repo part of that store's rollup. // Linking does ALL the wiring: record the checkout so rollups scan it, // and reference the store so agents here see its context — neither // failure may fail the created change. let referenceWiring: 'added' | 'already' | 'skipped' | null = null; - if (options.initiative?.includes('/')) { + if (options.serves?.includes('/')) { await recordLinkedRoot(projectRoot).catch(() => undefined); - const storeId = options.initiative.split('/')[0]; + const storeId = options.serves.split('/')[0]; try { referenceWiring = addReferenceToProjectConfig(projectRoot, storeId); } catch { @@ -181,6 +196,16 @@ export async function newChangeCommand(name: string | undefined, options: NewCha } } + // Resolve (never block on) the upstream link so a typo is visible at + // creation time instead of surfacing later as a dangling rollup entry. + let servesResolved = false; + if (options.serves) { + const link = await resolveUpstreamLink(result.changeDir, projectRoot).catch( + () => null + ); + servesResolved = link?.path !== null && link !== null; + } + const payload: NewChangeOutput = { change: { id: name, @@ -188,10 +213,11 @@ export async function newChangeCommand(name: string | undefined, options: NewCha metadataPath: path.join(result.changeDir, '.openspec.yaml'), schema: result.schema, }, - ...(options.initiative + ...(options.serves ? { - initiative: { - ref: options.initiative, + serves: { + ref: options.serves, + resolved: servesResolved, ...(referenceWiring ? { reference_wiring: referenceWiring } : {}), }, } @@ -205,7 +231,7 @@ export async function newChangeCommand(name: string | undefined, options: NewCha } spinner?.stop(); - printCreatedChangeHuman(payload, root, options.initiative, referenceWiring); + printCreatedChangeHuman(payload, root); } catch (error) { spinner?.stop(); if (options.json) { diff --git a/src/commands/workflow/schemas.ts b/src/commands/workflow/schemas.ts index 65c2cb5ba9..fd2b548600 100644 --- a/src/commands/workflow/schemas.ts +++ b/src/commands/workflow/schemas.ts @@ -48,6 +48,8 @@ export async function schemasCommand(options: SchemasOptions): Promise { let sourceLabel = ''; if (schema.source === 'project') { sourceLabel = chalk.cyan(' (project)'); + } else if (schema.source === 'store') { + sourceLabel = chalk.cyan(` (from store '${schema.store}')`); } else if (schema.source === 'user') { sourceLabel = chalk.dim(' (user override)'); } diff --git a/src/commands/workflow/shared.ts b/src/commands/workflow/shared.ts index f4350a6d9c..c3471f1d37 100644 --- a/src/commands/workflow/shared.ts +++ b/src/commands/workflow/shared.ts @@ -45,6 +45,8 @@ export interface ApplyInstructions { state: 'blocked' | 'all_done' | 'ready'; missingArtifacts?: string[]; instruction: string; + /** Workflow-level guidance from the schema's `notes:` field */ + schemaNotes?: string; /** Referenced-store index (read-only upstream context; omitted when none declared) */ references?: ReferenceIndexEntry[]; } diff --git a/src/core/artifact-graph/instruction-loader.ts b/src/core/artifact-graph/instruction-loader.ts index aa4a78e6be..6c641e52ea 100644 --- a/src/core/artifact-graph/instruction-loader.ts +++ b/src/core/artifact-graph/instruction-loader.ts @@ -51,6 +51,8 @@ export interface ChangeContext { changeDir: string; /** Project root directory */ projectRoot: string; + /** Workflow-level guidance from the schema's `notes:` field */ + schemaNotes?: string; /** Resolved planning home for this change */ planningHome?: PlanningHome; /** Parsed change metadata, when present */ @@ -86,6 +88,8 @@ export interface ArtifactInstructions { description: string; /** Guidance on how to create this artifact (from schema instruction field) */ instruction: string | undefined; + /** Workflow-level guidance from the schema's `notes:` field */ + schemaNotes?: string; /** Project context from config (constraints/background for AI, not to be included in output) */ context: string | undefined; /** Artifact-specific rules from config (constraints for AI, not to be included in output) */ @@ -244,6 +248,7 @@ export function loadChangeContext( changeName, changeDir, projectRoot, + ...(schema.notes ? { schemaNotes: schema.notes } : {}), ...(options.planningHome ? { planningHome: options.planningHome } : {}), ...(metadata ? { metadata } : {}), }; @@ -332,6 +337,7 @@ export function generateInstructions( existingOutputPaths: resolveArtifactOutputs(context.changeDir, artifact.generates), description: artifact.description, instruction: artifact.instruction, + ...(context.schemaNotes ? { schemaNotes: context.schemaNotes } : {}), context: configContext, rules: configRules, ...(options.references !== undefined ? { references: options.references } : {}), diff --git a/src/core/artifact-graph/resolver.ts b/src/core/artifact-graph/resolver.ts index 9ccd48abaf..abf281b7c3 100644 --- a/src/core/artifact-graph/resolver.ts +++ b/src/core/artifact-graph/resolver.ts @@ -2,6 +2,12 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import { getGlobalDataDir } from '../global-config.js'; +import { readProjectConfig } from '../project-config.js'; +import { + getStoreRegistryPath, + parseStoreRegistryState, +} from '../store/foundation.js'; +import { getStoreRootForBackend } from '../store/registry.js'; import { parseSchema, SchemaValidationError } from './schema.js'; import type { SchemaYaml } from './types.js'; @@ -45,13 +51,72 @@ export function getProjectSchemasDir(projectRoot: string): string { return path.join(projectRoot, 'openspec', 'schemas'); } +/** A referenced store whose schemas this root inherits. */ +export interface ReferencedSchemaSource { + storeId: string; + /** The store's openspec/schemas directory. */ + dir: string; +} + +/** + * The schema directories of every store this root references, in + * declaration order — a repo that declares `references: [team-hub]` + * inherits team-hub's schemas the way it already sees its specs. + * + * Deliberately tolerant and synchronous: schema resolution runs deep in + * sync call chains, and a missing config, registry, or checkout must + * degrade to "no inherited schemas", never an error. One hop only — + * references of references are not followed. + */ +export function getReferencedSchemaSources( + projectRoot: string +): ReferencedSchemaSource[] { + let references; + try { + references = readProjectConfig(projectRoot)?.references; + } catch { + return []; + } + if (!references || references.length === 0) return []; + + let registry; + try { + registry = parseStoreRegistryState( + fs.readFileSync(getStoreRegistryPath({}), 'utf-8') + ); + } catch { + return []; + } + + const resolvedRoot = path.resolve(projectRoot); + const sources: ReferencedSchemaSource[] = []; + for (const reference of references) { + const entry = registry.stores[reference.id]; + if (!entry) continue; + let storeRoot; + try { + storeRoot = getStoreRootForBackend(entry.backend); + } catch { + continue; + } + // A store referencing itself must not shadow its own project dir. + if (path.resolve(storeRoot) === resolvedRoot) continue; + sources.push({ + storeId: reference.id, + dir: getProjectSchemasDir(storeRoot), + }); + } + return sources; +} + /** * Resolves a schema name to its directory path. * * Resolution order (when projectRoot is provided): * 1. Project-local: /openspec/schemas//schema.yaml - * 2. User override: ${XDG_DATA_HOME}/openspec/schemas//schema.yaml - * 3. Package built-in: /schemas//schema.yaml + * 2. Referenced stores (declaration order): /openspec/schemas//schema.yaml + * 3. User override: ${XDG_DATA_HOME}/openspec/schemas//schema.yaml + * 4. Package built-in: /schemas//schema.yaml * * When projectRoot is not provided, only user override and package built-in are checked * (backward compatible behavior). @@ -71,6 +136,15 @@ export function getSchemaDir( if (fs.existsSync(projectSchemaPath)) { return projectDir; } + + // 1b. Check referenced stores (declaration order): schemas inherit + // through `references:` the way specs already surface through them. + for (const source of getReferencedSchemaSources(projectRoot)) { + const storeDir = path.join(source.dir, name); + if (fs.existsSync(path.join(storeDir, 'schema.yaml'))) { + return storeDir; + } + } } // 2. Check user override directory @@ -134,7 +208,7 @@ export function resolveSchema(name: string, projectRoot?: string): SchemaYaml { } try { - return parseSchema(content); + return applyInstructionFiles(parseSchema(content), schemaDir); } catch (err) { if (err instanceof SchemaValidationError) { throw new SchemaLoadError( @@ -152,6 +226,45 @@ export function resolveSchema(name: string, projectRoot?: string): SchemaYaml { } } +/** + * Long-form guidance can live beside the schema instead of inline YAML: + * `/instructions/.md` supplies an artifact's + * instruction (and `instructions/apply.md` the apply phase's), the same + * way templates already live in `/templates/`. A file wins + * over an inline `instruction:` value. + */ +function applyInstructionFiles(schema: SchemaYaml, schemaDir: string): SchemaYaml { + const instructionsDir = path.join(schemaDir, 'instructions'); + if (!fs.existsSync(instructionsDir)) { + return schema; + } + + const readInstruction = (id: string): string | undefined => { + const filePath = path.join(instructionsDir, `${id}.md`); + try { + return fs.existsSync(filePath) + ? fs.readFileSync(filePath, 'utf-8').trim() + : undefined; + } catch { + return undefined; + } + }; + + for (const artifact of schema.artifacts) { + const fromFile = readInstruction(artifact.id); + if (fromFile) { + artifact.instruction = fromFile; + } + } + if (schema.apply) { + const fromFile = readInstruction('apply'); + if (fromFile) { + schema.apply.instruction = fromFile; + } + } + return schema; +} + /** * Lists all available schema names. * Combines project-local, user override, and package built-in schemas. @@ -187,13 +300,17 @@ export function listSchemas(projectRoot?: string): string[] { } } - // Add project-local schemas (if projectRoot provided) + // Add project-local and referenced-store schemas (if projectRoot provided) if (projectRoot) { - const projectDir = getProjectSchemasDir(projectRoot); - if (fs.existsSync(projectDir)) { - for (const entry of fs.readdirSync(projectDir, { withFileTypes: true })) { + const dirs = [ + getProjectSchemasDir(projectRoot), + ...getReferencedSchemaSources(projectRoot).map((source) => source.dir), + ]; + for (const dir of dirs) { + if (!fs.existsSync(dir)) continue; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (entry.isDirectory()) { - const schemaPath = path.join(projectDir, entry.name, 'schema.yaml'); + const schemaPath = path.join(dir, entry.name, 'schema.yaml'); if (fs.existsSync(schemaPath)) { schemas.add(entry.name); } @@ -212,7 +329,9 @@ export interface SchemaInfo { name: string; description: string; artifacts: string[]; - source: 'project' | 'user' | 'package'; + source: 'project' | 'store' | 'user' | 'package'; + /** The referenced store the schema is inherited from (source: 'store'). */ + store?: string; } /** @@ -225,78 +344,44 @@ export function listSchemasWithInfo(projectRoot?: string): SchemaInfo[] { const schemas: SchemaInfo[] = []; const seenNames = new Set(); - // Add project-local schemas first (highest priority, if projectRoot provided) - if (projectRoot) { - const projectDir = getProjectSchemasDir(projectRoot); - if (fs.existsSync(projectDir)) { - for (const entry of fs.readdirSync(projectDir, { withFileTypes: true })) { - if (entry.isDirectory()) { - const schemaPath = path.join(projectDir, entry.name, 'schema.yaml'); - if (fs.existsSync(schemaPath)) { - try { - const schema = parseSchema(fs.readFileSync(schemaPath, 'utf-8')); - schemas.push({ - name: entry.name, - description: schema.description || '', - artifacts: schema.artifacts.map((a) => a.id), - source: 'project', - }); - seenNames.add(entry.name); - } catch { - // Skip invalid schemas - } - } - } + const collectFromDir = ( + dir: string, + source: SchemaInfo['source'], + store?: string + ): void => { + if (!fs.existsSync(dir)) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory() || seenNames.has(entry.name)) continue; + const schemaPath = path.join(dir, entry.name, 'schema.yaml'); + if (!fs.existsSync(schemaPath)) continue; + try { + const schema = parseSchema(fs.readFileSync(schemaPath, 'utf-8')); + schemas.push({ + name: entry.name, + description: schema.description || '', + artifacts: schema.artifacts.map((a) => a.id), + source, + ...(store !== undefined ? { store } : {}), + }); + seenNames.add(entry.name); + } catch { + // Skip invalid schemas } } - } + }; - // Add user override schemas (if not overridden by project) - const userDir = getUserSchemasDir(); - if (fs.existsSync(userDir)) { - for (const entry of fs.readdirSync(userDir, { withFileTypes: true })) { - if (entry.isDirectory() && !seenNames.has(entry.name)) { - const schemaPath = path.join(userDir, entry.name, 'schema.yaml'); - if (fs.existsSync(schemaPath)) { - try { - const schema = parseSchema(fs.readFileSync(schemaPath, 'utf-8')); - schemas.push({ - name: entry.name, - description: schema.description || '', - artifacts: schema.artifacts.map((a) => a.id), - source: 'user', - }); - seenNames.add(entry.name); - } catch { - // Skip invalid schemas - } - } - } + // Add project-local schemas first, then schemas inherited from + // referenced stores (declaration order) — mirrors getSchemaDir. + if (projectRoot) { + collectFromDir(getProjectSchemasDir(projectRoot), 'project'); + for (const source of getReferencedSchemaSources(projectRoot)) { + collectFromDir(source.dir, 'store', source.storeId); } } - // Add package built-in schemas (if not overridden by project or user) - const packageDir = getPackageSchemasDir(); - if (fs.existsSync(packageDir)) { - for (const entry of fs.readdirSync(packageDir, { withFileTypes: true })) { - if (entry.isDirectory() && !seenNames.has(entry.name)) { - const schemaPath = path.join(packageDir, entry.name, 'schema.yaml'); - if (fs.existsSync(schemaPath)) { - try { - const schema = parseSchema(fs.readFileSync(schemaPath, 'utf-8')); - schemas.push({ - name: entry.name, - description: schema.description || '', - artifacts: schema.artifacts.map((a) => a.id), - source: 'package', - }); - } catch { - // Skip invalid schemas - } - } - } - } - } + // Add user override schemas (if not shadowed), then package built-ins. + collectFromDir(getUserSchemasDir(), 'user'); + collectFromDir(getPackageSchemasDir(), 'package'); return schemas.sort((a, b) => a.name.localeCompare(b.name)); } diff --git a/src/core/artifact-graph/types.ts b/src/core/artifact-graph/types.ts index c2d2128e45..87353fe466 100644 --- a/src/core/artifact-graph/types.ts +++ b/src/core/artifact-graph/types.ts @@ -25,6 +25,10 @@ export const SchemaYamlSchema = z.object({ name: z.string().min(1, { error: 'Schema name is required' }), version: z.number().int().positive({ error: 'Version must be a positive integer' }), description: z.string().optional(), + // Workflow-level guidance surfaced verbatim to agents on every + // instruction surface — how this schema's lifecycle differs from the + // default (e.g. "no implementation phase; archive after specs"). + notes: z.string().optional(), artifacts: z.array(ArtifactSchema).min(1, { error: 'At least one artifact required' }), // Optional apply phase configuration (for schema-aware apply instructions) apply: ApplyPhaseSchema.optional(), diff --git a/src/core/change-metadata/schema.ts b/src/core/change-metadata/schema.ts index 7e8879ee1c..a58a6ea25c 100644 --- a/src/core/change-metadata/schema.ts +++ b/src/core/change-metadata/schema.ts @@ -13,7 +13,6 @@ const KebabIdentifierSchema = (label: string): z.ZodString => } }); -/** Legacy shape; carries the same data as the string ref `/`. */ export const InitiativeLinkSchema = z.object({ store: KebabIdentifierSchema('Store id'), id: KebabIdentifierSchema('Initiative id'), @@ -21,9 +20,10 @@ export const InitiativeLinkSchema = z.object({ export type InitiativeLink = z.infer; -// The upward link to an initiative: `` for one in this change's own -// root, or `/` for one in that registered store. -export const InitiativeRefSchema = z.string().superRefine((value, ctx) => { +// The upward link to the work this change serves: `` for a change +// in the same root, or `/` for one in that registered +// store. +export const ServesRefSchema = z.string().superRefine((value, ctx) => { const parts = value.split('/'); const valid = (parts.length === 1 || parts.length === 2) && @@ -32,7 +32,7 @@ export const InitiativeRefSchema = z.string().superRefine((value, ctx) => { ctx.addIssue({ code: 'custom', message: - 'initiative must be a kebab-case name, optionally prefixed with a store id: or /', + 'serves must be a kebab-case change id, optionally prefixed with a store id: or /', }); } }); @@ -49,7 +49,8 @@ export const ChangeMetadataSchema = z.object({ .optional(), goal: z.string().min(1).optional(), affected_areas: z.array(z.string().min(1)).optional(), - initiative: z.union([InitiativeRefSchema, InitiativeLinkSchema]).optional(), + serves: ServesRefSchema.optional(), + initiative: InitiativeLinkSchema.optional(), }); export type ChangeMetadata = z.infer; diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 407f455a93..f23befd742 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -51,8 +51,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'List changes explicitly (default)', }, { - name: 'initiatives', - description: "Show the root's portfolio: every initiative, and every change pointing at it", + name: 'downstream', + description: "Show the root's changes and every change on this machine that serves them", }, { name: 'sort', @@ -247,8 +247,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ takesValue: true, }, { - name: 'initiative', - description: 'Link this change to an initiative: or /', + name: 'serves', + description: 'Link this change to the work it serves: or /', takesValue: true, }, COMMON_FLAGS.json, diff --git a/src/core/init.ts b/src/core/init.ts index 4e791bd8a4..b6ab31ab77 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -75,7 +75,6 @@ const WORKFLOW_TO_SKILL_DIR: Record = { 'verify': 'openspec-verify-change', 'onboard': 'openspec-onboard', 'propose': 'openspec-propose', - 'initiatives': 'openspec-initiatives', }; // ----------------------------------------------------------------------------- diff --git a/src/core/initiatives.ts b/src/core/initiatives.ts deleted file mode 100644 index 873fbef8d8..0000000000 --- a/src/core/initiatives.ts +++ /dev/null @@ -1,529 +0,0 @@ -/** - * Initiatives (stores beta). - * - * `openspec/initiatives/` is the planning layer of a root — this repo, or a - * store the team shares. It holds two kinds of things: - * - * - Each subfolder is one **initiative**: a finite piece of work above a - * single change. Contents are freeform; numbered folders inside an - * initiative (`00_goal/`, `01_requirements/`, ...) are ordered stages. - * - Unnumbered top-level files are **evergreen artifacts** — the standing - * truths every initiative serves (product, roadmap, architecture). - * - * Changes point UP at an initiative with one metadata line in - * `.openspec.yaml`: `initiative: ` (an initiative in the change's own - * root) or `initiative: /` (one in that registered store). - * There is no manifest — rollup discovers changes by scanning for that line, - * so teams tag their own work and nothing central needs maintaining. - */ - -import { promises as fs } from 'fs'; -import path from 'path'; -import { parse as parseYaml } from 'yaml'; - -import { getTaskProgressForChange } from '../utils/task-progress.js'; -import { writeFileAtomically } from './file-state.js'; -import { - getStoresDir, - listStoreRegistryEntries, - readStoreRegistryState, - type StorePathOptions, -} from './store/foundation.js'; -import { getStoreRootForBackend } from './store/registry.js'; - -export const INITIATIVES_DIRNAME = 'initiatives'; - -/** Numbered entries are stages: 00_goal, 01-requirements, 2_design ... */ -const STAGE_PATTERN = /^\d+[-_]/; - -export interface InitiativeStage { - name: string; - files: number; -} - -export interface InitiativeChangeStatus { - id: string; - /** Registered store the change lives in; absent = the portfolio's own root. */ - store?: string; - /** Linked (non-store) repo the change lives in, by directory name. */ - repo?: string; - completedTasks: number; - totalTasks: number; - state: 'complete' | 'in-progress' | 'no-tasks'; -} - -export interface InitiativeInfo { - name: string; - /** False when changes reference this name but no folder exists on disk. */ - exists: boolean; - stages: InitiativeStage[]; - /** Unnumbered entries inside the initiative: freeform artifacts. */ - artifacts: string[]; - changes: InitiativeChangeStatus[]; - changesComplete: number; - changesTotal: number; - tasksComplete: number; - tasksTotal: number; -} - -export interface PortfolioInfo { - path: string; - /** Unnumbered top-level files: standing truths every initiative serves. */ - evergreen: string[]; - initiatives: InitiativeInfo[]; -} - -function initiativesDir(root: string): string { - return path.join(root, 'openspec', INITIATIVES_DIRNAME); -} - -async function countFiles(dir: string): Promise { - let count = 0; - let entries; - try { - entries = await fs.readdir(dir, { withFileTypes: true }); - } catch { - return 0; - } - for (const entry of entries) { - if (entry.isDirectory()) { - count += await countFiles(path.join(dir, entry.name)); - } else { - count += 1; - } - } - return count; -} - -/** - * Normalizes an `initiative:` metadata value to its reference form: - * `` or `/`. The legacy object shape - * `{ store, id }` carries the same data and maps to `/`. - */ -export function normalizeInitiativeRef(value: unknown): string | null { - if (typeof value === 'string') { - return value.length > 0 ? value : null; - } - if (value !== null && typeof value === 'object') { - const { store, id } = value as { store?: unknown; id?: unknown }; - if (typeof store === 'string' && store.length > 0 && typeof id === 'string' && id.length > 0) { - return `${store}/${id}`; - } - } - return null; -} - -// --------------------------------------------------------------------------- -// Linked roots -// -// Rollup can only scan checkouts it knows about. Store roots come from the -// store registry; plain code repos become known the moment they link a change -// to a store's initiative (`new change --initiative /` records -// the repo's path here). Linking IS the registration — no extra command, and -// nothing is written into the repo itself. -// --------------------------------------------------------------------------- - -const LINKED_ROOTS_FILE_NAME = 'linked-roots.yaml'; - -function getLinkedRootsPath(options: StorePathOptions = {}): string { - return path.join(getStoresDir(options), LINKED_ROOTS_FILE_NAME); -} - -/** Known linked roots. Tolerant: unreadable state means "none". */ -export async function readLinkedRoots( - options: StorePathOptions = {} -): Promise { - try { - const raw = await fs.readFile(getLinkedRootsPath(options), 'utf-8'); - const parsed = parseYaml(raw) as { roots?: unknown } | null; - if (!Array.isArray(parsed?.roots)) return []; - return parsed.roots.filter( - (entry): entry is string => typeof entry === 'string' && entry.length > 0 - ); - } catch { - return []; - } -} - -/** - * Records a repo root so rollups scan it. Idempotent; stale entries are - * harmless (a missing directory scans as empty). - */ -export async function recordLinkedRoot( - root: string, - options: StorePathOptions = {} -): Promise { - const resolved = path.resolve(root); - const existing = await readLinkedRoots(options); - if (existing.includes(resolved)) return; - const roots = [...existing, resolved].sort(); - await fs.mkdir(getStoresDir(options), { recursive: true }); - await writeFileAtomically( - getLinkedRootsPath(options), - `version: 1\nroots:\n${roots.map((entry) => ` - ${JSON.stringify(entry)}`).join('\n')}\n` - ); -} - -interface InitiativeShape { - name: string; - stages: InitiativeStage[]; - artifacts: string[]; -} - -/** - * Reads a root's initiatives folder: subfolders are initiatives (with their - * stages and freeform artifacts), unnumbered top-level files are evergreen - * artifacts. Returns null when there is no initiatives folder. - */ -export async function readInitiativesShape( - root: string -): Promise<{ evergreen: string[]; initiatives: InitiativeShape[] } | null> { - const dir = initiativesDir(root); - let entries; - try { - entries = await fs.readdir(dir, { withFileTypes: true }); - } catch { - return null; - } - - const evergreen: string[] = []; - const initiatives: InitiativeShape[] = []; - for (const entry of entries) { - if (entry.name.startsWith('.')) continue; - if (!entry.isDirectory()) { - evergreen.push(entry.name); - continue; - } - const inner = await fs.readdir(path.join(dir, entry.name), { - withFileTypes: true, - }); - const stages: InitiativeStage[] = []; - const artifacts: string[] = []; - for (const item of inner) { - if (item.name.startsWith('.')) continue; - if (item.isDirectory() && STAGE_PATTERN.test(item.name)) { - stages.push({ - name: item.name, - files: await countFiles(path.join(dir, entry.name, item.name)), - }); - } else { - artifacts.push(item.name); - } - } - stages.sort((a, b) => a.name.localeCompare(b.name)); - artifacts.sort(); - initiatives.push({ name: entry.name, stages, artifacts }); - } - evergreen.sort(); - initiatives.sort((a, b) => a.name.localeCompare(b.name)); - return { evergreen, initiatives }; -} - -/** - * Reads the `initiative:` line from a change's `.openspec.yaml`, normalized - * to reference form. Tolerant: any unreadable or unlinked metadata simply - * means "not part of an initiative". - */ -export async function readInitiativeRef(changeDir: string): Promise { - try { - const raw = await fs.readFile(path.join(changeDir, '.openspec.yaml'), 'utf-8'); - const parsed = parseYaml(raw) as { initiative?: unknown } | null; - return normalizeInitiativeRef(parsed?.initiative); - } catch { - return null; - } -} - -/** - * Scans one root's changes and returns, per matching initiative name, the - * changes that point at it. `toName` maps a normalized ref to the initiative - * name it addresses in the portfolio being rolled up — or null when the ref - * points elsewhere. - */ -async function collectMatchingChanges( - root: string, - toName: (ref: string) => string | null, - label: { store?: string; repo?: string } | undefined -): Promise> { - const changesDir = path.join(root, 'openspec', 'changes'); - const found = new Map(); - let entries; - try { - entries = await fs.readdir(changesDir, { withFileTypes: true }); - } catch { - return found; - } - - for (const entry of entries) { - if (!entry.isDirectory() || entry.name === 'archive') continue; - const ref = await readInitiativeRef(path.join(changesDir, entry.name)); - if (ref === null) continue; - const name = toName(ref); - if (name === null) continue; - - const progress = await getTaskProgressForChange(changesDir, entry.name, root); - const status: InitiativeChangeStatus = { - id: entry.name, - ...(label?.store ? { store: label.store } : {}), - ...(label?.repo ? { repo: label.repo } : {}), - completedTasks: progress.completed, - totalTasks: progress.total, - state: - progress.total === 0 - ? 'no-tasks' - : progress.completed === progress.total - ? 'complete' - : 'in-progress', - }; - const bucket = found.get(name); - if (bucket) { - bucket.push(status); - } else { - found.set(name, [status]); - } - } - return found; -} - -function mergeChanges( - target: Map, - source: Map -): void { - for (const [name, changes] of source) { - const bucket = target.get(name); - if (bucket) { - bucket.push(...changes); - } else { - target.set(name, changes); - } - } -} - -/** - * Rolls up a root's portfolio: its evergreen artifacts and initiatives, plus - * every change on this machine that points at them. Local changes match - * `initiative: ` (or `/`); when the root is a - * registered store, other registered roots are scanned for - * `/`. Names referenced by changes but missing on disk are - * included with `exists: false` — a bad reference should be visible, not - * silently dropped. Returns null when the root has no initiatives folder. - */ -export async function rollupInitiatives( - root: string, - options: { globalDataDir?: string } = {} -): Promise { - const shape = await readInitiativesShape(root); - if (shape === null) { - return null; - } - - const registry = await readStoreRegistryState( - options.globalDataDir ? { globalDataDir: options.globalDataDir } : {} - ).catch(() => null); - const registered = registry ? listStoreRegistryEntries(registry) : []; - - // Which registered store, if any, is this root? - const resolvedRoot = path.resolve(root); - let ownStoreId: string | undefined; - const storeRoots: Array<{ id: string; root: string }> = []; - for (const entry of registered) { - try { - const entryRoot = path.resolve(getStoreRootForBackend(entry.backend)); - storeRoots.push({ id: entry.id, root: entryRoot }); - if (entryRoot === resolvedRoot) { - ownStoreId = entry.id; - } - } catch { - // Unusable backend — skipped; doctor reports it. - } - } - - // A ref addresses this portfolio as `` from its own root, or as - // `/` from anywhere. - const ownPrefix = ownStoreId !== undefined ? `${ownStoreId}/` : null; - const byName = await collectMatchingChanges( - root, - (ref) => { - if (!ref.includes('/')) return ref; - if (ownPrefix !== null && ref.startsWith(ownPrefix)) { - return ref.slice(ownPrefix.length); - } - return null; - }, - undefined - ); - - if (ownStoreId !== undefined && ownPrefix !== null) { - const toOwnName = (ref: string) => - ref.startsWith(ownPrefix) ? ref.slice(ownPrefix.length) : null; - const scanned = new Set([resolvedRoot]); - for (const store of storeRoots) { - if (scanned.has(store.root)) continue; - scanned.add(store.root); - mergeChanges( - byName, - await collectMatchingChanges(store.root, toOwnName, { store: store.id }) - ); - } - // Plain code repos that linked a change here (recorded at link time). - for (const linkedRoot of await readLinkedRoots( - options.globalDataDir ? { globalDataDir: options.globalDataDir } : {} - )) { - if (scanned.has(linkedRoot)) continue; - scanned.add(linkedRoot); - mergeChanges( - byName, - await collectMatchingChanges(linkedRoot, toOwnName, { - repo: path.basename(linkedRoot), - }) - ); - } - } - - const initiatives: InitiativeInfo[] = []; - const addInitiative = ( - name: string, - exists: boolean, - stages: InitiativeStage[], - artifacts: string[] - ) => { - const changes = (byName.get(name) ?? []).sort((a, b) => - a.id.localeCompare(b.id) - ); - let changesComplete = 0; - let tasksComplete = 0; - let tasksTotal = 0; - for (const change of changes) { - tasksComplete += change.completedTasks; - tasksTotal += change.totalTasks; - if (change.state === 'complete') changesComplete += 1; - } - initiatives.push({ - name, - exists, - stages, - artifacts, - changes, - changesComplete, - changesTotal: changes.length, - tasksComplete, - tasksTotal, - }); - }; - - for (const initiative of shape.initiatives) { - addInitiative(initiative.name, true, initiative.stages, initiative.artifacts); - } - const known = new Set(shape.initiatives.map((initiative) => initiative.name)); - for (const name of [...byName.keys()].sort()) { - if (!known.has(name)) { - addInitiative(name, false, [], []); - } - } - - return { - path: initiativesDir(root), - evergreen: shape.evergreen, - initiatives, - }; -} - -/** - * The portfolios of every registered store that has one. This is the - * outside-a-root answer to "where does everything stand": the planning layer - * sits above repos, so asking it should not require standing in one. - */ -export async function rollupRegisteredStorePortfolios( - options: { globalDataDir?: string } = {} -): Promise> { - const registry = await readStoreRegistryState( - options.globalDataDir ? { globalDataDir: options.globalDataDir } : {} - ).catch(() => null); - const registered = registry ? listStoreRegistryEntries(registry) : []; - - const portfolios: Array<{ store: string; portfolio: PortfolioInfo }> = []; - for (const entry of registered) { - let root; - try { - root = getStoreRootForBackend(entry.backend); - } catch { - continue; // Unusable backend — skipped; doctor reports it. - } - const portfolio = await rollupInitiatives(root, options); - if ( - portfolio !== null && - (portfolio.initiatives.length > 0 || portfolio.evergreen.length > 0) - ) { - portfolios.push({ store: entry.id, portfolio }); - } - } - return portfolios; -} - -/** - * The names an agent surface shows for a root's planning layer: initiative - * names in order — or, when there are no initiatives yet, the evergreen - * artifact names. Either way the agent sees that the layer exists. - */ -export async function listInitiativeNames(root: string): Promise { - const shape = await readInitiativesShape(root); - if (shape === null) return []; - return shape.initiatives.length > 0 - ? shape.initiatives.map((initiative) => initiative.name) - : shape.evergreen; -} - -export interface ResolvedInitiativeLink { - /** The reference as written: `` or `/`. */ - ref: string; - name: string; - /** Present when the ref is store-qualified. */ - store?: string; - /** Absolute path to the initiative folder; null when not found on disk. */ - path: string | null; -} - -/** - * Resolves a change's upward link to the initiative folder it points at, so - * instruction surfaces can hand the agent the actual upstream context. A ref - * that resolves to no folder still returns (with `path: null`) — a stale - * link should be visible, not silently dropped. - */ -export async function resolveInitiativeLink( - changeDir: string, - root: string, - options: StorePathOptions = {} -): Promise { - const ref = await readInitiativeRef(changeDir); - if (ref === null) return null; - - const slash = ref.indexOf('/'); - const store = slash === -1 ? undefined : ref.slice(0, slash); - const name = slash === -1 ? ref : ref.slice(slash + 1); - - let initiativeRoot: string | null = slash === -1 ? root : null; - if (store !== undefined) { - const registry = await readStoreRegistryState(options).catch(() => null); - const entry = registry?.stores[store]; - if (entry) { - try { - initiativeRoot = getStoreRootForBackend(entry.backend); - } catch { - // Unusable backend — the link renders with path: null. - } - } - } - - let resolvedPath: string | null = null; - if (initiativeRoot !== null) { - const candidate = path.join(initiativesDir(initiativeRoot), name); - try { - const stat = await fs.stat(candidate); - if (stat.isDirectory()) resolvedPath = candidate; - } catch { - // Missing on disk — keep null. - } - } - - return { ref, name, ...(store !== undefined ? { store } : {}), path: resolvedPath }; -} diff --git a/src/core/openspec-root.ts b/src/core/openspec-root.ts index ff766beb47..d65882ee21 100644 --- a/src/core/openspec-root.ts +++ b/src/core/openspec-root.ts @@ -14,17 +14,12 @@ export const OPENSPEC_CONFIG_YML = 'openspec/config.yml'; export const OPENSPEC_SPECS_DIR = 'openspec/specs'; export const OPENSPEC_CHANGES_DIR = 'openspec/changes'; export const OPENSPEC_ARCHIVE_DIR = 'openspec/changes/archive'; -export const OPENSPEC_INITIATIVES_DIR = 'openspec/initiatives'; export const DEFAULT_OPENSPEC_SCHEMA = 'spec-driven'; export const DIRECTORY_ANCHOR_FILE_NAME = '.gitkeep'; // Git cannot track empty directories, so setup anchors otherwise-empty // conventional store directories for teammates who clone the repo later. -export const ANCHORED_OPENSPEC_DIRS = [ - OPENSPEC_SPECS_DIR, - OPENSPEC_ARCHIVE_DIR, - OPENSPEC_INITIATIVES_DIR, -] as const; +export const ANCHORED_OPENSPEC_DIRS = [OPENSPEC_SPECS_DIR, OPENSPEC_ARCHIVE_DIR] as const; type PathKind = 'missing' | 'directory' | 'file' | 'other'; @@ -315,7 +310,6 @@ export async function ensureOpenSpecRoot( await ensureDirectory(storeRoot, OPENSPEC_SPECS_DIR, ledger); await ensureDirectory(storeRoot, OPENSPEC_CHANGES_DIR, ledger); await ensureDirectory(storeRoot, OPENSPEC_ARCHIVE_DIR, ledger); - await ensureDirectory(storeRoot, OPENSPEC_INITIATIVES_DIR, ledger); await ensureDefaultConfig(storeRoot, ledger); if (options.anchorEmptyDirectories) { diff --git a/src/core/profile-sync-drift.ts b/src/core/profile-sync-drift.ts index 3c4c10ebc7..488d16cfdc 100644 --- a/src/core/profile-sync-drift.ts +++ b/src/core/profile-sync-drift.ts @@ -24,7 +24,6 @@ export const WORKFLOW_TO_SKILL_DIR: Record = { 'verify': 'openspec-verify-change', 'onboard': 'openspec-onboard', 'propose': 'openspec-propose', - 'initiatives': 'openspec-initiatives', }; function toKnownWorkflows(workflows: readonly string[]): WorkflowId[] { diff --git a/src/core/profiles.ts b/src/core/profiles.ts index cfb4718ead..acdc3ec953 100644 --- a/src/core/profiles.ts +++ b/src/core/profiles.ts @@ -11,15 +11,7 @@ import type { Profile } from './global-config.js'; * Core workflows included in the 'core' profile. * These provide the streamlined experience for new users. */ -export const CORE_WORKFLOWS = [ - 'propose', - 'explore', - 'apply', - 'update', - 'sync', - 'archive', - 'initiatives', -] as const; +export const CORE_WORKFLOWS = ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] as const; /** * All available workflows in the system. @@ -37,7 +29,6 @@ export const ALL_WORKFLOWS = [ 'bulk-archive', 'verify', 'onboard', - 'initiatives', ] as const; export type WorkflowId = (typeof ALL_WORKFLOWS)[number]; diff --git a/src/core/project-config.ts b/src/core/project-config.ts index 7bca0ca16e..3b25f5548d 100644 --- a/src/core/project-config.ts +++ b/src/core/project-config.ts @@ -39,6 +39,15 @@ export const ProjectConfigSchema = z.object({ .optional() .describe('Per-artifact rules, keyed by artifact ID'), + // Optional: what this root's non-reserved folders are for, keyed by + // folder path relative to openspec/ (e.g. "research/": "raw inputs"). + // Purely declarative — surfaced to agents in context and the + // references index so a store can explain its own layout. + structure: z + .record(z.string(), z.string()) + .optional() + .describe('Folder purposes, keyed by path relative to openspec/'), + // Note: the `references` field (id strings or {id, remote} maps) is // deliberately absent here — readProjectConfig parses and normalizes // it by hand (see DeclarationEntry below); a schema entry nothing @@ -233,6 +242,29 @@ export function readProjectConfig(projectRoot: string): ProjectConfig | null { } } + // Parse structure field: folder → purpose map, both non-empty strings. + if (raw.structure !== undefined) { + if (typeof raw.structure === 'object' && raw.structure !== null && !Array.isArray(raw.structure)) { + const parsedStructure: Record = {}; + let droppedFolders = false; + for (const [folder, purpose] of Object.entries(raw.structure)) { + if (typeof purpose === 'string' && purpose.length > 0 && folder.length > 0) { + parsedStructure[folder] = purpose; + } else { + droppedFolders = true; + } + } + if (droppedFolders) { + console.warn(`Some 'structure' entries are invalid (must map folder to a non-empty string), ignoring them`); + } + if (Object.keys(parsedStructure).length > 0) { + config.structure = parsedStructure; + } + } else { + console.warn(`Invalid 'structure' field in config (must be a folder → purpose map)`); + } + } + const references = parseDeclarationList(raw.references); if (references) { config.references = references; diff --git a/src/core/references.ts b/src/core/references.ts index 249d85c510..69ddeb4387 100644 --- a/src/core/references.ts +++ b/src/core/references.ts @@ -20,11 +20,10 @@ import { } from './store/foundation.js'; import { getStoreRootForBackend } from './store/registry.js'; import { inspectRegisteredStore, type ResolvedOpenSpecRoot } from './root-selection.js'; -import { getSpecIds } from '../utils/item-discovery.js'; +import { getSpecIds, getActiveChangeIds } from '../utils/item-discovery.js'; import { FileSystemUtils } from '../utils/file-system.js'; -import { MAX_CONTEXT_SIZE, type DeclarationEntry } from './project-config.js'; +import { MAX_CONTEXT_SIZE, readProjectConfig, type DeclarationEntry } from './project-config.js'; import { listSchemasWithInfo } from './artifact-graph/index.js'; -import { listInitiativeNames } from './initiatives.js'; export interface ReferenceSpecEntry { id: string; @@ -43,9 +42,11 @@ export interface ReferenceIndexEntry { root?: string; specs?: ReferenceSpecEntry[]; schemas?: ReferenceSchemaEntry[]; - /** The store's initiative names — or, with none yet, its evergreen - * artifact names (when it has an initiatives folder). */ - initiatives?: string[]; + /** The store's in-motion change ids — the work currently being drafted + * there. */ + changes?: string[]; + /** The store's declared folder purposes (config `structure:`). */ + structure?: Record; fetch?: string; status: StoreDiagnostic[]; } @@ -175,13 +176,32 @@ function collectSchemaEntries(referencedRoot: string): ReferenceSchemaEntry[] { } /** - * A store's planning layer for the index: initiative names in order, or — - * with no initiatives yet — its evergreen artifact names. + * A store's declared layout for the index: the config's `structure:` map, + * sanitized — the store explaining its own non-reserved folders. */ -async function collectInitiativeNames(referencedRoot: string): Promise { +function collectStructure(referencedRoot: string): Record { + let structure; try { - const names = await listInitiativeNames(referencedRoot); - return names.map((name) => sanitizeInline(name, 60)); + structure = readProjectConfig(referencedRoot)?.structure; + } catch { + return {}; + } + if (!structure) return {}; + const sanitized: Record = {}; + for (const [folder, purpose] of Object.entries(structure)) { + sanitized[sanitizeInline(folder, 100)] = sanitizeInline(purpose, 200); + } + return sanitized; +} + +/** + * A store's work in motion for the index: its active change ids. Specs say + * what is true; these say what the team is currently deciding. + */ +async function collectActiveChangeIds(referencedRoot: string): Promise { + try { + const ids = await getActiveChangeIds(referencedRoot); + return ids.map((id) => sanitizeInline(id, 60)); } catch { return []; } @@ -195,8 +215,8 @@ export function schemasFetchRecipe(storeId: string): string { return `openspec schemas --store ${storeId}`; } -export function initiativesFetchRecipe(storeId: string): string { - return `openspec list --initiatives --store ${storeId}`; +export function changesFetchRecipe(storeId: string): string { + return `openspec list --downstream --store ${storeId}`; } function specLine(spec: ReferenceSpecEntry): string { @@ -273,11 +293,17 @@ function renderEntryLines(entry: ReferenceIndexEntry): string[] { ); } } - if (entry.initiatives && entry.initiatives.length > 0) { + if (entry.changes && entry.changes.length > 0) { lines.push( - ` Initiatives: ${entry.initiatives.join(', ')} (${initiativesFetchRecipe(entry.store_id)})` + ` In motion: ${entry.changes.join(', ')} (${changesFetchRecipe(entry.store_id)})` ); } + if (entry.structure && Object.keys(entry.structure).length > 0) { + lines.push(' Layout:'); + for (const [folder, purpose] of Object.entries(entry.structure)) { + lines.push(` - ${folder}: ${purpose}`); + } + } if (entry.fetch) { lines.push(` Fetch: ${entry.fetch}`); } @@ -442,13 +468,15 @@ export async function assembleReferenceIndex( const specs = await collectSpecEntries(inspection.canonicalRoot); const schemas = collectSchemaEntries(inspection.canonicalRoot); - const initiatives = await collectInitiativeNames(inspection.canonicalRoot); + const changes = await collectActiveChangeIds(inspection.canonicalRoot); + const structure = collectStructure(inspection.canonicalRoot); const entry: ReferenceIndexEntry = { store_id: id, root: inspection.canonicalRoot, specs, ...(schemas.length > 0 ? { schemas } : {}), - ...(initiatives.length > 0 ? { initiatives } : {}), + ...(changes.length > 0 ? { changes } : {}), + ...(Object.keys(structure).length > 0 ? { structure } : {}), fetch: fetchRecipe(id), status: [], }; diff --git a/src/core/root-selection.ts b/src/core/root-selection.ts index 6ac64e9ec4..1f5bb618ff 100644 --- a/src/core/root-selection.ts +++ b/src/core/root-selection.ts @@ -384,8 +384,8 @@ export async function resolveOpenSpecRoot( if (registeredIds.length > 0) { // Lead with init: for work that belongs to THIS repo (like a change - // linking to a store's initiative), --store would land it in the wrong - // place — the store, not the repo. + // serving upstream work in a store), --store would land it in the + // wrong place — the store, not the repo. throw new RootSelectionError( `No OpenSpec root found in the current directory or its ancestors. Run openspec init to create a local root here, or pass --store to work in a registered store (registered: ${registeredIds.join(', ')}).`, 'no_root_with_registered_stores', diff --git a/src/core/shared/skill-generation.ts b/src/core/shared/skill-generation.ts index c966e77afd..f671b4de73 100644 --- a/src/core/shared/skill-generation.ts +++ b/src/core/shared/skill-generation.ts @@ -17,8 +17,6 @@ import { getVerifyChangeSkillTemplate, getOnboardSkillTemplate, getOpsxProposeSkillTemplate, - getInitiativesSkillTemplate, - getOpsxInitiativesCommandTemplate, getOpsxExploreCommandTemplate, getOpsxNewCommandTemplate, getOpsxContinueCommandTemplate, @@ -72,7 +70,6 @@ export function getSkillTemplates(workflowFilter?: readonly string[]): SkillTemp { template: getVerifyChangeSkillTemplate(), dirName: 'openspec-verify-change', workflowId: 'verify' }, { template: getOnboardSkillTemplate(), dirName: 'openspec-onboard', workflowId: 'onboard' }, { template: getOpsxProposeSkillTemplate(), dirName: 'openspec-propose', workflowId: 'propose' }, - { template: getInitiativesSkillTemplate(), dirName: 'openspec-initiatives', workflowId: 'initiatives' }, ]; if (!workflowFilter) return all; @@ -100,7 +97,6 @@ export function getCommandTemplates(workflowFilter?: readonly string[]): Command { template: getOpsxVerifyCommandTemplate(), id: 'verify' }, { template: getOpsxOnboardCommandTemplate(), id: 'onboard' }, { template: getOpsxProposeCommandTemplate(), id: 'propose' }, - { template: getOpsxInitiativesCommandTemplate(), id: 'initiatives' }, ]; if (!workflowFilter) return all; diff --git a/src/core/shared/tool-detection.ts b/src/core/shared/tool-detection.ts index 6e03753551..30622209dc 100644 --- a/src/core/shared/tool-detection.ts +++ b/src/core/shared/tool-detection.ts @@ -44,7 +44,6 @@ export const COMMAND_IDS = [ 'verify', 'onboard', 'propose', - 'initiatives', ] as const; export type CommandId = (typeof COMMAND_IDS)[number]; diff --git a/src/core/templates/skill-templates.ts b/src/core/templates/skill-templates.ts index 9a90e8fba2..598fcc4465 100644 --- a/src/core/templates/skill-templates.ts +++ b/src/core/templates/skill-templates.ts @@ -18,5 +18,4 @@ export { getBulkArchiveChangeSkillTemplate, getOpsxBulkArchiveCommandTemplate } export { getVerifyChangeSkillTemplate, getOpsxVerifyCommandTemplate } from './workflows/verify-change.js'; export { getOnboardSkillTemplate, getOpsxOnboardCommandTemplate } from './workflows/onboard.js'; export { getOpsxProposeSkillTemplate, getOpsxProposeCommandTemplate } from './workflows/propose.js'; -export { getInitiativesSkillTemplate, getOpsxInitiativesCommandTemplate } from './workflows/initiatives.js'; export { getFeedbackSkillTemplate } from './workflows/feedback.js'; diff --git a/src/core/templates/workflows/initiatives.ts b/src/core/templates/workflows/initiatives.ts deleted file mode 100644 index e264ac1d56..0000000000 --- a/src/core/templates/workflows/initiatives.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Skill Template Workflow Modules - * - * The initiatives skill: work above a single change. Routed by what is on - * disk; every move ends in a short numbered menu of real next actions. - */ -import type { SkillTemplate, CommandTemplate } from '../types.js'; -import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; - -const INITIATIVES_BODY = `Work above a single change: keep the evergreen truths current, run initiatives to completion, and keep the in-flight changes mapped against both. - -**Route by what is on disk — look before asking.** The folders carry the workflow, not job titles; anyone can pick up from what exists. - ---- - -## The shape you work with - -The planning layer lives in one folder: \`openspec/initiatives/\` — in this repo, or in a store the team shares. - -- **Unnumbered top-level files are evergreen artifacts** — the standing truths every initiative serves: \`product.md\`, \`roadmap.md\`, \`architecture.md\`, whatever names the user already uses. They are maintained forever, the way specs are. -- **Each subfolder is one initiative** — a finite piece of work above a single change. Contents are freeform. Numbered folders inside an initiative are ordered stages, and their names are the team's own workflow: \`00_product/ 01_engineering/\` for one team, \`00_analysis/ 01_product/ 02_design/ 03_engineering/\` for another. Everything lower-numbered is upstream. Any document works at any stage — a PRD, an RFC, a one-pager, design notes; position carries the meaning, not the format. -- **Changes point up** with one line in their \`.openspec.yaml\`: \`initiative: \` (this root) or \`initiative: /\` (a store's initiative). There is no list to maintain anywhere. - -The same loop runs at both altitudes: work flows down (an initiative decomposes into changes), truth flows up (finishing work updates the evergreen artifacts, the way archiving a change updates specs), and status flows back live through \`openspec list --initiatives\`. - -## Start from state - -Run \`openspec list --initiatives --json\` (add \`--store \` for a shared portfolio) before saying anything, then route: - -- **No initiatives folder yet** → offer to capture the conversation (below). Do not lecture about the feature first. -- **A portfolio exists, no initiative named** → open with where everything stands, in a few lines, from live status — then the menu. -- **Working one initiative** → the moves below, with everything upstream (evergreen files, lower-numbered stages) read first. -- **In \`openspec/changes/\` or a change** → that is change work — use the change skills (\`/opsx:apply\`, \`/opsx:continue\`), pulling the initiative in as upstream context. - -What drives your reactions must be structured facts from disk — a task checked off, a change archived, a change pointing at an initiative — never your memory of prose. Read state fresh; propose what to do about it. - -## The moves - -**Capture the conversation.** When the intent lives in the chat and no artifact exists yet, synthesize what you already know into one — do NOT re-interview the user. One page, their words. Standing truths go in an evergreen file; a finite effort becomes \`openspec/initiatives//\` (create the folder yourself — \`mkdir\` is the whole ceremony). - -**Advance the workflow.** The transition rule is one sentence: read everything upstream of where you stand (evergreen files, lower-numbered stages), then produce what your stage owes the next one. Never re-interview for what an upstream artifact already answers — cite it; when upstream is silent on something you need, say so and ask once. This one rule is why no persona needs its own skill: whoever owns a stage — analyst, PM, architect, designer, engineer, or a hat-switching solo dev — is making the same move from a different position, and decomposing the last stage into changes is just the final instance of it. - -**Ideate from what exists.** Read the evergreen artifacts, the initiative's files, and live change status; propose directions grounded in them — each with a one-line basis pointing at what it serves. Name what you are NOT proposing and why, in one line each. - -**Push back — twice, then move on.** When a goal is vague ("improve UX"), a metric can only go up, or a problem is stated as a feature, ask one sharper question using the user's own words. One question per message; offer numbered options when they scaffold the answer. After two rounds, capture what you have and note what is worth revisiting — planning is iterative. - -**Decompose and bridge.** Cut the initiative into tracer-bullet slices: each end-to-end and demoable alone, so anyone could pick one up without reading the others. Each slice should trace to something upstream — an acceptance criterion, a requirement, a decision; a slice that serves nothing upstream gets questioned out loud. Surface merge/split judgment calls; don't decide silently. When a slice is ready, make it real: - -\`\`\`bash -openspec new change --initiative # initiative in this root -openspec new change --initiative / # initiative in a store -\`\`\` - -The change IS the handoff: born linked, self-contained, ready for whoever — or whatever agent — picks it up next. Status flows back with no bookkeeping. - -**Sync up.** Compare \`openspec list --initiatives\` against the evergreen artifacts. When an initiative's changes are complete, that is the trigger to update the truths it served — and to say plainly what drifted instead of papering over it. - -**Filter input.** Weigh new input against the evergreen artifacts. Park what contradicts them in a cut file (e.g. \`notes/cut.md\`) with one line of why — preserved, not lost. - -## End every move in a menu - -Close with 2–4 numbered options computed from what is actually on disk — never a paragraph of suggestions. Exactly one option is marked **(recommended)**. Every option is a real next action: a command to run, a change to start, an artifact to write. Examples of state-driven options: no changes linked yet → "map the in-flight changes"; all changes complete → "sync the evergreen artifacts"; a slice is ready → "start the change". The user should mostly answer with a number. - -## Write less - -Plans die of verbosity. - -- One page per artifact. Longer means cut or split. -- Prefer a table to prose, a line to a paragraph. -- No file paths or line-anchored code in upstream artifacts — they rot. Naming a module, crate, or tool is fine; pointing at a path or line is not. -- Never restate another artifact; point to it. - -## Stay above the code - -Reading code to ground the work is fine. Writing code is not initiative work — that belongs to a change (\`/opsx:apply\`). - ---- - -${STORE_SELECTION_GUIDANCE}`; - -export function getInitiativesSkillTemplate(): SkillTemplate { - return { - name: 'openspec-initiatives', - description: - 'Work above a single change. Use when turning high-level intent (a roadmap, PRD, vision) into changes, when asked where the whole effort stands, or when finished work should update the roadmap.', - instructions: INITIATIVES_BODY, - license: 'MIT', - compatibility: 'Requires openspec CLI.', - metadata: { author: 'openspec', version: '1.0' }, - }; -} - -export function getOpsxInitiativesCommandTemplate(): CommandTemplate { - return { - name: 'OPSX: Initiatives', - description: - 'Work above a single change - turn high-level intent into changes and see where the effort stands', - category: 'Workflow', - tags: ['workflow', 'planning', 'experimental'], - content: INITIATIVES_BODY, - }; -} diff --git a/src/core/upstream.ts b/src/core/upstream.ts new file mode 100644 index 0000000000..828a779d35 --- /dev/null +++ b/src/core/upstream.ts @@ -0,0 +1,435 @@ +/** + * Upstream links (stores beta). + * + * A change can serve work that lives one level up — usually a change in a + * shared store where a team drafts requirements before code moves. The link + * is one metadata line in the change's `.openspec.yaml`: + * `serves: ` (a change in the same root) or + * `serves: /` (a change in that registered store). + * + * There is no manifest — rollup discovers serving changes by scanning for + * that line, so teams tag their own work and nothing central needs + * maintaining. When the upstream change archives, its requirements live on + * in the store's specs; the link keeps resolving to the archived copy. + */ + +import { promises as fs } from 'fs'; +import path from 'path'; +import { parse as parseYaml } from 'yaml'; + +import { getTaskProgressForChange } from '../utils/task-progress.js'; +import { writeFileAtomically } from './file-state.js'; +import { + getStoresDir, + listStoreRegistryEntries, + readStoreRegistryState, + type StorePathOptions, +} from './store/foundation.js'; +import { getStoreRootForBackend } from './store/registry.js'; + +export interface ServingChangeStatus { + id: string; + /** Registered store the serving change lives in; absent = the rolled-up root. */ + store?: string; + /** Linked (non-store) repo the serving change lives in, by directory name. */ + repo?: string; + completedTasks: number; + totalTasks: number; + state: 'complete' | 'in-progress' | 'no-tasks'; +} + +export interface UpstreamChangeInfo { + id: string; + /** False when serving changes reference this id but no change exists on disk. */ + exists: boolean; + /** True when the upstream change has been archived (requirements synced to specs). */ + archived: boolean; + changes: ServingChangeStatus[]; + changesComplete: number; + changesTotal: number; + tasksComplete: number; + tasksTotal: number; +} + +export interface DownstreamRollup { + path: string; + upstream: UpstreamChangeInfo[]; +} + +function changesDirOf(root: string): string { + return path.join(root, 'openspec', 'changes'); +} + +/** + * Reads the `serves:` line from a change's `.openspec.yaml`. Tolerant: any + * unreadable or unlinked metadata simply means "serves nothing upstream". + */ +export async function readServesRef(changeDir: string): Promise { + try { + const raw = await fs.readFile(path.join(changeDir, '.openspec.yaml'), 'utf-8'); + const parsed = parseYaml(raw) as { serves?: unknown } | null; + return typeof parsed?.serves === 'string' && parsed.serves.length > 0 + ? parsed.serves + : null; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Linked roots +// +// Rollup can only scan checkouts it knows about. Store roots come from the +// store registry; plain code repos become known the moment they link a change +// to a store's change (`new change --serves /` records the +// repo's path here). Linking IS the registration — no extra command, and +// nothing is written into the repo itself. +// --------------------------------------------------------------------------- + +const LINKED_ROOTS_FILE_NAME = 'linked-roots.yaml'; + +function getLinkedRootsPath(options: StorePathOptions = {}): string { + return path.join(getStoresDir(options), LINKED_ROOTS_FILE_NAME); +} + +/** Known linked roots. Tolerant: unreadable state means "none". */ +export async function readLinkedRoots( + options: StorePathOptions = {} +): Promise { + try { + const raw = await fs.readFile(getLinkedRootsPath(options), 'utf-8'); + const parsed = parseYaml(raw) as { roots?: unknown } | null; + if (!Array.isArray(parsed?.roots)) return []; + return parsed.roots.filter( + (entry): entry is string => typeof entry === 'string' && entry.length > 0 + ); + } catch { + return []; + } +} + +/** + * Records a repo root so rollups scan it. Idempotent; stale entries are + * harmless (a missing directory scans as empty). + */ +export async function recordLinkedRoot( + root: string, + options: StorePathOptions = {} +): Promise { + const resolved = path.resolve(root); + const existing = await readLinkedRoots(options); + if (existing.includes(resolved)) return; + const roots = [...existing, resolved].sort(); + await fs.mkdir(getStoresDir(options), { recursive: true }); + await writeFileAtomically( + getLinkedRootsPath(options), + `version: 1\nroots:\n${roots.map((entry) => ` - ${JSON.stringify(entry)}`).join('\n')}\n` + ); +} + +/** + * Locates an upstream change inside a root: active changes first, then the + * archive (archived copies are named `YYYY-MM-DD-`). Returns null when + * the id matches neither. + */ +async function locateChange( + root: string, + changeId: string +): Promise<{ path: string; archived: boolean } | null> { + const active = path.join(changesDirOf(root), changeId); + try { + if ((await fs.stat(active)).isDirectory()) { + return { path: active, archived: false }; + } + } catch { + // Not active — fall through to the archive. + } + const archiveDir = path.join(changesDirOf(root), 'archive'); + let entries; + try { + entries = await fs.readdir(archiveDir, { withFileTypes: true }); + } catch { + return null; + } + const suffix = `-${changeId}`; + const match = entries + .filter((entry) => entry.isDirectory() && entry.name.endsWith(suffix)) + .map((entry) => entry.name) + .sort() + .pop(); + return match ? { path: path.join(archiveDir, match), archived: true } : null; +} + +/** + * Scans one root's changes and returns, per matching upstream change id, the + * serving changes that point at it. `toId` maps a raw ref to the upstream + * change id it addresses in the root being rolled up — or null when the ref + * points elsewhere. + */ +async function collectServingChanges( + root: string, + toId: (ref: string) => string | null, + label: { store?: string; repo?: string } | undefined +): Promise> { + const changesDir = changesDirOf(root); + const found = new Map(); + let entries; + try { + entries = await fs.readdir(changesDir, { withFileTypes: true }); + } catch { + return found; + } + + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === 'archive') continue; + const ref = await readServesRef(path.join(changesDir, entry.name)); + if (ref === null) continue; + const id = toId(ref); + if (id === null) continue; + + const progress = await getTaskProgressForChange(changesDir, entry.name, root); + const status: ServingChangeStatus = { + id: entry.name, + ...(label?.store ? { store: label.store } : {}), + ...(label?.repo ? { repo: label.repo } : {}), + completedTasks: progress.completed, + totalTasks: progress.total, + state: + progress.total === 0 + ? 'no-tasks' + : progress.completed === progress.total + ? 'complete' + : 'in-progress', + }; + const bucket = found.get(id); + if (bucket) { + bucket.push(status); + } else { + found.set(id, [status]); + } + } + return found; +} + +function mergeChanges( + target: Map, + source: Map +): void { + for (const [id, changes] of source) { + const bucket = target.get(id); + if (bucket) { + bucket.push(...changes); + } else { + target.set(id, changes); + } + } +} + +/** + * Rolls up a root's downstream work: every active change in the root, plus + * every change on this machine that serves one of them. Local serving + * changes match `serves: ` (or `/`); when the root is + * a registered store, other registered roots and linked repos are scanned + * for `/`. Ids referenced by serving changes but missing on + * disk are included with `exists: false` — a bad reference should be + * visible, not silently dropped; archived upstream changes are included + * with `archived: true`. Returns null when the root has no changes folder. + */ +export async function rollupDownstream( + root: string, + options: { globalDataDir?: string } = {} +): Promise { + const changesDir = changesDirOf(root); + let ownEntries; + try { + ownEntries = await fs.readdir(changesDir, { withFileTypes: true }); + } catch { + return null; + } + const ownChanges = ownEntries + .filter((entry) => entry.isDirectory() && entry.name !== 'archive') + .map((entry) => entry.name) + .sort(); + + const registry = await readStoreRegistryState( + options.globalDataDir ? { globalDataDir: options.globalDataDir } : {} + ).catch(() => null); + const registered = registry ? listStoreRegistryEntries(registry) : []; + + // Which registered store, if any, is this root? + const resolvedRoot = path.resolve(root); + let ownStoreId: string | undefined; + const storeRoots: Array<{ id: string; root: string }> = []; + for (const entry of registered) { + try { + const entryRoot = path.resolve(getStoreRootForBackend(entry.backend)); + storeRoots.push({ id: entry.id, root: entryRoot }); + if (entryRoot === resolvedRoot) { + ownStoreId = entry.id; + } + } catch { + // Unusable backend — skipped; doctor reports it. + } + } + + // A ref addresses this root as `` from its own changes, or as + // `/` from anywhere. + const ownPrefix = ownStoreId !== undefined ? `${ownStoreId}/` : null; + const byId = await collectServingChanges( + root, + (ref) => { + if (!ref.includes('/')) return ref; + if (ownPrefix !== null && ref.startsWith(ownPrefix)) { + return ref.slice(ownPrefix.length); + } + return null; + }, + undefined + ); + + if (ownStoreId !== undefined && ownPrefix !== null) { + const toOwnId = (ref: string) => + ref.startsWith(ownPrefix) ? ref.slice(ownPrefix.length) : null; + const scanned = new Set([resolvedRoot]); + for (const store of storeRoots) { + if (scanned.has(store.root)) continue; + scanned.add(store.root); + mergeChanges( + byId, + await collectServingChanges(store.root, toOwnId, { store: store.id }) + ); + } + // Plain code repos that linked a change here (recorded at link time). + for (const linkedRoot of await readLinkedRoots( + options.globalDataDir ? { globalDataDir: options.globalDataDir } : {} + )) { + if (scanned.has(linkedRoot)) continue; + scanned.add(linkedRoot); + mergeChanges( + byId, + await collectServingChanges(linkedRoot, toOwnId, { + repo: path.basename(linkedRoot), + }) + ); + } + } + + const upstream: UpstreamChangeInfo[] = []; + const addUpstream = (id: string, exists: boolean, archived: boolean) => { + const changes = (byId.get(id) ?? []).sort((a, b) => a.id.localeCompare(b.id)); + let changesComplete = 0; + let tasksComplete = 0; + let tasksTotal = 0; + for (const change of changes) { + tasksComplete += change.completedTasks; + tasksTotal += change.totalTasks; + if (change.state === 'complete') changesComplete += 1; + } + upstream.push({ + id, + exists, + archived, + changes, + changesComplete, + changesTotal: changes.length, + tasksComplete, + tasksTotal, + }); + }; + + for (const id of ownChanges) { + addUpstream(id, true, false); + } + const known = new Set(ownChanges); + for (const id of [...byId.keys()].sort()) { + if (known.has(id)) continue; + const located = await locateChange(root, id); + addUpstream(id, located !== null, located?.archived ?? false); + } + + return { path: changesDir, upstream }; +} + +/** + * The rollups of every registered store that has one. This is the + * outside-a-root answer to "where does everything stand": shared planning + * sits above repos, so asking it should not require standing in one. + */ +export async function rollupRegisteredStores( + options: { globalDataDir?: string } = {} +): Promise> { + const registry = await readStoreRegistryState( + options.globalDataDir ? { globalDataDir: options.globalDataDir } : {} + ).catch(() => null); + const registered = registry ? listStoreRegistryEntries(registry) : []; + + const rollups: Array<{ store: string; rollup: DownstreamRollup }> = []; + for (const entry of registered) { + let root; + try { + root = getStoreRootForBackend(entry.backend); + } catch { + continue; // Unusable backend — skipped; doctor reports it. + } + const rollup = await rollupDownstream(root, options); + if (rollup !== null && rollup.upstream.length > 0) { + rollups.push({ store: entry.id, rollup }); + } + } + return rollups; +} + +export interface ResolvedUpstreamLink { + /** The reference as written: `` or `/`. */ + ref: string; + changeId: string; + /** Present when the ref is store-qualified. */ + store?: string; + /** Absolute path to the upstream change; null when not found on disk. */ + path: string | null; + /** True when the upstream change has been archived. */ + archived: boolean; +} + +/** + * Resolves a change's `serves:` link to the upstream change it points at, so + * instruction surfaces can hand the agent the actual upstream context. A ref + * that resolves to no change still returns (with `path: null`) — a stale + * link should be visible, not silently dropped. + */ +export async function resolveUpstreamLink( + changeDir: string, + root: string, + options: StorePathOptions = {} +): Promise { + const ref = await readServesRef(changeDir); + if (ref === null) return null; + + const slash = ref.indexOf('/'); + const store = slash === -1 ? undefined : ref.slice(0, slash); + const changeId = slash === -1 ? ref : ref.slice(slash + 1); + + let upstreamRoot: string | null = slash === -1 ? root : null; + if (store !== undefined) { + const registry = await readStoreRegistryState(options).catch(() => null); + const entry = registry?.stores[store]; + if (entry) { + try { + upstreamRoot = getStoreRootForBackend(entry.backend); + } catch { + // Unusable backend — the link renders with path: null. + } + } + } + + const located = + upstreamRoot !== null ? await locateChange(upstreamRoot, changeId) : null; + + return { + ref, + changeId, + ...(store !== undefined ? { store } : {}), + path: located?.path ?? null, + archived: located?.archived ?? false, + }; +} diff --git a/src/core/working-set.ts b/src/core/working-set.ts index e025835fac..58b1ae9b41 100644 --- a/src/core/working-set.ts +++ b/src/core/working-set.ts @@ -21,9 +21,10 @@ export interface WorkingSetMember { /** A referenced store's own custom artifact types (project schema names). * Present only when the store defines some and it is available. */ artifactTypes?: string[]; - /** A referenced store's initiative names — or, with none yet, its - * evergreen artifact names. */ - initiatives?: string[]; + /** A referenced store's in-motion change ids. */ + changes?: string[]; + /** A referenced store's declared folder purposes (config `structure:`). */ + structure?: Record; status: StoreDiagnostic[]; } diff --git a/src/utils/change-utils.ts b/src/utils/change-utils.ts index c47a4a3efe..b4a6a40e24 100644 --- a/src/utils/change-utils.ts +++ b/src/utils/change-utils.ts @@ -17,7 +17,7 @@ export interface CreateChangeOptions { /** Directory that should contain the change directories */ changesDir?: string; /** Additional metadata to persist in the change's .openspec.yaml */ - metadata?: Partial>; + metadata?: Partial>; } /** diff --git a/test/commands/change-initiative-link.test.ts b/test/commands/change-initiative-link.test.ts index 3226cb2811..c1a7797b57 100644 --- a/test/commands/change-initiative-link.test.ts +++ b/test/commands/change-initiative-link.test.ts @@ -7,11 +7,11 @@ import { readChangeMetadata } from '../../src/utils/change-metadata.js'; import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; /** - * The `initiative:` metadata field has two readable shapes: the current - * string ref (`` or `/`, written by - * `new change --initiative`) and the legacy object (`{store, id}`) from the - * earlier beta, which stays readable and untouched. `openspec set change` - * remains gone. This suite covers both shapes. + * Initiative-link creation was removed from normal change flows in the + * store-root-selection slice: `new change` no longer accepts `--initiative` + * and `openspec set change` is gone. Existing initiative metadata from the + * beta remains readable and untouched; this suite covers that legacy + * behavior. */ describe('legacy repo-local change initiative metadata', () => { let tempDir: string; @@ -101,35 +101,16 @@ describe('legacy repo-local change initiative metadata', () => { expect(metadata?.initiative).toBeUndefined(); }); - it('writes the string ref for new change --initiative', async () => { + it('rejects new change --initiative without writing files', async () => { const result = await runCLI( ['new', 'change', 'linked-change', '--initiative', 'billing-launch', '--json'], { cwd: tempDir, env } ); - expect(result.exitCode).toBe(0); + expect(result.exitCode).toBe(1); const json = parseJson(result); - expect(json.change.id).toBe('linked-change'); - expect(readChangeMetadata(changeDir('linked-change'), tempDir)?.initiative).toBe( - 'billing-launch' - ); - }); - - it('accepts a store-prefixed ref and rejects a malformed one', async () => { - const ok = await runCLI( - ['new', 'change', 'store-linked', '--initiative', 'team-plans/billing-launch', '--json'], - { cwd: tempDir, env } - ); - expect(ok.exitCode).toBe(0); - expect(readChangeMetadata(changeDir('store-linked'), tempDir)?.initiative).toBe( - 'team-plans/billing-launch' - ); - - const bad = await runCLI( - ['new', 'change', 'bad-linked', '--initiative', 'Not A Ref', '--json'], - { cwd: tempDir, env } - ); - expect(bad.exitCode).toBe(1); - expect(fs.existsSync(changeDir('bad-linked'))).toBe(false); + expect(json.change).toBeNull(); + expect(json.status[0].code).toBe('initiative_option_removed'); + expect(fs.existsSync(changeDir('linked-change'))).toBe(false); }); it('no longer provides openspec set change', async () => { diff --git a/test/core/initiatives.test.ts b/test/core/initiatives.test.ts deleted file mode 100644 index 478b2a3358..0000000000 --- a/test/core/initiatives.test.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; - -import { - listInitiativeNames, - normalizeInitiativeRef, - readInitiativesShape, - readLinkedRoots, - recordLinkedRoot, - resolveInitiativeLink, - rollupInitiatives, - rollupRegisteredStorePortfolios, -} from '../../src/core/initiatives.js'; -import { - readStoreRegistryState, - writeStoreRegistryState, -} from '../../src/core/store/foundation.js'; - -describe('initiatives', () => { - let tempDir: string; - let globalDataDir: string; - let savedXdgDataHome: string | undefined; - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-initiatives-')); - globalDataDir = path.join(tempDir, 'data', 'openspec'); - savedXdgDataHome = process.env.XDG_DATA_HOME; - process.env.XDG_DATA_HOME = path.join(tempDir, 'xdg'); - }); - - afterEach(() => { - if (savedXdgDataHome === undefined) { - delete process.env.XDG_DATA_HOME; - } else { - process.env.XDG_DATA_HOME = savedXdgDataHome; - } - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - function write(relativePath: string, content: string): void { - const full = path.join(tempDir, relativePath); - fs.mkdirSync(path.dirname(full), { recursive: true }); - fs.writeFileSync(full, content); - } - - function change(root: string, id: string, ref: string | null, tasks: string): void { - const metadata = - ref === null - ? 'schema: spec-driven\n' - : `schema: spec-driven\ninitiative: ${ref}\n`; - write(`${root}/openspec/changes/${id}/.openspec.yaml`, metadata); - write(`${root}/openspec/changes/${id}/tasks.md`, tasks); - } - - async function registerStore(id: string, root: string): Promise { - const existing = await readStoreRegistryState({ globalDataDir }).catch(() => null); - await writeStoreRegistryState( - { - version: 1, - stores: { - ...(existing?.stores ?? {}), - [id]: { backend: { type: 'git', local_path: path.join(tempDir, root) } }, - }, - }, - { globalDataDir } - ); - } - - it('splits initiatives from evergreen artifacts, and stages inside an initiative', async () => { - write('app/openspec/initiatives/roadmap.md', '# roadmap\n'); - write('app/openspec/initiatives/smoother-setup/00_goal/goal.md', '# goal\n'); - write('app/openspec/initiatives/smoother-setup/01_who/personas.md', '# who\n'); - write('app/openspec/initiatives/smoother-setup/notes.md', '# notes\n'); - write('app/openspec/initiatives/q3-payments/brief.md', '# brief\n'); - - const shape = await readInitiativesShape(path.join(tempDir, 'app')); - - expect(shape?.evergreen).toEqual(['roadmap.md']); - expect(shape?.initiatives.map((i) => i.name)).toEqual([ - 'q3-payments', - 'smoother-setup', - ]); - const smoother = shape?.initiatives.find((i) => i.name === 'smoother-setup'); - expect(smoother?.stages.map((s) => s.name)).toEqual(['00_goal', '01_who']); - expect(smoother?.stages.map((s) => s.files)).toEqual([1, 1]); - expect(smoother?.artifacts).toEqual(['notes.md']); - }); - - it('reads stage chains of any length as the workflow, in order', async () => { - write('app/openspec/initiatives/checkout-revamp/00_analysis/brief.md', '# brief\n'); - write('app/openspec/initiatives/checkout-revamp/01_product/product-spec.md', '# spec\n'); - write('app/openspec/initiatives/checkout-revamp/02_design/flows.md', '# flows\n'); - write('app/openspec/initiatives/checkout-revamp/03_engineering/plan.md', '# plan\n'); - - const shape = await readInitiativesShape(path.join(tempDir, 'app')); - const initiative = shape?.initiatives.find((i) => i.name === 'checkout-revamp'); - - expect(initiative?.stages.map((s) => s.name)).toEqual([ - '00_analysis', - '01_product', - '02_design', - '03_engineering', - ]); - }); - - it('returns null when there is no initiatives folder', async () => { - fs.mkdirSync(path.join(tempDir, 'app', 'openspec'), { recursive: true }); - expect(await readInitiativesShape(path.join(tempDir, 'app'))).toBeNull(); - expect( - await rollupInitiatives(path.join(tempDir, 'app'), { globalDataDir }) - ).toBeNull(); - }); - - it('normalizes the legacy object ref to /', () => { - expect(normalizeInitiativeRef('smoother-setup')).toBe('smoother-setup'); - expect(normalizeInitiativeRef('team-plans/q3')).toBe('team-plans/q3'); - expect(normalizeInitiativeRef({ store: 'team-plans', id: 'q3' })).toBe( - 'team-plans/q3' - ); - expect(normalizeInitiativeRef(undefined)).toBeNull(); - expect(normalizeInitiativeRef('')).toBeNull(); - expect(normalizeInitiativeRef({ store: 'team-plans' })).toBeNull(); - }); - - it('groups local changes under the initiative they name', async () => { - write('app/openspec/initiatives/smoother-setup/goal.md', '# goal\n'); - change('app', 'done-change', 'smoother-setup', '- [x] a\n- [x] b\n'); - change('app', 'wip-change', 'smoother-setup', '- [x] a\n- [ ] b\n'); - change('app', 'unrelated', null, '- [ ] a\n'); // no initiative line -> not included - - const portfolio = await rollupInitiatives(path.join(tempDir, 'app'), { - globalDataDir, - }); - - expect(portfolio?.initiatives.map((i) => i.name)).toEqual(['smoother-setup']); - const initiative = portfolio?.initiatives[0]; - expect(initiative?.exists).toBe(true); - expect(initiative?.changes.map((c) => c.id)).toEqual(['done-change', 'wip-change']); - expect(initiative?.changesComplete).toBe(1); - expect(initiative?.changesTotal).toBe(2); - expect(initiative?.tasksComplete).toBe(3); - expect(initiative?.tasksTotal).toBe(4); - expect(initiative?.changes.every((c) => c.store === undefined)).toBe(true); - }); - - it('surfaces a referenced name with no folder as exists: false', async () => { - write('app/openspec/initiatives/roadmap.md', '# roadmap\n'); - change('app', 'orphan-change', 'renamed-away', '- [ ] a\n'); - - const portfolio = await rollupInitiatives(path.join(tempDir, 'app'), { - globalDataDir, - }); - - const orphan = portfolio?.initiatives.find((i) => i.name === 'renamed-away'); - expect(orphan?.exists).toBe(false); - expect(orphan?.changes.map((c) => c.id)).toEqual(['orphan-change']); - }); - - it('finds changes across registered repos that point at a store initiative', async () => { - // The initiative lives in the team-plans store. - write('team-plans/openspec/initiatives/smoother-setup/goal.md', '# goal\n'); - await registerStore('team-plans', 'team-plans'); - // Two code repos, registered, each with a change pointing at it. - await registerStore('api-server', 'api-server'); - await registerStore('web-app', 'web-app'); - change('api-server', 'add-payments-api', 'team-plans/smoother-setup', '- [x] a\n- [x] b\n'); - change('web-app', 'add-payments-ui', 'team-plans/smoother-setup', '- [x] a\n- [ ] b\n- [ ] c\n'); - change('web-app', 'other-work', null, '- [ ] a\n'); - // Legacy object metadata still resolves to the same initiative. - write( - 'web-app/openspec/changes/legacy-change/.openspec.yaml', - 'schema: spec-driven\ninitiative:\n store: team-plans\n id: smoother-setup\n' - ); - write('web-app/openspec/changes/legacy-change/tasks.md', '- [x] a\n'); - // The store's own change can use the bare name or its own prefix. - change('team-plans', 'update-docs', 'smoother-setup', '- [x] a\n'); - change('team-plans', 'update-brand', 'team-plans/smoother-setup', '- [ ] a\n'); - - const portfolio = await rollupInitiatives(path.join(tempDir, 'team-plans'), { - globalDataDir, - }); - - const initiative = portfolio?.initiatives.find((i) => i.name === 'smoother-setup'); - const byId = Object.fromEntries((initiative?.changes ?? []).map((c) => [c.id, c])); - expect(Object.keys(byId).sort()).toEqual([ - 'add-payments-api', - 'add-payments-ui', - 'legacy-change', - 'update-brand', - 'update-docs', - ]); - expect(byId['add-payments-api'].store).toBe('api-server'); - expect(byId['add-payments-api'].state).toBe('complete'); - expect(byId['add-payments-ui'].store).toBe('web-app'); - expect(byId['add-payments-ui'].state).toBe('in-progress'); - expect(byId['legacy-change'].store).toBe('web-app'); - expect(byId['update-docs'].store).toBeUndefined(); - expect(initiative?.changesComplete).toBe(3); - expect(initiative?.tasksTotal).toBe(8); - }); - - it('finds changes in plain linked repos — linking is the registration', async () => { - write('team-plans/openspec/initiatives/smoother-setup/goal.md', '# goal\n'); - await registerStore('team-plans', 'team-plans'); - // A code repo that is NOT registered as a store; it linked a change, - // which recorded its checkout. - change('my-app', 'add-search', 'team-plans/smoother-setup', '- [x] a\n- [ ] b\n'); - await recordLinkedRoot(path.join(tempDir, 'my-app'), { globalDataDir }); - // Recording is idempotent. - await recordLinkedRoot(path.join(tempDir, 'my-app'), { globalDataDir }); - expect(await readLinkedRoots({ globalDataDir })).toEqual([ - path.join(tempDir, 'my-app'), - ]); - // A stale entry (deleted checkout) must not break the rollup. - await recordLinkedRoot(path.join(tempDir, 'gone-repo'), { globalDataDir }); - - const portfolio = await rollupInitiatives(path.join(tempDir, 'team-plans'), { - globalDataDir, - }); - - const initiative = portfolio?.initiatives.find((i) => i.name === 'smoother-setup'); - expect(initiative?.changes.map((c) => c.id)).toEqual(['add-search']); - expect(initiative?.changes[0].repo).toBe('my-app'); - expect(initiative?.changes[0].store).toBeUndefined(); - expect(initiative?.changes[0].state).toBe('in-progress'); - }); - - it('resolves a change link to the initiative folder it points at', async () => { - // Local ref: the initiative lives in the change's own root. - write('app/openspec/initiatives/smoother-setup/goal.md', '# goal\n'); - change('app', 'add-search', 'smoother-setup', '- [ ] a\n'); - const local = await resolveInitiativeLink( - path.join(tempDir, 'app/openspec/changes/add-search'), - path.join(tempDir, 'app'), - { globalDataDir } - ); - expect(local?.ref).toBe('smoother-setup'); - expect(local?.store).toBeUndefined(); - expect(local?.path).toBe(path.join(tempDir, 'app/openspec/initiatives/smoother-setup')); - - // Store-qualified ref: resolves through the registry. - write('team-plans/openspec/initiatives/q3-payments/brief.md', '# brief\n'); - await registerStore('team-plans', 'team-plans'); - change('app', 'add-payments', 'team-plans/q3-payments', '- [ ] a\n'); - const qualified = await resolveInitiativeLink( - path.join(tempDir, 'app/openspec/changes/add-payments'), - path.join(tempDir, 'app'), - { globalDataDir } - ); - expect(qualified?.store).toBe('team-plans'); - expect(qualified?.name).toBe('q3-payments'); - expect(qualified?.path).toBe( - path.join(tempDir, 'team-plans/openspec/initiatives/q3-payments') - ); - - // A stale ref stays visible with path: null instead of vanishing. - change('app', 'add-ghost', 'no-such-initiative', '- [ ] a\n'); - const stale = await resolveInitiativeLink( - path.join(tempDir, 'app/openspec/changes/add-ghost'), - path.join(tempDir, 'app'), - { globalDataDir } - ); - expect(stale?.ref).toBe('no-such-initiative'); - expect(stale?.path).toBeNull(); - - // An unlinked change resolves to nothing. - change('app', 'plain', null, '- [ ] a\n'); - expect( - await resolveInitiativeLink( - path.join(tempDir, 'app/openspec/changes/plain'), - path.join(tempDir, 'app'), - { globalDataDir } - ) - ).toBeNull(); - }); - - it('rolls up every registered store portfolio for the outside-a-root answer', async () => { - write('team-plans/openspec/initiatives/smoother-setup/goal.md', '# goal\n'); - write('other-store/openspec/specs/.gitkeep', ''); // registered, no initiatives - await registerStore('team-plans', 'team-plans'); - await registerStore('other-store', 'other-store'); - change('team-plans', 'update-docs', 'smoother-setup', '- [x] a\n'); - - const portfolios = await rollupRegisteredStorePortfolios({ globalDataDir }); - - expect(portfolios.map((p) => p.store)).toEqual(['team-plans']); - expect(portfolios[0].portfolio.initiatives[0].name).toBe('smoother-setup'); - expect(portfolios[0].portfolio.initiatives[0].changes.map((c) => c.id)).toEqual([ - 'update-docs', - ]); - }); - - it('lists initiative names for agent surfaces, falling back to evergreen names', async () => { - write('app/openspec/initiatives/roadmap.md', '# roadmap\n'); - expect(await listInitiativeNames(path.join(tempDir, 'app'))).toEqual([ - 'roadmap.md', - ]); - - write('app/openspec/initiatives/smoother-setup/goal.md', '# goal\n'); - expect(await listInitiativeNames(path.join(tempDir, 'app'))).toEqual([ - 'smoother-setup', - ]); - }); -}); diff --git a/test/vocabulary-sweep.test.ts b/test/vocabulary-sweep.test.ts index e24c4d2fd5..ab5bf157d9 100644 --- a/test/vocabulary-sweep.test.ts +++ b/test/vocabulary-sweep.test.ts @@ -71,16 +71,13 @@ describe('vocabulary sweep', () => { expect(offenders, `retired vocabulary found:\n${offenders.join('\n')}`).toEqual([]); }); - it('keeps the deleted workspace token surface from regrowing', () => { - // The command-group deletion slice retired the workspace machinery; a - // new workspace_ token in src/ must be a deliberate decision recorded - // in the ledger, not drift. Initiatives returned as a designed feature - // (change add-initiatives) — manifest-free, folder-per-initiative — so - // initiative_ tokens are no longer policed; the guarded surface there - // is the old manifest/collection machinery, which stays deleted. - const allowed = new Set([]); + it('keeps the deleted workspace/initiative token surface from regrowing', () => { + // The command-group deletion slice's ledger records exactly these + // survivors; a new (workspace|initiative)_ token in src/ must be a + // deliberate decision recorded in the ledger, not drift. + const allowed = new Set(['initiative_option_removed']); const found = new Set(); - const pattern = /workspace_[a-z_]+/g; + const pattern = /(workspace|initiative)_[a-z_]+/g; for (const filePath of walkFiles(path.join(REPO_ROOT, 'src'))) { const content = fs.readFileSync(filePath, 'utf-8'); From 6c7ee0e0fe602d5c2c268e5d91c343a6338ba91a Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 13 Jul 2026 10:03:46 -0500 Subject: [PATCH 30/45] test(stores): retarget suite to serves links, downstream rollup, schema inheritance Co-Authored-By: Claude Fable 5 --- src/commands/context.ts | 4 +- src/core/references.ts | 5 +- src/core/upstream.ts | 22 ++ test/cli-e2e/store-lifecycle.test.ts | 7 +- test/commands/artifact-workflow.test.ts | 31 +- test/commands/config-profile.test.ts | 7 +- test/commands/config.test.ts | 2 +- test/commands/context.test.ts | 19 +- test/commands/store-git.test.ts | 2 - test/commands/store-root-selection.test.ts | 16 +- test/commands/store.test.ts | 9 +- test/core/artifact-graph/resolver.test.ts | 98 ++++++ .../core/completions/command-registry.test.ts | 3 +- test/core/openspec-root.test.ts | 5 +- test/core/profiles.test.ts | 16 +- test/core/project-config.test.ts | 25 ++ test/core/references.test.ts | 28 +- test/core/shared/skill-generation.test.ts | 12 +- .../templates/skill-templates-parity.test.ts | 8 - test/core/upstream.test.ts | 299 ++++++++++++++++++ 20 files changed, 534 insertions(+), 84 deletions(-) create mode 100644 test/core/upstream.test.ts diff --git a/src/commands/context.ts b/src/commands/context.ts index e817d74e36..14a687eeb4 100644 --- a/src/commands/context.ts +++ b/src/commands/context.ts @@ -27,7 +27,7 @@ import { COMMON_FLAGS } from '../core/completions/shared-flags.js'; import { emitFailure, printJson } from './shared-output.js'; import { gatherRelationshipData } from './shared-gather.js'; import { listSchemasWithInfo } from '../core/artifact-graph/index.js'; -import { getActiveChangeIds } from '../utils/item-discovery.js'; +import { listActiveChangeIds } from '../core/upstream.js'; import { readProjectConfig } from '../core/project-config.js'; import { schemasFetchRecipe, changesFetchRecipe } from '../core/references.js'; @@ -89,7 +89,7 @@ async function enrichMembersWithStoreArtifacts( try { // In-motion change ids: specs say what is true; these say what the // team there is currently deciding. - const changeIds = await getActiveChangeIds(storeRoot); + const changeIds = await listActiveChangeIds(storeRoot); if (changeIds.length > 0) { member.changes = changeIds; } diff --git a/src/core/references.ts b/src/core/references.ts index 69ddeb4387..21858330d8 100644 --- a/src/core/references.ts +++ b/src/core/references.ts @@ -20,7 +20,8 @@ import { } from './store/foundation.js'; import { getStoreRootForBackend } from './store/registry.js'; import { inspectRegisteredStore, type ResolvedOpenSpecRoot } from './root-selection.js'; -import { getSpecIds, getActiveChangeIds } from '../utils/item-discovery.js'; +import { getSpecIds } from '../utils/item-discovery.js'; +import { listActiveChangeIds } from './upstream.js'; import { FileSystemUtils } from '../utils/file-system.js'; import { MAX_CONTEXT_SIZE, readProjectConfig, type DeclarationEntry } from './project-config.js'; import { listSchemasWithInfo } from './artifact-graph/index.js'; @@ -200,7 +201,7 @@ function collectStructure(referencedRoot: string): Record { */ async function collectActiveChangeIds(referencedRoot: string): Promise { try { - const ids = await getActiveChangeIds(referencedRoot); + const ids = await listActiveChangeIds(referencedRoot); return ids.map((id) => sanitizeInline(id, 60)); } catch { return []; diff --git a/src/core/upstream.ts b/src/core/upstream.ts index 828a779d35..ba614aa235 100644 --- a/src/core/upstream.ts +++ b/src/core/upstream.ts @@ -76,6 +76,28 @@ export async function readServesRef(changeDir: string): Promise { } } +/** + * A root's active change ids: every non-archive directory under + * openspec/changes/, regardless of which schema's artifacts it holds. + * (Unlike proposal-based discovery, this stays correct for custom + * schemas whose first artifact is not proposal.md.) + */ +export async function listActiveChangeIds(root: string): Promise { + let entries; + try { + entries = await fs.readdir(changesDirOf(root), { withFileTypes: true }); + } catch { + return []; + } + return entries + .filter( + (entry) => + entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'archive' + ) + .map((entry) => entry.name) + .sort(); +} + // --------------------------------------------------------------------------- // Linked roots // diff --git a/test/cli-e2e/store-lifecycle.test.ts b/test/cli-e2e/store-lifecycle.test.ts index 345e0a2f0e..4f0acd99c4 100644 --- a/test/cli-e2e/store-lifecycle.test.ts +++ b/test/cli-e2e/store-lifecycle.test.ts @@ -460,12 +460,7 @@ describe('standalone store lifecycle journey', () => { for (const entry of entries) { expect(entry).toMatch(/^(\.openspec-store(\/|\/store\.yaml)?|openspec(\/.*)?)$/); - // The old initiatives machinery (manifests, registration files) stays - // gone; the plain openspec/initiatives/ folder convention is expected. - expect(entry).not.toMatch(/workspace/i); - if (/initiative/i.test(entry)) { - expect(entry).toMatch(/^openspec\/initiatives\//); - } + expect(entry).not.toMatch(/initiative|workspace/i); } expect(entries).toContain('.openspec-store/store.yaml'); diff --git a/test/commands/artifact-workflow.test.ts b/test/commands/artifact-workflow.test.ts index 117694fbbe..52d07ba596 100644 --- a/test/commands/artifact-workflow.test.ts +++ b/test/commands/artifact-workflow.test.ts @@ -342,18 +342,43 @@ describe('artifact-workflow CLI commands', () => { expect(stat.isDirectory()).toBe(true); }); - it('writes the upward initiative link for --initiative', async () => { + it('rejects --initiative and writes no change', async () => { const result = await runCLI( ['new', 'change', 'linked-change', '--initiative', 'billing-launch'], { cwd: tempDir } ); + expect(result.exitCode).toBe(1); + const output = getOutput(result); + expect(output).toContain('--initiative is no longer supported'); + await expect(fs.stat(path.join(changesDir, 'linked-change'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); + + it('writes the upward serves link for --serves', async () => { + const result = await runCLI( + ['new', 'change', 'serving-change', '--serves', 'billing-launch'], + { cwd: tempDir } + ); expect(result.exitCode).toBe(0); const metadata = await fs.readFile( - path.join(changesDir, 'linked-change', '.openspec.yaml'), + path.join(changesDir, 'serving-change', '.openspec.yaml'), 'utf-8' ); - expect(metadata).toContain('initiative: billing-launch'); + expect(metadata).toContain('serves: billing-launch'); + }); + + it('rejects a malformed --serves ref and writes no change', async () => { + const result = await runCLI( + ['new', 'change', 'bad-serves', '--serves', 'a/b/c'], + { cwd: tempDir } + ); + expect(result.exitCode).toBe(1); + expect(getOutput(result)).toContain('serves must be a kebab-case change id'); + await expect(fs.stat(path.join(changesDir, 'bad-serves'))).rejects.toMatchObject({ + code: 'ENOENT', + }); }); it('rejects --areas and writes no affected-area metadata', async () => { diff --git a/test/commands/config-profile.test.ts b/test/commands/config-profile.test.ts index 5926016601..679e89a547 100644 --- a/test/commands/config-profile.test.ts +++ b/test/commands/config-profile.test.ts @@ -78,7 +78,7 @@ describe('deriveProfileFromWorkflowSelection', () => { it('returns core when selection has exactly core workflows in different order', async () => { const { deriveProfileFromWorkflowSelection } = await import('../../src/commands/config.js'); - expect(deriveProfileFromWorkflowSelection(['initiatives', 'archive', 'sync', 'update', 'apply', 'explore', 'propose'])).toBe('core'); + expect(deriveProfileFromWorkflowSelection(['archive', 'sync', 'update', 'apply', 'explore', 'propose'])).toBe('core'); }); }); @@ -107,7 +107,6 @@ describe('config profile interactive flow', () => { 'openspec-update-change', 'openspec-sync-specs', 'openspec-archive-change', - 'openspec-initiatives', ]; for (const dirName of coreSkillDirs) { const skillPath = path.join(projectDir, '.claude', 'skills', dirName, 'SKILL.md'); @@ -115,7 +114,7 @@ describe('config profile interactive flow', () => { fs.writeFileSync(skillPath, `name: ${dirName}\n`, 'utf-8'); } - const coreCommands = ['propose', 'explore', 'apply', 'update', 'sync', 'archive', 'initiatives']; + const coreCommands = ['propose', 'explore', 'apply', 'update', 'sync', 'archive']; for (const commandId of coreCommands) { const commandPath = path.join(projectDir, '.claude', 'commands', 'opsx', `${commandId}.md`); fs.mkdirSync(path.dirname(commandPath), { recursive: true }); @@ -409,7 +408,7 @@ describe('config profile interactive flow', () => { const config = getGlobalConfig(); expect(config.profile).toBe('core'); expect(config.delivery).toBe('skills'); - expect(config.workflows).toEqual(['propose', 'explore', 'apply', 'update', 'sync', 'archive', 'initiatives']); + expect(config.workflows).toEqual(['propose', 'explore', 'apply', 'update', 'sync', 'archive']); expect(select).not.toHaveBeenCalled(); expect(checkbox).not.toHaveBeenCalled(); expect(confirm).not.toHaveBeenCalled(); diff --git a/test/commands/config.test.ts b/test/commands/config.test.ts index 89854041ae..9d3541b686 100644 --- a/test/commands/config.test.ts +++ b/test/commands/config.test.ts @@ -250,7 +250,7 @@ describe('config profile command', () => { const result = getGlobalConfig(); expect(result.profile).toBe('core'); expect(result.delivery).toBe('skills'); // preserved - expect(result.workflows).toEqual(['propose', 'explore', 'apply', 'update', 'sync', 'archive', 'initiatives']); + expect(result.workflows).toEqual(['propose', 'explore', 'apply', 'update', 'sync', 'archive']); }); it('custom workflow selection should set profile to custom', async () => { diff --git a/test/commands/context.test.ts b/test/commands/context.test.ts index b5a68103be..edc8100ff4 100644 --- a/test/commands/context.test.ts +++ b/test/commands/context.test.ts @@ -106,8 +106,9 @@ describe('openspec context (4.1)', () => { expect(parseJson(declared).members).toHaveLength(2); }); - it("surfaces a referenced store's own artifact types and initiatives", async () => { - // The upstream store defines a custom artifact type and initiatives. + it("surfaces a referenced store's artifact types, work in motion, and layout", async () => { + // The upstream store defines a custom artifact type, active changes, + // and a declared layout. const schemaDir = path.join(upstream, 'openspec', 'schemas', 'team-brief'); fs.mkdirSync(path.join(schemaDir, 'templates'), { recursive: true }); fs.writeFileSync( @@ -117,12 +118,16 @@ describe('openspec context (4.1)', () => { ' template: brief.md\n requires: []\n instruction: y\n' ); fs.writeFileSync(path.join(schemaDir, 'templates', 'brief.md'), '# Brief\n'); - fs.mkdirSync(path.join(upstream, 'openspec', 'initiatives', 'smoother-setup'), { + fs.mkdirSync(path.join(upstream, 'openspec', 'changes', 'smoother-setup'), { recursive: true, }); - fs.mkdirSync(path.join(upstream, 'openspec', 'initiatives', 'q3-payments'), { + fs.mkdirSync(path.join(upstream, 'openspec', 'changes', 'q3-payments'), { recursive: true, }); + fs.writeFileSync( + path.join(upstream, 'openspec', 'config.yaml'), + 'schema: spec-driven\nstructure:\n research/: raw inputs\n' + ); const json = await runCLI(['context', '--json', '--store', 'team-context'], { cwd: tempDir, @@ -132,7 +137,8 @@ describe('openspec context (4.1)', () => { (member: any) => member.id === 'upstream-context' ); expect(upstreamMember.artifactTypes).toEqual(['team-brief']); - expect(upstreamMember.initiatives).toEqual(['q3-payments', 'smoother-setup']); + expect(upstreamMember.changes).toEqual(['q3-payments', 'smoother-setup']); + expect(upstreamMember.structure).toEqual({ 'research/': 'raw inputs' }); const human = await runCLI(['context', '--store', 'team-context'], { cwd: tempDir, @@ -142,8 +148,9 @@ describe('openspec context (4.1)', () => { 'Artifact types: team-brief (openspec schemas --store upstream-context)' ); expect(human.stdout).toContain( - 'Initiatives: q3-payments, smoother-setup (openspec list --initiatives --store upstream-context)' + 'In motion: q3-payments, smoother-setup (openspec list --downstream --store upstream-context)' ); + expect(human.stdout).toContain('Layout: research/ — raw inputs'); }); it('distinguishes self-reference omission from nothing declared', async () => { diff --git a/test/commands/store-git.test.ts b/test/commands/store-git.test.ts index 623ae6b35f..2a9ea8a03c 100644 --- a/test/commands/store-git.test.ts +++ b/test/commands/store-git.test.ts @@ -176,7 +176,6 @@ describe('store git lifecycle', () => { '.openspec-store/store.yaml', 'openspec/changes/archive/.gitkeep', 'openspec/config.yaml', - 'openspec/initiatives/.gitkeep', 'openspec/specs/keep-me.md', ]); @@ -322,7 +321,6 @@ describe('store git lifecycle', () => { expect(committedFiles).toEqual([ '.openspec-store/store.yaml', 'openspec/changes/archive/.gitkeep', - 'openspec/initiatives/.gitkeep', 'openspec/specs/.gitkeep', ]); diff --git a/test/commands/store-root-selection.test.ts b/test/commands/store-root-selection.test.ts index 974a2a906c..e50147e0d9 100644 --- a/test/commands/store-root-selection.test.ts +++ b/test/commands/store-root-selection.test.ts @@ -674,8 +674,8 @@ describe('store root selection for normal commands', () => { }); }); - describe('initiative links in normal change flows', () => { - it('accepts --initiative and writes the upward link', async () => { + describe('initiative links are retired from normal change flows', () => { + it('rejects --initiative and creates no files', async () => { const localRepo = path.join(tempDir, 'initiative-repo'); createOpenSpecRoot(localRepo); @@ -683,12 +683,12 @@ describe('store root selection for normal commands', () => { ['new', 'change', 'linked-change', '--initiative', 'billing-launch'], { cwd: localRepo, env } ); - expect(result.exitCode).toBe(0); - const metadata = fs.readFileSync( - path.join(localRepo, 'openspec', 'changes', 'linked-change', '.openspec.yaml'), - 'utf-8' - ); - expect(metadata).toContain('initiative: billing-launch'); + expect(result.exitCode).toBe(1); + const output = result.stdout + result.stderr; + expect(output).toContain('--initiative is no longer supported'); + expect( + fs.existsSync(path.join(localRepo, 'openspec', 'changes', 'linked-change')) + ).toBe(false); }); it('removes openspec set change entirely', async () => { diff --git a/test/commands/store.test.ts b/test/commands/store.test.ts index c1a59b2349..e07f4e5d51 100644 --- a/test/commands/store.test.ts +++ b/test/commands/store.test.ts @@ -157,11 +157,9 @@ describe('store command', () => { 'openspec/specs/', 'openspec/changes/', 'openspec/changes/archive/', - 'openspec/initiatives/', 'openspec/config.yaml', 'openspec/specs/.gitkeep', 'openspec/changes/archive/.gitkeep', - 'openspec/initiatives/.gitkeep', '.openspec-store/store.yaml', ]); expect(payload.status).toEqual([]); @@ -298,11 +296,9 @@ describe('store command', () => { 'openspec/specs/', 'openspec/changes/', 'openspec/changes/archive/', - 'openspec/initiatives/', 'openspec/config.yaml', 'openspec/specs/.gitkeep', 'openspec/changes/archive/.gitkeep', - 'openspec/initiatives/.gitkeep', '.openspec-store/store.yaml', ]); expect(fs.existsSync(path.join(storeRoot, '.git'))).toBe(true); @@ -322,12 +318,9 @@ describe('store command', () => { expect(result.exitCode).toBe(0); const payload = parseJson(result); // First-time accept of an existing root anchors its empty directories - // (specs/ has user content here, so only archive/ gets an anchor) and - // adds the initiatives/ folder a pre-initiatives root lacks. + // (specs/ has user content here, so only archive/ gets an anchor). expect(payload.created_files).toEqual([ - 'openspec/initiatives/', 'openspec/changes/archive/.gitkeep', - 'openspec/initiatives/.gitkeep', '.openspec-store/store.yaml', ]); expect(fs.existsSync(path.join(storeRoot, 'openspec', 'config.yaml'))).toBe(false); diff --git a/test/core/artifact-graph/resolver.test.ts b/test/core/artifact-graph/resolver.test.ts index 484cc3b406..b0bb23c38a 100644 --- a/test/core/artifact-graph/resolver.test.ts +++ b/test/core/artifact-graph/resolver.test.ts @@ -648,4 +648,102 @@ artifacts: expect(sharedSchema!.description).toBe('Project shared'); // project version wins }); }); + describe('schema inheritance through references', () => { + function writeSchema(dir: string, name: string, description: string): void { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'schema.yaml'), + `name: ${name}\nversion: 1\ndescription: ${description}\nartifacts:\n - id: brief\n generates: brief.md\n description: What and why\n template: brief.md\n` + ); + } + + function setUpReferencedStore(): { projectRoot: string; storeRoot: string } { + process.env.XDG_DATA_HOME = tempDir; + const storeRoot = path.join(tempDir, 'team-hub'); + const projectRoot = path.join(tempDir, 'web-app'); + writeSchema(path.join(storeRoot, 'openspec', 'schemas', 'team-brief'), 'team-brief', 'From the store'); + fs.mkdirSync(path.join(projectRoot, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, 'openspec', 'config.yaml'), + 'schema: spec-driven\nreferences:\n - team-hub\n' + ); + const storesDir = path.join(tempDir, 'openspec', 'stores'); + fs.mkdirSync(storesDir, { recursive: true }); + fs.writeFileSync( + path.join(storesDir, 'registry.yaml'), + `version: 1\nstores:\n team-hub:\n backend:\n type: git\n local_path: ${JSON.stringify(storeRoot)}\n` + ); + return { projectRoot, storeRoot }; + } + + it('resolves a schema from a referenced store when the project has none', () => { + const { projectRoot, storeRoot } = setUpReferencedStore(); + + expect(getSchemaDir('team-brief', projectRoot)).toBe( + path.join(storeRoot, 'openspec', 'schemas', 'team-brief') + ); + expect(listSchemas(projectRoot)).toContain('team-brief'); + }); + + it('lets a project-local schema shadow an inherited one', () => { + const { projectRoot } = setUpReferencedStore(); + writeSchema( + path.join(projectRoot, 'openspec', 'schemas', 'team-brief'), + 'team-brief', + 'Local override' + ); + + expect(getSchemaDir('team-brief', projectRoot)).toBe( + path.join(projectRoot, 'openspec', 'schemas', 'team-brief') + ); + }); + + it('reports inherited schemas with their store id', () => { + const { projectRoot } = setUpReferencedStore(); + + const info = listSchemasWithInfo(projectRoot).find((s) => s.name === 'team-brief'); + expect(info?.source).toBe('store'); + expect(info?.store).toBe('team-hub'); + }); + + it('degrades to no inherited schemas without a registry', () => { + const { projectRoot } = setUpReferencedStore(); + fs.rmSync(path.join(tempDir, 'openspec', 'stores', 'registry.yaml')); + + expect(getSchemaDir('team-brief', projectRoot)).toBeNull(); + }); + }); + + describe('schema folder extras', () => { + it('parses top-level notes', () => { + process.env.XDG_DATA_HOME = tempDir; + const dir = path.join(tempDir, 'openspec', 'schemas', 'noted'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'schema.yaml'), + 'name: noted\nversion: 1\nnotes: No implementation phase; archive after specs.\nartifacts:\n - id: brief\n generates: brief.md\n description: x\n template: brief.md\n' + ); + + const schema = resolveSchema('noted'); + expect(schema.notes).toBe('No implementation phase; archive after specs.'); + }); + + it('loads instructions/.md over an inline instruction, and instructions/apply.md', () => { + process.env.XDG_DATA_HOME = tempDir; + const dir = path.join(tempDir, 'openspec', 'schemas', 'filed'); + fs.mkdirSync(path.join(dir, 'instructions'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'schema.yaml'), + 'name: filed\nversion: 1\nartifacts:\n - id: brief\n generates: brief.md\n description: x\n template: brief.md\n instruction: inline version\n - id: tasks\n generates: tasks.md\n description: y\n template: tasks.md\napply:\n requires: [brief]\n tracks: tasks.md\n instruction: inline apply\n' + ); + fs.writeFileSync(path.join(dir, 'instructions', 'brief.md'), 'From the file.\n'); + fs.writeFileSync(path.join(dir, 'instructions', 'apply.md'), 'Apply from the file.\n'); + + const schema = resolveSchema('filed'); + expect(schema.artifacts.find((a) => a.id === 'brief')?.instruction).toBe('From the file.'); + // No file for tasks: inline (absent) stays untouched. + expect(schema.artifacts.find((a) => a.id === 'tasks')?.instruction).toBeUndefined(); + expect(schema.apply?.instruction).toBe('Apply from the file.'); + }); + }); }); diff --git a/test/core/completions/command-registry.test.ts b/test/core/completions/command-registry.test.ts index cb6e573d52..8c90604917 100644 --- a/test/core/completions/command-registry.test.ts +++ b/test/core/completions/command-registry.test.ts @@ -207,13 +207,14 @@ describe('command completion registry', () => { 'description', 'goal', 'schema', - 'initiative', + 'serves', 'json', 'store', ]); const storeFlag = newChange?.flags.find((flag) => flag.name === 'store'); expect(storeFlag?.description).toContain('OpenSpec root'); + expect(newChange?.flags.map((flag) => flag.name)).not.toContain('initiative'); expect(newChange?.flags.map((flag) => flag.name)).not.toContain('areas'); expect(newChange?.flags.map((flag) => flag.name)).not.toContain('store-path'); }); diff --git a/test/core/openspec-root.test.ts b/test/core/openspec-root.test.ts index 5f19fb77f0..d2059f31e0 100644 --- a/test/core/openspec-root.test.ts +++ b/test/core/openspec-root.test.ts @@ -110,7 +110,6 @@ describe('OpenSpec root helper', () => { 'openspec/specs/', 'openspec/changes/', 'openspec/changes/archive/', - 'openspec/initiatives/', 'openspec/config.yaml', ]); expect(result.inspection.healthy).toBe(true); @@ -126,9 +125,7 @@ describe('OpenSpec root helper', () => { const result = await ensureOpenSpecRoot(root); - // A pre-initiatives root gains only the (empty) initiatives directory; - // everything the user already had is untouched. - expect(result.createdArtifacts).toEqual(['openspec/initiatives/']); + expect(result.createdArtifacts).toEqual([]); expect(fs.existsSync(path.join(root, 'openspec', 'config.yaml'))).toBe(false); expect(fs.readFileSync(path.join(root, 'openspec', 'config.yml'), 'utf-8')).toBe( `schema: ${DEFAULT_OPENSPEC_SCHEMA}\n` diff --git a/test/core/profiles.test.ts b/test/core/profiles.test.ts index 254ddbbf10..b06456e016 100644 --- a/test/core/profiles.test.ts +++ b/test/core/profiles.test.ts @@ -9,15 +9,7 @@ import { describe('profiles', () => { describe('CORE_WORKFLOWS', () => { it('should contain the default core workflows', () => { - expect(CORE_WORKFLOWS).toEqual([ - 'propose', - 'explore', - 'apply', - 'update', - 'sync', - 'archive', - 'initiatives', - ]); + expect(CORE_WORKFLOWS).toEqual(['propose', 'explore', 'apply', 'update', 'sync', 'archive']); }); it('should include update in the core profile (default install, not expanded-only)', () => { @@ -32,14 +24,14 @@ describe('profiles', () => { }); describe('ALL_WORKFLOWS', () => { - it('should contain all 13 workflows', () => { - expect(ALL_WORKFLOWS).toHaveLength(13); + it('should contain all 12 workflows', () => { + expect(ALL_WORKFLOWS).toHaveLength(12); }); it('should contain expected workflow IDs', () => { const expected = [ 'propose', 'explore', 'new', 'continue', 'apply', 'update', - 'ff', 'sync', 'archive', 'bulk-archive', 'verify', 'onboard', 'initiatives', + 'ff', 'sync', 'archive', 'bulk-archive', 'verify', 'onboard', ]; expect([...ALL_WORKFLOWS]).toEqual(expected); }); diff --git a/test/core/project-config.test.ts b/test/core/project-config.test.ts index 73382f25fa..3951c2d922 100644 --- a/test/core/project-config.test.ts +++ b/test/core/project-config.test.ts @@ -695,6 +695,31 @@ rules: }); }); + describe('structure field', () => { + it('parses a folder → purpose map and drops invalid entries', () => { + const configPath = path.join(tempDir, 'openspec', 'config.yaml'); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync( + configPath, + 'schema: spec-driven\nstructure:\n research/: raw inputs\n decisions/: standing decisions\n bad/: 42\n', + 'utf-8' + ); + + expect(readProjectConfig(tempDir)?.structure).toEqual({ + 'research/': 'raw inputs', + 'decisions/': 'standing decisions', + }); + }); + + it('ignores a non-map structure field', () => { + const configPath = path.join(tempDir, 'openspec', 'config.yaml'); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, 'schema: spec-driven\nstructure: nope\n', 'utf-8'); + + expect(readProjectConfig(tempDir)?.structure).toBeUndefined(); + }); + }); + describe('addReferenceToProjectConfig', () => { function writeConfig(content: string): string { const configPath = path.join(tempDir, 'openspec', 'config.yaml'); diff --git a/test/core/references.test.ts b/test/core/references.test.ts index bbdc119df7..a790cc9f25 100644 --- a/test/core/references.test.ts +++ b/test/core/references.test.ts @@ -130,7 +130,7 @@ describe('reference index assembly', () => { expect(entries[0].status).toEqual([]); }); - it("indexes a store's own artifact types and initiatives with fetch commands", async () => { + it("indexes a store's own artifact types, work in motion, and layout", async () => { const storeRoot = await registerStore('team-context'); // A project-local schema (custom artifact types) in the store. const schemaDir = path.join(storeRoot, 'openspec', 'schemas', 'team-brief'); @@ -142,13 +142,14 @@ describe('reference index assembly', () => { ' template: brief.md\n requires: []\n instruction: Write it.\n' ); fs.writeFileSync(path.join(schemaDir, 'templates', 'brief.md'), '# Brief\n'); - // An initiatives folder in the store: one folder per initiative. - const initiativesDir = path.join(storeRoot, 'openspec', 'initiatives'); - fs.mkdirSync(path.join(initiativesDir, 'smoother-setup'), { recursive: true }); - fs.mkdirSync(path.join(initiativesDir, 'q3-payments'), { recursive: true }); + // Work in motion: active changes in the store. + const changesDir = path.join(storeRoot, 'openspec', 'changes'); + fs.mkdirSync(path.join(changesDir, 'smoother-setup'), { recursive: true }); + fs.mkdirSync(path.join(changesDir, 'q3-payments'), { recursive: true }); + // Declared layout in the store's config. fs.writeFileSync( - path.join(initiativesDir, 'smoother-setup', 'goal.md'), - '# goal\n' + path.join(storeRoot, 'openspec', 'config.yaml'), + 'schema: team-brief\nstructure:\n research/: raw inputs — interviews, transcripts\n' ); const [entry] = await assemble(['team-context']); @@ -160,7 +161,10 @@ describe('reference index assembly', () => { artifacts: ['brief'], }, ]); - expect(entry.initiatives).toEqual(['q3-payments', 'smoother-setup']); + expect(entry.changes).toEqual(['q3-payments', 'smoother-setup']); + expect(entry.structure).toEqual({ + 'research/': 'raw inputs — interviews, transcripts', + }); const block = renderReferencedStoresBlock([entry]); expect(block).toContain( @@ -168,17 +172,19 @@ describe('reference index assembly', () => { ); expect(block).toContain('- team-brief: Our own planning artifacts. [brief]'); expect(block).toContain( - 'Initiatives: q3-payments, smoother-setup (openspec list --initiatives --store team-context)' + 'In motion: q3-payments, smoother-setup (openspec list --downstream --store team-context)' ); + expect(block).toContain('- research/: raw inputs — interviews, transcripts'); }); - it('omits schemas/initiatives keys for a store that has none', async () => { + it('omits schemas/changes/structure keys for a store that has none', async () => { await registerStore('bare-context'); const [entry] = await assemble(['bare-context']); expect(entry.schemas).toBeUndefined(); - expect(entry.initiatives).toBeUndefined(); + expect(entry.changes).toBeUndefined(); + expect(entry.structure).toBeUndefined(); }); it('degrades an unregistered reference to reference_unresolved with a pasteable fix', async () => { diff --git a/test/core/shared/skill-generation.test.ts b/test/core/shared/skill-generation.test.ts index c806c906b3..5f4bba9d55 100644 --- a/test/core/shared/skill-generation.test.ts +++ b/test/core/shared/skill-generation.test.ts @@ -8,9 +8,9 @@ import { describe('skill-generation', () => { describe('getSkillTemplates', () => { - it('should return all 13 skill templates', () => { + it('should return all 12 skill templates', () => { const templates = getSkillTemplates(); - expect(templates).toHaveLength(13); + expect(templates).toHaveLength(12); }); it('should have unique directory names', () => { @@ -89,9 +89,9 @@ describe('skill-generation', () => { }); describe('getCommandTemplates', () => { - it('should return all 13 command templates', () => { + it('should return all 12 command templates', () => { const templates = getCommandTemplates(); - expect(templates).toHaveLength(13); + expect(templates).toHaveLength(12); }); it('should have unique IDs', () => { @@ -144,9 +144,9 @@ describe('skill-generation', () => { }); describe('getCommandContents', () => { - it('should return all 13 command contents', () => { + it('should return all 12 command contents', () => { const contents = getCommandContents(); - expect(contents).toHaveLength(13); + expect(contents).toHaveLength(12); }); it('should have valid content structure', () => { diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index 083b32412a..249d604b30 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -25,8 +25,6 @@ import { getOpsxProposeSkillTemplate, getOpsxUpdateCommandTemplate, getOpsxVerifyCommandTemplate, - getInitiativesSkillTemplate, - getOpsxInitiativesCommandTemplate, getSyncSpecsSkillTemplate, getUpdateChangeSkillTemplate, getVerifyChangeSkillTemplate, @@ -61,8 +59,6 @@ const EXPECTED_FUNCTION_HASHES: Record = { getOpsxVerifyCommandTemplate: 'b4a5717c25883ca74dc8da091aef2432492e2a377c8cd9c1a0369b6b854dc32c', getOpsxProposeSkillTemplate: 'ad11a374aa8f3978a93af3a2d5b4cea736d6a5f7b3f913b38a2b34aeeb2bec21', getOpsxProposeCommandTemplate: '8aa4d2e0ca201ad8e913e8d490223063cef9c2a7e2b7a464d5aa0452540ccc09', - getInitiativesSkillTemplate: '6a8da2ed910e732a56dc7f280cb26b90b487247af323f8059d3bda707e02d168', - getOpsxInitiativesCommandTemplate: '4297c838493ca508f7a08748be4f48393d220b59981f6276db6b704864c470c8', getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d', getUpdateChangeSkillTemplate: 'cb95d9c5c450087b5adf862986a6a81a65d57cce535162e693fbf9aea127c1c3', getOpsxUpdateCommandTemplate: '1657481ccf1f60c44cba192d51b20f5d7035b3c3a84e237b7323b703496fc149', @@ -80,7 +76,6 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-verify-change': 'ba6d6bc6e78d2ab9cf3c41d75961cdd08277ac591ff2454c98b4190a4e43fba6', 'openspec-onboard': 'd8baa141849b099671376d6928d29e4ed7dfb4be9675dbe3e1ff282548d883a9', 'openspec-propose': '273e432fd268546de6316520b2d116c1e6d01d5a8d57bd03ad71a47fb5cec112', - 'openspec-initiatives': '9f40e9c1e485186912e3fd88aedbbe8d4106ff2a4c3b45114047afacaf2a65da', 'openspec-update-change': '29c95eefe0df8efb4681dcaa6a90ae965535edda1832501f637a368608e33684', }; @@ -98,7 +93,6 @@ const GENERATED_SKILL_FACTORIES: Array<[string, () => SkillTemplate]> = [ ['openspec-verify-change', getVerifyChangeSkillTemplate], ['openspec-onboard', getOnboardSkillTemplate], ['openspec-propose', getOpsxProposeSkillTemplate], - ['openspec-initiatives', getInitiativesSkillTemplate], ['openspec-update-change', getUpdateChangeSkillTemplate], ]; @@ -147,8 +141,6 @@ describe('skill templates split parity', () => { getOpsxVerifyCommandTemplate, getOpsxProposeSkillTemplate, getOpsxProposeCommandTemplate, - getInitiativesSkillTemplate, - getOpsxInitiativesCommandTemplate, getFeedbackSkillTemplate, getUpdateChangeSkillTemplate, getOpsxUpdateCommandTemplate, diff --git a/test/core/upstream.test.ts b/test/core/upstream.test.ts new file mode 100644 index 0000000000..5ea904901a --- /dev/null +++ b/test/core/upstream.test.ts @@ -0,0 +1,299 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + readLinkedRoots, + readServesRef, + recordLinkedRoot, + resolveUpstreamLink, + rollupDownstream, + rollupRegisteredStores, +} from '../../src/core/upstream.js'; +import { + readStoreRegistryState, + writeStoreRegistryState, +} from '../../src/core/store/foundation.js'; + +describe('upstream links', () => { + let tempDir: string; + let globalDataDir: string; + let savedXdgDataHome: string | undefined; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-upstream-')); + globalDataDir = path.join(tempDir, 'data', 'openspec'); + savedXdgDataHome = process.env.XDG_DATA_HOME; + process.env.XDG_DATA_HOME = path.join(tempDir, 'xdg'); + }); + + afterEach(() => { + if (savedXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = savedXdgDataHome; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function write(relativePath: string, content: string): void { + const full = path.join(tempDir, relativePath); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + + function change(root: string, id: string, ref: string | null, tasks: string): void { + const metadata = + ref === null + ? 'schema: spec-driven\n' + : `schema: spec-driven\nserves: ${ref}\n`; + write(`${root}/openspec/changes/${id}/.openspec.yaml`, metadata); + write(`${root}/openspec/changes/${id}/tasks.md`, tasks); + } + + async function registerStore(id: string, root: string): Promise { + const existing = await readStoreRegistryState({ globalDataDir }).catch(() => null); + await writeStoreRegistryState( + { + version: 1, + stores: { + ...(existing?.stores ?? {}), + [id]: { backend: { type: 'git', local_path: path.join(tempDir, root) } }, + }, + }, + { globalDataDir } + ); + } + + describe('readServesRef', () => { + it('reads the serves line and tolerates absent or unreadable metadata', async () => { + change('app', 'add-search', 'team-hub/onboarding-revamp', '- [ ] task\n'); + change('app', 'unlinked', null, '- [ ] task\n'); + + const changesDir = path.join(tempDir, 'app', 'openspec', 'changes'); + expect(await readServesRef(path.join(changesDir, 'add-search'))).toBe( + 'team-hub/onboarding-revamp' + ); + expect(await readServesRef(path.join(changesDir, 'unlinked'))).toBeNull(); + expect(await readServesRef(path.join(changesDir, 'missing'))).toBeNull(); + }); + }); + + describe('linked roots', () => { + it('records roots idempotently and reads them back sorted', async () => { + const options = { globalDataDir }; + await recordLinkedRoot(path.join(tempDir, 'b-repo'), options); + await recordLinkedRoot(path.join(tempDir, 'a-repo'), options); + await recordLinkedRoot(path.join(tempDir, 'b-repo'), options); + + expect(await readLinkedRoots(options)).toEqual([ + path.resolve(tempDir, 'a-repo'), + path.resolve(tempDir, 'b-repo'), + ]); + }); + + it('reads unreadable state as none', async () => { + expect(await readLinkedRoots({ globalDataDir })).toEqual([]); + }); + }); + + describe('rollupDownstream in a plain repo', () => { + it('groups local serving changes under the changes they serve', async () => { + change('app', 'onboarding-revamp', null, ''); + change('app', 'add-search', 'onboarding-revamp', '- [x] one\n- [ ] two\n'); + change('app', 'add-tour', 'onboarding-revamp', '- [x] only\n'); + change('app', 'unrelated', null, '- [ ] task\n'); + + const rollup = await rollupDownstream(path.join(tempDir, 'app'), { globalDataDir }); + + const entry = rollup?.upstream.find((u) => u.id === 'onboarding-revamp'); + expect(entry?.exists).toBe(true); + expect(entry?.archived).toBe(false); + expect(entry?.changes.map((c) => c.id)).toEqual(['add-search', 'add-tour']); + expect(entry?.changesComplete).toBe(1); + expect(entry?.changesTotal).toBe(2); + expect(entry?.tasksComplete).toBe(2); + expect(entry?.tasksTotal).toBe(3); + + const unrelated = rollup?.upstream.find((u) => u.id === 'unrelated'); + expect(unrelated?.changesTotal).toBe(0); + }); + + it('keeps refs to missing changes visible instead of dropping them', async () => { + change('app', 'add-search', 'no-such-change', '- [ ] task\n'); + + const rollup = await rollupDownstream(path.join(tempDir, 'app'), { globalDataDir }); + + const entry = rollup?.upstream.find((u) => u.id === 'no-such-change'); + expect(entry?.exists).toBe(false); + expect(entry?.changes.map((c) => c.id)).toEqual(['add-search']); + }); + + it('resolves refs to archived changes and marks them archived', async () => { + write( + 'app/openspec/changes/archive/2026-07-01-onboarding-revamp/.openspec.yaml', + 'schema: spec-driven\n' + ); + change('app', 'add-search', 'onboarding-revamp', '- [x] done\n'); + + const rollup = await rollupDownstream(path.join(tempDir, 'app'), { globalDataDir }); + + const entry = rollup?.upstream.find((u) => u.id === 'onboarding-revamp'); + expect(entry?.exists).toBe(true); + expect(entry?.archived).toBe(true); + }); + + it('returns null when the root has no changes folder', async () => { + fs.mkdirSync(path.join(tempDir, 'app', 'openspec'), { recursive: true }); + expect(await rollupDownstream(path.join(tempDir, 'app'), { globalDataDir })).toBeNull(); + }); + }); + + describe('rollupDownstream for a registered store', () => { + it('scans other stores and linked repos for store-qualified refs', async () => { + await registerStore('team-hub', 'team-hub'); + await registerStore('other-store', 'other-store'); + change('team-hub', 'onboarding-revamp', null, ''); + // A change in another registered store serving team-hub's change. + change('other-store', 'update-docs', 'team-hub/onboarding-revamp', '- [x] a\n'); + // A change in a plain linked repo. + change('web-app', 'add-tour', 'team-hub/onboarding-revamp', '- [ ] b\n'); + await recordLinkedRoot(path.join(tempDir, 'web-app'), { globalDataDir }); + // A self-qualified ref inside the store itself. + change('team-hub', 'refine-copy', 'team-hub/onboarding-revamp', '- [x] c\n'); + + const rollup = await rollupDownstream(path.join(tempDir, 'team-hub'), { + globalDataDir, + }); + + const entry = rollup?.upstream.find((u) => u.id === 'onboarding-revamp'); + expect(entry?.changes).toEqual([ + expect.objectContaining({ id: 'add-tour', repo: 'web-app' }), + expect.objectContaining({ id: 'refine-copy' }), + expect.objectContaining({ id: 'update-docs', store: 'other-store' }), + ]); + expect(entry?.changesComplete).toBe(2); + expect(entry?.changesTotal).toBe(3); + }); + + it('ignores refs qualified for a different store', async () => { + await registerStore('team-hub', 'team-hub'); + change('team-hub', 'onboarding-revamp', null, ''); + change('web-app', 'add-tour', 'elsewhere/onboarding-revamp', '- [ ] b\n'); + await recordLinkedRoot(path.join(tempDir, 'web-app'), { globalDataDir }); + + const rollup = await rollupDownstream(path.join(tempDir, 'team-hub'), { + globalDataDir, + }); + + const entry = rollup?.upstream.find((u) => u.id === 'onboarding-revamp'); + expect(entry?.changes).toEqual([]); + }); + }); + + describe('rollupRegisteredStores', () => { + it('returns rollups for every registered store with changes', async () => { + await registerStore('team-hub', 'team-hub'); + await registerStore('empty-store', 'empty-store'); + change('team-hub', 'onboarding-revamp', null, ''); + fs.mkdirSync(path.join(tempDir, 'empty-store', 'openspec'), { recursive: true }); + + const rollups = await rollupRegisteredStores({ globalDataDir }); + + expect(rollups.map((r) => r.store)).toEqual(['team-hub']); + expect(rollups[0].rollup.upstream.map((u) => u.id)).toEqual([ + 'onboarding-revamp', + ]); + }); + }); + + describe('resolveUpstreamLink', () => { + it('resolves a same-root ref to the upstream change directory', async () => { + change('app', 'onboarding-revamp', null, ''); + change('app', 'add-search', 'onboarding-revamp', '- [ ] a\n'); + + const link = await resolveUpstreamLink( + path.join(tempDir, 'app', 'openspec', 'changes', 'add-search'), + path.join(tempDir, 'app'), + { globalDataDir } + ); + + expect(link).toEqual({ + ref: 'onboarding-revamp', + changeId: 'onboarding-revamp', + path: path.join(tempDir, 'app', 'openspec', 'changes', 'onboarding-revamp'), + archived: false, + }); + }); + + it('resolves a store-qualified ref through the registry', async () => { + await registerStore('team-hub', 'team-hub'); + change('team-hub', 'onboarding-revamp', null, ''); + change('web-app', 'add-tour', 'team-hub/onboarding-revamp', '- [ ] a\n'); + + const link = await resolveUpstreamLink( + path.join(tempDir, 'web-app', 'openspec', 'changes', 'add-tour'), + path.join(tempDir, 'web-app'), + { globalDataDir } + ); + + expect(link?.store).toBe('team-hub'); + expect(link?.path).toBe( + path.join(tempDir, 'team-hub', 'openspec', 'changes', 'onboarding-revamp') + ); + }); + + it('resolves an archived upstream change and marks it archived', async () => { + await registerStore('team-hub', 'team-hub'); + write( + 'team-hub/openspec/changes/archive/2026-07-01-onboarding-revamp/proposal.md', + '# done\n' + ); + change('web-app', 'add-tour', 'team-hub/onboarding-revamp', '- [ ] a\n'); + + const link = await resolveUpstreamLink( + path.join(tempDir, 'web-app', 'openspec', 'changes', 'add-tour'), + path.join(tempDir, 'web-app'), + { globalDataDir } + ); + + expect(link?.archived).toBe(true); + expect(link?.path).toBe( + path.join( + tempDir, + 'team-hub', + 'openspec', + 'changes', + 'archive', + '2026-07-01-onboarding-revamp' + ) + ); + }); + + it('keeps a stale link visible with path null', async () => { + change('web-app', 'add-tour', 'unregistered/onboarding-revamp', '- [ ] a\n'); + + const link = await resolveUpstreamLink( + path.join(tempDir, 'web-app', 'openspec', 'changes', 'add-tour'), + path.join(tempDir, 'web-app'), + { globalDataDir } + ); + + expect(link?.path).toBeNull(); + expect(link?.ref).toBe('unregistered/onboarding-revamp'); + }); + + it('returns null for a change that serves nothing', async () => { + change('app', 'plain', null, '- [ ] a\n'); + + expect( + await resolveUpstreamLink( + path.join(tempDir, 'app', 'openspec', 'changes', 'plain'), + path.join(tempDir, 'app'), + { globalDataDir } + ) + ).toBeNull(); + }); + }); +}); From 5b5b8f7de88c6e22d53165e91821d7ecd0bb0a72 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 13 Jul 2026 10:12:38 -0500 Subject: [PATCH 31/45] chore(stores): drop stale initiative-era exploration notes and update docs to the new direction Co-Authored-By: Claude Fable 5 --- docs/README.md | 1 + docs/agent-contract.md | 12 +- docs/stores-beta/upstream-work.md | 111 +++ docs/stores-beta/user-guide.md | 4 +- .../changes/add-upstream-links/.openspec.yaml | 2 + openspec/changes/add-upstream-links/design.md | 44 + .../changes/add-upstream-links/proposal.md | 44 + .../specs/schema-openness/spec.md | 38 + .../specs/upstream-links/spec.md | 49 ++ openspec/changes/add-upstream-links/tasks.md | 22 + .../.initiative.yaml | 27 - .../context-store-and-initiatives/README.md | 58 -- .../decisions.md | 225 ----- .../direction-git-native-work.md | 472 ----------- .../direction.md | 458 ----------- .../questions.md | 23 - .../context-store-and-initiatives/roadmap.md | 775 ------------------ .../context-store-and-initiatives/tasks.md | 321 -------- .../01-lock-the-direction/evidence.md | 154 ---- .../work-items/01-lock-the-direction/plan.md | 90 -- .../work-items/01-lock-the-direction/tasks.md | 44 - .../evidence.md | 68 -- .../plan.md | 80 -- .../tasks.md | 23 - .../evidence.md | 43 - .../03-add-context-store-foundation/plan.md | 85 -- .../03-add-context-store-foundation/tasks.md | 17 - .../04-add-collection-foundation/evidence.md | 77 -- .../04-add-collection-foundation/plan.md | 198 ----- .../04-add-collection-foundation/tasks.md | 14 - .../05-ship-initiative-mvp/evidence.md | 99 --- .../work-items/05-ship-initiative-mvp/plan.md | 236 ------ .../05-ship-initiative-mvp/tasks.md | 21 - .../evidence.md | 97 --- .../06-add-minimal-context-store-ux/plan.md | 333 -------- .../06-add-minimal-context-store-ux/tasks.md | 29 - .../evidence.md | 97 --- .../plan.md | 184 ----- .../tasks.md | 27 - .../evidence.md | 239 ------ .../plan.md | 279 ------- .../tasks.md | 22 - .../decision-review.md | 64 -- .../09-add-initiative-resolve/evidence.md | 106 --- .../09-add-initiative-resolve/plan.md | 141 ---- .../09-add-initiative-resolve/tasks.md | 22 - .../plan.md | 430 ---------- .../tasks.md | 43 - .../11-manual-beta-reality-pass/notes.md | 289 ------- .../11-manual-beta-reality-pass/plan.md | 39 - .../11-manual-beta-reality-pass/tasks.md | 8 - .../evidence.md | 45 - .../plan.md | 150 ---- .../tasks.md | 23 - .../evidence.md | 41 - .../plan.md | 106 --- .../tasks.md | 38 - .../14-workspaces-beta-guide-split/plan.md | 37 - .../14-workspaces-beta-guide-split/tasks.md | 9 - .../evidence.md | 140 ---- .../plan.md | 344 -------- .../tasks.md | 39 - .../work-items/16-add-escalation-ux/plan.md | 26 - .../work-items/16-add-escalation-ux/tasks.md | 7 - .../plan.md | 25 - .../tasks.md | 7 - .../evidence.md | 397 --------- .../plan.md | 180 ---- .../tasks.md | 28 - .../plan.md | 62 -- .../tasks.md | 16 - .../evidence.md | 47 -- .../plan.md | 90 -- .../tasks.md | 18 - 74 files changed, 319 insertions(+), 7940 deletions(-) create mode 100644 docs/stores-beta/upstream-work.md create mode 100644 openspec/changes/add-upstream-links/.openspec.yaml create mode 100644 openspec/changes/add-upstream-links/design.md create mode 100644 openspec/changes/add-upstream-links/proposal.md create mode 100644 openspec/changes/add-upstream-links/specs/schema-openness/spec.md create mode 100644 openspec/changes/add-upstream-links/specs/upstream-links/spec.md create mode 100644 openspec/changes/add-upstream-links/tasks.md delete mode 100644 openspec/explorations/context-store-and-initiatives/.initiative.yaml delete mode 100644 openspec/explorations/context-store-and-initiatives/README.md delete mode 100644 openspec/explorations/context-store-and-initiatives/decisions.md delete mode 100644 openspec/explorations/context-store-and-initiatives/direction-git-native-work.md delete mode 100644 openspec/explorations/context-store-and-initiatives/direction.md delete mode 100644 openspec/explorations/context-store-and-initiatives/questions.md delete mode 100644 openspec/explorations/context-store-and-initiatives/roadmap.md delete mode 100644 openspec/explorations/context-store-and-initiatives/tasks.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/01-lock-the-direction/evidence.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/01-lock-the-direction/plan.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/01-lock-the-direction/tasks.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/evidence.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/plan.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/tasks.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/03-add-context-store-foundation/evidence.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/03-add-context-store-foundation/plan.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/03-add-context-store-foundation/tasks.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/04-add-collection-foundation/evidence.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/04-add-collection-foundation/plan.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/04-add-collection-foundation/tasks.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/05-ship-initiative-mvp/evidence.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/05-ship-initiative-mvp/plan.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/05-ship-initiative-mvp/tasks.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/evidence.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/plan.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/tasks.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/evidence.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/plan.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/tasks.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/evidence.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/plan.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/tasks.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/09-add-initiative-resolve/decision-review.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/09-add-initiative-resolve/evidence.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/09-add-initiative-resolve/plan.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/09-add-initiative-resolve/tasks.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/plan.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/tasks.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/notes.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/plan.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/tasks.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/evidence.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/plan.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/tasks.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/evidence.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/plan.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/tasks.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/14-workspaces-beta-guide-split/plan.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/14-workspaces-beta-guide-split/tasks.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/evidence.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/plan.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/tasks.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/16-add-escalation-ux/plan.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/16-add-escalation-ux/tasks.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/17-harden-team-shared-coordination/plan.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/17-harden-team-shared-coordination/tasks.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/evidence.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/plan.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/tasks.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/19-review-workspace-beta-compatibility-before-public-release/plan.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/19-review-workspace-beta-compatibility-before-public-release/tasks.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/evidence.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/plan.md delete mode 100644 openspec/explorations/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/tasks.md diff --git a/docs/README.md b/docs/README.md index 2e3df67b1c..32c70be272 100644 --- a/docs/README.md +++ b/docs/README.md @@ -90,6 +90,7 @@ That second one matters more than it looks. OpenSpec has two halves: a command l | Doc | What it gives you | |-----|-------------------| | [Stores: User Guide](stores-beta/user-guide.md) | Plan in its own repo when your work spans repos or teams | +| [Upstream Work](stores-beta/upstream-work.md) | Typed planning in a store: your schemas, serves links, live status | | [Agent Contract](agent-contract.md) | The machine-readable CLI surfaces agents drive | ## The thirty-second version diff --git a/docs/agent-contract.md b/docs/agent-contract.md index ceb1ef7d09..b8f009759f 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -45,7 +45,7 @@ Successful JSON payloads embed the root: ## 4. Command JSON shapes ### 4.1 `list --json` -`{ "changes": [ { "name", "completedTasks", "totalTasks", "lastModified", "status": "no-tasks"|"complete"|"in-progress" } ], "root": RootOutput }` — note the per-change `status` is a string enum here. `--specs`: `{ "specs": [ { "id", "requirementCount" } ], "root" }`. `--initiatives`: `{ "initiatives": { "path", "evergreen": [string], "initiatives": [ { "name", "exists", "stages": [{name,files}], "artifacts": [string], "changes": [ { "id", "store"?, "completedTasks", "totalTasks", "state": "complete"|"in-progress"|"no-tasks" } ], "changesComplete", "changesTotal", "tasksComplete", "tasksTotal" } ] } | null, "root" }` — `initiatives` is null when the root has no `openspec/initiatives/` folder; an entry with `"exists": false` is a name changes reference but no folder provides. Changes are discovered by scanning `.openspec.yaml` files for `initiative: ` (the root's own changes) and `initiative: /` (changes in other registered roots pointing at this store's initiative; the legacy object `{store, id}` reads as `/`); `changes[].store` names where a change lives, absent = the portfolio's own root. Outside any root (and without `--store`), the command answers with registered store portfolios instead: `{ "initiatives": null, "stores": [ { "store", "portfolio": } ], "root": null }`. +`{ "changes": [ { "name", "completedTasks", "totalTasks", "lastModified", "status": "no-tasks"|"complete"|"in-progress" } ], "root": RootOutput }` — note the per-change `status` is a string enum here. `--specs`: `{ "specs": [ { "id", "requirementCount" } ], "root" }`. `--downstream`: `{ "downstream": { "path", "upstream": [ { "id", "exists", "archived", "changes": [ { "id", "store"?, "repo"?, "completedTasks", "totalTasks", "state": "complete"|"in-progress"|"no-tasks" } ], "changesComplete", "changesTotal", "tasksComplete", "tasksTotal" } ] } | null, "root" }` — `downstream` is null when the root has no `openspec/changes/` folder; each `upstream` entry is one of the root's changes plus every change on this machine that serves it. Serving changes are discovered by scanning `.openspec.yaml` files for `serves: ` (the root's own changes) and `serves: /` (changes in other registered roots and linked repos pointing at this store's change); `"exists": false` marks a ref whose target change is not on disk, `"archived": true` one whose target has been archived; `changes[].store`/`changes[].repo` name where a serving change lives, absent = the rolled-up root. Outside any root (and without `--store`), the command answers with registered store rollups instead: `{ "downstream": null, "stores": [ { "store", "rollup": } ], "root": null }`. ### 4.2 `show --json` Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id", "title", "overview", "requirementCount", "requirements": [...], "metadata": { "version", "format", "sourcePath"? }, "root" }`. @@ -57,12 +57,12 @@ Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id `{ "changeName", "schemaName", "planningHome"?: { "kind", "root", "changesDir", "defaultSchema" }, "changeRoot", "artifactPaths": { "": {outputPath, resolvedOutputPath, existingOutputPaths} }, "nextSteps": ["..."], "actionContext": { "mode": "repo-local", "sourceOfTruth": "repo", "planningArtifacts", "linkedContext", "allowedEditRoots", "requiresAffectedAreaSelection", "constraints" }, "isComplete", "applyRequires", "artifacts": [ {id, outputPath, status: "done"|"ready"|"blocked", missingDeps?} ], "root" }`. No active changes: `{ "changes": [], "message", "root" }`, exit 0. ### 4.5 `instructions --json` -`{ "changeName", "artifactId", "schemaName", "changeDir", "planningHome"?, "outputPath", "resolvedOutputPath", "existingOutputPaths", "description", "instruction"?, "context"?, "rules"?, "references"?: ReferenceIndexEntry[], "template", "dependencies": [{id,done,path,description}], "unlocks", "root" }`. +`{ "changeName", "artifactId", "schemaName", "changeDir", "planningHome"?, "outputPath", "resolvedOutputPath", "existingOutputPaths", "description", "instruction"?, "schemaNotes"?, "context"?, "rules"?, "references"?: ReferenceIndexEntry[], "upstream"?, "template", "dependencies": [{id,done,path,description}], "unlocks", "root" }` — `schemaNotes` carries the schema's top-level `notes:` verbatim; `upstream` is present when the change's metadata declares `serves:` and carries `{ "ref", "changeId", "store"?, "path": string|null, "archived" }` (path null = link is stale or the store is not registered here). -`ReferenceIndexEntry`: `{ "store_id", "root"?, "specs"?: [{id,summary}], "schemas"?: [{id,summary,artifacts}], "initiatives"?: [string], "fetch"?, "status": [] }` — resolved entries carry root/specs/fetch; `schemas` (the store's own project-local artifact types) and `initiatives` (the store's initiative names — or, with none yet, its evergreen artifact names) are present only when the store defines them; unresolved carry store_id + warning status. Index capped at 50KB (`reference_index_truncated`). +`ReferenceIndexEntry`: `{ "store_id", "root"?, "specs"?: [{id,summary}], "schemas"?: [{id,summary,artifacts}], "changes"?: [string], "structure"?: {"": ""}, "fetch"?, "status": [] }` — resolved entries carry root/specs/fetch; `schemas` (the store's own project-local artifact types), `changes` (its in-motion change ids), and `structure` (its config-declared folder purposes) are present only when the store defines them; unresolved carry store_id + warning status. Index capped at 50KB (`reference_index_truncated`). ### 4.6 `instructions apply --json` -`{ "changeName", "changeDir", "schemaName", "contextFiles": { "": ["/abs", ...] }, "progress": {total,complete,remaining}, "tasks": [{id,description,done}], "state": "blocked"|"all_done"|"ready", "missingArtifacts"?, "instruction", "references"?, "root" }`. +`{ "changeName", "changeDir", "schemaName", "contextFiles": { "": ["/abs", ...] }, "progress": {total,complete,remaining}, "tasks": [{id,description,done}], "state": "blocked"|"all_done"|"ready", "missingArtifacts"?, "instruction", "schemaNotes"?, "references"?, "upstream"?, "root" }` — `schemaNotes` and `upstream` as in 4.5. ### 4.7 `new change --json` Success: `{ "change": { "id", "path", "metadataPath", "schema" }, "root" }`. Failure: `{ "change": null, "status": [d] }`, exit 1. @@ -74,13 +74,13 @@ Success: `{ "archive": { "change", "archivedAs": "YYYY-MM-DD-name", "path", "spe `{ "root": { "path", "source", "store_id"?, "healthy", "status": [] }, "store": { "id", "metadata": {present,valid,remote?}, "origin_url"?, "status": [] } | null, "references": [...], "status": [] }`. Health findings of any severity exit 0. Failure payload: `{ "root": null, "store": null, "references": [], "status": [d] }`, exit 1. ### 4.10 `context --json` -`{ "root": { "path", "source", "store_id"?, "role": "openspec_root" }, "members": [ { "role": "referenced_store", "id", "path"?, "remote"?, "fetch"?, "artifactTypes"?: [string], "initiatives"?: [string], "status": [] } ], "status": [] }`. AVAILABLE = path present AND status empty. `artifactTypes` (the store's own project-local schema names) and `initiatives` (the store's initiative names — or, with none yet, its evergreen artifact names) are present only on available members whose store defines them. `--code-workspace ` writes `{folders:[{name,path}]}` (available referenced stores only, `ref:` prefixes); in JSON mode the write runs before printing so stdout holds exactly one document even on write failure. Failure: `{ "root": null, "members": [], "status": [d] }`, exit 1. +`{ "root": { "path", "source", "store_id"?, "role": "openspec_root" }, "members": [ { "role": "referenced_store", "id", "path"?, "remote"?, "fetch"?, "artifactTypes"?: [string], "changes"?: [string], "structure"?: {"": ""}, "status": [] } ], "status": [] }`. AVAILABLE = path present AND status empty. `artifactTypes` (the store's own project-local schema names), `changes` (its in-motion change ids), and `structure` (its config-declared folder purposes) are present only on available members whose store defines them. `--code-workspace ` writes `{folders:[{name,path}]}` (available referenced stores only, `ref:` prefixes); in JSON mode the write runs before printing so stdout holds exactly one document even on write failure. Failure: `{ "root": null, "members": [], "status": [d] }`, exit 1. ### 4.11 `store ... --json` setup/register: `{ "store": {id, root, metadata_path?}, "registry": {path, registered, already_registered}, "git": {is_repository, initialized, committed}, "created_files": [], "status": [] }`. unregister/remove: `{ "store", "registry": {path, removed}, "files": {deleted, deleted_path, left_on_disk}, "status": [] }`. list: `{ "stores": [{id, root}], "status": [] }`. doctor: `{ "stores": [ { id, root, metadata_path?, openspec_root: {...healthy, status}, metadata: {present, valid, id?, remote}, git: {is_repository, has_commits, has_uncommitted_changes, has_remote, origin_url}, status } ], "status": [] }` (`null` = unknown/not probed). Health findings exit 0; failures exit 1 with the matching null-shape. Prompt cancellation exits 130. ### 4.12 `schemas --json` / `templates --json` -`schemas`: bare array `[ {name, description, artifacts, source} ]` — supports `--store ` to list a store's schemas (success stays a bare array; a store-resolution failure emits `{status: [...]}` like other commands). `templates`: keyed object `{ "": {path, source} }`, cwd-based, no `--store`. +`schemas`: bare array `[ {name, description, artifacts, source, store?} ]` — `source` is `project|store|user|package`; `store` names the referenced store an inherited schema comes from. Supports `--store ` to list a store's schemas (success stays a bare array; a store-resolution failure emits `{status: [...]}` like other commands). `templates`: keyed object `{ "": {path, source} }`, cwd-based, no `--store`. ## 5. Exit-code contract diff --git a/docs/stores-beta/upstream-work.md b/docs/stores-beta/upstream-work.md new file mode 100644 index 0000000000..fe62606c6e --- /dev/null +++ b/docs/stores-beta/upstream-work.md @@ -0,0 +1,111 @@ +# Upstream work: typed planning in a store + +> Specs are what is true. Changes are what is in motion. **Upstream work is +> just a change in the store — typed by a schema the store defines.** + +A team that plans before code moves (product briefs, platform contracts, +requirements handoffs) does not need a new folder convention. It needs the +two primitives OpenSpec already has, working one level up: + +| Need | Mechanism | +|------|-----------| +| "Our planning work has its own shape" | a **schema in the store** (`openspec/schemas//`) | +| "Engineers' work should trace to ours" | a **serves link** (`openspec new change --serves /`) | +| "What is true, standing, forever" | the store's **specs/** — archive syncs them, as always | +| "Where does everything stand" | `openspec list --downstream --store ` | + +All output below is from a real run. + +## The store defines the workflow + +A schema is a folder. Long-form guidance lives in files beside it, and a +top-level `notes:` tells agents how this workflow differs from the default: + +```text +product-hub/openspec/ + config.yaml # schema: product-brief + structure: map + schemas/product-brief/ + schema.yaml # brief → requirements, notes: no implementation phase + instructions/brief.md # per-artifact guidance as files, like templates + instructions/requirements.md + templates/… + research/ # declared in config `structure:` so agents know + decisions/ # what these folders are for +``` + +Planning work is then an ordinary change, with review gates and artifact +dependencies enforced by the graph: + +```text +$ openspec new change onboarding-revamp --store product-hub +Schema: product-brief +``` + +There is no apply phase in this schema — the schema's `notes:` say so, and +every instruction surface repeats it to the agent verbatim +(`` in `openspec instructions`). When the requirements are +approved, archiving syncs them into the store's `specs/`. + +## Downstream repos inherit and link + +A repo that references the store sees its schemas the same way it already +sees its specs — resolution order is project → referenced stores → user → +package: + +```text +$ openspec schemas # in web-app, which references product-hub + product-brief (from store 'product-hub') +``` + +Linking a change to the work it serves does all the wiring in one flag: +it records the checkout so rollups find this repo (machine-local, nothing +committed), and references the store so agents here see its context: + +```text +$ openspec new change add-welcome-tour --serves product-hub/onboarding-revamp +Serves: product-hub/onboarding-revamp (rollup: openspec list --downstream --store product-hub) +Referenced store 'product-hub' in openspec/config.yaml — agents here now see its context. +``` + +The serving change's instructions open with the upstream context on disk — +the intent travels without anyone pasting it: + +```text + +This change serves the change 'onboarding-revamp' in store 'product-hub'. +Upstream context: ~/openspec/product-hub/openspec/changes/onboarding-revamp +Before working, read its artifacts (proposal, specs, and any others its schema defines); … + +``` + +## Status flows back live + +Rollup is discovered from facts on disk (one `serves:` line per change, task +checkboxes), never from prose, and works from anywhere: + +```text +$ openspec list --downstream --store product-hub + +onboarding-revamp 1/2 serving changes complete + ✓ add-invite-endpoint api 2/2 tasks + · add-welcome-tour web-app 1/2 tasks +``` + +Truth flows up: archive the upstream change when its requirements land, and +the link keeps resolving — marked so downstream agents trace against specs: + +```text +onboarding-revamp 1/2 serving changes complete (archived — its requirements now live in specs/) +``` + +## Reference + +| Surface | What it carries | +|---------|-----------------| +| `.openspec.yaml` | `serves: ` or `serves: /` | +| `schema.yaml` | optional `notes:` (workflow guidance, surfaced verbatim to agents) | +| `/instructions/.md` | per-artifact instruction files; a file wins over inline `instruction:` | +| `config.yaml` | `structure:` — folder → purpose map, surfaced in `context` and the references index | +| `openspec schemas [--store ]` | includes inherited schemas with their source store | +| `openspec list --downstream [--store ]` | the rollup; outside any root it shows every registered store's | +| `openspec context` | referenced stores show artifact types, in-motion changes, and layout | diff --git a/docs/stores-beta/user-guide.md b/docs/stores-beta/user-guide.md index ca2f5c1b29..5e333f9258 100644 --- a/docs/stores-beta/user-guide.md +++ b/docs/stores-beta/user-guide.md @@ -317,8 +317,8 @@ tells you which case you're in. if you intentionally want to convert that repo into a local store root. - **Some commands stay where they are.** `view`, `templates`, and the deprecated noun forms (`openspec change show`, ...) act on the current - directory only — no `--store`. (`schemas` now accepts `--store` — see the - [initiatives guide](initiatives.md).) + directory only — no `--store`. (`schemas` now accepts `--store` — see + [Upstream Work](upstream-work.md).) - **Per-machine state is per-machine.** The store registry and worksets are local settings. Nothing about your machine's layout is ever committed to shared planning. diff --git a/openspec/changes/add-upstream-links/.openspec.yaml b/openspec/changes/add-upstream-links/.openspec.yaml new file mode 100644 index 0000000000..b119b63505 --- /dev/null +++ b/openspec/changes/add-upstream-links/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-13 diff --git a/openspec/changes/add-upstream-links/design.md b/openspec/changes/add-upstream-links/design.md new file mode 100644 index 0000000000..6412f45b5f --- /dev/null +++ b/openspec/changes/add-upstream-links/design.md @@ -0,0 +1,44 @@ +# Design + +## The one decision + +Upstream work is not a new artifact kind. It is a change in a store, typed +by the store's schema. Everything else follows from refusing the parallel +ontology: + +| Planning need | Not this (initiatives experiment) | This | +|---|---|---| +| A finite piece of work above code | `initiatives//` folder | a change in the store | +| An ordered workflow with handoffs | numbered stage folders | schema artifacts with `requires:` | +| Guidance per stage | (none — freeform) | `instructions/.md` + review gates | +| Standing truths | unnumbered "evergreen" files | the store's `specs/`, synced by archive | +| Traceability | `initiative:` line to a folder | `serves:` line to a change | +| Status | folder scan + task counts | same rollup, grouped by upstream change | + +The pull-based rule the experiment liked ("read everything lower-numbered, +produce what your stage owes the next") is the artifact dependency graph: +your artifact's `requires` are satisfied → read them → draft yours. + +## Mechanics kept from the experiment (retargeted) + +Linking does all the wiring, exactly as before: `--serves /` +records the repo in `linked-roots.yaml` (machine-local; nothing committed) +and auto-adds the store to `references:` with comment-preserving YAML edits. +Rollup scans registered stores plus linked roots for one metadata line — no +manifest. A ref that resolves to nothing renders visibly, never silently. + +## Schema resolution order + +`project → referenced stores (declaration order, one hop) → user → package`. +The resolver reads the registry synchronously and degrades to "no inherited +schemas" on any unreadable state, because resolution runs deep in sync call +chains and must never turn a broken registry into a failed command. + +## Compatibility + +- Legacy `initiative:` metadata (object form) stays readable and is never + re-emitted, matching the existing vocabulary contract. +- `--initiative` stays registered as a hidden removed option with a + deliberate error pointing at `--serves`. +- `schemas --json` stays a bare array; entries gain `source: 'store'` + + `store` for inherited schemas. diff --git a/openspec/changes/add-upstream-links/proposal.md b/openspec/changes/add-upstream-links/proposal.md new file mode 100644 index 0000000000..a0f348bf8a --- /dev/null +++ b/openspec/changes/add-upstream-links/proposal.md @@ -0,0 +1,44 @@ +# Add upstream links and open the schema system upward + +## Why + +Teams that plan before code moves (product briefs, platform contracts) need +a home for that work and a traceable handoff to the repos that implement it. +The initiatives experiment answered this with a new untyped layer — folders, +stages, evergreen files — while everything else in OpenSpec is typed. Users +with custom schemas showed the better shape: upstream work is a change in a +store, typed by a schema the store defines. What is missing is not a new +noun; it is the existing machinery working one level up and across repos. + +## What Changes + +- **Serves links**: `serves: ` / `/` in change + metadata, written by `openspec new change --serves `. Linking records + the checkout machine-locally and auto-adds `references:` to the repo's + config, so context surfaces immediately. +- **Rollup**: `openspec list --downstream [--store ]` shows each of the + root's changes and every change on this machine that serves it, live from + task lists. Refs to missing changes stay visible; archived upstream + changes are marked (their requirements now live in specs). +- **Upstream context block**: instructions for a linked change open with + `` — the upstream change's on-disk path and how to trace to it. +- **Schema system opens up**: top-level `notes:` surfaced verbatim on every + instruction surface; per-artifact `instructions/.md` files beside the + schema; schemas inherit through `references:` (project → referenced stores + → user → package); `structure:` in config declares folder purposes, + surfaced in `context` and the references index. +- **Removed**: the initiatives folder convention, stages, evergreen files, + `list --initiatives`, and the initiatives skill. The `initiative` noun + returns to retired vocabulary; legacy metadata stays readable. + +## Capabilities + +### New Capabilities +- `upstream-links`: the serves link, downstream rollup, and upstream context block +- `schema-openness`: notes, instruction files, inheritance through references, structure declarations + +## Impact + +- CLI: `new change --serves`, `list --downstream`, `schemas --store` source labels +- Core: `src/core/upstream.ts` (new), artifact-graph resolver, project config, references index +- Docs: `docs/stores-beta/upstream-work.md`, agent contract JSON shapes diff --git a/openspec/changes/add-upstream-links/specs/schema-openness/spec.md b/openspec/changes/add-upstream-links/specs/schema-openness/spec.md new file mode 100644 index 0000000000..fde53818a4 --- /dev/null +++ b/openspec/changes/add-upstream-links/specs/schema-openness/spec.md @@ -0,0 +1,38 @@ +# Schema Openness + +## ADDED Requirements + +### Requirement: Schemas inherit through references + +Schema resolution SHALL consult the stores a root references, in declaration +order, between project-local and user-level schemas. Unreadable config, +registry, or checkouts SHALL degrade to "no inherited schemas", never an +error. `openspec schemas` SHALL label inherited schemas with their store. + +#### Scenario: A consumer repo uses the store's workflow +- **GIVEN** a repo whose config declares `references: [product-hub]` and a store defining `schemas/product-brief/` +- **WHEN** the user runs `openspec new change spike --schema product-brief` in the repo +- **THEN** the change is created with the store's schema and its artifact graph gates work as usual + +### Requirement: Schema authors can speak to agents + +A schema MAY carry top-level `notes:`; every instruction surface SHALL +render it verbatim. Per-artifact guidance MAY live in +`instructions/.md` beside the schema (and `instructions/apply.md` +for the apply phase); a file SHALL win over an inline `instruction:` value. + +#### Scenario: A documentation-only workflow steers the agent +- **GIVEN** a schema whose notes say it has no implementation phase +- **WHEN** any artifact's instructions render +- **THEN** the notes appear in a `` block before the artifact guidance + +### Requirement: A store can declare its layout + +Config MAY carry `structure:` — a folder → purpose map. `openspec context` +and the references index SHALL surface it for the root and for referenced +stores, so agents learn what non-reserved folders are for without guessing. + +#### Scenario: Downstream agents see the store's layout +- **GIVEN** a store whose config declares `structure: { research/: raw inputs }` +- **WHEN** a referencing repo runs `openspec context` +- **THEN** the store's member entry lists `research/` with its purpose diff --git a/openspec/changes/add-upstream-links/specs/upstream-links/spec.md b/openspec/changes/add-upstream-links/specs/upstream-links/spec.md new file mode 100644 index 0000000000..a18c89c383 --- /dev/null +++ b/openspec/changes/add-upstream-links/specs/upstream-links/spec.md @@ -0,0 +1,49 @@ +# Upstream Links + +## ADDED Requirements + +### Requirement: Changes declare the work they serve + +A change's metadata MAY carry `serves: ` (same root) or +`serves: /` (a registered store). `openspec new change +--serves ` SHALL validate the ref before creating anything, write the +metadata line, record the repo as a linked root, and add the store to the +repo's `references:` when it can do so safely. + +#### Scenario: Linking wires context automatically +- **GIVEN** a repo with no reference to store `product-hub` +- **WHEN** the user runs `openspec new change add-tour --serves product-hub/onboarding-revamp` +- **THEN** the change is created with `serves: product-hub/onboarding-revamp`, the repo is recorded machine-locally for rollups, and `references: [product-hub]` is added to `openspec/config.yaml` + +#### Scenario: A malformed ref creates nothing +- **GIVEN** any repo +- **WHEN** the user passes `--serves a/b/c` +- **THEN** the command fails with a validation message and no change directory is left behind + +### Requirement: The downstream rollup is discovered, not maintained + +`openspec list --downstream` SHALL show each of the root's changes with +every change on this machine that serves it, with task progress, scanning +the root, other registered stores, and linked roots. Refs whose target is +missing SHALL render visibly; archived targets SHALL be marked as archived. + +#### Scenario: Cross-repo rollup from one metadata line +- **GIVEN** changes in two repos whose metadata serves `product-hub/onboarding-revamp` +- **WHEN** the user runs `openspec list --downstream --store product-hub` +- **THEN** both serving changes appear under `onboarding-revamp` with their repo names and task counts + +#### Scenario: Archived upstream stays traceable +- **GIVEN** the upstream change has been archived +- **WHEN** the rollup or a serving change's instructions render +- **THEN** the link resolves to the archived copy and states that its requirements now live in specs + +### Requirement: Instructions carry upstream context + +Instruction surfaces for a serving change SHALL open with an `` +block naming the upstream change, its on-disk path (or that it is missing), +and the rollup command. + +#### Scenario: Intent travels without pasting +- **GIVEN** a change with `serves: product-hub/onboarding-revamp` and the store registered locally +- **WHEN** the user runs `openspec instructions --change ` +- **THEN** the output contains the upstream change's absolute path and directs the agent to read its artifacts before working diff --git a/openspec/changes/add-upstream-links/tasks.md b/openspec/changes/add-upstream-links/tasks.md new file mode 100644 index 0000000000..d5ab77a5a9 --- /dev/null +++ b/openspec/changes/add-upstream-links/tasks.md @@ -0,0 +1,22 @@ +# Tasks + +## Remove the parallel ontology +- [x] Delete initiatives module, skill, CLI mode, and folder conventions +- [x] Restore retired-vocabulary policing; keep legacy `initiative:` metadata readable + +## Upstream links +- [x] `serves:` metadata + `new change --serves` with full wiring (linked roots, auto-references) +- [x] `openspec list --downstream` rollup (own root, stores, linked repos; missing/archived visible) +- [x] `` context block in instructions and apply instructions + +## Schema openness +- [x] Top-level `notes:` parsed and surfaced on every instruction surface +- [x] `instructions/.md` and `instructions/apply.md` files beside schemas +- [x] Inheritance through `references:` in resolver, listings, and `schemas --store` labels +- [x] `structure:` config declarations surfaced in context and the references index + +## Verification +- [x] Unit tests: upstream module, resolver inheritance, structure parsing, references index +- [x] CLI tests: --serves validation and wiring, downstream rollup, context enrichment +- [x] Full suite green; golden skill hashes regenerated +- [x] Real end-to-end run captured for docs and PR body diff --git a/openspec/explorations/context-store-and-initiatives/.initiative.yaml b/openspec/explorations/context-store-and-initiatives/.initiative.yaml deleted file mode 100644 index c67efbeda8..0000000000 --- a/openspec/explorations/context-store-and-initiatives/.initiative.yaml +++ /dev/null @@ -1,27 +0,0 @@ -version: 1 -id: context-store-and-initiatives -title: Context Store And Initiatives Direction -status: exploring -summary: > - Define the direction for a synced context store, mounted collections, - initiatives, local workspaces, and repo-local changes. -owners: [] -artifacts: - readme: README.md - direction: direction.md - roadmap: roadmap.md - tasks: tasks.md - decisions: decisions.md - questions: questions.md - work_items: work-items/ -linked_changes: - - change: workspace-reimplementation-roadmap - relationship: informs - - change: workspace-agent-guidance - relationship: reframes - - change: workspace-apply-repo-slice - relationship: reframes - - change: workspace-verify-and-archive - relationship: reframes -links: [] -metadata: {} diff --git a/openspec/explorations/context-store-and-initiatives/README.md b/openspec/explorations/context-store-and-initiatives/README.md deleted file mode 100644 index 6574a7d4ca..0000000000 --- a/openspec/explorations/context-store-and-initiatives/README.md +++ /dev/null @@ -1,58 +0,0 @@ -# Context Store And Initiatives - -Status: transition evidence / beta history. - -This folder preserves the beta context-store and workspace direction, the -decisions made while exploring it, and the evidence that led to the simpler -Git-native model. - -It is not the active product roadmap or implementation queue. For current -direction, start with: - -1. `openspec/work/simplify-context-and-workspace-model/goal.md` -2. `openspec/work/simplify-context-and-workspace-model/roadmap.md` - -The `direction-git-native-work.md` note is the transition note that led to the -current goal. If it conflicts with the current `goal.md`, the current `goal.md` -wins. - -## Reading Order - -Use this reading order when researching the beta history: - -1. `direction-git-native-work.md` explains the transition from the old beta - model toward Git-native specs and work. -2. `direction.md` preserves the earlier context-store and initiative direction. -3. `roadmap.md` preserves the historical beta roadmap snapshot. -4. `tasks.md` preserves historical initiative-wide progress. -5. `decisions.md` records accepted decisions made during the beta. -6. `questions.md` tracks questions that were open at the time. -7. `work-items//` contains execution notes for one historical roadmap item. - -## Boundary - -These artifacts preserve product intent, roadmap decisions, and beta evidence -from the old model. OpenSpec specs describe the current behavioral contract -behind the code. - -Do not rewrite specs for future intent until behavior changes with an -implementation slice. - -The earlier product boundary was: - -```text -Context stores sync truth. -Collections shape truth. -Initiatives coordinate work. -Workspaces open local views. -Changes implement repo-owned slices. -``` - -The newer direction is: - -```text -OpenSpec is a Git-native artifact format for specs and work. - -Specs are what is true. -Work is what is in motion. -``` diff --git a/openspec/explorations/context-store-and-initiatives/decisions.md b/openspec/explorations/context-store-and-initiatives/decisions.md deleted file mode 100644 index e2aa873d6d..0000000000 --- a/openspec/explorations/context-store-and-initiatives/decisions.md +++ /dev/null @@ -1,225 +0,0 @@ -# Context Store And Initiatives Decisions - -## 2026-05-20: Track Roadmap Execution Inside The Initiative - -Decision: Track initiative roadmap implementation inside -`openspec/initiatives/context-store-and-initiatives/` rather than creating an -OpenSpec change for each roadmap item. - -Why: The initiative is the durable coordination object for this work. Repo-local -OpenSpec changes should be reserved for implementation slices owned by a repo or -team. Roadmap-item tracking belongs with the initiative until a task needs a -repo-owned implementation plan. - -Implications: - -- Use `tasks.md` as the initiative-wide progress dashboard. -- Use `work-items//` for detailed execution notes on one roadmap item. -- Link repo-local OpenSpec changes back to the initiative later when - implementation moves into a repo-owned slice. - -## 2026-05-20: Lock Workspace-To-Initiative Product Boundary - -Decision: Workspaces are local working views, not durable shared planning -objects. Durable coordination belongs to context stores and initiatives. Repo -local changes own implementation. - -Implications: - -- Preserve workspace setup, link, relink, list, open, update, and doctor as - beta local-view infrastructure. -- Treat workspace-planning behavior as beta or transitional compatibility. -- Defer workspace apply, verify, and archive until initiative-linked repo-local - changes exist. - -## 2026-05-21: Leave Specs Alone Until Behavior Changes - -Decision: Do not use the initial direction lock to rewrite OpenSpec specs. -Specs should describe the current behavioral contract behind the code. The -initiative artifacts should carry product intent, roadmap decisions, and future -direction until a later implementation change deliberately updates behavior and -its specs together. - -Implications: - -- Initial Item 1 cleanup should focus on initiative docs, historical roadmap - artifacts, active proposal disposition, and user-facing docs. -- Existing workspace-planning specs and schemas may continue to describe current - implemented behavior. -- Future changes to specs should happen with the behavior they govern. - -## 2026-05-21: Keep Deferred Workspace Changes As Reference Placeholders - -Decision: Keep the active workspace changes for agent guidance, repo-slice -apply, verify/archive, and the reimplementation roadmap as deferred reference -placeholders. - -Why: These areas are still expected to matter after context stores, initiatives, -and initiative-linked repo-local changes exist. Archiving or deleting them now -would lose useful research and continuity. - -Implications: - -- Do not pick them up as the immediate next implementation focus. -- Treat their current proposals as historical/deferred direction. -- Revisit and reframe them after initiative-linked repo-local changes define the - durable handoff model. - -## 2026-05-21: Generated Workspace Guidance Routes Work By Ownership - -Decision: Generated workspace guidance should describe workspaces as local -working views and route durable work to the owning artifact: initiatives own -cross-team or cross-repo intent, repo-local OpenSpec changes own implementation -plans, and linked repos or folders own their implementation. - -Why: The initiative direction supersedes the older model where a workspace-level -`changes/` tree owned the canonical shared cross-repo plan. New agent guidance -should not reinforce that old model. - -Implications: - -- Remove guidance that tells agents to use workspace-level `changes/` as the - planning home for coordinated work. -- Keep legacy or beta workspace-planning files readable as compatibility - context when present. -- Update generated workspace guidance before broad user-facing docs or specs. -- Leave specs untouched until the corresponding behavior intentionally changes. - -## 2026-05-21: Workspace Action Context Is Local Compatibility Context - -Decision: Workspace-planning action context should no longer describe -workspace-level artifacts as the source of truth. It should report -`sourceOfTruth: "workspace-local"` and describe workspace-local planning -artifacts as compatibility context for the current local view. - -Why: Workspace-planning artifacts can still exist in the beta workflow, but the -initiative direction assigns durable coordination to initiatives and -implementation planning to repo-local changes. - -Implications: - -- Keep `actionContext.mode: "workspace-planning"` for compatibility. -- Keep `allowedEditRoots: []` until an explicit edit root is selected. -- Keep linked repos and folders as context, not implicit edit roots. -- Route durable coordination to initiatives when initiative context exists. - -## 2026-05-21: Reorder Roadmap Around Agent-First Initiative Handoff - -Decision: Treat initiatives as an agent-first workflow. Users should be able to -prompt an agent with intent like "using initiative X, explore Y and create a -proposal"; OpenSpec should provide small CLI primitives the agent can compose. - -Why: The practical UX is not a human manually typing every coordination command. -Agents need reliable structured answers about where canonical initiative context -lives and how repo-local changes reference it. Local paths come from workspace -state, not from an initiative command. - -Implications: - -- Promote minimal context-store setup, registration, listing, and doctoring - before workspace initiative opening. -- Add `initiative show --json` before broader progress/status concepts. -- Connect repo-local changes with checked-in initiative metadata, not checked-in - snapshots of initiative prose. -- Do not add `initiative resolve`; workspace local-view state owns local path - mapping. -- Teach workspace opening about initiatives after show and repo-change linkage - semantics exist. - -## 2026-05-26: Workspace Initiative Opening Uses Generated Runtime Files - -Decision: Treat workspace initiative opening as a private local view record plus -generated runtime files. The workspace does not contain the work. It remembers -how this runtime opens the work. - -Why: Initiative context is shared truth in the context store, repo-local changes -own implementation, and agent/editor affordances need to exist in the runtime -where the agent actually runs. Persisting generated files as workspace truth -would blur local view state with shared coordination and create stale or -privacy-sensitive artifacts. - -Implications: - -- Persist only tiny private local view choices: selected store, selected - initiative, selected local links, opener, and selected tools. -- Preserve the selected context-store selector inside the private workspace - record, so a runtime-local `--store-path` open can be reopened without writing - machine-local paths into checked-in repo metadata. -- Generate agent guidance, skills, launch prompts, and editor workspace files as - runtime support when opening or preparing a view. -- Open existing local paths only; do not clone, branch, create worktrees, use - submodules, or infer local repos in Item 10. -- Treat generated runtime files as disposable and regenerable. -- Allow context-only initiative open; linked repos are optional local view - choices. -- Keep edit boundaries advisory in Item 10 until enforcement is designed. - -## 2026-05-26: Workspace Storage Is Keyed By Workspace Name - -Decision: Store private workspace views under -`getGlobalDataDir()/workspaces//`. The workspace name is the -local identity. The selected context store and initiative, if any, live inside -one durable private `workspace.yaml` record. - -Why: Workspaces are generic local views, not initiative-owned directories. A -user may want a custom workspace with linked repos and folders but no initiative, -or multiple personal workspaces over the same initiative. Keying storage by -store and initiative would overfit the filesystem layout to one workflow. - -Implications: - -- Keep initiative references optional inside `workspace.yaml`. -- Store initiative context with an explicit context-store binding rather than a - flat store id, because workspace state may need to remember a registry selector - or a runtime-local path selector. -- Generate `AGENTS.md`, opener workspace files, and tool-specific skills at the - managed workspace root. -- Keep `workspace.yaml` as the only view file for Item 10; do not add a separate - machine-readable view file. -- Do not introduce a separate generated-output directory for Item 10. -- If the user opens an initiative without a workspace name, derive a friendly - default workspace name from the initiative id when that is unambiguous. -- On workspace-name collisions or multiple workspaces pointing at the same - initiative, ask the human to choose or require an explicit workspace name in - non-interactive mode. - -## 2026-05-26: Item 10 Workspace Open UX Decisions - -Decision: Close the remaining Item 10 product decisions around runtime identity, -JSON output, Codex Desktop, edit boundaries, and implementation scope. - -Implications: - -- Use `getGlobalDataDir()` as the cross-platform runtime-local boundary. Do not - add path translation or a separate runtime id in Item 10. -- Keep `workspace open --json` as a machine-facing receipt for the same open - operation. It should return useful generated paths, selected context, opened - roots, skipped roots, opener, launch status, and warnings. -- Do not add `--prepare-only` for Item 10. -- For Codex Desktop, open the generated workspace root as the project and expose - attached initiative and repo/folder paths through generated guidance and - `workspace open --json` output. -- Emit advisory edit boundaries only; do not enforce write restrictions. -- Continue to open known existing local paths only. Do not clone, branch, create - worktrees, use submodules, or infer local repos in Item 10. - -## 2026-05-30: Defer Hardcoded Agent Handoff Guidance - -Decision: Skip Item 13, agent handoff output and delivery polish, as an -implementation item for now. - -Why: The underlying beta pain is real: users and agents need better receipts -after setup, initiative creation, workspace opening, and repo-local change -creation. However, fixed "Next for your agent" guidance assumes a linear -workflow path and may not fit dynamic agentic work, where the agent should -inspect current state and choose the next move. - -Implications: - -- Do not implement hardcoded next-step blocks yet. -- Preserve Item 13 as research context for a future receipt or affordance model. -- Prefer future output that reports what exists, where it lives, and what - actions are available, rather than prescribing one next command. -- Deterministic receipt improvements such as direct `created_paths` fields may - be split into a smaller implementation slice if they remain clearly useful. -- Delivery terminology concerns may be handled separately from handoff output. diff --git a/openspec/explorations/context-store-and-initiatives/direction-git-native-work.md b/openspec/explorations/context-store-and-initiatives/direction-git-native-work.md deleted file mode 100644 index a24b346d02..0000000000 --- a/openspec/explorations/context-store-and-initiatives/direction-git-native-work.md +++ /dev/null @@ -1,472 +0,0 @@ -# Git-Native Specs And Work Direction - -This note captures the current product direction after the initiative, -workspace, context-store, and multi-repo planning discussion. - -The positive shape is: - -```text -OpenSpec is a Git-native artifact format for specs and work. - -Specs are what is true. -Work is what is in motion. -``` - -OpenSpec artifacts live as files in Git. That Git repo may be the code repo, a -planning repo, or a contracts repo. OpenSpec should not introduce a separate -authoritative state system outside those files. - -## Core Shape - -The preferred future shape is: - -```text -openspec/ - README.md - openspec.yml - specs/ - work/ -``` - -- `specs/` describes accepted behavior. -- `work/` describes intended effort in motion. - -This shape should be the same whether the OpenSpec root lives beside code or in -a dedicated planning or contracts repo. - -```text -app-repo/ - openspec/ - specs/ - work/ - -planning-repo/ - openspec/ - specs/ - work/ -``` - -There is no separate product mode for "repo-local", "external", "workspace", -"context store", or "multi-repo" artifacts. The placement choice is simply -which Git repo contains the OpenSpec files. - -## Vocabulary - -Use a small vocabulary first: - -```text -Spec current accepted behavior -Work intended effort in motion -Change work that applies concrete deltas to targets -Initiative work that coordinates or decomposes other work -Target repo, service, package, path, or system where work lands -``` - -Users should not need to learn `context store`, `project`, `workspace`, -`artifact home`, or `index` as primary product nouns. - -## Domain Terms - -Use these terms when explaining the near-term product: - -```text -OpenSpec root - The `openspec/` directory that contains specs, changes, work, and config. - -In-project OpenSpec - OpenSpec initialized inside the project repo it helps describe. - -Standalone OpenSpec repo - A separate Git repo whose main purpose is to hold OpenSpec artifacts. - -Target project repo - A code repo that a change or work item applies to. - -Local repo map - Private local resolution from a target repo id to a checkout path. - -Workspace view - Legacy or beta local-view language. In the new direction, this should reduce - to a local repo map plus an optional focused OpenSpec root or work item. -``` - -Examples: - -```text -In-project OpenSpec: - -app-repo/ - openspec/ - specs/ - changes/ - -Standalone OpenSpec repo: - -app-openspec-repo/ - openspec/ - specs/ - changes/ - -Target project repo: - -app-repo/ - src/ - tests/ -``` - -The product should avoid the term `repo-local` for this distinction. It is too -easy to confuse "OpenSpec lives in this project repo" with "this work targets -this repo." - -The product should also avoid making `workspace` a primary user-facing noun. -The job that remains is simpler: map target repo ids to local checkout paths so -agents and commands can assemble the relevant Git repos on this machine. - -## Work Is The Primitive - -`work/` is one canonical area for units of work at different scales. - -```text -openspec/ - specs/ - auth/session-limits.md - work/ - add-login-rate-limit/ - work.yaml - proposal.md - tasks.md - deltas/ - checkout-modernization/ - work.yaml - README.md -``` - -A change is work with change capabilities: - -```yaml -id: add-login-rate-limit -kind: change -status: proposed -targets: - - repo: app -``` - -An initiative is also work: - -```yaml -id: checkout-modernization -kind: initiative -status: active -children: - - work: add-login-rate-limit - - work: add-checkout-tax -``` - -The distinction between a change and an initiative should not come from which -top-level folder the artifact lives in. It should come from metadata and -capabilities: - -- Work with targets and deltas can validate and archive those deltas into - `specs/`. -- Work with children, dependencies, and context can coordinate and roll up other - work. -- Some work may be both change-shaped and coordination-shaped. - -## Git Is The Source Of Truth - -OpenSpec should stay Git-native: - -- History comes from Git. -- Review uses normal Git and forge workflows. -- Diffs are normal file diffs. -- External planning means another Git repo, not another state system. -- Indexes, dashboards, status rollups, and orchestration are derived views. - -Forge-specific status such as pull request state, CI, review approvals, or -merge status may be read by adapters. That status should not become a competing -OpenSpec truth. - -## Targets - -Filesystem location should not imply implementation target. Work declares where -it lands. - -```yaml -targets: - - repo: api - - repo: web -``` - -Targets may later address repos, services, packages, paths, external systems, -or monorepo subtrees. Use plural `targets` in the format early, even if some MVP -lifecycle commands only support one target. - -## Nesting And References - -The rule is: - -```text -Nest within a repo. -Reference across repos. -``` - -Within one Git repo, work can nest when that is the real relationship: - -```text -app-repo/ - openspec/ - work/ - checkout-modernization/ - work.yaml - work/ - add-login-rate-limit/ -``` - -Across Git repo boundaries, work references other work by stable identity: - -```yaml -id: checkout-modernization -kind: initiative -children: - - repo: api - work: add-tax-api - - repo: web - work: update-checkout-ui -``` - -This keeps each repo's executable work close to the code it affects while still -allowing a planning or contracts repo to coordinate the larger effort. - -Work identity must come from metadata, not from the path. Folder paths can help -humans browse; they should not be the durable identity of the work. - -## Dependency And Sequencing - -Multi-repo complexity is mostly about sequencing, not folder placement. - -OpenSpec should be able to record dependency intent in Git: - -```yaml -depends_on: - - work: publish-tax-contract -``` - -Future views can answer: - -- How does this large effort decompose? -- What has to happen first? -- Which targets are affected? -- Which teams own the slices? -- What surrounding context does an agent need? - -The free artifact format should be able to describe ordering and dependencies. -Automation that enforces sequencing, gates merges, or rolls up live forge status -can remain a derived orchestration layer. - -## MVP Implication - -The immediate release path should keep the current OpenSpec baseline working: - -```text -openspec/ - README.md - openspec.yml - specs/ - changes/ -``` - -The first mental model is: - -```text -Specs = what is true. -Changes = what should change. -``` - -Near-term work should not require the future `work/` layout. `change` remains -important because a change applies deltas. The `work/` model is the future -layout direction, not a prerequisite for making standalone OpenSpec repos -useful. - -## Roadmap - -### 1. Preserve The Current Baseline - -Keep the existing in-project OpenSpec flow working and understandable: - -```text -app-repo/ - openspec/ - specs/ - changes/ -``` - -The first release goal is not to rename everything. It is to make the current -model boring and reliable. - -### 2. Make The Placement Choice Explicit - -Teach the product language: - -```text -OpenSpec can live inside your project repo, -or in its own Git repo. -``` - -Use: - -- `in-project OpenSpec` for `app-repo/openspec/` -- `standalone OpenSpec repo` for `app-openspec-repo/openspec/` - -Avoid `repo-local` as the user-facing term for this split. - -### 3. Support Standalone OpenSpec Repos - -Allow OpenSpec to be initialized and validated in a Git repo that does not hold -application code: - -```text -app-openspec-repo/ - openspec/ - specs/ - changes/ -``` - -This should use the same parser, templates, validation, and archive concepts as -in-project OpenSpec. A standalone repo is not a new state system. - -### 4. Add Target Project Repo Resolution - -Standalone OpenSpec repos need to describe where changes land: - -```yaml -targets: - - repo: app -``` - -The first slice can keep target resolution simple: - -- register local target repos -- validate that referenced targets exist -- report unresolved targets clearly -- let agents know which OpenSpec repo and target repos are involved - -Do not clone, branch, sync, orchestrate, or infer complex repo state yet. - -This is the simplified successor to the larger workspace-view concept. Existing -workspace beta behavior may remain as compatibility, but new direction should -use local repo mapping as the product shape. - -### 5. Add Cross-Repo Context And Doctoring - -Once standalone OpenSpec repos can target project repos, add read-oriented -support for relevant context: - -- doctor checks for missing target repo mappings -- local path mapping for agents -- read-only references to other OpenSpec repos when needed -- clear output showing which Git repo owns each artifact - -Remote Git URL support, pull/push helpers, status dashboards, and sequencing -enforcement can come later. - -### 6. Evolve Toward `work/` - -After the baseline and standalone repo flow are solid, introduce the future -layout direction: - -```text -openspec/ - specs/ - work/ -``` - -At that point: - -- existing `changes/` can be supported as legacy or migrated -- changes become change-shaped work -- initiatives become coordination-shaped work -- dependency and sequencing views can build on stable work identity - -Do not make `/work` block the standalone OpenSpec repo release. - -## Decisions Considered - -### Separate `changes/` And `initiatives/` - -Rejected as the preferred future shape: - -```text -openspec/ - changes/ - initiatives/ -``` - -This uses folders as the type system and makes changes and initiatives feel -artificially unrelated. The cleaner model is one `work/` tree where change and -initiative are shapes of work. - -### Initiative-Owned Change Folders - -Rejected as canonical storage: - -```text -openspec/ - initiatives/ - checkout-modernization/ - changes/ - add-tax-api/ -``` - -This makes initiative ownership look like lifecycle ownership. A larger unit of -work may coordinate a smaller one, but the smaller unit still has its own -identity, targets, deltas, and lifecycle. - -### Project Or Repo Buckets As Lifecycle Roots - -Rejected as the default: - -```text -projects/ - api/ - openspec/ - changes/ - web/ - openspec/ - changes/ -``` - -Repo buckets work when each artifact cleanly belongs to one repo, but they get -awkward for cross-repo work, shared contracts, monorepos, and initiatives that -span several targets. Repos should be targets, not mandatory lifecycle roots. - -### Stateful Context Store As Core Primitive - -Rejected as the core framing. - -A dedicated planning or contracts repo may hold OpenSpec artifacts, but it is -still a Git repo. OpenSpec should not create a separate authoritative store that -can disagree with Git. - -### Configurable Layout Modes - -Rejected as an MVP product shape. - -Custom layout modes force every tool, doc, and agent instruction to branch. -Prefer one opinionated layout and let users choose which Git repo contains it. - -### Workspace As A Primary Product Object - -Rejected as the new user-facing shape. - -The useful part of workspace-view behavior is local resolution: knowing where -the OpenSpec repo and target project repos are checked out on this machine. That -should be treated as a local repo map, not as a planning container, lifecycle -owner, or durable source of truth. - -## Supersession Note - -This direction supersedes the older product boundary that centered context -stores, collections, initiatives, workspaces, and repo-local changes as separate -primary nouns. Those artifacts remain useful historical context and describe -implemented beta behavior, but new product direction should start from the -Git-native `specs/` and `work/` shape. diff --git a/openspec/explorations/context-store-and-initiatives/direction.md b/openspec/explorations/context-store-and-initiatives/direction.md deleted file mode 100644 index c7bf117d01..0000000000 --- a/openspec/explorations/context-store-and-initiatives/direction.md +++ /dev/null @@ -1,458 +0,0 @@ -# Context Store And Initiatives Direction - -Status: historical beta direction. - -This document preserves the earlier context-store and workspace direction from -the workspace/initiative discussion. It is useful transition evidence, but it -is not the current product authority for the simplification work. - -For current direction, start with: - -1. `openspec/work/simplify-context-and-workspace-model/goal.md` -2. `openspec/work/simplify-context-and-workspace-model/roadmap.md` - -The main historical shift captured here was that "workspace" should not be the -durable shared planning object. In this earlier model, the durable shared -object was a synced context store, and initiatives were one opinionated -collection inside it. - -## Historical Core Model - -```text -Context Store - = synced shared content container - -Collection - = mounted content system inside a store - -Initiatives - = first major collection for cross-team implementation context - -Workspace - = local working view over context stores and repos - -Change - = repo/team-owned implementation plan -``` - -The clean rule: - -```text -Context stores sync truth. -Collections shape truth. -Initiatives coordinate work. -Workspaces open local views. -Changes implement repo-owned slices. -``` - -## Historical Locked Product Boundary - -The workspace-to-initiative pivot was the product boundary for this beta -coordination work: - -- A workspace is a regenerable, machine-local working view. It maps context - stores, initiatives, projects, repos, and folders to paths the current user can - open. -- A context store is the durable synced container for shared files. -- An initiative is the durable coordination object for cross-team or cross-repo - implementation context. -- A repo-local change remains the implementation plan owned by the repo or team - doing the work. - -This supersedes the older model where a workspace-level `changes/` tree owned -the canonical shared plan for cross-repo work. Existing workspace-planning -behavior can remain as beta or legacy infrastructure, but it should not steer -new lifecycle design. - -Workspace roadmap disposition: - -- Keep setup, link, relink, list, open, update, and doctor. -- Keep linked repos and folders visible for exploration before a change exists. -- Keep workspace-local agent guidance as local view setup, refreshed by - `workspace update`. -- Defer workspace apply, verify, and archive until initiatives can link to - repo-owned OpenSpec changes. -- Defer branch/worktree orchestration, multi-repo apply, strong cross-repo - validation, and dependency graph enforcement. - -## Agent-First UX - -The primary user experience for initiatives is expected to be agent-driven: - -```text -Using initiative billing-launch, explore the API work and create a proposal. -``` - -The user should not need to know every command. OpenSpec should expose small, -structured CLI primitives that an agent can use to: - -- find the intended initiative across registered context stores -- read canonical initiative files from the context store -- create or link a repo-local OpenSpec change -- use workspace state for local repo and folder views -- respect edit boundaries instead of treating every opened folder as editable - -The CLI is therefore the agent's tool surface, not the whole user workflow. -Prefer explicit, machine-readable commands such as `initiative show --json`, -`new change --initiative ...`, and workspace local-view commands over broad -interactive flows as the first slice. - -Canonical initiative context should stay in the context store. Repo-local -changes should reference the initiative rather than checking in copied snapshots -of initiative prose. If an agent needs a compact context pack, OpenSpec can -generate that as command output from the live initiative context. - -## Context Store - -A context store is the shared/synced folder of files. It is content-agnostic. -It should not know what an initiative is. - -Example: - -```text -acme-context/ - initiatives/ - decisions/ - api-catalog/ - playbooks/ -``` - -The first backend should be Git: - -```text -create/update/delete files - -> commit - -> push - -> other users pull - -> local views update -``` - -But the application should talk to a store abstraction, not directly to Git, so -the backend can later become a cloud database. - -## Backend - -A backend provides persistence and sync for a context store. - -Examples: - -- `git` backend: local clone, pull, commit, push, watch -- `cloud` backend: database records, subscriptions, hosted sync -- `memory` backend: tests and local prototypes - -The backend should expose generic file/object operations: - -```text -read -write -delete -list -sync -watch -``` - -It should not contain initiative-specific behavior. - -## Collections - -A collection is a mounted content system inside a context store. It is -plugin-like, but "collection" is the user-facing term. - -Each collection owns: - -- a folder namespace -- a content model -- templates -- validation/rules -- optional agent guidance -- optional UI views - -Example: - -```text -context-store/ - initiatives/ # Initiative collection - decisions/ # Decision collection - api-catalog/ # API catalog collection -``` - -Core should enforce that a collection only writes inside its mount. - -## Initiative Collection - -The initiative collection is the first enterprise-oriented collection. - -An initiative is shared, agent-consumable implementation context for a -coordinated outcome. It can span teams, repos, services, APIs, contracts, and -capabilities. - -Default shape: - -```text -initiatives/ - launch-billing-flow/ - initiative.yaml - requirements.md - design.md - contracts/ - decisions.md - questions.md - tasks.md -``` - -This describes the runtime initiative collection shape in context stores. This -roadmap folder may still contain legacy `.initiative.yaml` progress metadata -while the initiative itself is being used to manage the migration; that legacy -tracker is not the model new context-store initiatives should copy. - -The default structure should be opinionated for the enterprise design -partnership, but the collection system should allow other structures later. - -## Initiative Responsibilities - -Initiatives should own implementation-relevant shared context: - -- product/program intent -- accepted requirements -- high-level technical coordination -- capability and ownership maps -- API/event/schema contracts -- dependency assumptions -- decisions and open questions -- workspace-readable context for repo-local implementation work - -Initiatives should not try to become all of Jira or Confluence. The focused -positioning is: - -```text -OpenSpec stores agreed implementation context. -Jira tracks work. -Confluence stores broad prose. -GitHub/GitLab store code. -``` - -## Initiative And Change Scope - -An initiative can span one or many OpenSpec changes. - -Those changes may live: - -- in the same repo as the initiative -- in different repos -- in multiple context stores or OpenSpec roots later - -The initiative stores shared coordination context. Workspace views can associate -that context with local repos and repo-owned changes without making the -initiative store machine-local checkout links. - -This keeps grouping separate from storage: - -```text -Initiative = shared grouping/context -Change = execution artifact -Workspace = local opened view of initiative + repos -``` - -## Workspace - -A workspace is a local working view, not the source of truth. - -It can map context stores and project identifiers to local paths, configure an -opener, and launch coding agents with the right folders visible. - -A workspace can open an initiative by resolving: - -- the initiative's context store -- locally selected repo-local changes -- local checkout paths for participating repos - -The durable workspace record should stay tiny and private. It records this -runtime's local view choices, not generated agent files or shared initiative -content. - -```text -getGlobalDataDir()/workspaces// - workspace.yaml -``` - -The workspace name is the local identity. The workspace record can optionally -store a selected context store and initiative, plus stable link names to local -paths and opener preferences. Initiative references are data inside the record, -not path segments. - -Opening a workspace materializes opener-specific runtime files at the managed -workspace root. Those files can contain generated agent guidance, skills, -and editor workspace files. Machine-readable context is returned by JSON command -output. These are regenerated local support, not source of truth. - -```text -private local view record - -> generated runtime files - -> opener-specific launch - -> initiative context + selected local repos/folders -``` - -Workspaces should be regenerable and runtime-specific. They should not be the -canonical home for initiative content, checked-in collaboration state, branches, -worktrees, clones, or implementation progress. - -## Repo Changes - -Repo-local changes remain the team-owned implementation plan. - -An engineering team should be able to pull relevant initiative context into a -repo and create a linked OpenSpec change. - -Example: - -```text -repo/ - openspec/ - changes/ - add-billing-api/ - .openspec.yaml - proposal.md - design.md - specs/ - tasks.md -``` - -The local change should reference the initiative in metadata, for example: - -```yaml -initiative: - store: platform - id: billing-launch -``` - -This metadata is durable repo context and should be checked in. It should not -contain machine-local paths. Agents should read the initiative's canonical files -from the registered context store when they need the shared context. - -## Relationship Between Concepts - -```text -Context Store - contains Collections - -Collection - defines structure/rules for a mounted folder - -Initiative Collection - defines initiatives/ - -Initiative - coordinates one shared outcome - -Workspace - opens local views of context stores and repos - -Repo Change - implements one team's/repo's part of an initiative -``` - -End-to-end flow: - -```text -Product/program/architect creates initiative - -> initiative syncs through context store - -> engineers open local workspace - -> repo team pulls relevant initiative context - -> repo team creates linked OpenSpec change - -> repo team implements locally - -> workspace view surfaces local progress alongside initiative context -``` - -## Local API Direction - -The app should use dependency injection: - -```ts -const store = createStore({ - id: "acme-context", - backend: gitBackend({ - remote: "git@github.com:acme/context.git", - localPath: "~/.openspec/stores/acme-context", - autoSync: true, - }), - collections: [ - initiativeCollection({ mount: "initiatives" }), - ], -}); -``` - -Usage: - -```ts -const initiatives = store.collection("initiatives"); - -await initiatives.create({ id: "launch-billing-flow" }); -await initiatives.update("launch-billing-flow", patch); -await store.sync(); -``` - -Important separation: - -```text -Git backend knows Git. -Store knows sync/lifecycle/events. -Collection knows content structure. -Initiative collection knows initiatives. -``` - -## UI Direction - -The UI should be content-agnostic at the core: - -- browse folders/files -- edit Markdown/YAML -- preview content -- search -- show diffs/history -- sync status - -Collections can add richer views: - -- initiative status view -- contract table -- owner/dependency graph -- linked repo-change view - -The UI should work no matter which collections are mounted. - -## Open Questions - -- What is the first concrete context store command surface? -- Should stores be called `context`, `store`, or something more product-facing? -- Where should enterprise context stores live by default: customer GitHub, - OpenSpec-managed Git, or later hosted cloud? -- How do non-technical users edit Git-backed content without feeling Git? -- What is the minimum viable auto-sync behavior before conflict handling gets - painful? -- How does an initiative contract graduate into a canonical owner repo contract? -- How should linked repo changes report status back into an initiative without - becoming Jira? -- How should monorepos map capabilities, folders, and repo-local changes? -- What should the first repo-change linking command be called? -- Which initiative progress/status signals are useful after linked changes - exist? - -## Suggested Next Direction - -After the initial store, collection, and initiative create/list foundations, -build the next slices in this order: - -1. Reconcile the Initiative MVP around create/list, validation, templates, and - explicit deferral of read/update/delete policy. -2. Add minimal context-store UX for setup, registration, listing, and doctoring. -3. Add agent-first initiative discovery with `initiative show --json` and - registered-store lookup. -4. Add repo-local change metadata and an agent-friendly create/link flow for - `--initiative`. -5. Reject standalone `initiative resolve`; local path mapping belongs to - workspaces, not initiative commands. -6. Let workspaces open initiative-aware local views once show/link semantics - exist. -7. Add local-to-initiative escalation UX. -8. Harden team-shared coordination, sync, conflict guidance, and progress - status after real usage shapes those needs. diff --git a/openspec/explorations/context-store-and-initiatives/questions.md b/openspec/explorations/context-store-and-initiatives/questions.md deleted file mode 100644 index 79c42ebc8b..0000000000 --- a/openspec/explorations/context-store-and-initiatives/questions.md +++ /dev/null @@ -1,23 +0,0 @@ -# Context Store And Initiatives Questions - -## Open - -- Should the user-facing command vocabulary say `context`, `store`, or - something more product-facing? -- What migration or compatibility path should existing workspace-planning - changes get once initiatives exist? -- How should linked repo changes report progress back into an initiative without - becoming a Jira clone? -- How should monorepos map capabilities, folders, and repo-local changes? -- Should OpenSpec support configurable change homes across context stores and - local OpenSpec repos, and what ownership rules keep that model safe? - -## Resolved - -- Workspaces should not be the durable shared planning object. -- Initiative roadmap implementation should be tracked inside the initiative - until repo-owned implementation changes are needed. -- The first concrete context store command surface is `context-store setup`, - `context-store register`, `context-store list`/`ls`, and - `context-store doctor`. Sync, push/pull, remotes, and conflict handling are - future work. diff --git a/openspec/explorations/context-store-and-initiatives/roadmap.md b/openspec/explorations/context-store-and-initiatives/roadmap.md deleted file mode 100644 index 1f8d345ce7..0000000000 --- a/openspec/explorations/context-store-and-initiatives/roadmap.md +++ /dev/null @@ -1,775 +0,0 @@ -# Context Store And Initiatives Roadmap - -Status: historical beta roadmap snapshot. - -This roadmap preserves the implementation queue that existed while the -context-store and workspace model was being explored. It is not the active -roadmap for current simplification work. - -For current direction, start with: - -1. `openspec/work/simplify-context-and-workspace-model/goal.md` -2. `openspec/work/simplify-context-and-workspace-model/roadmap.md` - -The historical product decision underneath this roadmap was: - -```text -Context stores sync truth. -Collections shape truth. -Initiatives coordinate work. -Workspaces open local views. -Changes implement repo-owned slices. -``` - -## Historical Beta Priority Snapshot - -At the time, the manual beta pass pulled first-run friction forward. This was -the historical working order before investing in deeper schema or lifecycle -machinery: - -1. Finish the manual beta reality pass enough to keep the next slices grounded. -2. Item 12, context-store first-run and cleanup UX: interactive no-argument setup, - target-path safety, and a supported unregister/remove path. -3. Skip Item 13 as an implementation item for now. Preserve the handoff - findings, but avoid hardcoding linear "next step" guidance until the agent - handoff model is clearer. -4. Item 14, workspaces beta guide split: make user docs match the interactive - setup path and keep exact flags in the agent playbook. -5. Item 15, context store project roots and schema-led initiatives: sparse initiative - creation and store-local schemas. - -Escalation UX, team-sharing hardening, and initiative-hosted target-bound -changes remain important, but they should wait until the first-run path feels -boring in the good way. - -Before workspaces become public/stable, run Item 19 as a late beta cleanup pass -so beta compatibility code is reviewed intentionally instead of treated as a -permanent contract. - -## 1. Lock The Direction - -Goal: make the workspace-to-initiative pivot explicit so future workspace work -does not keep implementing the older "workspace owns the plan" model. - -Ship: - -- Record that workspaces are local working views, not durable shared planning - objects. -- Record that initiatives are the durable coordination object for cross-team or - cross-repo work. -- Mark the current workspace apply, verify, and archive direction as deferred or - superseded until initiative-linked repo changes exist. -- Keep the already-built workspace setup, link, open, update, and doctor - behavior as useful beta infrastructure. - -Done when: - -- Fresh agents can tell which workspace ideas still apply and which ones should - not steer implementation. - -Locked disposition: - -- Keep workspace setup, link, relink, list, open, update, and doctor as beta - local-view infrastructure. -- Keep "workspace visibility is not change commitment" as a safety rule for - linked repos and folders. -- Supersede "workspace is the durable planning home" with "initiatives are the - durable coordination object." -- Supersede workspace-level planning artifacts as the canonical shared - cross-repo plan. -- Defer workspace apply, verify, and archive as first-class lifecycle commands - until initiative-linked repo-local changes exist. -- Defer branch/worktree orchestration, strong cross-repo validation, dependency - graph enforcement, and shared contract governance. - -Fresh-agent historical reading rule: - -- Start from `openspec/work/simplify-context-and-workspace-model/goal.md` and - `openspec/work/simplify-context-and-workspace-model/roadmap.md` for current - product authority. -- Use `openspec/initiatives/context-store-and-initiatives/direction.md` as - historical beta direction, not as the current product authority. -- Treat `openspec/changes/workspace-reimplementation-roadmap/HISTORICAL_DIRECTION.md` and - `openspec/changes/workspace-reimplementation-roadmap/` as historical reference - material for preserved local-view behavior and POC lessons. -- Do not pick up `workspace-apply-repo-slice` or - `workspace-verify-and-archive` as the next implementation slice unless a later - initiative-linked repo-change design explicitly reactivates them. - -## 2. Stabilize Workspace As Local View - -Goal: keep workspaces useful without making them the source of truth. - -Ship: - -- Workspace guidance that routes durable coordination to initiatives, - implementation planning to repo-local changes, and linked repos or folders to - local context until an edit root is selected. -- Workspace-open behavior that launches the local planning view with linked - folders visible. -- Workspace doctor/status output that explains local path mappings, unresolved - links, installed agent skills, and repair steps. -- Clear docs that `workspace update` refreshes local agent guidance and does not - modify linked repos. - -Done when: - -- A user can set up a workspace, link repos, open an agent, and understand that - the workspace is a local view over context, not the canonical shared plan. - -## 3. Add Context Store Foundation - -Goal: create the generic local context-store foundation that can later hold -initiatives and other shared context collections. Sync/watch behavior remains a -future hardening slice. - -Ship: - -- A context store abstraction with generic local operations: read, write, - delete, and list. -- A first Git-shaped backend model that can point at a local store root. -- A test/memory backend for fast tests and prototypes. -- A store configuration model that does not contain initiative-specific logic. - -Done when: - -- OpenSpec can create and manipulate files inside a local context store without - the core store layer knowing what those files mean. Pull, push, watch, - remote creation, and conflict handling are tracked as future sync work. - -## 4. Add Collection Foundation - -Goal: let product-specific content systems live inside a context store without -hardcoding every future concept into the store layer. - -Ship: - -- A collection interface with a mounted folder namespace. -- Rules that keep a collection's writes inside its mount. -- Basic collection validation and template hooks. -- A way for collections to expose optional agent guidance or UI metadata later. - -Done when: - -- The context store can host a mounted `initiatives/` collection while staying - generic enough for future collections like decisions, API catalogs, or - playbooks. - -## 5. Ship Initiative MVP - -Goal: give coordinated work a durable, shared, agent-consumable home. - -Ship: - -- Initiative creation and listing. -- A default initiative file shape: - -```text -initiatives// - initiative.yaml - requirements.md - design.md - decisions.md - questions.md - tasks.md -``` - -- Templates for product intent, accepted requirements, design decisions, open - questions, and coordination tasks. -- Validation for required initiative metadata. -- Explicit deferral of full read/show, update, and delete policy until the - agent-first discovery and lifecycle needs are clearer. - -Done when: - -- A user or agent can create and list initiatives as shared planning objects - before any repo has committed to implementation details. - -## 6. Add Minimal Context Store UX - -Goal: make shared initiative storage usable before repo handoff or workspace -opening depends on it. - -Ship: - -- `context-store setup ` for creating a local Git-backed store folder with - portable store metadata and local registration. -- `context-store register ` for registering an existing clone or folder, - defaulting the store id from the repo or folder name. -- `context-store list` and `context-store doctor` for local visibility and - non-mutating diagnostics. -- `initiative list` defaulting to all registered stores, with `--store` as a - filter and `--store-path` as an escape hatch. -- Minimal human output and JSON output suitable for agents. - -Done when: - -- A single developer or teammate can create or register a shared context store, - list initiatives across registered stores, and diagnose missing or broken - local store setup without learning the internal registry layout. - -## 7. Add Agent-First Initiative Discovery - -Goal: let an agent resolve the initiative the user named and read canonical -initiative context from the source of truth. - -Ship: - -- `initiative show ` that searches registered stores by default. -- Ambiguity handling when the same initiative id exists in multiple stores. -- JSON output with canonical initiative metadata, store identity, initiative - root path, and metadata path. -- Human output focused on identity and available files, not work progress. - -Done when: - -- An agent can answer, "Which initiative did the user mean, where is the - canonical context, and where is the initiative metadata?" - -## 8. Connect Repo-Local Changes To Initiatives - -Goal: split shared coordination from repo-owned implementation plans cleanly. - -Discussion points to confirm before implementation: - -- Should the create/link flow explicitly report where the change lives, which - initiative it references, and the next suggested command? -- Should `--initiative ` search registered stores by default, or should it - require `--store` when more than one store is registered? -- What should the command do when the initiative exists but the current repo has - no obvious ownership match? - -Ship: - -- Repo-local change metadata that can reference an initiative by store id and - initiative id. -- An agent-friendly create or link flow such as - `new change --initiative /`. -- Guidance that repo-local changes remain responsible for implementation, - validation, and archive. -- No checked-in `initiative.md` snapshot by default; agents read canonical - initiative files live from the context store. - -Done when: - -- One initiative can coordinate several repo-local changes without copying the - shared plan into every repo, storing machine-local links in the initiative, or - making the initiative own implementation artifacts. - -## 9. Reject Initiative Resolve - -Decision: do not add `openspec initiative resolve`, now or later. - -Rationale: - -- `initiative show` already resolves canonical shared initiative context. -- A workspace is the local view over repos, folders, context stores, and - initiatives. -- Repo-local changes already carry durable initiative links in checked-in - `.openspec.yaml` metadata. -- Repo-local status already reports work progress. -- A standalone resolve command would either duplicate workspace local-view state - or produce weak output when no workspace is present. - -Do not ship: - -- `openspec initiative resolve ` -- all-workspace or all-repo scans for initiative availability -- explicit path scanning as an initiative command -- Git remote matching for initiative participation -- repo ownership inference -- cloning, branch creation, or worktree creation as part of initiative - resolution -- initiative backlinks -- local availability or progress dashboards under the initiative command - -Done when: - -- Future agents can see that "initiative resolve" is intentionally rejected and - should not be revived under another command name. - -## Proposed Discussion Point: Add Initiative Next / Agent Handoff UX - -Status: candidate work item, not locked into the numbered roadmap yet. - -Question to confirm: - -- Should this become a roadmap item before "Let Workspaces Open Initiatives"? - -Goal: give agents and users a small "what now?" command after initiative -discovery from the current repo or workspace, without turning it into a -dashboard or progress/status surface. - -Possible shape: - -```bash -openspec initiative next billing-launch --json -``` - -Possible JSON answer: - -```json -{ - "initiative": "billing-launch", - "next_action": "create_repo_change", - "reason": "initiative found, no linked local change exists for this repo", - "suggested_command": "openspec new change add-billing-api --initiative billing-launch" -} -``` - -Discussion points to confirm before implementation: - -- Is `initiative next` the right command name, or should this guidance belong - inside workspace initiative opening or repo-local status? -- Should it return exactly one suggested next action, or a ranked set of options? -- Should it ever inspect work progress, or stay limited to handoff/readiness? -- How should it behave when no stores are registered, the initiative is - ambiguous, or the local repo is unrelated? - -Done when, if accepted: - -- An agent can answer "what should I do next for this initiative from here?" - without guessing across `show`, workspace state, and repo-local - change metadata. - -## 10. Let Workspaces Open Initiatives - -Goal: connect durable initiative context to this runtime's local working view -after initiative show and repo-change linkage exist. - -Locked direction: - -- A workspace does not contain the work. It remembers how this runtime opens the - work. -- Persist only tiny private local view choices. -- Generate opener-specific runtime files on open. -- Attach initiative context and selected existing local repos or folders. -- Do not clone, branch, create worktrees, use submodules, or infer local repos in - this slice. -- Context-only open is valid. - -Product decision status: - -- No remaining Item 10 product decisions are open. Implementation may still - uncover mechanical details, but the intended UX shape is locked. - -Command UX decision: - -- Use `openspec workspace open --initiative `. -- Support `/` and ` --store `. -- Support `openspec workspace open --initiative ` - when the user wants to choose the local workspace identity explicitly. -- If only `` is provided, proceed when exactly one registered - context store has that initiative id. -- On ambiguity, list exact matches and require an explicit store selector. -- On no exact match, show likely matches when available and suggest `openspec - initiative list`; do not silently open a fuzzy match. -- If the user omits a workspace name, derive a friendly default from the - initiative id when that is unambiguous; otherwise require the user to pick an - explicit workspace name. - -Open target decision: - -- Open the initiative directory by default, not the whole context store. -- Generated guidance and JSON output should still report the context store root - and that broader context is available. -- A later explicit option may open the whole context store, but broad store - scope is not the Item 10 default. - -Local view record decision: - -- Use one private local view record for initiative-aware local views. -- Store initiative-view state in the root `workspace.yaml` file. -- The record stores selected context-store binding, initiative, local links, - opener, and selected tools. The binding may preserve a registry selector or a - runtime-local path selector. -- The context binding is optional, so a workspace can also be a custom local view - with linked folders and no initiative. - -Workspace storage decision: - -- Store each private workspace view under - `getGlobalDataDir()/workspaces//`. -- The workspace name is the local identity. Selected store and initiative, if - any, are data inside the private record rather than path segments. -- Use one durable `workspace.yaml` at the workspace root. -- Generate `AGENTS.md`, opener workspace files, and tool-specific skills at the - workspace root. -- Do not introduce a separate generated-output directory for Item 10. - -Runtime identity decision: - -- Use `getGlobalDataDir()` as the cross-platform runtime-local boundary. -- Local paths are valid only in the runtime that wrote the private - `workspace.yaml`. -- Do not add path translation or a separate `` path segment in Item - 10. - -Prepare/JSON decision: - -- Keep `workspace open --json` as a machine-facing receipt for the same open - operation. -- Do not add `--prepare-only` for Item 10. -- JSON should return useful generated paths, selected context, opened roots, - skipped roots, opener, launch status, and warnings rather than a bare success - response. - -Codex Desktop decision: - -- Open the generated workspace root as the Codex Desktop project. -- Expose attached initiative and linked repo/folder paths through generated - guidance and `workspace open --json` output. -- Defer Desktop multi-root automation until there is a clearer Desktop contract. - -Edit-boundary decision: - -- Emit advisory boundaries only. -- Label initiative/context-store files as shared coordination context and linked - repos/folders as local implementation context when selected. -- Do not enforce write restrictions in Item 10. - -Ship: - -- Private local view state that can remember the selected context store, - selected initiative, selected local links, opener, and selected tools for this - runtime. -- `workspace open` support for generating opener-specific runtime files and - opening initiative context plus locally resolved linked repos/folders. -- Agent guidance and machine-readable `workspace open --json` output that - explain the current initiative, opened roots, skipped roots, local paths, and - advisory edit boundaries. -- Workspace-name reuse behavior that avoids silently repointing an existing - workspace to a different initiative. -- Open-time warnings that skip missing linked repos/folders while failing when - the selected initiative or context store cannot be resolved. -- Continued support for custom non-initiative workspaces as first-class local - views. -- Doctor guidance for missing context stores, missing linked repos/folders, and - stale local view records. - -Done when: - -- A teammate can open the same initiative in their runtime while using their own - local paths and selected repo subset. -- Generated runtime files are clearly derived and can be regenerated without - losing the user's local view choices. - -## 11. Manual Beta Reality Pass - -Status: proposed immediate beta-learning item. - -Goal: manually run what exists and use the friction to update initiative notes -before designing more surface area. - -Ship: - -- A fresh-user walkthrough of context-store setup, initiative creation, - workspace opening, repo linking, doctor output, and repo-local linked change - creation. -- Notes on what felt clear, what felt odd, where prompts were missing, and where - docs pushed too many flags onto the user. -- A short disposition that separates docs-only fixes from follow-on - implementation slices. - -Done when: - -- The initiative contains concrete notes from trying the current beta flow by - hand. -- The next implementation or docs slice is grounded in observed friction rather - than guessed workflow shape. - -## 12. Context Store First-Run And Cleanup UX - -Goal: make context-store setup and cleanup feel like a normal local workflow, -without adding sync, remote, or governance automation. - -Work item: -`work-items/12-context-store-first-run-and-cleanup-ux/` - -Ship: - -- Interactive no-argument `context-store setup` for terminal users. -- Deterministic non-interactive and JSON behavior when required setup choices - are missing. -- Target-path safety output for managed defaults, explicit paths, existing Git - repos, and non-empty directories. -- A supported local cleanup path for unregistering or removing a context store - without hand-editing the registry. -- Setup output that explains local registry state and Git state, including - uncommitted shared-store files after `--init-git`. - -Done when: - -- A fresh user can set up or clean up a local context store without knowing - hidden registry paths, environment variables, or manual file edits. - -## 13. Agent Handoff Output And Delivery Polish - -Status: deferred as an implementation item. - -Goal, if revisited: define an agent handoff receipt model that reports what -exists, where it lives, and which affordances are available without prescribing -one linear next step. - -Work item: -`work-items/13-agent-handoff-output-and-delivery-polish/` - -Ship: - -Do not ship fixed "Next for your agent" guidance yet. The current shape assumes -that users and agents move through the beta flow linearly, but real agentic -workflows may inspect, branch, skip steps, or start from existing context. - -Preserve for future exploration: - -- Whether command output should include context receipts, available affordances, - or nothing beyond deterministic paths. -- Whether direct path fields like `created_paths` are a small standalone receipt - improvement rather than part of a broader handoff model. -- How delivery wording should distinguish baseline OpenSpec guidance from - workflow entrypoints without coupling it to this handoff item. - -## 14. Workspaces Beta Guide Split - -Status: proposed immediate beta-learning item. - -Goal: make the beta docs reflect the intended division of labor: - -```text -Users make local choices. -Agents run OpenSpec work commands. -``` - -Ship: - -- A user-facing guide that prefers interactive terminal setup for local choices - such as context-store location, opener, and local repo paths. -- An agent-facing CLI playbook that keeps explicit commands, JSON output, - current-directory rules, and caveats. -- A clear rule for which flags are normal user-facing escape hatches and which - are mostly agent-facing precision. - -Done when: - -- A new user can get to a working beta setup without reading a flag-heavy CLI - tutorial. -- A coding agent can still find the exact commands needed to create initiatives, - link repo-local changes, and inspect state safely. - -## 15. Context Store Project Roots And Schema-Led Initiatives - -Goal: let context stores behave like OpenSpec roots for shared planning config -and schemas, while keeping implementation changes repo-owned by default. - -Work item: -`work-items/15-context-store-project-roots-and-schema-led-initiatives/` - -Product decision to confirm: - -- A context store can have `openspec/config.yaml` and `openspec/schemas/` like a - repo after `openspec init`. -- That project-like shape is for shared context configuration and initiative - schemas. It must not silently make the context store an implementation repo. -- `initiative create` should create a sparse shell and let reviewed initiative - artifacts grow through schema-led status/instructions. - -Ship: - -- Context-store setup that creates or supports store-local OpenSpec config. -- A default initiative schema for high-level requirements and design artifacts. -- Sparse initiative creation: `initiative.yaml` plus a short `brief.md`, with no - `TBD` placeholders and no default `tasks.md`. -- Initiative artifact status and instructions output rooted in the initiative - directory. -- Guardrails so `openspec new change` does not accidentally create executable - repo-local changes inside a context store just because the store has an - `openspec/` directory. -- Compatibility for existing six-file MVP initiatives. - -Done when: - -- A context store can resolve store-local initiative schemas. -- Agents can iteratively create initiative requirements and design artifacts - from CLI instructions. -- Existing MVP initiatives continue to list and show. -- Docs stop presenting initiative creation as "fill every markdown file now." - -## 16. Add Escalation UX - -Goal: let users start locally and upgrade only when coordination is actually -needed. - -Work item: -`work-items/16-add-escalation-ux/` - -Ship: - -- Explore/propose guidance that starts in the current repo by default. -- A recommendation path when work spans multiple owned areas: - -```text -This appears to span multiple owned areas. -OpenSpec can upgrade it into a coordinated initiative and carry the current -planning context forward. -``` - -- Carry-forward behavior for the current change name, product goal, notes, - inferred areas, and relevant questions. -- Clear prompts that ask about concrete affected areas rather than abstract - storage models. - -Done when: - -- Coordinated planning feels like a continuation of local planning, not a - workflow restart. - -## 17. Harden Team-Shared Coordination - -Goal: make initiatives practical for teams without turning setup into an admin -ceremony. - -Work item: -`work-items/17-harden-team-shared-coordination/` - -Ship: - -- A recommended Git-backed shared context store pattern. -- Lightweight teammate onboarding: - -```text -Clone the context store. -Run openspec workspace doctor. -Open the initiative with your agent. -``` - -- Repair flows for local path mappings. -- Sync status and conflict guidance. -- Clear separation between committed initiative state and machine-local - workspace state. - -Done when: - -- Several teammates can share the same initiative while each keeps their own - local checkout layout. - -## 18. Explore Initiative-Hosted Target-Bound Change Artifacts - -Goal: decide whether shared initiative artifacts can graduate into executable -OpenSpec changes only after they are bound to a target repo or spec root, -without blurring initiative coordination, repo ownership, and workspace -local-view boundaries. - -Work item: -`work-items/18-explore-initiative-hosted-target-bound-change-artifacts/` - -Discussion points to confirm before exploration: - -- Should "change home" stay internal resolver language, with user-facing - phrasing like "where should this plan live?" and "editable target"? -- What is the difference between initiative work items, briefs, target-bound - changes, and repo-local changes? -- What portable target metadata is required before an initiative-hosted artifact - can be considered implementation-ready? -- Should shared target-bound changes require explicit opt-in, or can - initiative/store policy select them? -- What user/team scenario would justify an initiative-hosted target-bound change - instead of a repo-local linked change? - -Ship: - -- Audit commands, templates, validation, archive, apply, completion, and docs - for repo-local `openspec/changes/` assumptions. -- Define the concepts of artifact home, implementation target, allowed edit - roots, and action context. -- Decide how initiative-hosted target-bound changes bind to repo specs, - implementation roots, branches, validation, archive, and sync/conflict - behavior. -- Define agent-readable JSON output for work target, artifact home, - implementation target, initiative link, edit boundaries, unsupported - lifecycle commands, and next commands. -- Record compatibility behavior for existing repo-local and workspace-local - changes. -- Recommend whether this should become an implementation slice, remain deferred, - start as initiative work items only, or be limited to specific schemas or - workflows first. - -Done when: - -- The initiative has a concrete recommendation, opt-in/config examples, affected - command list, and go/no-go criteria for implementation. - -## 19. Review Workspace Beta Compatibility Before Public Release - -Goal: decide which workspace beta compatibility behavior should survive into the -public workspace contract, and remove or migrate the rest while workspaces are -still unpublished. - -Work item: -`work-items/19-review-workspace-beta-compatibility-before-public-release/` - -Why this is late: - -- Workspaces are still beta and not public/stable yet. -- We do not need to preserve every intermediate beta file shape forever. -- Early cleanup risks churn while first-run UX and initiative behavior are still - changing. -- The right compatibility contract is easier to define after manual beta usage - shows which local workspace artifacts real users have actually created. - -Ship: - -- Inventory workspace compatibility code, including legacy split state readers, - registry fallbacks, `codex` to `codex-cli` aliases, generated `.gitignore` - cleanup, and empty compatibility shims. -- Classify each path as public contract, beta migration, test-only shim, or - removable dead weight. -- Remove beta-only shims that only support unpublished intermediate workspace - shapes. -- Define any migration behavior worth keeping for people who tried the beta. -- Update docs, tests, generated guidance, and release notes so the public - workspace compatibility promise is explicit. - -Done when: - -- The workspace compatibility surface is intentionally small. -- Public docs do not imply support for beta-only workspace internals. -- Any remaining migration code has a clear owner, reason, and removal policy. - -## Later, Not First - -These are important, but should wait until the initiative model has real usage: - -- Workspace apply, verify, and archive as first-class lifecycle commands. -- Branch or worktree orchestration. -- Strong cross-repo validation. -- Dependency graph enforcement. -- Shared contract ownership workflows. -- Sponsor/driver governance flows. -- Initiative progress/status dashboards. -- Cloud-hosted context stores. - -## Suggested Shipping Sequence - -1. Lock the direction and defer old workspace lifecycle slices. -2. Stabilize workspace as local view and agent launcher. -3. Add context store foundation. -4. Add collection foundation. -5. Ship initiative MVP. -6. Add minimal context-store UX. -7. Add agent-first initiative discovery. -8. Link repo-local changes to initiatives. -9. Keep initiative resolve rejected; use workspace local-view mapping instead. -10. Let workspaces open initiatives. -11. Manual beta reality pass. -12. Context store first-run and cleanup UX. -13. Skip agent handoff output and delivery polish until the handoff model is - clearer. -14. Workspaces beta guide split. -15. Context store project roots and schema-led initiatives. -16. Add local-to-initiative escalation UX. -17. Harden team-shared coordination. -18. Explore initiative-hosted target-bound change artifacts. -19. Review workspace beta compatibility before public release. - -Pending discussion: revisit handoff receipts after the beta guide and sparse -initiative model clarify what context agents actually need. diff --git a/openspec/explorations/context-store-and-initiatives/tasks.md b/openspec/explorations/context-store-and-initiatives/tasks.md deleted file mode 100644 index 1acbd6f60f..0000000000 --- a/openspec/explorations/context-store-and-initiatives/tasks.md +++ /dev/null @@ -1,321 +0,0 @@ -# Context Store And Initiatives Tasks - -Status: historical beta progress snapshot. - -This file preserves the task state from the old context-store and workspace -initiative. It is not the active implementation queue for current -simplification work. - -For current direction, start with: - -1. `openspec/work/simplify-context-and-workspace-model/goal.md` -2. `openspec/work/simplify-context-and-workspace-model/roadmap.md` - -Historical roadmap items live in `roadmap.md`; detailed working notes live -under `work-items/`. - -## Historical Beta Priority Snapshot - -At the time, the manual beta pass prioritized the things a fresh user hit while -getting started before deeper model work: - -1. Finish Item 11 observations enough to keep implementation grounded. -2. Item 12: no-argument context-store setup, path safety, and - cleanup. -3. Skip Item 13 as an implementation item for now. Preserve the findings, but - do not hardcode linear "next step" guidance until the agent handoff shape is - better understood. -4. Item 14: update the beta guide so it matches the improved first-run flow. -5. Item 15: context-store project roots and sparse schema-led - initiatives. -6. Items 16-18: leave escalation, team hardening, and initiative-hosted - target-bound changes until after the onboarding path feels sane. -7. Item 19: review beta workspace compatibility near the end, before workspace - behavior becomes public/stable. - -## 1. Lock The Direction - -Work item: `work-items/01-lock-the-direction/` - -- [x] Record the workspace-to-initiative product boundary in initiative docs. -- [x] Mark the old workspace reimplementation roadmap as historical reference. -- [x] Defer workspace apply, verify, and archive until initiative-linked repo - changes exist. -- [x] Complete a non-spec direction pass so roadmap, work items, docs, and - active change artifacts point to the initiative as product intent. -- [x] Decide whether user-facing workspace docs need any change now; default to - no unless they misrepresent current behavior. -- [x] Decide how to handle active no-task workspace changes after the - disposition pass. -- [x] Record final evidence and remaining risks for Item 1. - -## 2. Stabilize Workspace As Local View - -Work item: `work-items/02-stabilize-workspace-as-local-view/` - -- [x] Re-anchor generated workspace guidance in the initiative direction. -- [x] Decide that generated guidance should stop recommending workspace-level - `changes/` as the planning home for coordinated work. -- [x] Decide that `workspace update` should refresh generated workspace - guidance for existing workspaces. -- [x] Decide that workspace-planning action context should treat beta workspace - artifacts as local compatibility context. -- [x] Decide to defer doctor installed-skill summaries and only update stale - `workspace update` wording for now. -- [x] Define exact local-view behavior to preserve. -- [x] Review current workspace setup, link, relink, list, open, update, and - doctor behavior against that definition. -- [x] Identify any product wording or guidance gaps left after Item 1. - -## 3. Add Context Store Foundation - -Work item: `work-items/03-add-context-store-foundation/` - -- [x] Define the initial store/backend data model. -- [x] Decide that the first slice is core API only, with no CLI surface yet. -- [x] Decide that the first backend is Git/local checkout config only. -- [x] Decide where context store roots, local registry YAML, and portable store - metadata YAML live. -- [x] Implement context-store foundation helpers and tests. - -## 4. Add Collection Foundation - -Work item: `work-items/04-add-collection-foundation/` - -- [x] Define collection mount rules. -- [x] Decide validation/template hooks stay inert extension fields for this - slice. -- [x] Prove `initiatives/` can mount without store-specific logic. - -## 5. Ship Initiative MVP - -Work item: `work-items/05-ship-initiative-mvp/` - -- [x] Define initiative file shape and validation. -- [x] Add templates for requirements, design, decisions, questions, and tasks. -- [x] Implement create/list mounted collection operations and CLI adapter. -- [x] Decide full read/show, update, and delete policy should move to later - agent-first discovery and lifecycle work. - -## 6. Add Minimal Context Store UX - -Work item: `work-items/06-add-minimal-context-store-ux/` - -- [x] Create Item 6 work-item tracking notes. -- [x] Define high-level `context-store setup`, `register`, `list`, and `doctor` - UX direction. -- [x] Decide exact checked-in store metadata and machine-local registry - behavior. -- [x] Decide setup/register/list/doctor human behavior and responsibility split. -- [x] Decide `initiative list` partial-success behavior across registered - stores. -- [x] Decide final Item 6 edge cases: id inference, non-empty setup folders, - registry conflicts, empty states, JSON exit behavior, and static completions. -- [x] Update `initiative list` to default across registered stores, with - `--store` as a filter and `--store-path` as an escape hatch. -- [x] Add focused tests and verification for context-store CLI behavior. - -## 7. Add Agent-First Initiative Discovery - -- [x] Define `initiative show ` human and JSON output. -- [x] Search registered stores by default and handle ambiguous initiative ids. -- [x] Return canonical initiative metadata, store identity, root path, and - metadata path for agent reads. -- [x] Keep work-progress status out of this command. - -## 8. Connect Repo-Local Changes To Initiatives - -Work item: `work-items/08-connect-repo-local-changes-to-initiatives/` - -- [x] Decide that the initiative link lives in repo-local `.openspec.yaml`. -- [x] Add repo-local initiative metadata. -- [x] Add an agent-friendly create or link flow for repo-local changes. -- [x] Decide command naming for `--initiative` linking on new change creation. -- [x] Confirm whether create/link output should report where the change lives, - which initiative it references, and the next suggested command. -- [x] Confirm whether `--initiative ` searches registered stores by default - or requires explicit store selection in multi-store setups. -- [x] Keep canonical initiative context in the context store; do not add a - checked-in `initiative.md` snapshot by default. - -## 9. Reject Initiative Resolve - -Work item: `work-items/09-add-initiative-resolve/` - -- [x] Pressure-test whether a standalone `initiative resolve` command is needed. -- [x] Decide not to add `openspec initiative resolve`, now or later. -- [x] Keep canonical initiative discovery in `initiative show`. -- [x] Keep local path mapping in workspace behavior. -- [x] Keep implementation progress in repo-local status. -- [x] Reject all-repo scans, all-workspace scans, explicit path scanning as an - initiative command, Git remote matching, cloning, worktree creation, and - initiative backlinks. - -## Proposed Discussion: Initiative Next / Agent Handoff UX - -Work item draft: -`work-items/proposed-initiative-next-agent-handoff-ux/` - -- [ ] Decide whether to add this as a numbered roadmap item between Item 9 and - Item 10. -- [ ] Decide whether the surface is `initiative next`, workspace initiative - opening, or repo-local status guidance. -- [ ] Decide whether it suggests one next action or multiple ranked options. -- [ ] Decide that progress/status stays out of scope, unless we explicitly want - this command to grow into a broader status surface. - -## 10. Let Workspaces Open Initiatives - -- [x] Create Item 10 work-item tracking notes. -- [x] Lock the command UX for opening an initiative as a local workspace view. -- [x] Define the private local view record for selected context store, - initiative, local links, opener, and selected tools. -- [x] Decide the private local view record storage namespace and keying. -- [x] Decide the default open target: initiative directory versus full context - store. -- [x] Decide where generated runtime files live and how they are regenerated. -- [x] Define runtime identity rules for macOS, Codespaces, WSL, SSH, and - containers without path translation. -- [x] Decide the prepare/JSON surface for agents and desktop integrations. -- [x] Decide the Codex Desktop behavior for generated workspace roots and attached - paths. -- [x] Define advisory edit-boundary output for Item 10. -- [x] Confirm this slice opens known local paths only and does not create - clones, branches, worktrees, or submodules. - -## 11. Manual Beta Reality Pass - -Work item: `work-items/11-manual-beta-reality-pass/` - -- [ ] Manually run the current context-store, initiative, workspace, and - repo-local change flows from a fresh user's point of view. -- [ ] Capture notes on confusing commands, missing prompts, unclear output, and - places where the docs over-explain or under-explain. -- [ ] Update initiative notes as observations come in. -- [ ] Decide which findings should become implementation slices versus docs-only - fixes. - -## 12. Context Store First-Run And Cleanup UX - -Work item: `work-items/12-context-store-first-run-and-cleanup-ux/` - -- [x] Decide and implement interactive no-argument `context-store setup`. -- [x] Define target-path safety behavior for managed defaults, explicit paths, - Git repos, and non-empty directories. -- [x] Add local cleanup support for unregistering or removing a context store. -- [x] Make setup and cleanup output report the agreed human-facing summary and - exact JSON state without workflow `next_commands`. -- [x] Update docs and tests for first-run setup and cleanup behavior. - -## 13. Agent Handoff Output And Delivery Polish - -Work item: `work-items/13-agent-handoff-output-and-delivery-polish/` - -Status: deferred. Do not implement fixed "Next for your agent" output from this -item yet. - -- [ ] Revisit the handoff model after Item 14/15 clarify the beta guide and - sparse initiative flow. -- [ ] If needed, split deterministic receipt improvements such as direct - `created_paths` into a smaller future implementation slice. -- [ ] Avoid prescribing one linear workflow path; future handoff output should - report state, paths, and possible affordances that agents can compose. - -## 14. Workspaces Beta Guide Split - -Work item: `work-items/14-workspaces-beta-guide-split/` - -- [ ] Update the user-facing guide to prefer interactive terminal setup for - local choices. -- [ ] Move initiative creation, initiative editing, and repo-local change - creation into "ask your coding agent" guidance. -- [ ] Keep explicit flags, JSON output, cwd rules, and caveats in the - agent-facing CLI playbook. -- [ ] Decide which flags remain useful in user docs as escape hatches for - ambiguity. -- [ ] Record any interactive prompt gaps found while writing the guide. - -## 15. Context Store Project Roots And Schema-Led Initiatives - -Work item: -`work-items/15-context-store-project-roots-and-schema-led-initiatives/` - -- [x] Create Item 15 work-item tracking notes. -- [ ] Update initiative direction language so context stores are OpenSpec-aware - shared project roots, not only cross-team/cross-repo coordination folders. -- [ ] Decide the minimal context-store OpenSpec structure: - `.openspec-store/store.yaml`, `openspec/config.yaml`, - `openspec/schemas/`, and collection mounts. -- [ ] Decide the store-local config shape for initiative collection defaults, - including whether to use `collections.initiatives.schema`. -- [ ] Decide how context-store setup creates, preserves, or repairs - store-local `openspec/config.yaml`. -- [ ] Define the built-in high-level initiative schema and its initial - artifacts. -- [ ] Decide whether `initiative create` creates only `initiative.yaml`, or - `initiative.yaml` plus one schema-selected seed artifact such as `brief.md`. -- [ ] Replace eager six-file initiative scaffolding with sparse iterative - creation. -- [ ] Add initiative artifact status/instructions behavior rooted at the - initiative directory. -- [ ] Reuse project-local schema resolution with the context-store root as the - project root for initiative commands. -- [ ] Decide whether schema CLI commands need `--store` or `--store-path` - selectors. -- [ ] Guard planning-home resolution so context stores with `openspec/config.yaml` - do not accidentally make the store an implementation repo. -- [ ] Preserve existing six-file beta initiatives as readable valid - initiatives. -- [ ] Update docs, generated agent guidance, and tests for the project-like - context-store model. - -## 16. Add Escalation UX - -Work item: `work-items/16-add-escalation-ux/` - -- [ ] Define local-to-initiative recommendation triggers. -- [ ] Carry current planning context into a new initiative. -- [ ] Keep prompts grounded in affected areas. - -## 17. Harden Team-Shared Coordination - -Work item: `work-items/17-harden-team-shared-coordination/` - -- [ ] Document recommended Git-backed store setup. -- [ ] Define teammate onboarding and repair flows. -- [ ] Add sync status and conflict guidance. - -## 18. Explore Initiative-Hosted Target-Bound Change Artifacts - -Work item: `work-items/18-explore-initiative-hosted-target-bound-change-artifacts/` - -- [ ] Confirm "change home" stays internal language and user-facing wording is - closer to "where should this plan live?" -- [ ] Define user-facing naming for initiative work items, briefs, - target-bound changes, artifact homes, and editable targets. -- [ ] Decide whether initiative-hosted artifacts can graduate into executable - changes, and which target metadata is required first. -- [ ] Decide the configuration or opt-in surface for repo-local versus - initiative-hosted artifacts. -- [ ] Define how `openspec new change` selects and reports the artifact home, - implementation target, initiative link, and action context. -- [ ] Decide how initiative-hosted target-bound changes bind to repo specs, - implementation roots, validation, archive, and sync behavior. -- [ ] Record compatibility behavior for existing repo-local and - workspace-local changes. -- [ ] Identify follow-on implementation slices and risks. - -## 19. Review Workspace Beta Compatibility Before Public Release - -Work item: -`work-items/19-review-workspace-beta-compatibility-before-public-release/` - -- [ ] Inventory workspace beta compatibility code and tests. -- [ ] Decide which beta-only compatibility paths should be removed before - public release. -- [ ] Decide which compatibility paths need explicit migration behavior or - release notes. -- [ ] Remove low-value shims that only support unpublished beta workspace - shapes. -- [ ] Update docs, tests, and agent guidance to match the chosen public - workspace compatibility contract. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/01-lock-the-direction/evidence.md b/openspec/explorations/context-store-and-initiatives/work-items/01-lock-the-direction/evidence.md deleted file mode 100644 index d9483abe69..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/01-lock-the-direction/evidence.md +++ /dev/null @@ -1,154 +0,0 @@ -# Work Item 01 Evidence - -## 2026-05-20 Initial Direction Lock - -Completed before this work item folder was created: - -- Added locked disposition to `roadmap.md`. -- Added locked product boundary to `direction.md`. -- Marked `openspec/changes/workspace-reimplementation-roadmap/START_HERE.md` as historical reference. -- Marked `openspec/changes/workspace-reimplementation-roadmap/HISTORICAL_DIRECTION.md` as historical reference. -- Marked `openspec/changes/workspace-reimplementation-roadmap/` as historical - reference. -- Marked `workspace-apply-repo-slice` and `workspace-verify-and-archive` as - deferred until initiative-linked repo-local changes exist. - -Research findings: - -- Current workspace setup, link, relink, list, open, update, and doctor behavior - is useful beta local-view infrastructure and should be preserved. -- Live specs describe current workspace-planning behavior. They should not be - rewritten during the initial direction lock; initiative artifacts should carry - future product intent until behavior changes. -- Existing runtime behavior should remain intact until initiatives and linked - repo-local changes can replace workspace-level planning. - -Verification: - -- `git diff --check` passed after the initial direction-lock edits. -- `openspec validate workspace-reimplementation-roadmap --no-interactive`, - `openspec validate workspace-apply-repo-slice --no-interactive`, and - `openspec validate workspace-verify-and-archive --no-interactive` failed - because those existing active changes have no spec deltas. That predates the - disposition wording and is tracked as an active-change cleanup question. - -## 2026-05-21 Initiative Entry Point - -Added `README.md` as the initiative entry point and linked it from -`.initiative.yaml`. - -The README explains: - -- this initiative is the source of product intent -- the reading order for direction, roadmap, tasks, decisions, questions, and - work items -- specs remain the current behavioral contract behind the code -- specs should not be rewritten for future intent until behavior changes - -Updated `work-items/01-lock-the-direction/tasks.md` to mark the initiative -source-of-intent review complete. - -## 2026-05-21 Historical Workspace Roadmap Review - -Reviewed the historical workspace reimplementation entry points: - -- `openspec/changes/workspace-reimplementation-roadmap/START_HERE.md` -- `openspec/changes/workspace-reimplementation-roadmap/HISTORICAL_DIRECTION.md` -- `openspec/changes/workspace-reimplementation-roadmap/README.md` -- `openspec/changes/workspace-reimplementation-roadmap/proposal.md` -- `openspec/changes/workspace-reimplementation-roadmap/POC_REFERENCE_GUIDE.md` - -Added a guard near the top of -`openspec/changes/workspace-reimplementation-roadmap/HISTORICAL_DIRECTION.md` stating -that the remaining sections are historical POC follow-up direction and should -not be treated as active implementation guidance. - -The roadmap README and handoff prompt already direct agents to the initiative -direction first and warn not to continue the old flat sibling queue unless a -later initiative-linked repo-change design reactivates it. - -## 2026-05-21 Active Workspace Proposal Review - -Reviewed active workspace proposal artifacts: - -- `workspace-reimplementation-roadmap` -- `workspace-agent-guidance` -- `workspace-apply-repo-slice` -- `workspace-verify-and-archive` - -Added small notes to `workspace-apply-repo-slice` and -`workspace-verify-and-archive` clarifying that the remaining proposal sections -are preserved for later reference, not discarded, and should become relevant -again after initiatives and initiative-linked repo-local changes exist. - -Left `workspace-agent-guidance` untouched because it already has unrelated -worktree edits and should be handled as a separate active-change disposition -decision. - -## 2026-05-21 User-Facing Docs Decision - -Decision: Do not update `docs/cli.md` as part of the initial direction lock -unless it misrepresents current user-facing behavior. - -Reasoning: - -- The direction lock is for contributors and agents deciding what to build next. -- User-facing docs should describe current CLI behavior, not future initiative - intent. -- Initiatives do not have a CLI surface yet, so announcing the pivot in user - docs would draw attention to an internal product direction before users can act - on it. - -Revisit user-facing docs when initiative or context-store commands exist, or if -current docs promise unavailable workspace apply, verify, or archive behavior. - -Verification: - -- `git diff --check` passed. -- No files under `openspec/specs/` or `schemas/workspace-planning/` were - modified in this pass. - -## 2026-05-21 Active Change Disposition - -Decision: Keep the active workspace changes as deferred reference placeholders. - -Rationale: - -- Workspace agent guidance, apply, verify, and archive are still expected to - matter after initiative infrastructure exists. -- The immediate focus should be context stores, initiatives, and - initiative-linked repo-local changes. -- Keeping the proposals preserves research and continuity without making them - the next implementation queue. - -Follow-up: - -- Revisit the deferred workspace changes after initiative-linked repo-local - changes define the durable handoff model. - -## Final Item 1 State - -Item 1 is complete. - -What is locked: - -- Initiative artifacts are the source of product intent for context stores, - collections, initiatives, workspaces, and repo-local changes. -- Specs and schemas remain the current behavioral contract and were not edited - for future intent. -- Historical workspace roadmap artifacts remain available as reference, not as - the active shipping queue. -- Deferred workspace changes remain active reference placeholders because their - domains are expected to matter after initiative infrastructure exists. -- User-facing docs were intentionally left unchanged unless they misrepresent - current behavior. - -Remaining risks: - -- `openspec list` still shows deferred workspace changes as active no-task - changes. This is intentional for now but may remain visually noisy. -- `workspace-agent-guidance` has unrelated worktree edits and should be handled - carefully before any future commit or archive decision. -- Future agents still need to read the initiative README first; the historical - workspace docs are safer now, but still contain useful old lifecycle details - deeper in the file. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/01-lock-the-direction/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/01-lock-the-direction/plan.md deleted file mode 100644 index 05a15c5a82..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/01-lock-the-direction/plan.md +++ /dev/null @@ -1,90 +0,0 @@ -# Work Item 01: Lock The Direction - -## Goal - -Make the workspace-to-initiative pivot explicit enough that future agents and -contributors do not continue implementing the older "workspace owns the plan" -model. - -The locked model is: - -```text -Context stores sync truth. -Collections shape truth. -Initiatives coordinate work. -Workspaces open local views. -Changes implement repo-owned slices. -``` - -## Direction - -This work item is a non-spec direction pass, not a runtime removal. - -Specs should continue to describe the current behavioral contract behind the -code. Product intent, roadmap decisions, and future direction should live in the -initiative artifacts until a later implementation change intentionally updates -behavior and its specs together. - -Keep: - -- workspace setup, link, relink, list, open, update, and doctor -- linked repos and folders as local planning context -- workspace-local skills as local agent guidance -- "workspace visibility is not change commitment" - -Mark as transitional: - -- workspace-level `changes/` planning -- `workspace-planning` schema -- workspace-scoped status/instructions compatibility - -Defer: - -- workspace apply, verify, and archive as first-class lifecycle commands -- branch/worktree orchestration -- strong cross-repo validation -- dependency graph enforcement - -Supersede: - -- workspace as the durable shared planning home -- workspace-level planning artifacts as the canonical cross-repo plan -- workspace change planning as the long-term source of truth - -## Files To Review Now - -- `openspec/initiatives/context-store-and-initiatives/*.md` -- `openspec/initiatives/context-store-and-initiatives/work-items/**/*.md` -- `openspec/changes/workspace-reimplementation-roadmap/START_HERE.md` -- `openspec/changes/workspace-reimplementation-roadmap/HISTORICAL_DIRECTION.md` -- `openspec/changes/workspace-reimplementation-roadmap/*` -- active `openspec/changes/workspace-*` proposals -- `docs/cli.md` - -## Files To Leave Alone For Now - -- `openspec/specs/**/*.md` -- `schemas/workspace-planning/**` - -Those files should change only when we intentionally change behavior or create a -repo-owned implementation change that updates the relevant behavioral contract. - -## Non-Goals - -- Do not remove current workspace-planning runtime behavior. -- Do not delete the `workspace-planning` schema. -- Do not add CLI deprecation warnings until the initiative replacement exists. -- Do not implement context stores in this work item. -- Do not edit OpenSpec specs as part of the initial direction lock. - -## Done When - -- Initiative artifacts clearly carry the product intent and roadmap decisions. -- Historical workspace roadmap artifacts no longer read as the active shipping - queue. -- User-facing docs describe current workspaces as local views where that does - not contradict current behavior. -- Existing workspace-planning behavior is clearly treated as current behavior, - not the future product model, in initiative and roadmap artifacts. -- Workspace apply, verify, and archive are clearly deferred. -- Fresh agents can identify the initiative direction as the source of truth. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/01-lock-the-direction/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/01-lock-the-direction/tasks.md deleted file mode 100644 index d04b60a620..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/01-lock-the-direction/tasks.md +++ /dev/null @@ -1,44 +0,0 @@ -# Work Item 01 Tasks - -## Tracking Setup - -- [x] Create initiative-level `tasks.md`, `decisions.md`, and `questions.md`. -- [x] Create `work-items/01-lock-the-direction/`. -- [x] Record why roadmap implementation is tracked inside the initiative instead - of creating a new OpenSpec change. - -## Direction Lock Already Captured - -- [x] Add locked disposition to `roadmap.md`. -- [x] Add locked product boundary to `direction.md`. -- [x] Mark `openspec/changes/workspace-reimplementation-roadmap/START_HERE.md` as historical reference. -- [x] Mark `openspec/changes/workspace-reimplementation-roadmap/HISTORICAL_DIRECTION.md` as historical reference. -- [x] Mark `workspace-reimplementation-roadmap` as historical reference. -- [x] Mark `workspace-apply-repo-slice` as deferred. -- [x] Mark `workspace-verify-and-archive` as deferred. - -## Non-Spec Direction Pass - -- [x] Keep OpenSpec specs unchanged until behavior changes. -- [x] Review initiative artifacts for a clear source-of-intent story. -- [x] Review historical workspace roadmap artifacts for any remaining language - that tells agents to continue the old shipping queue. -- [x] Review active workspace proposal artifacts for any remaining language that - presents workspace apply, verify, or archive as next. -- [x] Decide whether user-facing docs need changes now; default to no unless - they misrepresent current behavior. -- [x] Record a decision that specs remain current behavioral contracts, while - initiative docs carry future product intent. - -## Active Change Disposition - -- [x] Decide whether `workspace-agent-guidance` should be reframed, closed, or - kept as a local-view guidance item. -- [x] Decide whether no-task deferred workspace changes should stay active, - move to archive, or be represented only by initiative work items. - -## Verification - -- [x] Run `git diff --check`. -- [x] Confirm no OpenSpec specs were modified in this pass. -- [x] Record evidence in `evidence.md`. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/evidence.md b/openspec/explorations/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/evidence.md deleted file mode 100644 index 85d0b0a03b..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/evidence.md +++ /dev/null @@ -1,68 +0,0 @@ -# Stabilize Workspace As Local View Evidence - -## Direction Evidence - -`direction.md` says the durable shared object is a synced context store, with -initiatives as the first major collection. It defines workspaces as local -working views over context stores and repos, and repo changes as repo/team-owned -implementation plans. - -The locked product boundary supersedes the older model where a workspace-level -`changes/` tree owned the canonical shared cross-repo plan. Existing -workspace-planning behavior can remain as beta or legacy infrastructure, but it -should not steer new lifecycle design. - -## Subagent Research - -Implementation research found that workspace setup, link, relink, list, open, -update, and doctor already mostly behave like local-view infrastructure: - -- shared link names live in workspace state -- machine-local paths and opener/skill state live in local state -- `workspace open` launches linked folders as a local working set -- linked repos are treated as context for workspace-planning commands -- `workspace update` refreshes workspace-local skills and leaves linked repos - untouched - -Guidance research found that the generated `AGENTS.md` block is the most -important mismatch because it still frames the workspace as planning across -linked repos and says to use `changes/` for workspace-level planning. - -Test research found strong current coverage for setup/list/doctor, link/relink, -open, update, artifact placement, and workspace-planning guards. The targeted -workspace/artifact test slice passed, as did the skill-template parity test. - -## Main Risk - -If generated workspace guidance continues to recommend workspace-level -`changes/`, agents may treat the workspace as the durable shared planning -object even though the initiative direction assigns durable coordination to -initiatives and implementation planning to repo-local changes. - -## Implementation Evidence - -The first implementation slice updates the generated workspace `AGENTS.md` -guidance and makes `workspace update` refresh the workspace-local open surface. -It also updates workspace-planning action context so beta workspace artifacts are -reported as `workspace-local` compatibility context instead of the source of -truth. - -Doctor/status review found that local path mappings, unresolved links, repair -steps, malformed local state, missing local state, repo specs paths, and skill -drift warnings are already covered. Normal installed-skill summaries are -deferred for now; the current slice only updates stale `workspace update` -wording so it matches the guidance refresh behavior. - -Verification: - -- `pnpm run build` -- `pnpm exec vitest run test/commands/workspace.test.ts test/commands/artifact-workflow.test.ts test/core/workspace/foundation.test.ts` -- `pnpm run lint` -- `git diff --check` - -## Closeout Evidence - -Live docs no longer describe workspaces as durable planning homes or as the -canonical place for cross-repo planning. Historical and deferred workspace -artifacts remain as reference material, with active deferred proposals labeled -so they do not steer the next implementation slice. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/plan.md deleted file mode 100644 index 147b94556a..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/plan.md +++ /dev/null @@ -1,80 +0,0 @@ -# Stabilize Workspace As Local View - -## Status - -Complete for the current local-view stabilization slice. Remaining workspace -planning/apply/verify/archive behavior stays deferred until initiative-linked -repo-local changes exist. - -## Source Of Truth - -Start from `../direction.md`. - -The relevant model is: - -```text -Context stores sync truth. -Collections shape truth. -Initiatives coordinate work. -Workspaces open local views. -Changes implement repo-owned slices. -``` - -## Goal - -Keep workspace setup, link, relink, list, open, update, and doctor useful while -making it clear that a workspace is a regenerable machine-local view, not the -durable coordination object. - -## Agreed Guidance Direction - -Generated workspace guidance should route agents by ownership: - -- Use the workspace to open the local view of coordinated work. -- Use initiatives for durable cross-team or cross-repo intent, decisions, - requirements, and coordination context. -- Use repo-local OpenSpec changes for implementation plans owned by a repo or - team. -- Use linked repos and folders to inspect context, understand ownership, and - make edits in the place that owns the work. -- Keep workspace-local files focused on local paths, opener state, agent setup, - and other machine-specific view state. -- Use OpenSpec workspace commands instead of hand-editing - `.openspec-workspace/*.yaml`. -- If a workspace contains legacy or beta workspace-level planning files, treat - them as compatibility context unless the user explicitly asks to use that beta - flow. - -## Guidance To Stop Reinforcing - -Do not tell agents to use workspace-level `changes/` as the planning home for -coordinated work. That reinforces the superseded model where a workspace-level -`changes/` tree owned the canonical shared cross-repo plan. - -Existing workspace-planning behavior may remain as beta or legacy -infrastructure, but it should not steer new lifecycle design. - -## Likely Repo Slice - -- Reword generated workspace guidance in - `src/core/workspace/open-surface.ts`. -- Update focused guidance tests. -- Make `workspace update` refresh the guidance block for existing workspaces. -- Keep specs untouched until a behavior change intentionally updates them. - -## Closeout - -Implemented: - -- generated workspace guidance now routes work by ownership -- `workspace update` refreshes workspace-local guidance/open-surface files and - managed agent skills -- workspace-planning action context treats beta workspace artifacts as - `workspace-local` compatibility context -- live docs describe workspaces as local views instead of durable planning homes - -Deferred: - -- normal doctor installed-skill inventory -- workspace apply, verify, and archive -- initiative-linked repo-local change orchestration diff --git a/openspec/explorations/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/tasks.md deleted file mode 100644 index 5a07a1f579..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/02-stabilize-workspace-as-local-view/tasks.md +++ /dev/null @@ -1,23 +0,0 @@ -# Stabilize Workspace As Local View Tasks - -- [x] Research current workspace runtime, guidance, and test coverage. -- [x] Re-anchor guidance direction in `direction.md`. -- [x] Decide that generated guidance should route durable coordination to - initiatives and implementation planning to repo-local changes. -- [x] Decide that generated guidance should stop recommending workspace-level - `changes/` as the planning home. -- [x] Decide that `workspace update` refreshes the generated guidance block - for existing workspaces. -- [x] Update workspace-planning action context so beta workspace artifacts are - compatibility context, not the source of truth. -- [x] Decide to defer normal doctor skill summaries until users need an - installed-skill inventory. -- [x] Update `workspace update` wording to include workspace-local guidance and - agent skills. -- [x] Define the minimal doctor/status improvement for local paths, unresolved - links, and installed agent skills. -- [x] Identify the focused code/test files for the implementation slice. -- [x] Run the targeted workspace and artifact workflow test slice before - landing implementation. -- [x] Close out live docs wording that still framed workspaces as durable - planning homes. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/03-add-context-store-foundation/evidence.md b/openspec/explorations/context-store-and-initiatives/work-items/03-add-context-store-foundation/evidence.md deleted file mode 100644 index 402c2f8fbe..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/03-add-context-store-foundation/evidence.md +++ /dev/null @@ -1,43 +0,0 @@ -# Add Context Store Foundation Evidence - -## Research Summary - -Existing OpenSpec patterns point toward a small explicit foundation: - -- Global data uses XDG/platform locations from `getGlobalDataDir()`. -- Workspace registries are machine-local convenience indexes under global data. -- Workspace portable state uses versioned YAML and strict Zod validation. -- Existing read/write helpers validate state before writing and use - `FileSystemUtils.writeFile()` to create parent directories. -- Schema/backend-style code favors small explicit adapters and registries over - heavy framework abstractions. - -## Decisions - -- The first context-store backend is Git/local checkout config only. -- OpenSpec records where the local checkout lives; it does not decide where real - team stores are cloned by default. -- The local registry is not source of truth. It is a machine-local index. -- Store-root metadata is portable source-of-identity for the synced store. -- Initiatives and collections are later consumers, not part of the store - foundation. -- A thin facade should hide raw registry/metadata writes before initiative CLI - wiring. - -## Implementation Evidence - -- `src/core/context-store/registry.ts` registers Git/local context stores, - lists local registry entries, and resolves registered stores with metadata id - validation. -- `src/core/context-store/index.ts` exports the facade. -- `test/core/context-store/registry.test.ts` covers registration, registry - merge/update, metadata mismatch rejection, listing, resolution, missing or - mismatched metadata, and initiative collection mounting from a resolved root. - -## Verification - -- `pnpm exec vitest run test/core/context-store/foundation.test.ts` -- `pnpm exec vitest run test/core/context-store/registry.test.ts` -- `pnpm run build` -- `pnpm run lint` -- `git diff --check` diff --git a/openspec/explorations/context-store-and-initiatives/work-items/03-add-context-store-foundation/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/03-add-context-store-foundation/plan.md deleted file mode 100644 index 2ea0c27afd..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/03-add-context-store-foundation/plan.md +++ /dev/null @@ -1,85 +0,0 @@ -# Add Context Store Foundation - -## Status - -Registration/resolution facade implemented. - -## Source Of Truth - -Start from `../direction.md`. - -The relevant model is: - -```text -Context stores sync truth. -Collections shape truth. -Initiatives coordinate work. -Workspaces open local views. -Changes implement repo-owned slices. -``` - -## Goal - -Add the smallest core foundation for context stores without making the store -layer know about initiatives, collections, workspaces, or repo-local changes. - -## Locked Direction - -- Support one backend for the first slice: a Git/local checkout backend. -- Treat the actual context store root as a user-chosen local Git checkout or - synced folder. -- Do not hide real team context stores under XDG data by default. -- Store the machine-local registry under global data: - `$XDG_DATA_HOME/openspec/context-stores/registry.yaml`. -- Store portable context-store identity inside the store root: - `/.openspec-store/store.yaml`. -- Start with backend identity/config, strict validation, path helpers, and - registry/metadata read-write helpers. -- Add a thin registration/resolution facade before initiative CLI wiring so - callers do not manipulate raw registry and metadata YAML directly. -- Do not reimplement the TypeScript or Node filesystem APIs as the public store - interface. -- Do not add initiative, collection, workspace-open, sync, pull, push, or CLI - behavior in this slice. - -## Initial Shape - -Machine-local registry: - -```yaml -version: 1 -stores: - acme-context: - backend: - type: git - local_path: /Users/me/repos/acme-context - remote: git@github.com:acme/context.git - branch: main -``` - -Portable metadata in the store root: - -```yaml -version: 1 -id: acme-context -``` - -## Likely Repo Slice - -- Add `src/core/context-store/foundation.ts`. -- Add `src/core/context-store/registry.ts`. -- Add `src/core/context-store/index.ts`. -- Export the core context-store foundation from `src/core/index.ts`. -- Add focused tests under `test/core/context-store/`. -- Keep specs untouched until a behavior/API contract is deliberately surfaced. - -## Implemented Facade Slice - -- Added `registerContextStore(...)`. -- Added `listRegisteredContextStores(...)`. -- Added `resolveRegisteredContextStore(...)`. -- Registration writes portable store metadata when missing, validates existing - metadata when present, and merges/updates the machine-local registry. -- Resolution validates that the registry id matches the store-root metadata id. -- No Git clone, pull, push, sync, workspace state, collection manifest, or CLI - behavior was added. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/03-add-context-store-foundation/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/03-add-context-store-foundation/tasks.md deleted file mode 100644 index 4aeddc285d..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/03-add-context-store-foundation/tasks.md +++ /dev/null @@ -1,17 +0,0 @@ -# Add Context Store Foundation Tasks - -- [x] Research existing config, registry, file-system, and schema/backend - patterns. -- [x] Decide to start with Git/local backend identity only, not a generic file - API. -- [x] Decide that real context store roots are user-chosen Git checkouts or - synced folders. -- [x] Decide that the local registry lives under global data and portable store - metadata lives inside the store root. -- [x] Add context-store foundation types, path helpers, parse/serialize, and - read/write helpers. -- [x] Add focused tests for validation, paths, registry roundtrip, metadata - roundtrip, and Git/local backend path resolution. -- [x] Run targeted verification. -- [x] Decide registration/resolution facade should precede initiative CLI. -- [x] Add context-store registration/list/resolve facade and tests. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/04-add-collection-foundation/evidence.md b/openspec/explorations/context-store-and-initiatives/work-items/04-add-collection-foundation/evidence.md deleted file mode 100644 index fe751b6b91..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/04-add-collection-foundation/evidence.md +++ /dev/null @@ -1,77 +0,0 @@ -# Add Collection Foundation Evidence - -## Research Summary - -Subagent and local review converged on the same direction: - -- Item 4 should define the boundary between store identity and product-specific - content meaning. -- The collection layer should own mounted namespaces and logical path fences. -- The context-store layer should stay content-agnostic. -- Initiative CRUD and initiative file shape belong to Item 5. -- A runtime injected registry is enough for now; persisted manifests and dynamic - plugins are premature. -- A thin registration facade should hide metadata and local registry writes, but - Item 4 should not depend on that facade. - -## Clean-Code Notes - -- Use module boundaries and mounted objects to carry context. -- Prefer `validateMount`, `parseCollectionPath`, `createCollectionRegistry`, - and `mountCollections` inside the collection module. -- Avoid public helper names that stack every concept together, such as - `validateContextStoreCollectionRelativePath`. -- Keep path resolution pure and lexical until a future write-capable layer - deliberately handles symlinks, canonical parent paths, and backend behavior. -- Keep persisted YAML shape below the public setup surface. Runtime/public - handles should use camelCase fields such as `storeRoot`; persisted backend - state can continue to use `local_path`. - -## Chosen Pattern - -Use a two-step pattern: - -```ts -const store = await registerContextStore({ - id: "acme-context", - backend: gitLocalBackend({ - localPath: "/Users/me/repos/acme-context", - remote: "git@github.com:acme/context.git", - branch: "main", - }), -}); - -const collections = createCollectionRegistry([ - { id: "initiatives", mount: "initiatives" }, -]); - -const mounted = mountCollections({ - storeRoot: store.storeRoot, - collections, -}); -``` - -For Item 4 itself, `mountCollections({ storeRoot, collections })` is the -canonical API. One-call setup facades, store lifecycle objects, builder DSLs, -and initiative-specific setup presets are deferred. - -## Implementation Evidence - -- `src/core/collections/runtime.ts` defines runtime collection - definitions, registries, mounted collection contexts, logical path parsing, - and mount/path resolution. -- `src/core/collections/index.ts` exports the collection module, and - `src/core/index.ts` re-exports it for core consumers. -- `test/core/collections/runtime.test.ts` covers mount and id validation, - logical path parsing, duplicate id/mount rejection, Windows-style roots, - `createHandle(context)`, no filesystem creation, and generic `initiatives/` - mounting. - -## Verification - -- `pnpm exec vitest run test/core/collections/runtime.test.ts` -- `pnpm run build` -- `pnpm exec vitest run test/core/collections/runtime.test.ts test/core/context-store/foundation.test.ts test/core/planning-home.test.ts` -- `pnpm exec vitest run test/utils/file-system.test.ts` -- `pnpm run lint` -- `git diff --check` diff --git a/openspec/explorations/context-store-and-initiatives/work-items/04-add-collection-foundation/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/04-add-collection-foundation/plan.md deleted file mode 100644 index 6475df3a88..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/04-add-collection-foundation/plan.md +++ /dev/null @@ -1,198 +0,0 @@ -# Add Collection Foundation - -## Status - -First implementation slice implemented. - -## Source Of Truth - -Start from `../../direction.md`. - -The relevant model is: - -```text -Context stores sync truth. -Collections shape truth. -Initiatives coordinate work. -Workspaces open local views. -Changes implement repo-owned slices. -``` - -## Goal - -Add the smallest collection foundation that lets product-specific content -systems mount inside a context store without making the context-store layer know -what those systems mean. - -## Locked Direction So Far - -- Treat Item 4 as a mount/path foundation, not a collection runtime. -- Keep collection composition runtime-only and dependency-injected. -- Keep context-store registration separate from runtime collection mounting. -- Use a future thin registration facade for metadata/registry setup instead of - showing raw registry or metadata state writes in public examples. -- Do not add a persisted collection manifest yet. -- Do not add CLI behavior yet. -- Do not add generic `read`, `write`, `list`, or `delete` helpers. -- Do not add initiative file shape, initiative CRUD, or initiative validation - yet. -- Prove `initiatives/` can mount through generic collection definitions, not - through initiative-specific context-store logic. - -## Naming Direction - -Use the module/object boundary to carry context instead of growing helper names. - -Use a focused generic module such as `src/core/collections/runtime.ts` with -short names: - -```ts -validateCollectionId(id); -validateMount(mount); -parseCollectionPath(input); - -createCollectionRegistry(...); -mountCollections(...); -``` - -Prefer mounted objects for context-aware operations: - -```ts -const mounted = collections.require("initiatives"); - -mounted.resolvePath("launch-billing-flow/initiative.yaml"); -mounted.toStorePath("launch-billing-flow/initiative.yaml"); -``` - -Avoid names like `validateContextStoreCollectionRelativePath`. They indicate -that too much context has leaked into a standalone helper name. - -## Minimal API Shape - -The first slice should stay close to this: - -```ts -interface CollectionDefinition { - id: string; - mount: string; - metadata?: CollectionMetadata; - hooks?: CollectionHooks; - createHandle?: (context: MountedCollectionContext) => THandle; -} - -interface MountedCollectionContext { - storeRoot: string; - collectionId: string; - mount: string; - mountRoot: string; - resolvePath(relativePath?: string): string; - toStorePath(relativePath?: string): string; -} - -interface MountedCollection { - collectionId: string; - mount: string; - mountRoot: string; - context: MountedCollectionContext; - handle: THandle | undefined; -} -``` - -Use `id` on definitions, but `collectionId` on mounted handles and contexts so -domain object IDs such as initiative IDs do not collide with collection type IDs. - -## Setup And Mounting Pattern - -Use two separate layers: - -1. A context-store registration facade for setup. -2. A pure runtime collection mounting API for Item 4. - -Registration should hide persisted YAML details: - -```ts -const store = await registerContextStore({ - id: "acme-context", - backend: gitLocalBackend({ - localPath: "/Users/me/repos/acme-context", - remote: "git@github.com:acme/context.git", - branch: "main", - }), -}); -``` - -The registration facade can call lower-level helpers such as backend config -normalization, metadata writes, and local registry writes internally. Public -examples should not call raw `writeContextStoreMetadataState(...)`, -`writeContextStoreRegistryState(...)`, or expose persisted snake_case backend -state such as `local_path`. - -Item 4 mounting should stay independent of registration and accept only the -authority it needs: - -```ts -const collections = createCollectionRegistry([ - { id: "initiatives", mount: "initiatives" }, -]); - -const mounted = mountCollections({ - storeRoot: store.storeRoot, - collections, -}); - -mounted.require("initiatives").resolvePath( - "launch-billing-flow/initiative.yaml" -); -``` - -Prefer `mountCollections({ storeRoot, collections })` as the canonical first -API. Passing a whole store handle can wait until there is a real need. - -## Path Direction - -- Mount names are single-segment kebab-case folder names such as `initiatives`, - `decisions`, or `api-catalog`. -- Collection-relative paths are logical portable paths inside a mount. -- The path resolver is lexical only. It proves that a logical path belongs under - a collection mount; it does not claim to be a filesystem security sandbox. -- Future write-capable helpers must revisit symlink and canonical parent-path - handling before touching disk. - -Reject: - -- empty mounts -- `.` -- `..` -- hidden/reserved mounts such as `.openspec-store` -- absolute paths -- Windows drive paths -- UNC paths -- NUL bytes -- traversal segments -- sibling-prefix escapes - -## Deferred - -- Store-level collection config files. -- Dynamic plugin loading. -- One-call `setupContextStore({ id, backend, collections })` APIs. -- `createStore(...).setup()` lifecycle APIs. -- Builder-style setup DSLs. -- Initiative-specific setup presets in the generic context-store layer. -- Template override search paths. -- Rich validation execution. -- Agent guidance generation. -- Workspace integration. -- Git sync, commits, pull, push, watch, or conflict behavior. - -## Implemented Slice - -- Added a pure runtime collection module at - `src/core/collections/runtime.ts`. -- Exported the module through `src/core/collections/index.ts` and - `src/core/index.ts`. -- Added focused tests under `test/core/collections/runtime.test.ts`. -- Proved a generic `{ id: "initiatives", mount: "initiatives" }` definition can - mount and resolve paths without initiative-specific store logic. -- Kept validation/template hooks as inert extension fields for now; rich hook - execution remains deferred. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/04-add-collection-foundation/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/04-add-collection-foundation/tasks.md deleted file mode 100644 index 215b8092da..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/04-add-collection-foundation/tasks.md +++ /dev/null @@ -1,14 +0,0 @@ -# Add Collection Foundation Tasks - -- [x] Research what Item 4 needs to decide. -- [x] Compare collection model options. -- [x] Run clean-code and design-pattern review. -- [x] Decide to keep Item 4 as a runtime mount/path foundation. -- [x] Decide to avoid long context-stacked helper names. -- [x] Decide to separate context-store registration from runtime collection - mounting. -- [x] Define exact collection mount and path rules. -- [x] Define the minimal runtime registry and mounted collection API. -- [x] Implement collection foundation helpers and tests. -- [x] Prove `initiatives/` can mount without store-specific initiative logic. -- [x] Run targeted verification. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/05-ship-initiative-mvp/evidence.md b/openspec/explorations/context-store-and-initiatives/work-items/05-ship-initiative-mvp/evidence.md deleted file mode 100644 index 7b9e7ab036..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/05-ship-initiative-mvp/evidence.md +++ /dev/null @@ -1,99 +0,0 @@ -# Ship Initiative MVP Evidence - -## Research Summary - -- Initiative code should live in `src/core/collections/initiatives/`, outside - `src/core/context-store/`. -- Initiative APIs should consume a mounted `initiatives` collection from Item 4 - rather than raw context-store roots. -- The first coding slice should lock metadata and templates before mounted - create/list operations. -- Visible `initiative.yaml` is preferred for the new shared initiative model. -- `links.yaml` should not exist in the initiative MVP. Repo-change wiring is a - workspace/local coordination concern to revisit later. -- Read/show, update, and delete have extra policy risk, so create/list should - come before broader lifecycle behavior. -- The first mounted operation slice should do create/list only. A full - `readInitiative` API is deferred until the return shape is clearer. - -## Decisions - -- Use `src/core/collections/initiatives/` for initiative-domain code. -- Do not put initiative semantics into `src/core/context-store/`. -- Add `initiative.yaml` strict parse/serialize helpers. -- Generate Markdown files up front, but do not validate Markdown content beyond - existence/templates in the first pass. -- Defer workspace opening, repo resolution, status dashboards, sync, linked - change lifecycle, `links.yaml`, `contracts/`, and CLI behavior. -- Detect initiatives by valid `initiative.yaml`: missing means ignore, invalid - means fail loudly, and the YAML `id` must match the folder name. - -## Suggested First Coding Slice - -Add: - -- `src/core/collections/initiatives/schema.ts` -- `src/core/collections/initiatives/templates.ts` -- `src/core/collections/initiatives/operations.ts` -- `src/core/collections/initiatives/index.ts` -- focused tests under `test/core/collections/initiatives/` - -Cover: - -- constants for initiative file names -- `validateInitiativeId` -- strict `initiative.yaml` parse/serialize -- create/list operations through a mounted `initiatives` collection -- template builders for `requirements.md`, `design.md`, `decisions.md`, - `questions.md`, and `tasks.md` -- tests for valid and invalid metadata, invalid IDs, unknown YAML fields, - required `created`, and generated template names/content shape - -## Implementation Evidence - -- `src/core/collections/initiatives/schema.ts` defines initiative constants, - strict persisted `initiative.yaml` parsing/serialization, required - `created`, bounded JSON-like metadata, statuses, and portable kebab-case - initiative IDs. -- `src/core/collections/initiatives/templates.ts` defines deterministic default - Markdown file builders for requirements, design, decisions, questions, and - tasks. -- `src/core/collections/initiatives/index.ts` exports the initiative - schema/template surface inside the initiative module only. -- `src/core/collections/initiatives/operations.ts` creates MVP initiative - folders and lists initiative states using the valid-`initiative.yaml` - detection rule. -- `src/core/collections/index.ts` exports the initiative module now that it has - a mounted operation API. -- `test/core/collections/initiatives/schema.test.ts` covers file constants, - no `links.yaml`, ID validation, strict YAML behavior, required `created`, - default owners/metadata, metadata validation, and serialization round trips. -- `test/core/collections/initiatives/templates.test.ts` covers generated - Markdown file names, deterministic ordering, trailing newlines, and expected - section headings. -- `test/core/collections/initiatives/operations.test.ts` covers create, list, - duplicate protection, cleanup on partial write failure, missing - `initiative.yaml` ignored, invalid `initiative.yaml` failure, and folder/id - mismatch failure. -- `src/core/context-store/registry.ts` was added as the next integration - enabler before CLI wiring. -- `src/commands/initiative.ts` adds `openspec initiative create/list` as a thin - CLI adapter over the context-store facade and mounted initiatives collection. -- `src/cli/index.ts` registers the initiative command. -- `src/core/completions/command-registry.ts` registers static completion - metadata for `initiative create/list/ls`. -- `test/commands/initiative.test.ts` covers JSON create, `--store-path` list, - human output, selector errors, duplicate create errors, and completion - registry entries. - -## Verification - -- `pnpm exec vitest run test/core/collections/initiatives/schema.test.ts test/core/collections/initiatives/templates.test.ts` -- `pnpm exec vitest run test/core/collections/initiatives/operations.test.ts` -- `pnpm exec vitest run test/core/collections/initiatives/schema.test.ts test/core/collections/initiatives/templates.test.ts test/core/collections/initiatives/operations.test.ts test/core/collections/runtime.test.ts test/core/context-store/foundation.test.ts test/core/planning-home.test.ts` -- `pnpm exec vitest run test/commands/initiative.test.ts` -- `pnpm exec vitest run test/core/context-store/registry.test.ts test/core/collections/initiatives/operations.test.ts test/core/collections/initiatives/schema.test.ts test/core/collections/initiatives/templates.test.ts test/core/collections/runtime.test.ts` -- `pnpm exec vitest run test/commands/workspace.test.ts` -- `pnpm run build` -- `pnpm run lint` -- `git diff --check` diff --git a/openspec/explorations/context-store-and-initiatives/work-items/05-ship-initiative-mvp/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/05-ship-initiative-mvp/plan.md deleted file mode 100644 index d6463765cb..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/05-ship-initiative-mvp/plan.md +++ /dev/null @@ -1,236 +0,0 @@ -# Ship Initiative MVP - -## Status - -Create/list operation and CLI adapter slices complete. Full read/show, update, -and delete policy is deferred to later agent-first discovery and lifecycle -work. - -## Source Of Truth - -Start from `../../direction.md`. - -The relevant model is: - -```text -Context stores sync truth. -Collections shape truth. -Initiatives coordinate work. -Workspaces open local views. -Changes implement repo-owned slices. -``` - -## Goal - -Give coordinated work a durable, shared, agent-consumable home inside an -`initiatives/` collection. - -## Roadmap Shape - -Default initiative shape: - -```text -initiatives// - initiative.yaml - requirements.md - design.md - decisions.md - questions.md - tasks.md -``` - -Direction also leaves room for later `contracts/` content: - -```text -initiatives// - contracts/ -``` - -## Initial Boundaries - -- Initiative code should live outside `src/core/context-store/`. -- Context-store core should not know initiative semantics. -- Initiative APIs should consume a mounted `initiatives` collection from Item 4. -- Repo-local OpenSpec changes remain the implementation artifacts; initiatives - coordinate intent, decisions, questions, and tasks. -- Do not implement workspace opening, repo resolution, status dashboards, sync, - or linked change lifecycle in this item. - -## Locked Direction So Far - -- Put initiative code under `src/core/collections/initiatives/`. -- Export initiatives from `src/core/index.ts` only after a real API exists. -- Use visible `initiative.yaml`, not hidden `.initiative.yaml`, for the runtime - context-store initiative model. Existing roadmap folders may still carry - legacy `.initiative.yaml` progress metadata until that tracker is migrated or - retired. -- Use strict YAML parsing and validation, following the existing foundation - patterns. -- Do not create `links.yaml` in the initiative MVP. Repo-change wiring belongs - to workspace/local coordination work later. -- Keep Markdown validation light; generate useful structure but do not validate - prose content yet. -- Start implementation with initiative schema and template helpers before - mounted collection operations. -- For the first mounted operation slice, add create and list only. Avoid a - broad `readInitiative` API until the shape of "full initiative" is clearer. -- Treat a child folder as an initiative only when it contains a valid - `initiative.yaml`. Missing `initiative.yaml` means "not an initiative"; - invalid `initiative.yaml` means broken shared state and should fail loudly. - -## Deferred From Item 5 - -- Full initiative show/read behavior belongs in agent-first initiative discovery - once the return shape is clearer. -- Metadata update and guarded delete belong in later lifecycle work after - create/list usage has shaped the policy. - -## Initial `initiative.yaml` - -Recommended shape: - -```yaml -version: 1 -id: launch-billing-flow -title: Launch Billing Flow -summary: > - Coordinate the billing launch across product, API, and client surfaces. -status: exploring -created: "2026-05-21" -owners: [] -metadata: {} -``` - -Required: - -- `version` -- `id` -- `title` -- `summary` -- `status` -- `created` - -Defaulted or optional: - -- `owners` -- `metadata` - -Initial statuses: - -- `exploring` -- `active` -- `complete` -- `archived` - -## Initial Markdown Templates - -Create these files up front: - -- `requirements.md`: product intent, accepted requirements, out of scope. -- `design.md`: context, approach, affected areas, dependencies, risks. -- `decisions.md`: accepted decisions with date/title/decision/why/implications. -- `questions.md`: open and resolved questions. -- `tasks.md`: coordination tasks only, not repo implementation tasks. - -Defer `contracts/`, `README.md`, milestones, dependency graphs, external issue -links, workspace path mappings, status dashboards, `links.yaml`, and Markdown -content validation. - -## Likely Repo Slice - -- Add `src/core/collections/initiatives/schema.ts`. -- Add `src/core/collections/initiatives/templates.ts`. -- Add `src/core/collections/initiatives/index.ts`. -- Add focused tests under `test/core/collections/initiatives/`. -- Add types, constants, ID validation, strict `initiative.yaml` - parse/serialize helpers, and default template builders. -- Add create/list mounted collection operations after schema and templates are - locked. -- Keep context-store collection APIs unchanged unless a real integration gap is - found. - -## Implemented Slice - -- Added `src/core/collections/initiatives/schema.ts`. -- Added `src/core/collections/initiatives/templates.ts`. -- Added `src/core/collections/initiatives/index.ts`. -- Added focused tests under `test/core/collections/initiatives/`. -- Exported initiatives through `src/core/collections/index.ts` now that a - mounted operation API exists. -- Kept `links.yaml` out of the initiative MVP file contract. - -## Operation Slice Direction - -- Add `src/core/collections/initiatives/operations.ts`. -- Export initiatives through `src/core/collections/index.ts` now that a mounted - operation API exists. -- `createInitiative` should create exactly the MVP file shape: - `initiative.yaml`, `requirements.md`, `design.md`, `decisions.md`, - `questions.md`, and `tasks.md`. -- `createInitiative` should generate `created` through an injectable date - provider, fail if the initiative folder already exists, and clean up a - partially created folder on write failure. -- `listInitiatives` should inspect immediate child directories under the - mounted `initiatives` collection, ignore folders without `initiative.yaml`, - parse and validate folders with `initiative.yaml`, require - `initiative.yaml.id` to match the folder name, and return initiative states - sorted by id. - -## Implemented Operation Slice - -- Added `src/core/collections/initiatives/operations.ts`. -- Added `createInitiative` for creating the MVP folder shape through a mounted - `initiatives` collection. -- Added `listInitiatives` using the valid-`initiative.yaml` detection rule. -- Exported initiatives through `src/core/collections/index.ts`. -- Added focused operation tests under - `test/core/collections/initiatives/operations.test.ts`. - -## Next Integration Enabler - -Before adding `openspec initiative create/list`, add a context-store -registration/resolution facade so CLI code can resolve a named store and mount -the initiatives collection without exposing raw registry or metadata YAML. - -## CLI Adapter Direction - -Add the first initiative CLI surface as a thin adapter over the mounted -collection operations: - -```bash -openspec initiative create --store --title --summary <summary> -openspec initiative create <id> --store-path <path> --title <title> --summary <summary> -openspec initiative list --store <store-id> -openspec initiative list --store-path <path> -``` - -Use `initiative create/list` as a deliberate noun namespace, similar to -`workspace` and `schema`, even though newer OpenSpec conventions generally -prefer verb-first top-level commands. The stricter alternative would spread -initiative behavior across `new initiative` and global `list` flags, which is a -larger surface for this slice because initiative commands must resolve a -context store. - -Keep store selection explicit in the first CLI slice. Require either -`--store <id>` or `--store-path <path>`, reject both together, and do not add -current-directory discovery, single-store auto-selection, an interactive picker, -a global default store, or workspace selected-store state yet. - -Because shell completions are manually registered, adding the runtime command -also requires adding `initiative create/list/ls` to `COMMAND_REGISTRY`. Keep -completion support static for now: command names and flags only, with no dynamic -store-id or initiative-id completion. - -## Implemented CLI Adapter Slice - -- Added `src/commands/initiative.ts`. -- Registered `openspec initiative create` and `openspec initiative list` from - the top-level CLI. -- Added `openspec initiative ls` as an alias for list. -- Required explicit context-store selection through `--store <id>` or - `--store-path <path>`. -- Rejected conflicting `--store` and `--store-path` selectors. -- Returned workspace-style JSON payloads with a top-level `status` diagnostics - array. -- Added static shell completion metadata for `initiative create/list/ls`. -- Added focused command tests under `test/commands/initiative.test.ts`. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/05-ship-initiative-mvp/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/05-ship-initiative-mvp/tasks.md deleted file mode 100644 index 197a333bf1..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/05-ship-initiative-mvp/tasks.md +++ /dev/null @@ -1,21 +0,0 @@ -# Ship Initiative MVP Tasks - -- [x] Create Item 5 work-item tracking notes. -- [x] Research initiative shape, API, module placement, and first slice. -- [x] Decide where initiative code lives. -- [x] Decide required `initiative.yaml` metadata. -- [x] Decide no initiative `links.yaml` in the MVP. -- [x] Decide first coding slice starts with initiative schema/templates before operations. -- [x] Add initiative schema helpers and tests. -- [x] Add default initiative templates. -- [x] Run targeted verification for schema/templates. -- [x] Decide create/list-only operation slice. -- [x] Add create/list mounted initiative operations and tests. -- [x] Run targeted verification for operations. -- [x] Research initiative CLI adapter gaps. -- [x] Decide explicit context-store selection for first CLI slice. -- [x] Document noun-command and manual-completion tradeoffs. -- [x] Add `openspec initiative create/list` CLI adapter. -- [x] Register static shell completions for initiative commands. -- [x] Add focused CLI tests for create/list, selection errors, and completions. -- [x] Run targeted verification for the initiative CLI adapter. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/evidence.md b/openspec/explorations/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/evidence.md deleted file mode 100644 index 6b0bc32b2e..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/evidence.md +++ /dev/null @@ -1,97 +0,0 @@ -# Add Minimal Context Store UX Evidence - -## Conversation Decisions - -- The next roadmap step should not jump straight to repo-local change linking - or workspace initiative opening. -- Teams first need a simple way to create or register the shared context store - that holds initiatives. -- The workflow is agent-first: the user prompts an agent, and the agent uses CLI - primitives to discover stores and initiatives. -- `context-store` should be the top-level command namespace for now. It is more - explicit for agents than `store`, and `store` can remain shorthand in scoped - flags such as `initiative list --store <id>`. -- A store can start as a local Git-backed folder. OpenSpec can help create the - folder, write metadata, register it locally, and optionally initialize Git. -- When setup does not receive `--path`, it should create or use `./<id>`. This - keeps the real shared store visible and avoids hiding it under global data. -- Using the current directory should require explicit `--path .`. -- If a user registers an existing folder or clone, the default store id can be - the repo or folder name. -- Portable `.openspec-store/store.yaml` metadata should be checked in and should - not include local paths. -- `.openspec-store/store.yaml` is the identity file itself, not a bundle beside - another checked-in metadata file. It should contain only `version` and `id` - for now. -- Future backend, sync, collection, permission, or policy config should not be - added to `store.yaml` by default. -- The local registry maps store ids to local paths on one machine. -- Remote-url clone/setup sugar is useful but can wait. -- `initiative list` should list all registered stores by default; `--store` - should filter. -- Interactive setup should prompt for Git initialization and default to yes - when no explicit Git flag is provided. -- Non-interactive, JSON, `--init-git`, and `--no-init-git` setup should not - prompt. -- `context-store register` should be idempotent for the same id/path and fail - for the same id with a different path until a future explicit replacement - option exists. -- `context-store list` should stay a simple registry index and should not show - health warnings. -- `context-store doctor` owns health diagnostics. The first slice should check - registry/path/metadata and cheap Git repository presence, not dirty state, - branch, remote, sync, pull/push, or conflicts. -- `initiative list` should allow partial success in all-store mode: show - initiatives from readable stores and print one small warning pointing to - `context-store doctor` when other registered stores cannot be read. -- Filtered `initiative list --store` and explicit `--store-path` should fail - directly when the selected store cannot be read. -- Partial success should exit 0 with warning diagnostics in JSON. Total failure - should exit nonzero. -- Register id inference should use the repo/folder name as-is with normal - context-store id validation. Do not add normalization in this slice. -- Setup should reject non-empty folders without context-store metadata for now. -- Registry conflicts should fail when the same id points at a different path or - the same path is already registered under a different id. -- Empty states should stay simple: no stores registered for `context-store list` - and `doctor`; no initiatives found because no stores are registered for - `initiative list`. -- Static shell completion metadata is now part of the shipped command surface; - dynamic store-id and initiative-id completions remain deferred. - -## Risks To Check Before Implementation - -- Existing command naming conventions may prefer verb-first flows, while - context-store commands are naturally noun namespaced. -- Shell completions are manually registered; keep future command additions in - `src/core/completions/command-registry.ts` with focused registry tests. -- Human output should match existing compact CLI output patterns. -- JSON output should be stable enough for agents without over-modeling future - sync or remote behavior. - -## Implementation Evidence - -- `src/commands/context-store.ts` adds the `context-store` command namespace - with setup, register, list, and doctor subcommands. -- `src/cli/index.ts` registers the context-store command. -- `src/commands/context-store.ts` keeps strict CLI setup/register policy in the - command layer while reusing context-store foundation helpers. -- `src/commands/initiative.ts` now lets `initiative list` search all registered - stores by default, keeps `--store` as a filter, preserves `--store-path`, and - reports all-store partial success with warning diagnostics. -- `src/core/completions/command-registry.ts` registers static completion - metadata for the context-store command surface. -- `test/commands/context-store.test.ts` covers setup, register, list, doctor, - conflict handling, non-empty setup rejection, and interactive Git init. -- `test/commands/initiative.test.ts` covers all-store initiative listing, - compact human output, empty registered-store state, partial success, and all - unreadable stores. - -## Verification - -- `pnpm run build` -- `pnpm exec vitest run test/commands/context-store.test.ts test/commands/initiative.test.ts` -- `pnpm exec vitest run test/core/context-store/foundation.test.ts - test/core/context-store/registry.test.ts - test/core/collections/initiatives/operations.test.ts` -- `pnpm run lint` diff --git a/openspec/explorations/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/plan.md deleted file mode 100644 index 35f64ceb5c..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/plan.md +++ /dev/null @@ -1,333 +0,0 @@ -# Add Minimal Context Store UX - -## Status - -Minimal context-store CLI and all-store initiative listing implemented. - -## Source Of Truth - -Start from `../../direction.md`. - -The current roadmap order is: - -```text -Context stores sync truth. -Collections shape truth. -Initiatives coordinate work. -Workspaces open local views. -Changes implement repo-owned slices. -``` - -This item exists because agent-first initiative workflows need a usable shared -store before repo-local handoff and workspace opening can feel coherent. - -## Goal - -Let a user or agent create, register, list, and diagnose local context stores -without knowing the internal registry layout. - -## Agent-First Framing - -The expected user prompt is closer to: - -```text -Using initiative billing-launch, explore the API work and create a proposal. -``` - -Before an agent can do that, it needs to answer: - -- Which context stores are registered locally? -- Which store contains the named initiative? -- Is the registered store path valid? -- Is store metadata present and consistent? -- If no store exists yet, how should one be created? - -This work item should provide those primitives. It should not implement -repo-local initiative linking, initiative resolution, workspace opening, or -progress/status dashboards. - -## Locked Direction So Far - -- Keep the user-facing term `store` for now; naming polish is deferred. -- Use `context-store` as the top-level CLI namespace for this slice. It is more - explicit for agents and avoids overloading a broad top-level `store` command. - Keep `store` as shorthand only when the context is already scoped, such as - `initiative list --store <id>`. -- `context-store setup <id>` should create or use a local folder, write portable - store metadata, register the local path, and optionally initialize Git. -- When `--path` is omitted, `context-store setup <id>` should default to - `./<id>`. -- Using the current directory should be explicit with `--path .`; setup should - not silently turn the current repo into a context store. -- The actual shared context store should be visible on disk, not hidden under - XDG/global data. XDG/global data is only for the machine-local registry. -- `context-store register <path>` should register an existing clone or folder. -- Registration means "this folder already exists on my machine; remember it as - a known context store." It should not create the folder, initialize Git, pull, - push, commit, or create remotes. -- Default the store id from the repo or folder name when metadata is missing. -- Portable store metadata is exactly `.openspec-store/store.yaml`. It should be - checked into the context-store repo and contain only portable identity for - now: - -```yaml -version: 1 -id: team-context -``` - -- Do not put backend config, local paths, remote URLs, collection config, sync - policy, or permissions in `store.yaml`. -- If future collection/store config is needed, add a separate explicit file - rather than expanding the identity file by default. -- Machine-local registry state should stay outside the checked-in store and map - store ids to local paths. -- Registration should not pull, push, commit, or create remote repositories. -- Remote-url registration or clone sugar can come later. -- `initiative list` should default to all registered stores. `--store` should - filter to one store, and `--store-path` should remain an explicit escape - hatch. -- Human output should stay compact and avoid a `Status` column for now. - -## Suggested Command Shape - -```bash -openspec context-store setup <id> [--path <path>] [--init-git|--no-init-git] [--json] -openspec context-store register <path> [--id <id>] [--json] -openspec context-store list [--json] -openspec context-store doctor [id] [--json] -openspec initiative list [--store <id>] [--store-path <path>] [--json] -``` - -## Command Behavior - -### `context-store setup` - -`context-store setup <id>` creates or uses a visible local store root and -registers it on the current machine. - -Locked behavior: - -- Default path is `./<id>` when `--path` is omitted. -- Current-directory setup is allowed only with explicit `--path .`. -- Missing folders are created. -- Existing folders are allowed when metadata is missing or matches the requested - id. -- Non-empty folders without context-store metadata are not supported for setup - in this slice. -- Existing metadata with a different id fails. -- File paths fail. -- `.openspec-store/store.yaml` is written when missing. -- The store is registered in the machine-local registry. -- Interactive TTY mode prompts for Git initialization when neither - `--init-git` nor `--no-init-git` is provided; the default answer is yes. -- `--json`, non-TTY execution, `--init-git`, and `--no-init-git` do not prompt. -- Git is initialized only when the prompt answer is yes or `--init-git` is - passed. -- Setup does not commit, push, pull, create remotes, or create hosted repos. -- If a user wants to initialize an existing non-empty folder, fail with a clear - message and suggest filing the use case or using `context-store register` for - an existing context store. - -Suggested human output: - -```text -Context store setup complete - -ID: team-context -Location: /Users/me/work/team-context -Metadata: /Users/me/work/team-context/.openspec-store/store.yaml -Registry: /Users/me/.local/share/openspec/context-stores/registry.yaml -Git: initialized -``` - -### `context-store register` - -`context-store register <path>` records an existing local folder or clone as a -known context store on the current machine. - -Locked behavior: - -- Path must already exist and be a directory. -- If `.openspec-store/store.yaml` exists, use its id. -- `--id` may confirm the metadata id but cannot conflict with it. -- If metadata is missing, infer the id from the folder or repo name unless - `--id` is passed. -- Inference uses the folder or repo name as-is and then applies normal context - store id validation. Do not do clever normalization in this slice. -- Missing metadata is written. -- The machine-local registry is updated. -- Same id and same path is an idempotent success. -- Same id and different path fails for now; a future `--replace` can make - replacement explicit. -- Same path already registered under a different id fails for now. -- Register does not create the folder, initialize Git, pull, push, commit, - create remotes, or clone. - -Suggested human output: - -```text -Context store registered - -ID: team-context -Location: /Users/me/src/team-context -Metadata: /Users/me/src/team-context/.openspec-store/store.yaml -Registry: /Users/me/.local/share/openspec/context-stores/registry.yaml -``` - -### `context-store list` - -`context-store list` is an index view of the local registry. - -Locked behavior: - -- Reads the local registry. -- Shows registered id and location only. -- Sorts by store id. -- Does not check metadata, path health, Git, sync, remote, dirty state, or - conflicts. -- Does not mutate anything. -- Prints no health warnings; health belongs to `context-store doctor`. - -Suggested human output: - -```text -OpenSpec context stores (2) - -ID Location -platform /Users/me/src/platform-context -team-context /Users/me/src/team-context -``` - -Empty output: - -```text -No context stores registered. - -Next: - openspec context-store setup team-context - openspec context-store register /path/to/context-store -``` - -### `context-store doctor` - -`context-store doctor [id]` is the non-mutating health and repair surface. - -Locked behavior: - -- Checks all registered stores by default. -- Checks one store when `id` is passed. -- Checks registry presence, path existence, directory shape, metadata presence, - metadata parsing, and metadata id matching. -- Includes a cheap Git repository presence check. -- Does not check dirty state, branch, remote, sync, pull/push, or conflicts in - this slice. -- Does not mutate anything. - -Empty output: - -```text -No context stores registered. -``` - -Suggested human output: - -```text -Context store doctor - -team-context - Location: /Users/me/src/team-context - Metadata: ok - Git: repository detected - Issues: none -``` - -### `initiative list` - -`initiative list` becomes the agent-friendly discovery command across -registered stores. - -Locked behavior: - -- Without `--store` or `--store-path`, list initiatives from all readable - registered stores. -- If no context stores are registered, print a concise empty message. -- Sort by store id, then initiative id. -- Do not show a `Status` column in human output. -- Do not print detailed health diagnostics. -- If some stores cannot be read, still show initiatives from readable stores - and print one small warning that points to `context-store doctor`. -- If all registered stores are unreadable, print a concise failure/empty message - and point to `context-store doctor`. -- With `--store <id>`, filter to one registered store. -- With `--store-path <path>`, list from that explicit store path. -- Filtered `--store` or `--store-path` mode fails directly if that store cannot - be read, because there are no fallback stores. - -Suggested all-store output: - -```text -OpenSpec initiatives (3 across 2 stores) - -ID Store Title -billing-launch platform Billing Launch -docs-refresh platform Docs Refresh -api-cleanup team API Cleanup - -Some registered context stores could not be read. -Run: openspec context-store doctor -``` - -No registered stores output: - -```text -No initiatives found because no context stores are registered. -``` - -Suggested filtered output: - -```text -OpenSpec initiatives in platform (2) - -ID Title -billing-launch Billing Launch -docs-refresh Docs Refresh - -Location: /Users/me/src/platform-context -``` - -## Boundaries - -Do not implement in this item: - -- initiative `show` -- repo-local change metadata -- `new change --initiative` -- initiative local resolution -- workspace initiative opening -- sync, pull, push, remote repository creation, or conflict handling - -## Remaining Decisions - -None before implementation. JSON shapes can follow the existing command pattern: -top-level result objects plus a `status` diagnostics array. Partial success -returns exit code 0 with warning diagnostics; total failure returns nonzero. - -## Implemented Slice - -- Added `openspec context-store setup/register/list/doctor`. -- Registered the `context-store` command from the top-level CLI. -- Initially kept shell completion metadata out of scope; static metadata was - added later with the shipped command surface. -- Implemented strict CLI registration policy without changing the permissive - lower-level registry facade. -- Added setup behavior for default `./<id>`, explicit `--path .`, interactive - Git init prompt, non-interactive/JSON no-prompt behavior, non-empty directory - rejection, and metadata writing. -- Added register behavior for existing folders, id inference from folder name, - metadata writing, id/path conflict rejection, and registry updates. -- Added list behavior as a registry index only. -- Added doctor behavior for registry/path/metadata health and cheap Git - presence. -- Updated `initiative list` so no selector lists across registered stores, - `--store` filters, `--store-path` remains an escape hatch, human output is - compact, and all-store partial success returns warning diagnostics. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/tasks.md deleted file mode 100644 index e17b34dd7a..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/06-add-minimal-context-store-ux/tasks.md +++ /dev/null @@ -1,29 +0,0 @@ -# Add Minimal Context Store UX Tasks - -- [x] Create Item 6 work-item tracking notes. -- [x] Capture agent-first setup and discovery direction. -- [x] Decide `context-store` is the first CLI namespace. -- [x] Decide setup defaults to `./<id>` when `--path` is omitted. -- [x] Decide current-directory setup requires explicit `--path .`. -- [x] Record that checked-in store metadata stays minimal. -- [x] Decide checked-in store metadata is exactly `.openspec-store/store.yaml` - and contains portable identity only. -- [x] Record that machine-local registry state stays outside the store. -- [x] Record that `initiative list` should default across registered stores. -- [x] Decide setup interactive and non-interactive behavior. -- [x] Decide register behavior. -- [x] Decide context-store list is registry index only. -- [x] Decide doctor owns health checks. -- [x] Decide initiative list partial-success behavior. -- [x] Decide JSON and exit behavior for partial success and total failure. -- [x] Decide id inference uses folder/repo name as-is with normal validation. -- [x] Decide setup rejects non-empty folders without context-store metadata. -- [x] Decide registry path/id conflicts fail for now. -- [x] Decide empty states for list, doctor, and initiative list. -- [x] Initially defer completion metadata; later add static metadata with the - rest of the shipped command surface. -- [x] Finalize exact JSON payload fields for setup, register, list, doctor, and - all-store initiative list. -- [x] Implement `context-store setup/register/list/doctor`. -- [x] Update `initiative list` all-store behavior and output. -- [x] Add focused tests and verification evidence. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/evidence.md b/openspec/explorations/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/evidence.md deleted file mode 100644 index a08d1fe17d..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/evidence.md +++ /dev/null @@ -1,97 +0,0 @@ -# Add Agent-First Initiative Discovery Evidence - -## Conversation Decisions - -- `initiative show <id>` should be a locator/discovery command for agents. -- The command should answer which initiative the user meant, where the - canonical context lives, and where the initiative metadata is. -- The command should not concatenate markdown, summarize initiative contents, - compute work progress, resolve local repos, list linked changes, or open a - workspace. -- Default lookup should search all registered context stores. -- `--store <id>` should disambiguate or filter to one registered store. -- `--store-path <path>` should remain the explicit local-path escape hatch. -- Duplicate initiative ids across stores should fail with an ambiguity error. -- Default all-store lookup should fail when any registered store is unreadable, - because uniqueness is unknowable. -- Explicit `--store` and `--store-path` lookup should only care about the - selected store. -- `initiative.status` should be omitted from the v1 output projection. -- `owners` should be omitted from the v1 output projection. -- Arbitrary `metadata` should be omitted from the v1 output projection. -- `version` and `created` should stay in the v1 initiative projection. -- `files` should be omitted from v1. -- `initiative.metadata_path` should point to the validated `initiative.yaml`. -- `initiative.root` is enough for an agent to inspect the folder with normal - filesystem tools. -- Top-level `matches` should be omitted. Ambiguity and incomplete-lookup - candidates should live under the diagnostic that needs them, for example - `status[0].details.matches`. -- `context_store.source` should be omitted from `initiative show` v1 because it - is selector provenance, not context-store identity. -- A top-level `resolution` field is not needed in v1. -- Existing `initiative create/list` output can keep `context_store.source` for - now; this item should not refactor old output shapes. -- `readInitiative` should return `null` when the exact initiative is absent and - throw when `initiative.yaml` exists but is invalid or has the wrong id. -- In default all-store lookup, any unreadable registered store should make the - primary error `initiative_lookup_incomplete`, even when readable stores have - partial matches. -- If `initiatives/<id>/initiative.yaml` exists but is invalid or has the wrong - id, `initiative show` should fail as broken initiative state instead of - treating that store as not found. -- Human output should be a compact locator view on success: title, id, summary, - context store, location, and canonical filenames. -- Human ambiguity and incomplete-lookup errors should show matching or partial - matching stores inline, then point to the next command. -- Static shell completion metadata should ship for `initiative show`. -- Dynamic completions for store ids and initiative ids should remain deferred. - -## Research Notes - -- Current initiative create/list output spreads the full parsed - `initiative.yaml` state, which is useful for MVP but too broad for the first - `show` contract. -- A focused per-initiative read operation is preferred over implementing `show` - through `listInitiatives`, because exact lookup should not fail due to an - unrelated malformed initiative folder. -- Other initiative files are schema/config dependent and should not be - hardcoded into `show`. -- Keeping candidates inside diagnostic details follows the same general shape as - GraphQL-style responses: successful data stays clean, while error-specific - context travels with the error. -- If selector provenance is needed later, add a separate explicit field such as - `resolution` rather than putting provenance inside `context_store`. -- Human output should stay compact: title, id, summary, context store, - location, and metadata path. - -## Implementation Evidence - -- `src/core/collections/initiatives/operations.ts` adds `readInitiative` for - exact initiative lookup. -- `src/commands/initiative.ts` adds `initiative show <id>` with all-store - default lookup, `--store`, `--store-path`, JSON output, compact human output, - ambiguity diagnostics, and incomplete-lookup diagnostics. -- `src/core/completions/command-registry.ts` adds static completion metadata for - `initiative show`. -- `test/core/collections/initiatives/operations.test.ts` covers exact read, - absent initiatives, invalid exact initiatives, id mismatches, and unrelated - invalid folders. -- `test/commands/initiative.test.ts` covers `initiative show` success, - `--store-path`, human output, ambiguity, incomplete lookup, not found, - invalid exact initiative state, no `context_store.source`, no `files`, no - top-level `matches`, and static completions. - -## Verification - -- `pnpm run build` -- `pnpm exec vitest run test/core/collections/initiatives/operations.test.ts` -- `pnpm exec vitest run test/commands/initiative.test.ts` -- `pnpm exec vitest run test/commands/context-store.test.ts - test/commands/initiative.test.ts test/core/context-store/foundation.test.ts - test/core/context-store/registry.test.ts - test/core/collections/initiatives/operations.test.ts` -- `pnpm run lint` -- `git diff --check` -- Markdown line-length check for the initiative roadmap, task tracker, and Item - 7 work-item notes. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/plan.md deleted file mode 100644 index d59c23bc88..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/plan.md +++ /dev/null @@ -1,184 +0,0 @@ -# Add Agent-First Initiative Discovery - -## Status - -Implementation complete; verification in progress. - -## Source Of Truth - -Start from `../../direction.md`. - -This item exists because the expected workflow is agent-first: - -```text -Using initiative billing-launch, explore the API work and create a proposal. -``` - -Before repo-local linking, local resolution, or workspace opening can work, the -agent needs a small command that answers: - -- Which initiative did the user mean? -- Which context store contains the canonical initiative? -- Where is the initiative metadata, and what root should the agent inspect? - -## Goal - -Add agent-first initiative discovery without turning `show` into a reader, -progress dashboard, repo resolver, or workspace launcher. - -## Locked Direction So Far - -- `initiative show <id>` is a locator/discovery command. -- It should return identity, context-store location, initiative location, and - the initiative metadata path. -- It should not concatenate markdown, summarize file contents, compute progress, - resolve repos, list linked changes, or open workspaces. -- Default lookup searches all locally registered context stores. -- `--store <id>` filters to one registered store. -- `--store-path <path>` remains the explicit local-path escape hatch. -- Duplicate initiative ids across stores are ambiguous. The command should not - auto-pick a match. -- In default all-store lookup, unreadable stores make the lookup incomplete. - The command should fail rather than silently returning a possibly false - unique match. -- Explicit `--store` and `--store-path` modes only consider the selected store. - -## Output Contract Direction - -The first JSON contract should be a resolver/read-pointer projection, not a -full serialization of `initiative.yaml`. - -Suggested success shape: - -```json -{ - "context_store": { - "id": "platform", - "root": "/path/to/platform-context" - }, - "initiative": { - "version": 1, - "id": "billing-launch", - "title": "Billing Launch", - "summary": "Coordinate billing launch work.", - "created": "2026-05-21", - "root": "/path/to/platform-context/initiatives/billing-launch", - "store_path": "initiatives/billing-launch", - "metadata_path": "/path/to/platform-context/initiatives/billing-launch/initiative.yaml" - }, - "status": [] -} -``` - -Locked field decisions: - -- Keep `initiative.version`. -- Keep `initiative.created`. -- Keep `initiative.id`, `title`, `summary`, `root`, `store_path`, and - `metadata_path`. -- Keep `context_store.id` and `root`. -- Omit `context_store.source` from `initiative show` v1. It is selector - provenance, not context-store identity. Existing create/list output can remain - unchanged for now. -- Omit a top-level `resolution` field from v1. -- Omit `initiative.status` from the v1 projection. -- Omit `initiative.owners` from the v1 projection. -- Omit arbitrary `initiative.metadata` from the v1 projection. -- Omit a `files` list from the v1 projection. -- Omit top-level `matches`. -- Put ambiguity and incomplete-lookup candidates under the relevant diagnostic - entry, such as `status[0].details.matches`. -- Keep top-level `status` as command diagnostics only, not initiative work - progress. - -## Still To Decide - -- Nothing for the minimal v1 slice. - -## Human Output Direction - -Success output should stay locator-focused: - -```text -OpenSpec initiative: Billing Launch - -ID: billing-launch -Summary: Coordinate billing launch work. -Context store: platform -Location: /path/to/platform-context/initiatives/billing-launch - -Files: - Metadata: /path/to/platform-context/initiatives/billing-launch/initiative.yaml -``` - -Error output should stay plain: - -- Not found: say the initiative was not found in registered context stores and - suggest `openspec initiative list`. -- Ambiguous: show matching stores and paths, then suggest - `openspec initiative show <id> --store <store>`. -- Incomplete lookup: say some context stores could not be read, include partial - matches when present, then suggest `openspec context-store doctor`. - -## File Listing Direction - -`initiative show` should not list initiative folder contents in v1. - -Only `initiative.yaml` is required to identify and validate the initiative. All -other files are schema/config dependent and may differ across teams. Once the -command has resolved `initiative.root`, agents can use normal filesystem tools -to inspect the folder. Later schema-aware views can expose important files -without hardcoding today's default template filenames. - -## Completion Direction - -Add static shell completion metadata for: - -```text -initiative show <id> --store <id> --store-path <path> --json -``` - -Do not add dynamic completions for registered store ids or initiative ids in -this slice. - -## Core Read Operation Direction - -Add a focused `readInitiative` operation for exact lookup. - -Behavior: - -- Return `null` when the initiative folder or `initiative.yaml` is absent. -- Throw when `initiative.yaml` exists but is invalid. -- Throw when the parsed `initiative.yaml` id does not match the folder id. -- Do not scan unrelated initiative folders. - -## Lookup Error Precedence - -For default all-store lookup, any unreadable registered store makes lookup -incomplete. - -If one or more readable stores contain the initiative and one or more other -stores cannot be read, the primary error should still be -`initiative_lookup_incomplete`, not success or ambiguity. Include any readable -partial matches under the diagnostic details. - -Explicit `--store` and `--store-path` modes are scoped to the selected store and -do not check unrelated registered stores. - -Invalid exact initiative folders are broken shared state, not "not found". - -If `initiatives/<id>/initiative.yaml` exists but is invalid or has a mismatched -id, `initiative show` should fail with an invalid-initiative diagnostic. In -default all-store lookup, unreadable stores still take precedence as -`initiative_lookup_incomplete` because the full candidate set is unknowable. - -## Explicitly Out Of Scope - -- Top-level `openspec show` integration. -- Markdown content bundles or generated context packs. -- Checked-in initiative snapshots in repo-local changes. -- Repo-local change linking. -- Local repo/workspace resolution. -- Workspace opening. -- Git sync status, dirty state, remotes, pull, push, or conflicts. -- Initiative progress or status dashboards. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/tasks.md deleted file mode 100644 index 2bf8440d78..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/07-add-agent-first-initiative-discovery/tasks.md +++ /dev/null @@ -1,27 +0,0 @@ -# Add Agent-First Initiative Discovery Tasks - -- [x] Create Item 7 work-item tracking notes. -- [x] Decide `initiative show <id>` is a locator/discovery command. -- [x] Decide default lookup searches all registered context stores. -- [x] Decide `--store` and `--store-path` remain the narrowing selectors. -- [x] Decide duplicate initiative ids are ambiguity errors. -- [x] Decide unreadable stores make default all-store lookup incomplete. -- [x] Decide the v1 projection omits `initiative.status`, `owners`, and - arbitrary `metadata`. -- [x] Decide the v1 projection keeps `initiative.version` and `created`. -- [x] Decide v1 omits `files` and only returns initiative root plus metadata - path. -- [x] Decide ambiguity and incomplete-lookup candidates live under diagnostic - details, not top-level `matches`. -- [x] Decide exact human output direction for success and error states. -- [x] Decide `initiative show` omits `context_store.source`. -- [x] Decide `initiative show` omits a top-level `resolution` field. -- [x] Decide static completion metadata ships with Item 7. -- [x] Decide `readInitiative` returns `null` for absent and throws for invalid. -- [x] Decide incomplete lookup takes precedence over success or ambiguity in - default all-store mode. -- [x] Decide invalid exact initiative folders are errors, not not-found. -- [x] Implement a focused per-initiative read operation. -- [x] Implement `initiative show`. -- [x] Register static completion metadata for `initiative show`. -- [x] Add focused tests and verification evidence. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/evidence.md b/openspec/explorations/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/evidence.md deleted file mode 100644 index 917c121652..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/evidence.md +++ /dev/null @@ -1,239 +0,0 @@ -# Connect Repo-Local Changes To Initiatives Evidence - -## Decision 1: Initiative Link Location - -The initiative link should live in the repo-local change `.openspec.yaml`. - -Example: - -```yaml -schema: spec-driven -created: 2026-05-22 -initiative: - store: platform - id: billing-launch -``` - -This keeps repo implementation ownership in the repo while preserving a durable -reference to canonical initiative context. - -The link should not include local paths, copied initiative prose, or backlinks -inside the initiative store. - -## Research Notes - -- `createChange()` already writes `.openspec.yaml` for every change. -- `ChangeMetadataSchema` currently allows schema, created, goal, and - affected-area fields. Item 8 can extend that schema with `initiative`. -- Archive moves the whole change directory, so the initiative link will move - with archived changes. -- Apply, validate, and archive should not require context-store availability in - this slice. - -## Decision 2: Create Command Shape - -Initiative-linked creation should use `openspec new change` with `--initiative`. - -Supported first-slice forms: - -```bash -openspec new change add-billing-api --initiative billing-launch --json -openspec new change add-billing-api --initiative platform/billing-launch --json -openspec new change add-billing-api --initiative billing-launch --store platform --json -``` - -This keeps the operation repo-owned. The initiative is a reference on the -change, not the actor that creates or owns the change. - -The first slice should also add `--json` to `new change` so agents can capture -the created change path, metadata path, and initiative reference. - -## Decision 3: Initiative Lookup Behavior - -Bare `--initiative <id>` should reuse `initiative show` lookup semantics. - -It searches all registered context stores and succeeds only when the lookup is -complete and exactly one readable store contains the initiative. - -Explicit store selectors narrow lookup: - -```bash -openspec new change add-billing-api --initiative platform/billing-launch -openspec new change add-billing-api --initiative billing-launch --store platform -openspec new change add-billing-api --initiative billing-launch --store-path ./context -``` - -`--store-path` validates the explicit path and reads its store id, but does not -auto-register the store. Metadata still stores only the portable store id and -initiative id. - -Repo-local metadata should not be written until initiative lookup is complete -and unambiguous. - -## Decision 4: Repo-Local Only For V1 - -Item 8 should support initiative links only on repo-local changes. - -If `openspec new change <id> --initiative ...` runs from a workspace planning -home, v1 should refuse and tell the user to run the command from the repo that -owns the implementation plan. - -Existing workspace-planning changes remain compatibility behavior and should not -gain initiative linkage in this slice. - -This preserves the boundary that initiatives coordinate shared context, -repo-local changes own implementation plans, and workspaces open local views. - -## Decision 5: No Repo Ownership Matching In V1 - -Item 8 should not verify that the current repo is named by, owned by, or inferred -from the initiative. - -Creating a repo-local change with an initiative link records participation in the -initiative. It does not prove ownership, repo impact, or coverage of an -initiative area. - -Repo ownership matching can be revisited after initiative resolution or explicit -initiative metadata has a real repo/area model. - -## Decision 6: JSON And Human Output - -Create output should stay factual and minimal. - -Human output should confirm: - -- the created change id and location -- the schema -- the initiative link `{ store, id }` - -JSON output should include: - -```json -{ - "change": { - "id": "add-billing-api", - "path": "/repo/openspec/changes/add-billing-api", - "metadataPath": "/repo/openspec/changes/add-billing-api/.openspec.yaml", - "schema": "spec-driven" - }, - "initiative": { - "store": "platform", - "id": "billing-launch" - } -} -``` - -The output should not include `next` or other suggested workflow actions. API -responses should report operation results or errors; choosing the next action is -the agent's responsibility and depends on broader context. - -## Decision 7: Existing Change Recovery - -Item 8 should include a friendly recovery command for existing repo-local -changes: - -```bash -openspec set change add-billing-api --initiative billing-launch --json -openspec set change add-billing-api --initiative platform/billing-launch --json -openspec set change add-billing-api --initiative billing-launch --store platform --json -openspec set change add-billing-api --initiative billing-launch --store-path ../context --json -``` - -This command is a validated setter for checked-in repo-local change metadata. In -Item 8, the only supported settable field is the initiative link, and the only -file it may mutate is `openspec/changes/<id>/.openspec.yaml`. - -The command should not edit proposal, design, tasks, specs, or initiative-store -files. It should not store local paths or write backlinks into the initiative. - -If the requested initiative link already exists, the command should succeed as -an idempotent no-op. If a different initiative link already exists, the command -should fail without writing. Replacement, relink, unlink, and dry-run behavior -are deferred. - -Rationale: - -- Agents can forget to link a change during creation, so a first-class recovery - path is useful. -- `set change` matches the actual side effect: writing validated change metadata - to `.openspec.yaml`. -- Keeping the command scoped to `.openspec.yaml` avoids creating a broad change - editing surface. -- `openspec change ...` is currently deprecated, `edit` implies opening an - editor, and `update` already means refreshing local OpenSpec tooling or - guidance. - -## Decision 8: Status And Instructions Visibility - -Status and instructions should surface that the repo-local change is linked to -an initiative, but should not display or resolve the initiative itself. - -Human status output should show the stored initiative reference, and JSON status -output should include the stored initiative `{ store, id }`. Instructions output -should include a concise factual note that the change is linked to the -initiative. - -Status and instructions should not read, summarize, validate, or resolve the -initiative from the context store in v1. Missing or unavailable context stores -should not make repo-local status or instructions fail. - -This keeps the relationship visible during ordinary repo-local workflows while -preserving the boundary that initiative lookup and context reading belong to -initiative-specific commands. - -## Latest Open-Decision Notes - -Date: 2026-05-23. - -All decisions for Item 8 are now confirmed for implementation. - -Implementation should keep the first slice small: - -- The light release should test whether initiative-linked repo-local changes are - useful before adding gating, ownership inference, or broader workflow - integration. -- Standalone `initiative resolve` was later rejected; workspace local-view state - owns local path mapping. -- Source provenance, history/export, contract maps, and target-bound - initiative-hosted changes remain useful future discussion points, but should - not block this initial slice. - -## Implementation Evidence - -Date: 2026-05-23. - -Implemented: - -- `openspec new change <id> --initiative ...` for repo-local changes, with - `--json`, `--store`, and `--store-path` support. -- `openspec set change <id> --initiative ...` for existing repo-local changes. -- Portable checked-in metadata under `initiative: { store, id }`. -- Status and instructions visibility from stored metadata only. -- Workspace refusal, lookup-failure no-write behavior, same-link idempotency, - and different-link conflict protection. - -Verification: - -```bash -pnpm run build -``` - -Result: passed. - -```bash -pnpm exec eslint src/commands/workflow/new-change.ts src/commands/workflow/set-change.ts src/commands/workflow/initiative-link.ts src/commands/workflow/instructions.ts src/commands/workflow/status.ts src/commands/workflow/shared.ts src/commands/initiative.ts src/core/artifact-graph/types.ts src/core/artifact-graph/instruction-loader.ts src/utils/change-utils.ts src/cli/index.ts -``` - -Result: passed. - -```bash -pnpm exec vitest run test/utils/change-metadata.test.ts test/commands/change-initiative-link.test.ts -``` - -Result: passed, 39 tests. - -```bash -pnpm exec vitest run test/commands/artifact-workflow.test.ts test/commands/initiative.test.ts test/core/artifact-graph/instruction-loader.test.ts -``` - -Result: passed, 110 tests. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/plan.md deleted file mode 100644 index 6019549f95..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/plan.md +++ /dev/null @@ -1,279 +0,0 @@ -# Connect Repo-Local Changes To Initiatives - -## Status - -Implemented. The original decision text below is preserved as design record; -current completion evidence lives in `tasks.md` and `evidence.md`. - -## Source Of Truth - -Start from `../../direction.md` and the Item 8 roadmap entry. - -The relevant boundary is: - -```text -Initiatives coordinate shared context. -Repo-local changes own implementation plans. -Workspaces open local views. -``` - -## Goal - -Let an agent create or link a repo-local OpenSpec change to a shared -initiative without copying initiative prose, storing machine-local paths, or -making the initiative own repo implementation artifacts. - -Example user prompt: - -```text -Using initiative billing-launch, create a proposal for API work. -``` - -## Decisions - -### 1. Initiative Link Location - -Decision: Store the initiative link in the repo-local change `.openspec.yaml`. - -Suggested metadata shape: - -```yaml -schema: spec-driven -created: 2026-05-22 -initiative: - store: platform - id: billing-launch -``` - -Rules: - -- Store only the context store id and initiative id. -- Do not store local context-store paths. -- Do not store local repo paths. -- Do not create a checked-in `initiative.md` snapshot by default. -- Do not write backlinks into the initiative. - -Rationale: - -- `.openspec.yaml` is already the per-change machine-readable metadata file. -- The link is durable repo context and should be checked in with the change. -- The canonical initiative context remains in the context store. -- The metadata stays portable across teammates and machines. - -### 2. Create Command Shape - -Decision: Add initiative linking to the repo-local change creation command with -`--initiative`. - -Supported first-slice forms: - -```bash -openspec new change add-billing-api --initiative billing-launch --json -openspec new change add-billing-api --initiative platform/billing-launch --json -openspec new change add-billing-api --initiative billing-launch --store platform --json -``` - -Rules: - -- The command starts from `new change` because the change is repo-owned. -- `--initiative` modifies repo-local change creation; it does not make the - initiative create or own the change. -- `--json` should be added to `new change` for agent-readable handoff output. -- A separate initiative-owned create command is not part of the first slice. - -Rationale: - -- The expected user flow is agent-first: "using initiative X, create a proposal - for repo work." -- Agents need one normal repo-local create command that can also write the - initiative reference. -- Keeping the verb rooted in `new change` preserves the boundary that changes - implement repo-owned slices. - -### 3. Initiative Lookup Behavior - -Decision: Reuse `initiative show` lookup semantics for `--initiative`. - -Rules: - -- Bare `--initiative <id>` searches all registered context stores. -- Bare lookup succeeds only when exactly one readable registered store contains - the initiative id. -- Duplicate initiative ids across stores fail as ambiguous. -- Any unreadable registered store makes bare lookup incomplete and fails before - writing change metadata. -- `--initiative <store>/<id>` selects one registered store by id. -- `--initiative <id> --store <store>` also selects one registered store by id. -- `--initiative <id> --store-path <path>` validates the explicit local context - store path, reads its store id, and writes only `{ store, id }` to metadata. -- `--store-path` does not auto-register the context store. -- Do not write repo-local initiative metadata until lookup is complete and - unambiguous. - -Rationale: - -- Agents can use the short form when it is safe. -- Durable repo-local links should not be created from partial knowledge. -- The behavior matches existing agent-first discovery semantics. - -### 4. Repo-Local Only For V1 - -Decision: Item 8 supports initiative links only on repo-local changes. - -Rules: - -- `openspec new change <id> --initiative ...` creates an initiative-linked - change only when the current planning home is repo-local. -- If the command runs from a workspace planning home, v1 refuses with clear - guidance to run the command from the repo that owns the implementation plan. -- Existing workspace-planning changes remain compatibility behavior and are not - extended with initiative linkage in this slice. - -Rationale: - -- The current product boundary assigns implementation plans to repo-local - OpenSpec changes. -- Workspaces are local views, not the durable planning owner for initiative - work. -- Extending workspace-planning changes would revive the superseded - workspace-owns-the-plan model. - -### 5. Repo Ownership Matching - -Decision: Do not attempt repo ownership matching in v1. - -Rules: - -- Creating a repo-local change with an initiative link records participation in - the initiative. -- The link does not claim that OpenSpec verified repo ownership, repo impact, or - initiative area coverage. -- The command should not block or warn solely because the current repo is absent - from initiative content. - -Rationale: - -- Item 8 should not invent repo ownership or monorepo area semantics. -- Ownership matching belongs with later initiative resolution or explicit - initiative metadata. -- Keeping v1 small lets teams test whether linked repo-local changes are useful - before adding policy gates. - -### 6. JSON And Human Output - -Decision: Keep create output factual and minimal. - -Rules: - -- Output should report what the command did, not recommend workflow next steps. -- Human output should confirm the created change location, schema, and initiative - link. -- JSON output should include stable fields for the created change and initiative - link. -- JSON output should not include a `next` command or suggested workflow action. -- Output should not include initiative summaries, repo ownership claims, - resolved local context-store paths, or progress/status-like fields. - -Suggested JSON shape: - -```json -{ - "change": { - "id": "add-billing-api", - "path": "/repo/openspec/changes/add-billing-api", - "metadataPath": "/repo/openspec/changes/add-billing-api/.openspec.yaml", - "schema": "spec-driven" - }, - "initiative": { - "store": "platform", - "id": "billing-launch" - } -} -``` - -Rationale: - -- CLI/API-style responses should state operation results or errors. -- Accurately choosing the next action depends on agent context and should remain - the agent's responsibility. -- Keeping output factual avoids coupling change creation to later lifecycle - design. - -### 7. Existing Change Recovery - -Decision: Include a recovery command for setting the initiative link on an -existing repo-local change. - -Command shape: - -```bash -openspec set change add-billing-api --initiative billing-launch --json -openspec set change add-billing-api --initiative platform/billing-launch --json -openspec set change add-billing-api --initiative billing-launch --store platform --json -openspec set change add-billing-api --initiative billing-launch --store-path ../context --json -``` - -Rules: - -- `openspec set change <id> --initiative ...` is a validated setter for - repo-local change metadata. -- In Item 8, the only supported settable field is the initiative link. -- The command only mutates `openspec/changes/<id>/.openspec.yaml`. -- The command does not edit proposal, design, tasks, specs, or initiative-store - files. -- The command uses the same initiative lookup semantics as - `openspec new change <id> --initiative ...`. -- If the same initiative link already exists, the command succeeds as an - idempotent no-op. -- If a different initiative link already exists, the command fails without - writing. Replacement, relink, unlink, and dry-run behavior are not part of v1. -- If the command runs from a workspace planning home, it refuses for the same - reason as initiative-linked `new change`. - -Rationale: - -- Agents can forget to pass `--initiative` during change creation; v1 needs a - friendly recovery path. -- `set change` describes the real operation: setting checked-in change metadata, - not creating an initiative-owned relationship. -- Keeping the command limited to `.openspec.yaml` avoids a broad edit surface. -- Avoid `openspec change ...` because that namespace is currently deprecated. -- Avoid `edit` because it implies opening an editor, and avoid `update` because - OpenSpec already uses update for local guidance/tool refresh. - -### 8. Status And Instructions Visibility - -Decision: Surface the initiative link in status and instructions output without -resolving or displaying the initiative itself. - -Rules: - -- Human status output should show that the change is linked to an initiative. -- JSON status output should include the stored initiative `{ store, id }`. -- Instructions output should include a concise factual note that the change is - linked to the initiative. -- Status and instructions must not read, summarize, validate, or resolve the - initiative from the context store in v1. -- Missing or unavailable context stores must not make repo-local status or - instructions fail. -- Output should not add next-step recommendations. - -Rationale: - -- The initiative link should be visible in normal repo-local workflow output so - users and agents do not miss the relationship. -- Keeping visibility to stored metadata avoids introducing context-store - availability as a dependency for repo-local workflow commands. -- Initiative resolution belongs to initiative-specific commands, not status or - instructions in this slice. - -## Open Decisions - -None. Decision pass complete; confirm the decisions before implementation. - -## Latest Suggested Resolutions - -These were the suggested answers carried into implementation: - -- Surface the stored initiative link in status and instructions without reading - or displaying the initiative itself. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/tasks.md deleted file mode 100644 index 926e34ebbd..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/08-connect-repo-local-changes-to-initiatives/tasks.md +++ /dev/null @@ -1,22 +0,0 @@ -# Connect Repo-Local Changes To Initiatives Tasks - -## Decisions - -- [x] Decide where the initiative link lives. -- [x] Decide command shape for creating initiative-linked changes. -- [x] Decide initiative lookup behavior for `--initiative`. -- [x] Decide whether workspace-scoped changes are allowed in this slice. -- [x] Decide whether repo ownership matching is attempted in v1. -- [x] Decide JSON and human output shape. -- [x] Decide whether Item 8 includes linking existing changes. -- [x] Decide whether status/instructions surface initiative links. -- [x] Confirm latest suggested resolutions in `plan.md` before implementation. - -## Implementation - -- [x] Extend change metadata schema with an optional initiative link. -- [x] Persist initiative metadata when creating repo-local changes. -- [x] Add command support for creating initiative-linked changes. -- [x] Add tests for metadata validation and persistence. -- [x] Add tests for command output and lookup failures. -- [x] Add status/instruction visibility for stored initiative links. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/09-add-initiative-resolve/decision-review.md b/openspec/explorations/context-store-and-initiatives/work-items/09-add-initiative-resolve/decision-review.md deleted file mode 100644 index a7bf4c51a4..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/09-add-initiative-resolve/decision-review.md +++ /dev/null @@ -1,64 +0,0 @@ -# Item 9 Decision: Reject Initiative Resolve - -## Final Decision - -Do not implement a standalone `openspec initiative resolve <id>` command, now -or later. - -The command is unnecessary because it tries to do work that already belongs to -other concepts: - -- `initiative show` finds the canonical initiative. -- A workspace is the local view over repos and folders. -- Repo-local changes link themselves to initiatives. -- Repo-local status reports work progress. - -## Decision 1: No Command - -No separate initiative command is needed. - -If the user only has a context store, `initiative show` is enough. If the user -has a workspace, the local view is already represented by that workspace. If the -user is inside a repo, repo-local commands are enough. - -## Decision 2: Local Resolution Belongs To Workspace - -A workspace maps local repos and folders to paths on one machine. Future -initiative-aware local opening belongs in workspace behavior. - -## Decision 3: Agent Behavior - -Agents should: - -- Use `openspec initiative show <id> --json` for shared context. -- Use the current workspace view when the user is working in a workspace. -- Use repo-local commands when the user is working in a repo. -- Let the user decide which repos are present locally. - -## Decision 4: Rejected Scope - -Remove all standalone resolve behavior: - -- no `initiative resolve` -- no all-repo scan -- no all-workspace scan -- no `--path` search roots -- no Git remote matching -- no cloning -- no worktree or branch creation -- no initiative backlinks -- no local availability dashboard - -## Decision 5: Roadmap Update - -Convert Item 9 into a decision-only checkpoint. - -Replacement: - -```text -Item 9. Reject Initiative Resolve - -Decision: do not add `openspec initiative resolve`, now or later. Initiative -discovery belongs to `initiative show`; local path mapping belongs to -workspaces; implementation progress belongs to repo-local changes. -``` diff --git a/openspec/explorations/context-store-and-initiatives/work-items/09-add-initiative-resolve/evidence.md b/openspec/explorations/context-store-and-initiatives/work-items/09-add-initiative-resolve/evidence.md deleted file mode 100644 index 26ce8ca83d..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/09-add-initiative-resolve/evidence.md +++ /dev/null @@ -1,106 +0,0 @@ -# Reject Initiative Resolve Evidence - -## Decision Summary - -Date: 2026-05-25. - -After review, the standalone `openspec initiative resolve <id>` command should -not be implemented, now or later. - -The useful distinction is already covered by existing concepts: - -- `initiative show` resolves canonical shared initiative context. -- A workspace is the local view over repos and folders. -- Repo-local changes link themselves to initiatives through checked-in metadata. -- Repo-local status reports implementation progress. - -A standalone resolve command would mostly duplicate workspace local-view state -or provide weak output when no workspace is present. - -## Pressure Test - -Scenario: - -```bash -git clone git@github.com:acme/context.git -openspec context-store register ./context --id platform -openspec initiative show billing-launch --json -``` - -This can locate: - -```text -platform/billing-launch -./context/initiatives/billing-launch -./context/initiatives/billing-launch/initiative.yaml -``` - -It cannot know: - -```text -which implementation repos should exist locally -where those repos are on this machine -which repos the user intends to work in -which repos should be cloned -which workspace view the user wants -``` - -That knowledge belongs to the user and the workspace, not the initiative. - -## Why Workspace Changes The Answer - -When a user has a workspace, the local view is already resolved by the -workspace: - -```text -workspace -> link names -> machine-local paths -``` - -The agent can operate from the workspace context. A separate -`initiative resolve` command would add another layer that mostly reprints what -the workspace already owns. - -If future UX needs initiative-aware opening, it should be part of workspace -behavior, such as opening or preparing a workspace around a selected initiative. -It should not be a standalone initiative command pretending to infer local repo -availability. - -## Research Notes Retained - -The earlier investigation is still useful as background: - -- `initiative show` already has correct context-store lookup behavior, - ambiguity handling, incomplete lookup handling, and JSON locator output. -- Item 8 stores initiative links in repo-local `.openspec.yaml` as - `{ store, id }`. -- Workspace state owns local path mappings and generated open surfaces. -- Existing repo-local status and instructions expose initiative links but do not - resolve or summarize the initiative. - -Those findings support the final decision: do not add a standalone command; keep -each responsibility in its existing owner. - -## Rejected Scope - -Rejected for Item 9: - -- `openspec initiative resolve <id>` -- path-resolution dashboards -- progress dashboards -- all-workspace scans -- all-repo scans -- explicit path scanning as an initiative command -- Git remote matching -- repo ownership inference -- cloning or branch/worktree orchestration -- initiative backlinks - -## Verification - -This pass updates decision artifacts only. - -```bash -git diff --check -``` - -Result: passed after this revision. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/09-add-initiative-resolve/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/09-add-initiative-resolve/plan.md deleted file mode 100644 index 6a5a482f47..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/09-add-initiative-resolve/plan.md +++ /dev/null @@ -1,141 +0,0 @@ -# Reject Initiative Resolve - -## Status - -Final decision: do not implement a standalone `openspec initiative resolve` -command, now or later. - -## Source Of Truth - -Start from `../../direction.md` and the boundary: - -```text -Context stores sync truth. -Collections shape truth. -Initiatives coordinate work. -Workspaces open local views. -Changes implement repo-owned slices. -``` - -Item 8 already established that repo-local changes may reference initiatives -through portable checked-in metadata: - -```yaml -initiative: - store: platform - id: billing-launch -``` - -## Final Decision - -Do not ship `openspec initiative resolve <id>` as a user-facing command in this -slice or any future slice. - -The earlier command framing was too broad. It tried to join initiative identity, -workspace local paths, explicit repo roots, and linked repo-local changes into a -new CLI surface. That makes the command look authoritative even though the -initiative does not own local repo paths, repo participation, or implementation -state. - -## Why The Command Is Not Needed - -If a user only has a context store clone, OpenSpec can already resolve the -canonical initiative with: - -```bash -openspec initiative show billing-launch --json -``` - -That answers: - -```text -What initiative is this, which context store contains it, and where is the -canonical initiative folder? -``` - -It cannot answer: - -```text -Which local implementation repos should exist on this machine? -``` - -because that information is not in the context store. - -If a user has a workspace, the workspace is already the local view. It already -maps local repos and folders to paths on this machine. A separate -`initiative resolve` command would mostly re-describe the workspace the user is -already using. - -If a user is in a repo, the repo-local change commands and status commands -already operate from that repo. The user or agent can inspect the current repo's -changes directly. - -## Product Rule - -Do not create a new command whose main job is to discover local paths that the -workspace already represents. - -Rules: - -- `initiative show` remains the command for canonical initiative discovery. -- Workspaces remain the local view over repos, folders, context stores, and - initiatives. -- Repo-local changes remain the implementation artifacts. -- Agents should use the current workspace or current repo context rather than - asking a standalone initiative command to infer local availability. -- OpenSpec should not infer repo ownership, scan arbitrary repos, clone repos, - create worktrees, or write backlinks to make resolve appear smarter than it - is. - -## What To Do Instead - -Keep the pieces separate: - -- Use `openspec initiative show <id> --json` to locate canonical shared context. -- Use workspace commands to set up, link, relink, list, open, update, and doctor - local views. -- Use repo-local `openspec new change ... --initiative ...` and - `openspec set change ... --initiative ...` to create durable links from repo - work to initiative context. -- Use `openspec status --change <id> --json` inside the owning repo to inspect - implementation progress. - -If a future workspace workflow needs to open an initiative-specific view, it -should be designed under workspace behavior, not as a standalone initiative -resolve command. - -## Deferred Or Replaced Scope - -The following ideas are not part of Item 9 implementation: - -- `openspec initiative resolve <id>` -- scanning all registered workspaces -- scanning all repos on disk -- explicit `--path` based initiative resolution -- Git remote matching -- repo ownership inference -- cloning, fetching, pulling, pushing -- branch or worktree creation -- initiative backlinks -- progress dashboards -- local availability dashboards - -## Roadmap Disposition - -Item 9 is a decision-only checkpoint. It records that standalone initiative -resolution is rejected permanently. - -Roadmap framing: - -```text -Item 9. Reject Initiative Resolve - -Decision: do not add `openspec initiative resolve`, now or later. Initiative -discovery belongs to `initiative show`; local path mapping belongs to -workspaces; implementation progress belongs to repo-local changes. -``` - -## Next Useful Work - -The next useful implementation slice is workspace initiative opening, without a -standalone resolve prerequisite. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/09-add-initiative-resolve/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/09-add-initiative-resolve/tasks.md deleted file mode 100644 index f442d86bd9..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/09-add-initiative-resolve/tasks.md +++ /dev/null @@ -1,22 +0,0 @@ -# Reject Initiative Resolve Tasks - -## Decisions - -- [x] Create Item 9 work-item tracking notes. -- [x] Pressure-test whether a standalone `initiative resolve` command is needed. -- [x] Decide that a standalone user-facing `initiative resolve` command should - not be implemented now or later. -- [x] Decide `initiative show` remains sufficient for canonical initiative - discovery. -- [x] Decide workspace local-view state is the right place for local repo/path - mapping. -- [x] Decide repo-local status remains the right place for work progress. -- [x] Decide not to add all-repo scanning, all-workspace scanning, Git remote - matching, cloning, worktree creation, or initiative backlinks. - -## Follow-Up - -- [x] Update the central roadmap entry for Item 9. -- [x] Update the initiative task tracker. -- [x] Record workspace initiative opening as the next useful implementation - slice. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/plan.md deleted file mode 100644 index 42444b568b..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/plan.md +++ /dev/null @@ -1,430 +0,0 @@ -# Let Workspaces Open Initiatives - -## Status - -Product decisions are locked. The remaining work is implementation design and -delivery. - -## Source Of Truth - -Start from `../../direction.md` and the boundary: - -```text -Context stores sync truth. -Collections shape truth. -Initiatives coordinate work. -Workspaces open local views. -Changes implement repo-owned slices. -``` - -Item 9 rejected standalone initiative resolution. Initiative discovery belongs -to `initiative show`; local path mapping belongs to workspace local-view state. - -## Locked Direction - -A workspace does not contain the work. It remembers how this runtime opens the -work. - -```text -private local view record - -> generated runtime files - -> opener-specific launch - -> initiative context + selected local repos/folders -``` - -The durable part is the user's private local view choice. The generated part is -runtime support for agents and editors. - -## Product Goal - -Let a user open a shared initiative in their own local runtime with the context -and repos they care about. - -Examples: - -- A Team A developer opens `platform/billing-launch` with local Repo A and Repo - B. -- A Team B developer opens the same initiative with local Repo C only. -- A user opens the initiative context only, links repos later, and still gets - useful agent guidance. - -## Non-Goals - -- Do not clone repos. -- Do not create branches or worktrees. -- Do not use Git submodules as the workspace primitive. -- Do not infer all participating repos from Git remotes or disk scans. -- Do not write generated agent files into linked repos or context stores. -- Do not make workspace-level `changes/` the durable planning model. -- Do not enforce edit permissions in Item 10. - -## Decision Register - -### Command UX - -Status: decided. - -Use `workspace open` for initiative local-view realization: - -```bash -openspec workspace open --initiative platform/billing-launch -openspec workspace open --initiative billing-launch --store platform -openspec workspace open --initiative billing-launch -openspec workspace open team-a-billing --initiative platform/billing-launch -``` - -Rationale: the action being performed is local view realization, so the command -belongs under `workspace open` rather than `initiative open`. - -Lookup behavior: - -- If the user provides `<store>/<initiative>`, use that exact store selector. -- If the user provides `<initiative> --store <store>`, use that exact store - selector. -- If the user provides only `<initiative>`, search registered context stores and - proceed when there is exactly one exact match. -- If multiple stores contain the same initiative id, stop and show the matching - stores with a hint to retry using `<store>/<initiative>` or `--store`. -- If no exact match exists, do not silently open the closest match. Show a small - list of likely matches when available, plus a hint to run `openspec - initiative list`. -- If some registered stores cannot be read, keep the result conservative. Do not - choose a match that could be ambiguous behind an unreadable store unless the - user supplied an explicit store selector. - -Interactive UX may let a human choose from suggestions. JSON and non-interactive -UX should return structured errors and suggestions without prompting. - -Workspace-name behavior: - -- The optional positional workspace name remains the local view identity. -- If the user provides a workspace name with `--initiative`, create or reuse that - named local view. -- If the user omits a workspace name, create or reuse a friendly default derived - from the initiative id when that is unambiguous. -- On name collisions or multiple existing local views for the same initiative, - let the human choose interactively or require an explicit workspace name in - non-interactive mode. - -### Open Target - -Status: decided. - -Default to opening the initiative directory, not the whole context store. - -User-facing behavior: - -```bash -openspec workspace open --initiative billing-launch -``` - -opens a focused local view: - -```text -generated files in the workspace root -context-store/initiatives/billing-launch/ -selected local repos/folders -``` - -It should not open the entire context store by default. - -Rationale: - -- The user asked for one initiative, so the opened context should be focused on - that initiative. -- Agents receive less unrelated shared context. -- Unrelated initiatives and shared files are not exposed by default. -- The local view stays easier to understand: generated workspace root plus this - initiative plus selected implementation roots. - -Generated guidance and JSON output should still report the context store root -and that broader context exists. A later explicit option may open the full -context store, for example `--context-scope store` or `--include-store`, but -broad store scope is not the default for Item 10. - -### Local View Record - -Status: decided. - -Use one private local view record: the root `workspace.yaml` file. - -```yaml -version: 1 -name: billing-launch -context: - kind: initiative - store: - id: platform - selector: - kind: registry - id: platform - initiative: - id: billing-launch -links: - repo-a: /Users/me/repos/repo-a - repo-b: /Users/me/repos/repo-b -preferred_opener: codex -tools: - - codex -``` - -This decision covers the conceptual record shape and the fact that generated -runtime files are not durable state. - -If the user selected a context store by local path, the private workspace record -can keep that runtime-local selector without changing checked-in repo metadata: - -```yaml -context: - kind: initiative - store: - id: platform - selector: - kind: path - path: /Users/me/context/platform - observed_id: platform - initiative: - id: billing-launch -``` - -The context binding is optional. A user can also create a workspace that is not -linked to any initiative: - -```yaml -version: 1 -name: team-a-local -context: null -links: - repo-a: /Users/me/repos/repo-a - repo-b: /Users/me/repos/repo-b -preferred_opener: codex -tools: - - codex -``` - -This is a first-class workspace shape, not only an edge case for initiative -opening. Item 10 should preserve custom non-initiative workspaces while adding -initiative-aware opening. - -### Workspace Storage And Generated Files - -Status: decided. - -Store each private workspace view under the user's OpenSpec global data -directory, keyed by workspace name: - -```text -getGlobalDataDir()/workspaces/<workspace-name>/ -``` - -The workspace name is the local identity. The selected store and initiative, if -any, are data inside the private record; they do not define the storage path. -This keeps the workspace API generic enough for custom local views that are not -initiative-linked. - -Initial shape: - -```text -getGlobalDataDir()/workspaces/<workspace-name>/ - workspace.yaml - AGENTS.md - <workspace-name>.code-workspace - .codex/ - skills/ - .claude/ - skills/ -``` - -`workspace.yaml` is the durable private view record and the only view file in -Item 10. The other files are generated runtime support owned by OpenSpec. They -may be overwritten by `workspace open`, `workspace update`, or a future explicit -preparation surface. - -Do not add a separate generated-output directory for Item 10. The managed -workspace root is already the private generated view. - -Initiative open defaults: - -- If the user provides a workspace name and no workspace exists, create that - workspace bound to the selected initiative. -- If the user provides a workspace name and it already points at the same - initiative, reuse it and regenerate runtime files. -- If the user provides a workspace name and it has no context binding, bind it - to the selected initiative only after clear user confirmation; in - non-interactive mode, fail and require an explicit future rebind/update - surface. -- If the user provides a workspace name and it points at a different initiative - or context, do not silently repoint it. Stop with a clear error and require an - explicit future rebind/update surface. -- If the user omits a workspace name and exactly one existing workspace points at - the selected initiative, reuse it. -- If the user omits a workspace name and no existing workspace points at the - selected initiative, create a friendly default workspace name derived from the - initiative id only when that name is unused. -- If the derived workspace name collides with another workspace, ask for an - explicit workspace name or show matching workspace choices instead of hiding - the collision behind a path convention. -- If multiple workspaces point at the same initiative, let the user choose or - require an explicit workspace name in non-interactive mode. - -### Generated Runtime Files - -Status: decided. - -Generate runtime files at the workspace root, next to `workspace.yaml`. - -```text -getGlobalDataDir()/workspaces/<workspace-name>/ -``` - -The generated files can contain `AGENTS.md`, skills, launch prompts, and -generated editor workspace files. - -Regeneration behavior: - -- `workspace open` regenerates the managed runtime files before launching the - opener. -- `workspace update` regenerates the managed runtime files without changing - durable local view choices unless the user asked for a state change. -- Generated files are OpenSpec-owned and may be overwritten each time. -- `workspace.yaml` is not generated output and should not be overwritten except - when the local view record itself changes. - -### Runtime Identity - -Status: decided. - -Use `getGlobalDataDir()` as the runtime-local boundary. It is already -cross-platform and resolves to the appropriate user data directory for macOS, -Linux, Windows, Codespaces, WSL, SSH hosts, and containers. - -Local paths in `workspace.yaml` are valid only in the runtime that wrote them. -If the same user opens the same initiative from another runtime, they create or -relink that runtime's workspace there. Item 10 should not add path translation, -shared machine identities, or an extra `<runtime-id>` path segment. - -### Prepare/JSON Surface - -Status: decided. - -Keep `workspace open --json` as a machine-facing receipt for the same open -operation. Do not add `--prepare-only` for Item 10. - -The JSON response should be useful to agents and desktop integrations, not just -a success boolean. It should include the workspace name, workspace root, -generated file paths, selected context, opened roots, skipped or missing roots, -opener, launch status, and warnings. - -Human-facing behavior remains the normal `workspace open` output. JSON mode is -for tools that need structured facts after OpenSpec has prepared the workspace -root and attempted the requested open. - -### Missing Paths At Open Time - -Status: decided. - -Workspace opening should be strict about the selected initiative/context and -forgiving about optional linked local paths. - -- If the selected initiative cannot be resolved, fail before launch. -- If the context store or initiative path is unavailable, fail before launch and - point to context-store registration/doctor guidance. -- If a linked repo or folder is missing, warn and skip that root; do not block a - context-only or partially linked open. -- Human output should name skipped links and suggest `workspace doctor` or - relink guidance. -- JSON output should include skipped or missing roots and warnings. - -### Codex Desktop - -Status: decided. - -Open the generated workspace root as the Codex Desktop project. Surface the -attached initiative path and linked repo/folder paths through generated guidance -and the `workspace open --json` response. - -Do not depend on Desktop multi-root automation for Item 10. If Desktop later has -a clearer multi-root contract, it can become an enhancement without changing the -workspace storage model. - -### Edit Boundaries - -Status: decided. - -Item 10 emits advisory boundaries only. Generated context should distinguish -coordination context from implementation targets, but it should not enforce -write restrictions. - -The generated view should label initiative/context-store files as shared -coordination context and linked repos/folders as local implementation context -when selected. Strong enforcement can come later. - -## First-Run UX Sketch - -Status: deferred beyond the first implementation slice. - -This sketch captures the eventual human interactive flow. Item 10 should not -depend on building a full guided setup wizard; the first implementation may use -explicit flags and structured errors first. - -```text -Found initiative: platform/billing-launch -No local workspace view exists for this runtime. - -Create a local view? -> Open context only - Link existing local repos/folders - Cancel -``` - -No option in this first-run flow should clone, branch, create worktrees, or -create submodules. - -## Machine-Readable Open Contract - -`workspace open --json` is the machine-readable contract for the generated -runtime context. Item 10 should not create a separate machine-readable view -file; the durable view record is `workspace.yaml`. - -The JSON response should tell agents: - -- schema version -- workspace name and workspace root -- selected initiative id, title, and path -- selected context store id and path -- generated file paths -- opened roots -- skipped or missing roots -- linked repo-local changes when known -- advisory edit boundaries -- next repair commands -- warnings and launch status when produced by `workspace open --json` - -If no implementation target is selected, `allowedEditRoots` should be empty or -explicitly advisory. - -The exact schema can evolve during implementation, but the JSON response should -make the generated view self-describing enough for agents and desktop -integrations without scraping human output. - -## Forward Compatibility - -The initial `context` record supports the selected context store and initiative. -Do not design the YAML parser so narrowly that future records cannot add fields -for configurable change homes, artifact homes, target bindings, or other -collection/view metadata. - -## Compatibility Notes - -The current beta workspace implementation creates a managed root with -`changes/`, `AGENTS.md`, `.gitignore`, -`.openspec-workspace/workspace.yaml`, `.openspec-workspace/local.yaml`, and a -durable `.code-workspace` file. - -Item 10's intended new shape is a root `workspace.yaml` plus generated runtime -files at the managed workspace root. Existing beta workspaces should be treated -as compatibility inputs. Migration or removal of all beta internals is deferred -unless the implementation slice intentionally scopes that migration. - -For the initiative-opening model, generated runtime files are derived artifacts, -not workspace truth. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/tasks.md deleted file mode 100644 index a44944c96c..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/10-let-workspaces-open-initiatives/tasks.md +++ /dev/null @@ -1,43 +0,0 @@ -# Let Workspaces Open Initiatives Tasks - -## Decisions - -- [x] Create Item 10 work-item tracking notes. -- [x] Lock the high-level direction: private local view record plus generated - runtime files. -- [x] Decide command UX. -- [x] Decide default open target. -- [x] Decide private local view record shape. -- [x] Decide private local view record storage namespace and keying. -- [x] Decide generated runtime file location and lifetime. -- [x] Decide runtime identity rules. -- [x] Decide prepare/JSON surface. -- [x] Decide Codex Desktop behavior. -- [x] Decide Item 10 edit-boundary semantics. - -## Implementation Scope To Confirm Later - -- [x] Add or adapt workspace local-view state for initiative opening. -- [x] Preserve non-initiative custom workspaces as first-class local views. -- [x] Resolve initiative context through existing `initiative show` semantics. -- [x] Implement workspace-name reuse and collision behavior for initiative open. -- [x] Generate opener-specific runtime files. -- [x] Return explicit machine-readable view context from `workspace open --json`. -- [x] Launch agent/editor with generated workspace root plus initiative context and - selected local repos/folders. -- [x] Warn and skip missing linked repos/folders at open time while failing on - missing selected initiative/context. -- [x] Add doctor guidance for missing context stores, missing local links, stale - view records, and advisory edit boundaries. -- [x] Ensure Item 10 opens known local paths only and does not clone, branch, - create worktrees, or use submodules. - -## Deferred - -- [ ] Multiple saved views per initiative. -- [ ] Shared/exported workspace templates. -- [ ] Repo auto-discovery or Git remote matching. -- [ ] Strong edit-boundary enforcement. -- [ ] Codex Desktop multi-root automation if the Desktop contract is not clear - enough for Item 10. -- [ ] Migration or removal of all existing beta workspace root artifacts. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/notes.md b/openspec/explorations/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/notes.md deleted file mode 100644 index 317aaac695..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/notes.md +++ /dev/null @@ -1,289 +0,0 @@ -# Manual Beta Reality Pass Notes - -Use this as the scratchpad while trying the beta flow. - -## What Worked - -- Manual beta pass caught the bad default before building more surface area. -- After changing the default, rerunning - `openspec context-store setup team-context --init-git` from inside the - OpenSpec repo created the store at - `~/.local/share/openspec/context-stores/team-context` instead of nesting it in - the repo. -- Minimal fresh-agent handoff worked for initiative creation. A subagent given - only the store id and a loose topic created `agent-trace-hooks` in the correct - context-store location: - `~/.local/share/openspec/context-stores/team-context/initiatives/agent-trace-hooks`. - -## What Felt Weird - -- Fresh-user guidance immediately drifted into sandbox/environment setup - (`XDG_CONFIG_HOME`, `XDG_DATA_HOME`) instead of letting the user just run the - beta locally. Strong reaction: this should work as a normal local workflow. -- `openspec context-store setup` with no args feels like it should start an - interactive setup, but it does not. The command name itself creates that - expectation. -- `openspec context-store setup team-context --init-git` created - `team-context/` inside the current OpenSpec repo because the default path is - `./<id>`. User expected a default outside the current repo, not a new Git repo - nested in whatever directory they happened to run from. -- Cleaning up the accidental store had no obvious CLI path. `context-store` - exposes setup/register/list/doctor, but no unregister/remove command, so - cleanup required removing the folder and editing the registry manually. -- `context-store setup --init-git` initializes Git, but leaves - `.openspec-store/` and new initiatives untracked. That may be fine, but the - beta flow does not tell the user or agent whether to stage/commit the shared - context store. -- `openspec workspace open` with no arguments prompts only for known local - workspace views. It does not show registered context stores or initiatives, so - `team-context` is absent even though the next guide step is opening an - initiative from that store. This is technically consistent with the current - implementation, but confusing in the beta flow because the command name reads - like the broad "open something OpenSpec-related" entrypoint. -- The post-initiative step has the wrong first-run verb. After creating a - context store and an initiative, the user is conceptually creating a local - workspace view for that initiative. "Open" implies the workspace already - exists, so the beta guide and CLI make the user infer a hidden create-or-open - behavior. - -## Missing Prompts Or Too Many Flags - -- Need clearer guidance for whether a beta pass should use existing local - OpenSpec state or create a normal local test context store. Avoid requiring - environment variables as the default manual path. -- Missing prompt: when no context-store id is provided, ask for the store id, - path, and Git initialization choice instead of requiring the user to know the - positional argument/flags. -- Missing prompt/safety check: before creating a default context store under - the current directory, show the target path and ask for confirmation or offer - a managed default location. -- Missing handoff guidance: after context-store setup, the guide tells the user - to ask an agent to create an initiative, but a fresh agent may not know the - beta initiative CLI or where to find the agent playbook. -- Missing prompt: `workspace open` should either offer an "open initiative from - context store" path when registered initiatives exist, or make the zero-arg - prompt text explicit that it is selecting an existing local workspace view - only. If it offers initiatives, it should likely list references like - `team-context/agent-trace-hooks`, not just the store id. -- Missing first-run workspace creation flow: after an initiative exists, the - user should be guided through creating the local workspace view. A simple - interactive path could ask what to set up, list registered initiatives such as - `team-context/agent-trace-hooks`, suggest a workspace name from the initiative - id, optionally link existing repo/folder paths, choose an opener, then create - the workspace view. -- Better minimal beta path: keep lazy workspace creation, but make bare - interactive `openspec workspace open` initiative-aware. The picker should show - existing local workspace views and registered initiatives that can create a - local view on selection, with labels that preserve the distinction between - "workspace" and "initiative." -- The generated initiative file contract is underexplained. The CLI creates - exactly `initiative.yaml`, `requirements.md`, `design.md`, `decisions.md`, - `questions.md`, and `tasks.md`, but docs describe the Markdown files as - "typical" or "then edit" rather than naming the contract clearly. -- A user looking at the generated initiative tree may reasonably ask where that - structure came from. The exact six-file contract is clear in code and the - internal MVP work item, but public beta docs do not make it explicit and the - broader direction doc still mentions future `contracts/` content. - -## Agent Handoff Notes - -- The first agent step has a bootstrapping problem. `context-store setup` does - not create repo-local guidance, and `workspace open --initiative` cannot run - until the initiative exists. A fresh agent needs either an explicit pasted - mini-playbook, installed OpenSpec skills, or CLI output that prints the exact - next agent prompt/command. -- In the manual subagent test, the agent ran `initiative create --help`, then - created the initiative with `--store team-context --title ... --summary ... - --json`. It correctly resolved the store and did not create files in the - OpenSpec repo. -- The subagent replaced generated `TBD` placeholders with useful short content, - which suggests the templates give enough structure but not enough guidance. - There is no CLI option to seed richer content beyond title and summary. -- `initiative create --json` reports `created_files` as relative names. Agents - have to combine those with the returned root to get absolute paths. -- "Commands only" is product-ambiguous for this beta. The implementation treats - it as "remove all skills and install only slash command files," but users may - read it as "I prefer slash commands for workflow entry points." They still - likely expect their coding agent to understand OpenSpec concepts, context - stores, initiatives, and workspace handoff. - -## Delivery UX Model - -- Split the concept into two layers: - - Baseline OpenSpec literacy: "Does the agent understand OpenSpec concepts and - know how to inspect context stores, initiatives, workspaces, and repo-local - changes?" - - Workflow entrypoints: "How does the user invoke workflow actions such as - propose/apply/archive?" -- Current `delivery` acts like a generated-artifact cleanup switch. That is too - low-level for the user-facing choice. -- Better meaning: - - `skills`: install the baseline guide skill plus workflow skills. - - `commands`: install the baseline guide skill plus workflow slash commands. - - `both`: install the baseline guide skill plus workflow skills and workflow - slash commands. -- In UI copy, avoid "commands only" if it implies no skills at all. Prefer - labels like "Slash commands as workflow entrypoints" or "Workflow commands - only" with helper text that baseline OpenSpec guidance is still installed - when the selected agent supports skills. -- For tools without a command adapter, commands-oriented delivery should warn - clearly that workflow slash commands are unavailable for that tool. The tool - should still receive the baseline guide skill if it supports skills, so the - selected agent is not left with nothing. - -## Initiative Placement UX - -- A fresh agent also needs to know whether a new planning object belongs in a - context store or in the current repo. This should not be left to vibes. -- Product distinction: - - Initiatives in context stores are durable planning and coordination context - that intentionally lives outside implementation repos: product intent, - decisions, questions, roadmap notes, and tasks that should not necessarily - be checked into the code repo. - - Repo-local OpenSpec changes are implementation plans owned by the repo that - will change: proposal/design/spec deltas/tasks/validation. - - Workspaces are local views that connect shared context to local repos; they - should not become a third durable planning home. -- Agent guidance should not assume repo-local is preferred just because work - touches one repo. Use or create a context-store initiative when the user wants - OpenSpec artifacts outside the repo, when a monorepo has multiple teams with - separate planning contexts, when repo policy discourages planning artifacts, - when work is cross-repo/team-coordinated, long-lived, pre-implementation - discovery, or already tied to an existing context store. -- If a request is ambiguous, the agent should inspect first: - `openspec initiative list --json`, `openspec list --json`, and workspace - state when available. If still ambiguous, ask: "Should these OpenSpec - artifacts live outside the repo in a context store, or inside this repo as a - repo-local implementation change?" -- CLI/skill copy should make the linked flow explicit: create/read initiative - in the context store, then create repo-local changes from the owning repo with - `--initiative <store>/<initiative>`. - -## Initiative Creation Rethink - -- `openspec initiative create` currently creates a full six-file planning - packet with `TBD` placeholders. That is too eager for the intended audience: - PMs, designers, architects, and agents facilitating early product/architecture - thinking. -- Initial creation should register the initiative shell, not invent the plan. - The most conservative first slice is `initiative.yaml` plus either: - - a short `brief.md` seeded from title/summary/current understanding; or - - a lightweight `requirements.md` with no `TBD` placeholders and no claims of - accepted requirements until the content has been reviewed. -- Follow-up artifacts should be created iteratively when they become real: - - `requirements.md`: accepted high-level requirements, goals, non-goals, - unresolved product questions. - - `design.md`: reviewed product/UX/architecture direction and tradeoffs. - - `questions.md`: optional question log when questions need tracking. - - `decisions.md`: optional decision log appended only after decisions happen. - - Avoid default `tasks.md`; implementation tasks belong in repo-local changes. - If initiative-level coordination is needed later, use clearer language like - `workstreams.md`, `milestones.md`, or `coordination.md`. -- This should ideally become schema-led. Reuse the artifact-graph idea - (artifact ids, generated paths, templates, dependencies, status/instructions), - but root it at the initiative directory instead of repo-local changes. -- A minimal initiative schema could start with only `requirements` and `design`, - where design depends on requirements. `decisions` and `questions` are living - logs, so file-existence completion semantics may not fit them. -- For next-release safety, avoid a strict top-level `schema:` field in - `initiative.yaml` until metadata compatibility is designed. If a schema hint - needs persistence, store it under `metadata` or keep the default implicit. - -## Docs Fixes - -- The beta guide says "This creates a local context store" but does not explain - that the default location is `./<id>` relative to the current working - directory. That needs to be explicit if the default remains. -- Immediate docs/code fix changed the default away from `./<id>` and documented - the managed local data location instead. -- Step 2 should not assume the agent already knows the beta initiative command. - Include a copy-paste bootstrap prompt or link/inline excerpt from the agent - CLI playbook. -- Step 3 says "Open Your Local Workbench," but the command is actually - create-or-open when `--initiative` is passed. The guide should make that - explicit: "Create or open a local workspace view for the initiative." It - should also warn that bare `openspec workspace open` selects existing - workspace views only and will not list context stores like `team-context`. -- Better: change the user-facing flow so the first-time path is explicitly - creation/setup. The guide should send humans to an interactive workspace setup - path for the initiative, then reserve `workspace open` for reopening an - existing workspace view. -- Subagent UX/model passes recommended a leaner beta change: keep - `workspace open --initiative <store>/<initiative>` as the explicit - create-or-reuse path, but make bare interactive `workspace open` show - initiatives as selectable targets. Selecting an initiative should say it is - creating/opening a local workspace view. - -## Possible Implementation Slices - -- Make `openspec context-store setup` interactive when no id is provided: - prompt for store id, default path, and Git initialization; keep `--json` / - non-interactive behavior deterministic with a helpful fix message. -- Reconsider the default context-store setup path. Options: use the managed - OpenSpec data directory by default, or keep `./<id>` only after an interactive - confirmation that names the full target path. - - Implemented during the pass: use the managed OpenSpec data directory by - default and keep `--path` for explicit locations. -- Add `openspec context-store unregister <id>` or `remove <id>` for local - registry cleanup, with an explicit choice about whether to delete files or - only forget the local registration. -- Add a first-run handoff affordance after context-store setup, such as printing - "Next for your agent" guidance or adding a command that emits the agent - playbook for shared context/initiative setup. -- Add interactive workspace creation for initiative views. Candidate surfaces: - extend `openspec workspace setup` with initiative selection, add - `openspec workspace setup --initiative <store>/<initiative>`, or introduce a - clearer `workspace create` command. The key UX requirement is that a fresh - user can run an interactive command after initiative creation and be led to - "create a local workspace view for this initiative" without knowing - `--initiative` or the derived workspace-name convention. -- Add an initiative-aware `workspace open` picker as the smallest product fix: - on bare interactive open, list local workspaces plus registered initiatives. - If the user selects an initiative, feed it through the existing - `--initiative` create/reuse path. Do not auto-create workspaces during - context-store setup or initiative creation, and do not make workspaces 1:1 - with initiatives. - - Implemented during the pass: bare interactive `workspace open` now shows - registered initiatives that do not already have a known local view, and - selecting one creates/reuses the initiative-bound workspace view. - - Follow-up fix: when that lazy initiative view is new, `workspace open` - now runs the same repo/folder link prompt as `workspace setup` before - creating the workspace view. This avoids opening an empty workspace and - makes the first-run path collect implementation roots at the moment the - user expects it. -- Consider splitting baseline OpenSpec literacy from workflow delivery. A - small default `use-openspec` skill could be installed whenever a selected - agent supports skills, even if workflow delivery is set to commands-only, so - "commands only" means "workflow actions are slash commands" rather than "the - agent gets no OpenSpec context." -- Simpler possible slice: treat `use-openspec` as a normal managed skill bundled - with the configurator and installed by default. Keep it skill-only even if it - is presented as part of the default profile, so it does not create a slash - command, workflow artifact, or user-facing workflow action. -- Rethink `openspec initiative create` as a sparse, schema-led container - instead of a fully scaffolded planning packet. Initial create should likely - write only `initiative.yaml` plus a short `brief.md` seeded from title and - summary. Follow-up agent/CLI actions can add `requirements.md`, `design.md`, - `questions.md`, `decisions.md`, or coordination artifacts when there is - reviewed content to capture. Avoid default `TBD` sections, fake decisions, - and default initiative-level `tasks.md` that may be confused with repo-local - implementation tasks. -- Manual follow-up converted the test `agent-trace-hooks` initiative to the - proposed sparse shape: kept `initiative.yaml`, added `brief.md`, and removed - the eager generated planning files. `initiative show` still resolves because - current initiative identity depends on `initiative.yaml`. -- Promoted the broader fix into - `work-items/15-context-store-project-roots-and-schema-led-initiatives/`: - context stores should behave like OpenSpec roots for shared context, with - store-local config, schemas, and sparse schema-led initiative artifacts. -- Workspace shape correction: managed workspace views should not look like - repos. New workspace views should contain the generated root files - (`AGENTS.md`, `workspace.yaml`, and `<workspace>.code-workspace`) without a - default `changes/` directory or generated `.gitignore`; VS Code multi-root - views should show linked repos first, then initiative context, then the small - OpenSpec workspace folder. -- Guide correction: after opening a workspace, the user should ask the agent to - explore or draft using the initiative. The agent should resolve workspace - state, initiative context, and linked repo ownership, then run repo-local - OpenSpec commands from the owning repo. The user-facing flow should not make - humans type `openspec new change` or `cd` into implementation repos. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/plan.md deleted file mode 100644 index 93ca446d80..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/plan.md +++ /dev/null @@ -1,39 +0,0 @@ -# Manual Beta Reality Pass - -## Status - -Proposed next work item. - -## Goal - -Try the current beta flow by hand and use the friction as product input before -building more surface area. - -## Pass Shape - -Start from a fresh local setup and walk through: - -- context store setup or registration -- initiative creation and editing through an agent -- workspace open -- workspace link or relink -- workspace doctor -- repo-local change creation linked to an initiative -- handoff back to an agent - -## Output - -The output should be notes, not polish: - -- what felt easy -- what felt weird -- where flags leaked into user-facing docs -- where prompts were missing -- what an agent needed to be told explicitly -- what should become a follow-on implementation slice - -## Non-Goals - -- Do not require a clean public tutorial state. -- Do not solve every issue found during the pass. -- Do not turn the beta flow into a progress dashboard. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/tasks.md deleted file mode 100644 index 1d7f4133c0..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/11-manual-beta-reality-pass/tasks.md +++ /dev/null @@ -1,8 +0,0 @@ -# Manual Beta Reality Pass Tasks - -- [ ] Run the current beta flow from a fresh user's point of view. -- [ ] Capture notes in the initiative as the pass happens. -- [ ] Mark where the user should type commands versus prompt an agent. -- [ ] Record confusing output, missing prompts, and unclear command names. -- [ ] Update beta docs with immediate findings. -- [ ] Split larger findings into proposed implementation work items. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/evidence.md b/openspec/explorations/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/evidence.md deleted file mode 100644 index 214921ad2d..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/evidence.md +++ /dev/null @@ -1,45 +0,0 @@ -# Context Store First-Run And Cleanup UX Evidence - -## Manual Beta Source Notes - -The manual beta pass found: - -- no-argument `openspec context-store setup` feels like it should start an - interactive setup; -- accidental setup previously created a store under the current repo before the - managed default was corrected; -- cleanup had no CLI path and required deleting files plus editing the registry - manually; -- Git initialization left shared files untracked without telling the user or - agent what to do next. - -## Initial Recommendation - -Keep context-store first-run UX small and local: - -- prompt only for local setup choices; -- never push, pull, commit, create remotes, or delete files implicitly; -- keep JSON output explicit enough for agents to continue safely; -- leave team sync policy to the later shared-coordination hardening work. - -## Implementation Result - -- `openspec context-store setup` now runs a guided setup in interactive - terminals when no id is provided. -- Non-interactive and `--json` setup require explicit inputs and fail with a - structured setup-id diagnostic when the id is missing. -- Explicit setup paths inside another Git repository are blocked - non-interactively and require explicit confirmation interactively. -- `context-store unregister <id>` removes only the local registry entry. -- `context-store remove <id>` removes the local registry entry and deletes the - local folder only after confirmation or `--yes`; it refuses to delete folders - without matching context-store metadata. -- Human success output is intentionally compact; JSON output carries exact - registry, file, and Git state without `next_commands`. - -Verification: - -- `pnpm build` -- `pnpm lint` -- `pnpm vitest run test/commands/context-store.test.ts test/core/context-store/registry.test.ts` -- `pnpm test` diff --git a/openspec/explorations/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/plan.md deleted file mode 100644 index 39b4326c3e..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/plan.md +++ /dev/null @@ -1,150 +0,0 @@ -# Context Store First-Run And Cleanup UX - -## Status - -Implemented. - -This work item covers the context-store setup and cleanup gaps that were not -fully captured by later docs, schema, or handoff work. - -## Source Of Truth - -Manual beta notes: - -- `../11-manual-beta-reality-pass/notes.md`, especially the findings around - no-argument setup, cleanup, target path safety, and shared-store Git guidance. - -Preserve the current boundary: - -```text -Context stores sync truth. -Collections shape truth. -Initiatives coordinate work. -Workspaces open local views. -Changes implement repo-owned slices. -``` - -## Why This Exists - -The beta pass found that `openspec context-store setup` feels like a first-run -entrypoint, but no-argument setup currently does not guide the user through the -choices they need to make. The pass also found that recovering from a mistaken -store setup requires manual registry edits and file deletion. - -These are local lifecycle problems, not shared coordination model problems. -They should be solved before asking new users or teammates to trust context -stores as normal local workflow. - -## Goals - -- Make no-argument `context-store setup` a friendly interactive setup path in a - terminal. -- Keep non-interactive and JSON behavior deterministic and agent-safe. -- Make the target store path explicit before creation. -- Provide a supported local cleanup command for removing or unregistering a - context store from this machine. -- Keep Git setup limited to optional local initialization, without staging, - committing, pushing, creating remotes, or choosing team workflow. - -## Non-Goals - -- Do not add remote creation, clone, pull, push, watch, or sync automation. -- Do not make setup choose team governance, branching, or review policy. -- Do not delete shared files without an explicit user choice. -- Do not make context stores implementation repos. - -## UX Direction - -Locked decisions from the product pass: - -- `openspec context-store setup` with no arguments should start a guided setup - when run in an interactive terminal. Agents, scripts, CI, and `--json` callers - should pass the equivalent explicit inputs instead of relying on prompts. -- The guided setup should ask only for values that map to existing setup flags: - context store id, context store path, and whether to initialize Git. -- User-facing prompt copy should stay direct: - `Context store name`, `Where should this context store live?`, - `Initialize Git in this context store?`, then a final - `Create this context store?` confirmation after showing the resolved summary. -- The default location should be the managed OpenSpec context-store directory, - not the current working directory. Users can still choose any explicit safe - local path; OpenSpec stores that machine-local path in the local registry, not - in shared context-store metadata. -- Setup should be protective around risky paths: create missing paths, accept - empty directories, treat matching context-store metadata as idempotent, stop - on metadata/id conflicts, stop on files, and stop or explicitly warn before - using a non-empty unmarked directory or a path inside another Git repository. -- Cleanup should expose two explicit intents: `context-store unregister <id>` - forgets the machine-local registry entry and leaves files alone, while - `context-store remove <id>` unregisters the store and deletes the local folder - only after showing the exact path and receiving confirmation. -- Happy-path human output should stay small: show the context store id, its - location, and the next user-facing step. Do not show Git state, metadata - paths, registry paths, or created-file lists unless there is a warning, - failure, `--json`, or `context-store doctor` output. -- JSON output should report exact resulting state, not workflow guidance. Include - ids, roots, metadata paths, registry state, Git facts, created/deleted files, - and warnings/errors where present, but do not include `next_commands`. Empty - `status: []` can be preserved where existing JSON compatibility needs it, but - new behavior should not rely on blank status arrays for meaning. -- Git initialization is an optional local convenience only. When requested, - OpenSpec may run `git init`, but it must not stage, commit, push, create - remotes, create branches, or define team Git policy. - -Interactive setup should cover the minimum choices: - -```text -Store id -Target path, defaulting to the managed OpenSpec context-store location -Whether to initialize Git -``` - -Before writing files, output should show the resolved target path. If an -explicit path is inside another Git repo or an existing non-empty directory, -the command should either ask for confirmation with clear wording or fail with -a fix message in non-interactive mode. - -Cleanup should distinguish local registration from file deletion: - -```bash -openspec context-store unregister team-context -openspec context-store remove team-context -``` - -The command names are explicit because the user intents are different: - -- forget this local registry entry only -- delete this local context-store folder too - -If Git initialization fails, setup should explain that the user can install Git -or rerun setup without Git. Successful Git initialization stays out of the -happy-path human output. - -## Agent / JSON Contract - -JSON setup output should report: - -- store id -- root path -- metadata path -- whether Git was initialized -- whether files were created or already existed -- local registry path or registry entry identity - -JSON cleanup output should report: - -- store id -- removed local registry entry, if any -- deleted root path, if requested -- files left on disk, if deletion was not requested -- warnings for missing, ambiguous, or already-removed state - -## Done When - -- A fresh user can run `openspec context-store setup` in a terminal and be led - through the normal local setup path without knowing flags. -- Non-interactive and JSON setup still fail predictably when required choices - are missing. -- A mistaken local store registration can be removed through the CLI without - hand-editing the registry. -- Setup and cleanup output make local file, registry, and Git state explicit. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/tasks.md deleted file mode 100644 index 95245a1406..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/12-context-store-first-run-and-cleanup-ux/tasks.md +++ /dev/null @@ -1,23 +0,0 @@ -# Context Store First-Run And Cleanup UX Tasks - -- [x] Decide exact no-argument `context-store setup` behavior for TTY, - non-TTY, and `--json` invocations. -- [x] Design the interactive setup prompts for store id, target path, and Git - initialization. -- [x] Define target-path safety behavior for managed defaults, explicit paths, - paths inside existing Git repos, and non-empty directories. -- [x] Implement the interactive setup flow without changing deterministic - non-interactive behavior. -- [x] Decide whether the cleanup surface is `unregister`, `remove`, or both. -- [x] Define cleanup semantics for "forget local registration" versus "delete - local files too". -- [x] Implement local registry cleanup with explicit confirmation before file - deletion. -- [x] Add human output that stays small and JSON output that reports exact setup - and cleanup state without `next_commands`. -- [x] Keep Git initialization scoped to local `git init` with no auto-staging, - committing, pushing, remote creation, or team policy. -- [x] Add focused tests for setup prompts, non-interactive failures, path - safety, registry cleanup, and JSON output. -- [x] Update beta docs and agent playbook references for first-run setup and - cleanup. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/evidence.md b/openspec/explorations/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/evidence.md deleted file mode 100644 index 40b8808512..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/evidence.md +++ /dev/null @@ -1,41 +0,0 @@ -# Agent Handoff Output And Delivery Polish Evidence - -## Manual Beta Source Notes - -The manual beta pass found: - -- after context-store setup, the user is told to ask an agent to create an - initiative, but a fresh agent may not know the beta CLI or where to find the - playbook; -- `initiative create --json` reports `created_files` as relative names, so - agents must combine them with the returned root before writing; -- "commands only" can sound like "the agent gets no OpenSpec guidance," even - though users may only mean slash commands as workflow entrypoints; -- tools without command adapters need a clear warning when workflow slash - commands cannot be installed. - -## Initial Recommendation - -Treat this as output polish, not a new workflow engine: - -- add direct path fields rather than breaking existing relative fields; -- keep handoff guidance concrete and command-sized; -- keep baseline OpenSpec literacy separate from workflow entrypoints; -- leave the broader "what should I do next?" command to the proposed handoff - work item. - -## Reassessment - -After reviewing practical examples, the proposed "Next for your agent" shape -looked too prescriptive. It assumes a fixed linear beta path, but agents may -inspect state, branch, skip setup, continue an existing change, or use context in -a different order. - -Conclusion on 2026-05-30: - -- Skip Item 13 as an implementation item for now. -- Preserve the evidence because the handoff pain is real. -- Do not hardcode fixed next-step guidance until the product has a clearer - receipt or affordance model. -- Consider splitting deterministic `created_paths` style receipt fields into a - smaller future slice if they remain useful. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/plan.md deleted file mode 100644 index af578e2de4..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/plan.md +++ /dev/null @@ -1,106 +0,0 @@ -# Agent Handoff Output And Delivery Polish - -## Status - -Deferred as an implementation item. - -This work item captures a real beta pain, but the current "Next for your agent" -shape should not be built yet. It assumes a linear workflow path and risks -hardcoding guidance that does not fit dynamic agentic work. - -Decision on 2026-05-30: skip this item for now. Keep the notes as research -input for a future handoff receipt model. - -## Source Of Truth - -Manual beta notes: - -- `../11-manual-beta-reality-pass/notes.md`, especially the findings around - post-setup agent guidance, relative `created_files`, and commands-oriented - delivery warnings. - -Related work: - -- `../proposed-initiative-next-agent-handoff-ux/` -- `../14-workspaces-beta-guide-split/` -- `../15-context-store-project-roots-and-schema-led-initiatives/` - -## Why This Was Proposed - -The beta pass showed that agents can succeed if they know which command to run, -but the first handoff is still too implicit. Setup output, JSON receipts, docs, -and generated delivery artifacts should make the next move obvious without -requiring the user to paste tribal knowledge. - -That pain is still valid. The uncertain part is the product shape. A fixed next -step may be wrong when the agent can inspect current state, discover existing -initiatives, skip workspace setup, continue from a repo-local change, or choose -a different planning route. - -## Future Direction - -If this is revisited, frame it as a receipt or affordance model: - -- report what now exists; -- report where canonical context and created artifacts live; -- report relevant state and selected local bindings; -- optionally report available actions, not a required next command; -- avoid a single `next_command` unless the next action is genuinely - deterministic. - -Small deterministic output improvements, such as absolute `created_paths`, may -still be worth splitting into a narrower implementation slice. - -## Non-Goals - -- Do not implement an `initiative next` command in this slice. -- Do not add progress dashboards or work-status rollups. -- Do not create initiatives, changes, or workspaces automatically as part of - setup output. -- Do not make every relative path field disappear if existing compatibility - requires it; add direct absolute path fields instead. -- Do not hardcode a single user or agent journey. - -## Deferred Output Sketch - -Avoid this prescriptive shape for now: - -```text -Next for your agent: - Ask your coding agent to create or update an initiative in team-context. -``` - -If a future model exists, prefer contextual receipts: - -```json -{ - "created_files": ["brief.md"], - "created_paths": [ - "/path/to/store/initiatives/billing-launch/brief.md" - ], - "handoff_context": { - "store": "team-context", - "initiative": "billing-launch", - "workspace": null - }, - "available_actions": [ - "inspect_initiative", - "open_workspace_view", - "create_repo_local_change" - ] -} -``` - -Delivery copy may still need separate work to distinguish: - -- baseline OpenSpec guidance or literacy; -- workflow entrypoints such as skills or slash commands. - -## Revisit When - -- Item 14 clarifies the human guide versus agent playbook split. -- Item 15 clarifies sparse initiative artifacts and context-store project-root - behavior. -- There is enough beta evidence to decide whether command output should expose - state receipts, available affordances, direct paths only, or no special - handoff block. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/tasks.md deleted file mode 100644 index 243af6d171..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/13-agent-handoff-output-and-delivery-polish/tasks.md +++ /dev/null @@ -1,38 +0,0 @@ -# Agent Handoff Output And Delivery Polish Tasks - -Status: deferred. Do not implement these tasks until the handoff model is -redesigned as contextual receipts or affordances rather than fixed linear -"next step" guidance. - -- [ ] Revisit after Item 14 and Item 15 clarify the beta docs, sparse - initiative flow, and context-store project-root model. -- [ ] Decide whether any deterministic receipt improvements, such as - `created_paths`, should be split into a smaller independent slice. -- [ ] Decide whether delivery terminology belongs in a separate - command-surface/delivery item. - -## Deferred Original Tasks - -- [ ] Decide which existing commands should print a "Next for your agent" - handoff block. -- [ ] Define the minimal handoff content for context-store setup, initiative - creation, workspace opening, and repo-local linked change creation. -- [ ] Add direct created-path fields, such as `created_paths`, where JSON output - currently forces agents to combine relative file names with returned - roots. -- [ ] Preserve compatibility for existing relative `created_files` fields where - callers may already depend on them. -- [ ] Update `initiative create --json` and sparse initiative creation output - from Item 15 to include direct artifact paths and next commands. -- [ ] Decide how generated docs or setup output points to the agent CLI - playbook without requiring a pasted mini-playbook in every guide step. -- [ ] Clarify delivery terminology so commands-oriented delivery means workflow - commands as entrypoints, not absence of baseline OpenSpec guidance. -- [ ] Add warnings when a selected tool does not support workflow slash command - delivery. -- [ ] Define how baseline OpenSpec guidance is reported when commands-oriented - delivery is selected for a tool that still supports skills. -- [ ] Add tests or fixtures for human output, JSON output, and delivery-warning - behavior. -- [ ] Update beta docs and generated agent guidance with the polished handoff - and delivery language. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/14-workspaces-beta-guide-split/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/14-workspaces-beta-guide-split/plan.md deleted file mode 100644 index c4e1d8882e..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/14-workspaces-beta-guide-split/plan.md +++ /dev/null @@ -1,37 +0,0 @@ -# Workspaces Beta Guide Split - -## Status - -Proposed next work item. - -## Goal - -Make the beta docs match how people should actually use the feature: - -- humans use terminal prompts for local setup and local paths -- coding agents use explicit CLI commands for OpenSpec work - -## Working Model - -User-facing docs should be light on flags and heavy on agent prompts. The agent -CLI playbook should carry the exact commands, JSON surfaces, cwd rules, and -current caveats. - -Manual beta clarification: after a workspace is opened, the user should ask the -agent to explore or draft from the workspace. The agent should resolve the -workspace and initiative context, identify the owning linked repo, and run -repo-local OpenSpec commands from that repo. The workspace is the conversation -surface, not the artifact home. - -## Scope - -- Revise `docs/workspaces-beta/user-guide.md`. -- Revise `docs/workspaces-beta/agent-cli-playbook.md`. -- Keep the docs minimal until the flow has been tried manually. -- Record command or prompt gaps found during the doc pass. - -## Non-Goals - -- Do not change CLI behavior in this work item. -- Do not promise sync, cloning, branching, worktrees, progress dashboards, or - enforced edit boundaries. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/14-workspaces-beta-guide-split/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/14-workspaces-beta-guide-split/tasks.md deleted file mode 100644 index 00d86c8c71..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/14-workspaces-beta-guide-split/tasks.md +++ /dev/null @@ -1,9 +0,0 @@ -# Workspaces Beta Guide Split Tasks - -- [x] Identify which setup steps should be typed by the user. -- [x] Identify which initiative and change steps should be delegated to a coding - agent. -- [x] Update the user guide around interactive setup and agent prompts. -- [x] Update the agent CLI playbook around explicit commands and cwd rules. -- [x] Add a tiny caveat section that reflects shipped beta behavior. -- [ ] Capture any product gaps exposed by the docs pass. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/evidence.md b/openspec/explorations/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/evidence.md deleted file mode 100644 index 0d52781bba..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/evidence.md +++ /dev/null @@ -1,140 +0,0 @@ -# Context Store Project Roots And Schema-Led Initiatives Evidence - -## Manual Beta Findings - -- A fresh-agent style prompt successfully created `agent-trace-hooks` in the - registered `team-context` context store. -- The current CLI created this hardcoded file set: - -```text -initiative.yaml -requirements.md -design.md -decisions.md -questions.md -tasks.md -``` - -- The generated markdown templates started with `TBD` placeholders. -- The agent filled those documents with plausible but unreviewed planning - content. -- We manually reduced the test initiative to a sparse shape: - -```text -initiative.yaml -brief.md -``` - -- `openspec initiative show team-context/agent-trace-hooks --json` and - `openspec initiative list --store team-context --json` continued to resolve, - which proves current identity/listing logic does not require the six-file - packet. - -## Code Observations - -- Initiative file names are hardcoded in - `src/core/collections/initiatives/schema.ts`. -- Initiative markdown templates are hardcoded in - `src/core/collections/initiatives/templates.ts`. -- `createInitiative` writes `initiative.yaml` and then all default template - files in `src/core/collections/initiatives/operations.ts`. -- `initiative create --json` reports `created_files` from - `INITIATIVE_FILE_NAMES` in `src/commands/initiative.ts`. -- Initiative list/show read only `initiative.yaml`. -- Project-local schema resolution already uses - `<projectRoot>/openspec/schemas/<name>/schema.yaml` in - `src/core/artifact-graph/resolver.ts`. -- Project config already reads `<projectRoot>/openspec/config.yaml` in - `src/core/project-config.ts`. -- Change artifact status/instructions are coupled to repo-local change context - through `src/core/artifact-graph/instruction-loader.ts`. -- Planning-home detection currently treats an ancestor containing `openspec/` - as a possible repo planning root, so adding config to context stores needs a - safety check. - -## UX/Product Pass - -Recommended user meaning: - -```text -context store = shared OpenSpec context project -initiative = iterative high-level planning object -repo change = implementation plan -workspace = local view -``` - -Docs should avoid saying initiatives are only for cross-repo or cross-team -work. A user may choose a context store simply because they want OpenSpec -artifacts outside the implementation repo. - -`initiative create` should make the smallest useful shared object and then -teach the agent how to continue through status/instructions. It should not -pretend requirements, decisions, and tasks exist before review. - -## Architecture Pass - -Feasible minimal path: - -1. Treat the context store root as a project root for config/schema resolution. -2. Create `openspec/config.yaml` during context-store setup. -3. Resolve initiative schemas with `projectRoot = contextStoreRoot`. -4. Add initiative-specific status/instructions helpers using artifact graph - primitives. -5. Change initiative creation to write a sparse shell. - -Main risks: - -- strict `initiative.yaml` parsing if a new top-level `schema` field is added -- tests currently asserting the six-file MVP contract -- docs and generated agent guidance currently telling agents to edit the five - generated Markdown files -- ambiguity between initiative planning artifacts and repo-local implementation - tasks -- context-store roots becoming accidental repo planning homes after they gain - `openspec/config.yaml` - -## Subagent / Research Notes - -Three focused passes converged on the same direction. - -Architecture pass: - -- Model a context store as an OpenSpec planning root: - -```text -context-store/ - .openspec-store/store.yaml - openspec/config.yaml - openspec/schemas/ - initiatives/ -``` - -- Keep `.openspec-store/store.yaml` as store identity and - `openspec/config.yaml` as behavior/configuration. -- Reuse project-local config and schema resolution with the context-store root - as the project root. -- Add an initiative-specific artifact context instead of forcing initiatives - through repo-local change context. -- Guard planning-home discovery so a context store with `openspec/config.yaml` - does not become an accidental implementation repo. - -UX/product pass: - -- Describe a context store as an OpenSpec-managed planning home. It may be used - for cross-repo coordination, but also simply to keep OpenSpec artifacts out of - an implementation repo. -- Make `initiative create` sparse: `initiative.yaml` plus a seed artifact such - as `brief.md`. -- Add status/instructions output so agents create requirements and design - artifacts only when there is reviewed content to capture. -- Stop treating default initiative artifacts as files the user or agent should - immediately fill in. - -Release-risk pass: - -- Keep old six-file beta initiatives readable. -- Update tests that assert the old generated file list. -- Avoid strict top-level additions to `initiative.yaml` until metadata - versioning is designed. -- Defer context-store-hosted executable changes to the configurable change-home - work instead of bundling them into this slice. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/plan.md deleted file mode 100644 index 22a01ff2fe..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/plan.md +++ /dev/null @@ -1,344 +0,0 @@ -# Context Store Project Roots And Schema-Led Initiatives - -## Status - -Proposed from the manual beta reality pass. - -This work item replaces the current "initiative create writes a full hardcoded -six-file packet" model with a project-like context-store root and an iterative, -schema-led initiative artifact flow. - -## Source Of Truth - -Start from `../../direction.md` and preserve the current boundary: - -```text -Context stores sync truth. -Collections shape truth. -Initiatives coordinate work. -Workspaces open local views. -Changes implement repo-owned slices. -``` - -Manual beta evidence: agents can create the current MVP initiative shape, but -the generated `requirements.md`, `design.md`, `decisions.md`, `questions.md`, -and `tasks.md` invite premature, unreviewed planning content. - -## Why This Exists - -`openspec initiative create` currently creates a complete-looking planning -packet from hardcoded TypeScript constants and `TBD` templates. That made the -MVP tangible, but it is the wrong default for real initiative work. - -Initiatives are high-level shared planning surfaces for PMs, designers, -architects, and agents. They should capture intent, reviewed requirements, -design direction, open questions, and decisions as those artifacts become real. -They should not create empty or fake documents that look finished just because -the folder exists. - -The broader product shape is that a context store should feel like an OpenSpec -root in the same way a repo does after `openspec init`: it can have local -OpenSpec configuration and project-local schemas. The difference is lifecycle: -a context store is the shared context root, not an implementation repo. - -## Product Model - -Repo after `openspec init`: - -```text -repo/ - openspec/ - config.yaml - schemas/ - changes/ - specs/ -``` - -Context store after setup: - -```text -context-store/ - .openspec-store/ - store.yaml - openspec/ - config.yaml - schemas/ - initiatives/ -``` - -The `openspec/` directory inside a context store exists for OpenSpec config and -schema resolution. It does not by itself make the context store an executable -implementation planning home. - -## Goals - -- Let context stores carry OpenSpec config, including a default initiative - schema. -- Let context stores carry project-local schemas under `openspec/schemas/`. -- Replace hardcoded initiative file creation with a schema-led artifact model. -- Make `initiative create` sparse and safe by default. -- Let agents grow initiative artifacts one step at a time through status and - instructions output. -- Keep existing six-file MVP initiatives readable. -- Keep repo-local changes as the default implementation artifact. - -## Non-Goals - -- Do not make context-store-hosted executable changes part of this slice. That - remains Item 18. -- Do not add cross-repo apply, archive, validation, or spec-sync orchestration. -- Do not make workspace-local artifacts the shared planning source of truth. -- Do not migrate existing initiatives automatically. -- Do not install AI-tool runtime files into context stores by default unless a - later UX decision explicitly opts into that. - -## Default Initiative Shape - -New initiative creation should create a shell, not the whole plan: - -```text -initiatives/<id>/ - initiative.yaml - brief.md -``` - -`brief.md` is a seed document, not a completion marker for reviewed -requirements or design. It should contain the title, summary, and a short -"current understanding" section with no `TBD` placeholders. - -Reviewed planning artifacts are created later through the initiative schema. - -Default built-in schema, conceptually: - -```yaml -name: product-initiative -version: 1 -description: High-level initiative planning for PMs, designers, architects, and agents -usage: initiative -artifacts: - - id: requirements - generates: requirements.md - description: Product intent, goals, non-goals, requirements, and open questions - template: requirements.md - requires: [] - - - id: design - generates: design.md - description: Product, UX, and architecture direction with constraints and tradeoffs - template: design.md - requires: - - requirements -``` - -Do not include `tasks.md` in the default initiative schema. Implementation -tasks belong in repo-local changes. Coordination tasks, workstreams, decision -logs, and question logs can be separate later schemas or explicit artifacts once -the usage pattern is clearer. - -## UX Direction - -Store setup should make the store project-like enough for schemas: - -```bash -openspec context-store setup team-context --init-git -``` - -Expected created shape: - -```text -team-context/ - .openspec-store/store.yaml - openspec/config.yaml - initiatives/ -``` - -Preferred config direction: - -```yaml -initiative_schema: product-initiative -``` - -This avoids overloading the existing repo-local `schema` field, which currently -means "default change schema." If implementation chooses to reuse `schema` -instead, docs and JSON output must make the context-store scope explicit. - -Then initiative creation stays small: - -```bash -openspec initiative create agent-trace-hooks \ - --store team-context \ - --title "Agent Trace Hooks" \ - --summary "Explore lightweight capture of agent trace events and hook outcomes." -``` - -Expected next action: - -```bash -openspec initiative status team-context/agent-trace-hooks --json -openspec initiative instructions requirements team-context/agent-trace-hooks --json -``` - -The agent writes `requirements.md` only when the conversation has enough -reviewed content. `design.md` becomes ready after requirements exist. - -## Technical Approach - -Reuse the artifact graph primitives, but add an initiative-specific loader -instead of forcing initiatives through `loadChangeContext`. - -Current reusable pieces: - -- `src/core/artifact-graph/graph.ts` -- `src/core/artifact-graph/state.ts` -- `src/core/artifact-graph/outputs.ts` -- `src/core/artifact-graph/resolver.ts` -- `src/core/artifact-graph/instruction-loader.ts` template loading -- `src/core/project-config.ts` - -New initiative-specific pieces: - -- a context-store OpenSpec-root helper that treats the store root as the - `projectRoot` for config and schema lookup -- an initiative artifact context loader rooted at - `context-store/initiatives/<id>/` -- initiative `status` and `instructions` commands that mirror the repo-local - artifact workflow but return initiative-specific fields -- a sparse `initiative create` path that writes `initiative.yaml` and `brief.md` - only - -Do not use the existing repo planning-home resolver unchanged. Once context -stores contain `openspec/config.yaml`, the current "nearest `openspec/` folder -means repo planning home" heuristic can accidentally make a context store look -like an implementation repo. This work must either: - -- teach planning-home resolution to detect `.openspec-store/store.yaml` and - return or reject a context-store kind for implementation commands, or -- explicitly reject `openspec new change` from a context-store root until Item - 15 defines target-bound executable changes. - -## Schema And Config Compatibility - -Prefer a next-release-safe config path: - -- Add `initiative_schema` to project config, or an equivalent - collection-specific config field. -- Continue using existing `schema` as the default repo-local change schema. -- Store per-initiative schema overrides in existing `metadata` if needed. -- Avoid adding a new top-level `schema` field to `initiative.yaml` until - initiative metadata versioning is designed. - -Why: `initiative.yaml` is currently strict and versioned as `version: 1`. -Adding a top-level field would make older CLIs reject new initiatives. Existing -`metadata` can carry forward-compatible fields without breaking old readers. - -Schema namespace needs one explicit decision: - -- Either add a `usage: change | initiative` discriminator to schema files and - filter commands accordingly, or -- use a separate initiative schema namespace while reusing the same artifact - graph format. - -The simplest user-facing model is still `openspec/schemas/`, but commands must -avoid listing `product-initiative` as a valid repo-local change workflow. - -## JSON Contract - -`initiative create --json` should report the shell and next actions: - -```json -{ - "context_store": { - "id": "team-context", - "root": "/path/to/store" - }, - "initiative": { - "id": "agent-trace-hooks", - "root": "/path/to/store/initiatives/agent-trace-hooks", - "metadata_path": "/path/to/store/initiatives/agent-trace-hooks/initiative.yaml", - "schema": "product-initiative" - }, - "created_files": [ - "initiative.yaml", - "brief.md" - ], - "next_commands": { - "status": "openspec initiative status team-context/agent-trace-hooks --json", - "requirements": "openspec initiative instructions requirements team-context/agent-trace-hooks --json" - }, - "status": [] -} -``` - -`initiative status --json` should include: - -- context store identity and root -- initiative identity, root, metadata path, and selected schema -- artifact paths keyed by artifact id -- artifact statuses: `done`, `ready`, `blocked` -- next steps -- action context that says this is shared planning context, not an editable - implementation target - -`initiative instructions --json` should include: - -- resolved output path -- existing output paths -- schema instruction -- template content -- dependencies and unlocks -- store config context/rules if supported - -## Release Risk And Migration - -This is compatible if implemented as a sparse, additive layer: - -- Existing six-file initiatives remain readable because list/show only require - `initiative.yaml`. -- Existing optional markdown files can remain in old initiative folders. -- New status/instructions can ignore files outside the selected schema. -- Old CLIs can still read new initiatives if schema data stays under - `metadata` or store config rather than new strict top-level fields. - -High-risk areas: - -- Planning-home detection after context stores gain `openspec/config.yaml`. -- Tests and docs that assert the six-file MVP initiative shape. -- Schema lists and completions if initiative schemas share the same namespace - as change schemas. -- Agent guidance that still tells agents to edit every generated initiative - markdown file after creation. - -## Test And Doc Touch Points - -Likely tests to update or add: - -- `test/core/collections/initiatives/schema.test.ts` -- `test/core/collections/initiatives/templates.test.ts` -- `test/core/collections/initiatives/operations.test.ts` -- `test/commands/initiative.test.ts` -- `test/commands/context-store.test.ts` -- `test/commands/artifact-workflow.test.ts` -- planning-home tests for context-store roots with `openspec/config.yaml` -- schema listing/completion tests if schema usage filtering is added - -Likely docs to update: - -- `docs/workspaces-beta/agent-cli-playbook.md` -- `docs/workspaces-beta/user-guide.md` -- `docs/cli.md` -- schema docs if `usage` or `initiative_schema` is added -- `openspec/initiatives/context-store-and-initiatives/work-items/05-ship-initiative-mvp/` - with a note that the MVP shape was superseded by this work item - -## Done When - -- A new context store has OpenSpec config and can resolve project-local - initiative schemas. -- `initiative create` creates only the sparse initiative shell. -- Agents can use initiative status/instructions to create high-level planning - artifacts iteratively. -- `openspec new change` does not accidentally treat a context store as a normal - implementation repo just because the store has `openspec/config.yaml`. -- Existing MVP initiatives continue to list and show. -- Docs describe initiative artifacts as reviewed, iterative context rather than - files to fill in immediately. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/tasks.md deleted file mode 100644 index dace62c162..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/15-context-store-project-roots-and-schema-led-initiatives/tasks.md +++ /dev/null @@ -1,39 +0,0 @@ -# Context Store Project Roots And Schema-Led Initiatives Tasks - -- [x] Create Item 15 work-item tracking notes. -- [ ] Record the product decision that context stores should behave like - OpenSpec roots for config and schema resolution, but not as implementation - repos by default. -- [ ] Define the context-store root layout, including `.openspec-store/`, - `openspec/config.yaml`, `openspec/schemas/`, and `initiatives/`. -- [ ] Decide the config key for the default initiative schema, with - `initiative_schema` as the preferred next-release-safe direction. -- [ ] Decide whether initiative schemas share `openspec/schemas/` with a - `usage: initiative` discriminator or use a separate namespace while - reusing the artifact graph format. -- [ ] Add or design the built-in `product-initiative` schema for high-level - requirements and design artifacts. -- [ ] Define `brief.md` as the sparse creation seed and decide whether it sits - outside the artifact graph or is represented as an already-complete - artifact. -- [ ] Change `initiative create` from hardcoded six-file generation to sparse - `initiative.yaml` plus `brief.md` creation. -- [ ] Add initiative artifact status resolution rooted at - `context-store/initiatives/<id>/`. -- [ ] Add initiative artifact instructions output that returns schema guidance, - template content, dependencies, output path, and existing paths. -- [ ] Ensure store-local config context and rules can be read for initiative - artifact instructions without confusing repo-local change config. -- [ ] Guard planning-home resolution so context stores with - `openspec/config.yaml` do not silently become repo-local implementation - homes. -- [ ] Update `initiative create --json`, human output, and next-command guidance - for sparse creation and iterative artifacts. -- [ ] Update tests that currently assert the MVP six-file initiative shape. -- [ ] Add compatibility tests proving old six-file initiatives still list and - show. -- [ ] Add tests for context-store local schemas and store config defaults. -- [ ] Update beta docs and agent guidance to stop telling agents to edit every - initiative markdown file immediately after creation. -- [ ] Record migration behavior and a note that Item 5's six-file MVP shape has - been superseded by this schema-led sparse model. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/16-add-escalation-ux/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/16-add-escalation-ux/plan.md deleted file mode 100644 index 12ddd5d3bb..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/16-add-escalation-ux/plan.md +++ /dev/null @@ -1,26 +0,0 @@ -# Add Escalation UX - -## Status - -Future work item. Kept after first-run setup, handoff output, guide cleanup, -and schema-led initiatives because escalation should build on a sane onboarding -path. - -## Goal - -Let users start locally and upgrade into a coordinated initiative only when the -work actually needs shared context. - -## Ship - -- Explore/propose guidance that starts in the current repo by default. -- Recommendation triggers when work spans multiple owned areas. -- Carry-forward behavior for current change name, product goal, notes, inferred - areas, and relevant questions. -- Prompts grounded in concrete affected areas instead of abstract storage - models. - -## Done When - -- Coordinated planning feels like a continuation of local planning, not a - workflow restart. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/16-add-escalation-ux/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/16-add-escalation-ux/tasks.md deleted file mode 100644 index 6dcd465915..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/16-add-escalation-ux/tasks.md +++ /dev/null @@ -1,7 +0,0 @@ -# Add Escalation UX Tasks - -- [ ] Define local-to-initiative recommendation triggers. -- [ ] Carry current planning context into a new initiative. -- [ ] Keep prompts grounded in affected areas. -- [ ] Decide where escalation guidance appears in agent instructions, command - output, or interactive prompts. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/17-harden-team-shared-coordination/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/17-harden-team-shared-coordination/plan.md deleted file mode 100644 index c36f9230f3..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/17-harden-team-shared-coordination/plan.md +++ /dev/null @@ -1,25 +0,0 @@ -# Harden Team-Shared Coordination - -## Status - -Future work item. This should follow the first-run UX and schema-led initiative -work so team guidance is built on the stable beta path. - -## Goal - -Make initiatives practical for several teammates without turning setup into an -admin ceremony. - -## Ship - -- Recommended Git-backed shared context-store setup. -- Lightweight teammate onboarding. -- Repair flows for local path mappings. -- Sync status and conflict guidance. -- Clear separation between committed initiative state and machine-local - workspace state. - -## Done When - -- Several teammates can share the same initiative while each keeps their own - local checkout layout. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/17-harden-team-shared-coordination/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/17-harden-team-shared-coordination/tasks.md deleted file mode 100644 index fa51ad070a..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/17-harden-team-shared-coordination/tasks.md +++ /dev/null @@ -1,7 +0,0 @@ -# Harden Team-Shared Coordination Tasks - -- [ ] Document recommended Git-backed store setup. -- [ ] Define teammate onboarding and repair flows. -- [ ] Add sync status and conflict guidance. -- [ ] Define how committed initiative state and machine-local workspace state - should be explained in docs and generated guidance. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/evidence.md b/openspec/explorations/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/evidence.md deleted file mode 100644 index eaaea23940..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/evidence.md +++ /dev/null @@ -1,397 +0,0 @@ -# Explore Initiative-Hosted Target-Bound Change Artifacts Evidence - -## Initial Research Notes - -- Current product direction says context stores sync shared truth, initiatives - coordinate work, and repo-local changes own implementation planning. -- Roadmap Item 8 currently assumes repo-local changes linked to initiatives. -- Existing planning-home behavior distinguishes repo-local and workspace - planning homes, but does not have a context-store-backed change home. -- New artifact workflow commands already consume resolved planning paths in - some places, which may be a useful seam for future change-home resolution. -- Older command surfaces still assume `openspec/changes/` under a local - OpenSpec project and need an explicit audit before any implementation slice. - -## Initial Framing - -Configurable change homes are a product-boundary question, not just a path -change. A context-store-hosted artifact would still need a clear target repo or -spec root before validation, apply, archive, or spec sync can run safely. - -The future exploration should keep "change home" as internal resolver language -and use clearer product language around initiative-hosted planning artifacts, -target-bound changes, implementation targets, and editable roots. - -## Agent-First Team UX Research Pass - -Date: 2026-05-23. - -Question explored: - -```text -What does a great agent-first developer experience look like for teams using -context stores, initiatives, workspaces, and repo-local changes? -``` - -### External Pattern Notes - -- Linear uses initiatives as higher-level coordination objects that group - projects and expose health, ownership, and active project rollups. -- Jira planning commonly uses initiatives above epics or other child work - items for multi-team planning. -- GitLab roadmaps show higher-level epics and milestones across groups or - projects. -- GitHub Projects emphasize flexible planning that stays connected to issues - and repo work. - -These patterns point toward a common split: - -```text -Higher-level object = coordination and rollup -Execution item = work owned closer to a team, project, repo, or issue -``` - -OpenSpec should keep that separation while making the agent handoff sharper -than human project-management tools can. - -### Clean Mental Model - -The strongest mental model from the research pass: - -```text -Initiative = shared coordination truth -Workspace = local lens over initiative + repos -Repo change = executable implementation plan -``` - -Expanded product rule: - -```text -Context stores remember. -Initiatives coordinate. -Workspaces open. -Repo-local changes implement. -``` - -The key invariant: - -```text -Work identity is not storage location. -Storage location is not edit permission. -``` - -This keeps three decisions separate for agents: - -- What work is the user talking about? -- Where should the planning artifact live? -- Which files or repos may be edited now? - -### Suggested Artifact Types - -Repo context: - -```text -openspec/changes/<change-id>/ -``` - -Use for repo-owned implementation plans. A repo-local change may reference an -initiative through portable metadata: - -```yaml -initiative: - store: platform - id: billing-launch -``` - -Workspace context: - -```text -<store>/initiatives/<initiative-id>/work-items/<work-id>/ -``` - -Use for shared initiative planning before repo ownership or implementation -targets are clear. These should be called initiative work items, planning -briefs, or proposals, not executable OpenSpec changes, until Item 18 defines a -full lifecycle for context-store-backed changes. - -Workspace-local changes: - -```text -<workspace>/changes/<change-id>/ -``` - -Keep as legacy or beta compatibility unless the user explicitly opts into the -workspace-planning flow. - -### Agent-First UX Scenarios - -Single repo team: - -- User asks the agent to create a proposal from inside the repo. -- Agent resolves the initiative if named. -- Agent creates a repo-local change linked to the initiative. -- Apply, validate, sync, and archive stay repo-local. - -Monorepo: - -- One repo-local change can cover several packages or capabilities. -- The agent may need an area or package hint. -- The repo remains the implementation owner; areas clarify scope but do not - become separate change homes. - -Multi-repo platform: - -- Workspace opens the shared initiative context plus local repo clones. -- The initiative coordinates the platform outcome. -- Each owning repo gets its own linked repo-local change when implementation - ownership is known. -- Workspace state should report available local repos, missing local paths, and - edit boundaries. - -Central architecture team: - -- Architects may update initiative requirements, designs, contracts, decisions, - and questions without owning implementation. -- The agent should offer to draft shared initiative context or ask for the - owning repo before creating a repo-local change. - -Ownership unknown: - -- The agent should not create an implementation change. -- It should add or update initiative-level questions, or return target options - with a request for a repo or area decision. - -Teammate onboarding: - -```text -Clone or register the context store. -Run context-store doctor. -Open or resolve the initiative. -Link local repos through workspace mappings. -Ask the agent to continue from the initiative. -``` - -### Ideal Agent JSON Blocks - -Agents need stable routing vocabulary across create, status, instructions, -resolve, and list: - -```json -{ - "workTarget": { - "kind": "repo-change | initiative-work-item | workspace-change", - "id": "add-billing-api", - "root": "/absolute/path", - "storePath": "initiatives/billing-launch/work-items/add-billing-api" - }, - "initiativeLink": { - "store": "platform", - "id": "billing-launch", - "root": "/absolute/store/initiatives/billing-launch" - }, - "invocationContext": { - "kind": "repo | workspace", - "root": "/absolute/current/context" - }, - "actionContext": { - "mode": "implementation-ready | planning-only | target-selection-required", - "sourceOfTruth": "repo | context-store | workspace-local", - "allowedEditRoots": [], - "requiresTargetSelection": true, - "constraints": [ - "Use resolved output paths from the CLI.", - "Do not infer editable repos from the current working directory." - ] - }, - "nextCommands": {} -} -``` - -The important fields are: - -- `workTarget`: the object the agent is acting on. -- `initiativeLink`: the canonical shared coordination context, when present. -- `invocationContext`: where the command was run. -- `actionContext`: what the agent may edit. -- `nextCommands`: follow-up commands the agent should run instead of inventing - paths. - -### Lifecycle Rules - -- Repo-local changes are implementation-ready when the repo is the allowed edit - root. -- Initiative work items are planning-only until they select or link repo-local - implementation changes. -- Workspace-local changes are compatibility artifacts, not the preferred new - shared planning model. -- Apply, archive, repo spec sync, and repo delta validation should remain - repo-local until context-store-backed change lifecycle is explicitly designed. -- If `allowedEditRoots` is empty or target selection is required, agents should - stop before editing implementation files. - -### Edge Cases To Design For - -- Same initiative id exists in multiple stores. -- Some registered stores are unreadable or out of sync. -- A workspace can see a repo path but the user has not selected it as an edit - target. -- The terminal is inside a workspace, but the intended work belongs in a linked - repo. -- The terminal is inside a linked repo, but the user wants shared initiative - planning first. -- A repo-local change references an initiative store that is not registered on - the current machine. -- A context-store work item uses a schema that another teammate does not have. -- A change id exists both as a repo-local change and an initiative work item. -- A central team edits initiative context while implementation teams edit - linked repo-local changes. - -### Suggested Direction From The Pass - -Keep Item 8 narrow: - -- Add initiative metadata to repo-local changes. -- Add `new change <id> --initiative <store>/<initiative> --json`. -- Use `initiative show` plus workspace/repo context as the agent handoff - backbone. -- Do not implement context-store-backed OpenSpec changes in Item 8. - -Use Item 18 to decide the larger model: - -- Whether initiative work items should become a first-class artifact. -- Whether "change home" remains internal language. -- How context-store-hosted work binds to repo targets, specs, validation, - apply, archive, and sync. -- How skills and generated guidance teach agents to trust CLI JSON instead of - hardcoded paths or current working directory assumptions. - -## Target-Bound Reframe Subagent Pass - -Date: 2026-05-23. - -Question explored: - -```text -Given the product tension around central versus repo-local change storage, how -should Item 18 be reframed before implementation work begins? -``` - -Three subagent passes reviewed Item 18 from product semantics, agent-first UX, -and lifecycle/implementation angles. - -### Product Semantics Findings - -- The visible work item should not be framed as generic configurable storage. - That makes the hard question sound like path plumbing. -- The sharper product question is whether initiative-hosted artifacts can - become executable OpenSpec changes after they are bound to a target repo or - spec root. -- Repo-local changes remain the default executable implementation artifact. -- Initiative-hosted artifacts start as planning-only work items, briefs, or - proposals. -- "Change home" can stay as internal resolver language, but should not be the - main user-facing concept. - -Recommended naming: - -```text -Explore Initiative-Hosted Target-Bound Change Artifacts -``` - -### Agent-First UX Findings - -Agents need stable CLI output that separates the artifact from the thing the -agent may edit: - -```text -Plan lives in: repo-local OpenSpec | initiative context -Editable target: selected repo path | none yet -Linked initiative: platform/billing-launch -``` - -Commands should report structured action context rather than making generated -skills infer paths: - -```json -{ - "workTarget": { - "kind": "repo-change | initiative-work-item | initiative-hosted-change", - "id": "add-billing-api", - "root": "/absolute/path" - }, - "initiativeLink": { - "store": "platform", - "id": "billing-launch" - }, - "implementationTarget": { - "kind": "repo", - "id": "billing-api", - "specRoot": "openspec" - }, - "actionContext": { - "mode": "implementation-ready | planning-only | target-selection-required | unsupported", - "sourceOfTruth": "repo | context-store | workspace-local", - "allowedEditRoots": [], - "constraints": [ - "Use CLI-reported paths.", - "Do not infer editable repos from the current working directory." - ] - }, - "nextCommands": {} -} -``` - -If `allowedEditRoots` is empty, the agent should stop before editing -implementation files. If target selection is required, the command should return -next-step options rather than silently creating an ambiguous implementation -change. - -### Lifecycle And Implementation Findings - -Local code still has strong repo-local assumptions: - -- `src/core/planning-home.ts` models planning homes as `repo | workspace`. -- `src/commands/workflow/new-change.ts` resolves storage from the current - planning home and does not yet expose `--initiative` or `--json`. -- `src/commands/validate.ts` validates changes and specs from - `process.cwd()/openspec/...`. -- `src/core/archive.ts` archives by reading `openspec/changes`, applying deltas - to `openspec/specs`, and moving the change into `openspec/changes/archive`. -- `src/core/artifact-graph/types.ts` metadata does not yet model initiative - links, target repo identity, artifact home, or edit boundaries. -- Generated skills and workflow templates still contain repo-local path - assumptions such as `openspec/changes/<name>/`. - -These are not bugs in the current repo-local model. They are evidence that an -initiative-hosted executable change is a lifecycle design, not a small path -switch. - -### Updated Recommendation - -Keep Item 8 narrow: - -- Create or link repo-local changes with initiative metadata. -- Add JSON output for the agent handoff. -- Do not implement context-store-hosted executable changes in Item 8. - -Use Item 18 to answer the bigger question: - -- What initiative-hosted artifacts exist before an implementation target is - known? -- What target metadata lets a shared artifact graduate into an executable - change? -- How do local workspace and registry mappings resolve target repo identity to - machine-local paths? -- Which lifecycle commands should refuse, hand off to a repo-local change, or - operate directly against a resolved target? -- How should command and skill output teach agents to trust CLI-reported paths, - edit roots, and next commands? - -Go/no-go criterion: - -```text -Do not implement initiative-hosted executable changes until create/link, -show/status/list/instructions, validate, apply, archive, spec sync, workspace -resolution, generated skills, and JSON output all share one target-resolution -model. -``` diff --git a/openspec/explorations/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/plan.md deleted file mode 100644 index 9ae6faabcb..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/plan.md +++ /dev/null @@ -1,180 +0,0 @@ -# Explore Initiative-Hosted Target-Bound Change Artifacts - -## Status - -Not started. Added as a future exploratory work item. Framing updated from -generic "configurable change homes" to the sharper question of when shared -initiative artifacts can become executable, target-bound OpenSpec changes. - -## Source Of Truth - -Start from `../../direction.md`, especially the current boundary: - -```text -Context stores sync truth. -Collections shape truth. -Initiatives coordinate work. -Workspaces open local views. -Changes implement repo-owned slices. -``` - -## Why This Exists - -The current initiative direction assumes OpenSpec changes usually live in the -local repo that owns implementation. That keeps validation, archive, and spec -sync close to the code that will change. - -Some coordinated work may need a shared home before the owning repo is obvious. -A team may want initiative-hosted planning artifacts, and later may want some -of those artifacts to become implementation-ready plans for a specific repo or -spec root. - -This is not just a storage preference. A shared artifact is planning-only until -it has an explicit portable target binding and lifecycle rules for validate, -apply, archive, spec sync, and conflict handling. - -## Goal - -Decide whether OpenSpec should support initiative-hosted artifacts that can -graduate into executable changes only after they are bound to an implementation -target. - -Repo-local changes remain the default executable implementation artifact. Item -18 should decide if, when, and how a context-store-hosted artifact can safely be -treated as a change. - -The answer should preserve three boundaries: - -- Initiatives coordinate shared context. -- Changes describe executable implementation plans. -- Workspaces open local views and must not imply edit permission. - -## Model To Explore - -```text -Initiative artifact - -> planning-only by default - -> may become target-bound later - -Repo-local change - -> home: repo/openspec/changes/<id>/ - -> target: implicit current repo/spec root - -> lifecycle: validate/apply/archive/spec sync are repo-local - -Initiative-hosted target-bound change - -> home: context-store/initiatives/<initiative>/changes/<id>/ - -> target: explicit repo/spec root identity - -> lifecycle: unsupported until target resolution is designed - -Agent output - -> reports the work target - -> reports where the artifact lives - -> reports the implementation target, if any - -> reports allowed edit roots for this machine -``` - -Keep "change home" as internal resolver language. User-facing and agent-facing -output should prefer clearer phrases like "plan lives in repo-local OpenSpec", -"plan lives with the initiative", and "editable target". - -## Core Invariants - -- Storage location does not imply ownership, edit permission, or lifecycle. -- Work identity, artifact home, execution target, and allowed edit roots are - separate decisions. -- Shared context-store files must not store machine-local checkout paths. -- A targetless initiative artifact is a brief, work item, or proposal, not an - implementation-ready OpenSpec change. -- A context-store-hosted artifact can be considered executable only after it has - explicit target metadata and lifecycle command support. -- Item 8 remains repo-local: `new change <id> --initiative ...` creates or links - a repo-local change only. - -## Questions To Answer - -- What exact artifact types exist under an initiative: work items, briefs, - target-bound changes, or something else? -- What portable target metadata is required before an initiative-hosted artifact - can be executable? -- How does local resolution map a target repo identity to a checkout path, - OpenSpec root, branch, and allowed edit roots? -- Should central target-bound changes require explicit opt-in such as - `--home initiative`, or can initiative/store policy choose that behavior? -- If config exists, what is the deterministic precedence across explicit CLI - flags, repo config, initiative preference, context-store default, user default, - and built-in repo-local behavior? -- How does `openspec new change` report work target, artifact home, - implementation target, initiative link, action context, and next commands in - JSON? -- How do validate, apply, archive, and spec sync behave when the artifact lives - in a context store but the target specs live in a repo? -- Should archive for an initiative-hosted target-bound change archive centrally, - materialize a repo-local handoff change, or refuse until a repo-local change - exists? -- Which command and skill surfaces still hardcode `openspec/changes/`, current - working directory, or repo-local edit assumptions? -- What compatibility behavior preserves existing repo-local and workspace-local - changes? - -## Agent-First Output Contract - -Any future command that creates, reads, or resolves this work should make the -agent's next move explicit: - -```json -{ - "workTarget": { - "kind": "repo-change | initiative-work-item | initiative-hosted-change", - "id": "add-billing-api", - "root": "/absolute/path/reported/by/cli" - }, - "initiativeLink": { - "store": "platform", - "id": "billing-launch" - }, - "implementationTarget": { - "kind": "repo", - "id": "billing-api", - "specRoot": "openspec" - }, - "actionContext": { - "mode": "implementation-ready | planning-only | target-selection-required | unsupported", - "sourceOfTruth": "repo | context-store | workspace-local", - "allowedEditRoots": [], - "constraints": [ - "Use CLI-reported paths.", - "Do not infer editable repos from the current working directory." - ] - }, - "nextCommands": {} -} -``` - -If `allowedEditRoots` is empty, the agent should not edit implementation files. -If target selection is required, the command should return options or next -commands instead of creating an ambiguous implementation plan. - -## Explicitly Out Of Scope - -- Implementing context-store-hosted executable changes before the model is - decided. -- Moving existing repo-local changes into a context store automatically. -- Making initiatives own implementation artifacts by default. -- Making workspace-level changes the new shared planning model. -- Cross-repo apply, archive, or validation orchestration. -- Storing machine-local checkout paths in shared context-store files. -- Adding global defaults that can surprise ordinary repo-local commands into - writing shared artifacts. - -## Go/No-Go Criteria - -Do not implement initiative-hosted executable changes until OpenSpec has one -target-resolution model that can cover: - -- create and link output -- status, show, list, and instructions output -- validate, apply, archive, and spec sync behavior -- workspace registry and local repo mapping behavior -- generated skill guidance and command examples -- JSON output for work target, artifact home, implementation target, edit roots, - unsupported lifecycle commands, and next commands diff --git a/openspec/explorations/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/tasks.md deleted file mode 100644 index 6218f63bf1..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/18-explore-initiative-hosted-target-bound-change-artifacts/tasks.md +++ /dev/null @@ -1,28 +0,0 @@ -# Explore Initiative-Hosted Target-Bound Change Artifacts Tasks - -- [x] Create Item 18 work-item tracking notes. -- [x] Reframe Item 18 from generic change-home configuration to - initiative-hosted target-bound change artifacts. -- [ ] Audit commands, templates, validation, archive, apply, completion, and - docs for repo-local `openspec/changes/` assumptions. -- [ ] Define user-facing naming for initiative work items, briefs, - target-bound changes, artifact homes, and editable targets. -- [ ] Decide whether initiative-hosted artifacts can graduate into executable - changes, and which target metadata is required first. -- [ ] Decide the configuration or opt-in surface for repo-local versus - initiative-hosted artifacts. -- [ ] Define how `openspec new change` selects and reports the artifact home, - implementation target, initiative link, and action context. -- [ ] Define how initiative linking and workspace guidance discover artifact - homes and target repo mappings. -- [ ] Decide how initiative-hosted target-bound changes bind to repo specs, - implementation roots, branches, and local checkout paths. -- [ ] Decide validation, apply, archive, sync, and conflict behavior for - initiative-hosted target-bound changes. -- [ ] Define the agent JSON contract for work target, artifact home, - implementation target, allowed edit roots, unsupported lifecycle commands, and - next commands. -- [ ] Record compatibility behavior for existing repo-local and workspace-local - changes. -- [ ] Produce a recommendation, opt-in/config examples, affected command list, - and go/no-go criteria for implementation. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/19-review-workspace-beta-compatibility-before-public-release/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/19-review-workspace-beta-compatibility-before-public-release/plan.md deleted file mode 100644 index 04c4354a48..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/19-review-workspace-beta-compatibility-before-public-release/plan.md +++ /dev/null @@ -1,62 +0,0 @@ -# Review Workspace Beta Compatibility Before Public Release - -## Goal - -Before workspaces become public/stable, decide what beta workspace -compatibility behavior is actually worth carrying forward. - -This is intentionally late-stage work. Workspaces have not been publicly -released yet, so unpublished beta internals should not automatically become a -permanent compatibility contract. - -## Background - -The beta currently contains a few compatibility paths: - -- Legacy split workspace state readers for `.openspec-workspace/workspace.yaml` - and `.openspec-workspace/local.yaml`. -- Managed workspace registry fallback behavior. -- `codex` to `codex-cli` opener normalization. -- Generated `.gitignore` cleanup for old workspace `.code-workspace` ignore - rules. -- Empty or deprecated helper shims that exist only because previous workspace - slices exposed them internally. - -Some of these may be useful for local beta testers. Others may be safer to -delete before public release. - -## Scope - -Review workspace compatibility only. Do not use this item to reopen unrelated -legacy migration systems such as old slash-command cleanup, telemetry config -migration, or deprecated `change`/`spec` command aliases. - -## Decisions To Make - -- Which workspace compatibility paths are part of the public contract? -- Which paths are beta-only migration helpers and can be removed after one - release note or cleanup pass? -- Which paths are only test compatibility and can be deleted before release? -- Should beta workspace roots be migrated automatically, left readable, or - intentionally unsupported? -- Should old generated `.gitignore` cleanup exist at all, given workspaces are - managed local folders rather than repos? - -## Implementation Notes - -- Prefer deletion over preserving compatibility for unpublished intermediate - beta states. -- If a compatibility path remains, document why it exists and what would allow - it to be removed later. -- Keep user-owned files safe. Do not clean or rewrite ambiguous local files - unless OpenSpec can prove it owns them. -- Update tests so they describe the chosen public contract rather than the - accidental beta history. - -## Done When - -- Workspace compatibility code is inventoried and classified. -- Low-value beta-only shims are removed. -- Remaining compatibility behavior has focused tests and release-note language. -- Public docs and generated agent guidance do not mention unsupported beta - internals. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/19-review-workspace-beta-compatibility-before-public-release/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/19-review-workspace-beta-compatibility-before-public-release/tasks.md deleted file mode 100644 index 4ef391fa8a..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/19-review-workspace-beta-compatibility-before-public-release/tasks.md +++ /dev/null @@ -1,16 +0,0 @@ -# Tasks - -- [ ] Inventory workspace compatibility code paths and tests. -- [ ] Classify each path as public contract, beta migration, test-only shim, or - removable dead weight. -- [ ] Decide whether legacy split workspace state remains readable after public - release. -- [ ] Decide whether old generated `.gitignore` cleanup should remain, become - more conservative, or be removed entirely. -- [ ] Decide how long `codex` should remain accepted as an alias for - `codex-cli`. -- [ ] Remove beta-only compatibility paths that do not need to survive public - release. -- [ ] Update tests to encode the chosen compatibility contract. -- [ ] Update docs, generated guidance, and release notes with the final public - behavior. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/evidence.md b/openspec/explorations/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/evidence.md deleted file mode 100644 index 884feca24d..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/evidence.md +++ /dev/null @@ -1,47 +0,0 @@ -# Proposed Initiative Next / Agent Handoff UX Evidence - -## Source - -This discussion item came from the GSD workspace comparison. - -GSD's useful lesson was not its storage model. It was the simple user loop: -create context, move to the next concrete step, and keep the agent from guessing -where it is in the workflow. - -OpenSpec should keep the current boundary: - -```text -Context stores sync truth. -Initiatives coordinate work. -Workspaces open local views. -Changes implement repo-owned slices. -``` - -The possible gap is that `initiative show`, repo-local change linking, and -workspace opening may still require an agent to stitch together the next action -by hand. - -## Current Recommendation - -Keep this as a discussion draft until workspace initiative opening is clearer. -If accepted, the first version should be a small handoff/readiness command, not -status, progress, dashboarding, or workspace orchestration. - -## Manual Beta Pass Addition - -The 2026-05-28 manual beta pass found that command-level handoff is not the -only missing layer. A fresh agent also needs a small, tool-readable guide for -how to use OpenSpec at all: - -- inspect context stores, initiatives, workspaces, and repo-local changes before - guessing; -- understand that context stores can be artifact homes outside implementation - repos, not only cross-team coordination spaces; -- understand that repo-local changes own implementation planning when the user - wants artifacts in the repo; -- treat workspaces as local views, not durable planning homes; -- route to narrower OpenSpec workflow skills when available. - -As a temporary beta aid, a manual Codex skill was created at -`.codex/skills/use-openspec/` with references for shared context and artifact -placement. This is not yet productized in the configurator. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/plan.md b/openspec/explorations/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/plan.md deleted file mode 100644 index ca7b13b24b..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/plan.md +++ /dev/null @@ -1,90 +0,0 @@ -# Proposed Initiative Next / Agent Handoff UX - -## Status - -Discussion draft. Not locked into the numbered roadmap yet. - -## Why This Exists - -The GSD workspace comparison highlighted a UX gap: OpenSpec has increasingly -good discovery primitives, but agents still need to infer the next useful step -from several commands. - -The candidate idea is a tiny "what now?" handoff command after initiative -discovery from the current repo or workspace. It should not become a dashboard, -work-progress status view, or replacement for workspace local-view behavior. - -The manual beta pass surfaced a second, related handoff gap: before a command -like `initiative next` exists, a fresh coding agent still needs baseline -OpenSpec literacy. It needs to understand context stores, initiatives, -workspaces, repo-local changes, and where artifacts should live. A small -`use-openspec` skill may be the simplest first slice. - -## Candidate Goal - -Help an agent answer: - -```text -What should I do next for this initiative from the current repo or workspace? -``` - -## Possible Command Shape - -```bash -openspec initiative next <id> --json -``` - -Possible response: - -```json -{ - "initiative": "billing-launch", - "next_action": "create_repo_change", - "reason": "initiative found, no linked local change exists for this repo", - "suggested_command": "openspec new change add-billing-api --initiative billing-launch" -} -``` - -## Possible Skill Shape - -```text -use-openspec/ - SKILL.md - references/ - shared-context-beta.md - artifact-placement.md -``` - -This would be a baseline guide skill, not a workflow action. It should not -produce `/opsx:use-openspec`, should not appear as an implementation workflow, -and should not imply that workflow command delivery is unavailable. - -Open design question: whether this is literally part of the default profile, a -separate always-on bundled skill, or a managed guide skill installed by default -whenever the selected agent supports skills. - -## Discussion Points To Review - -- Should this become a numbered roadmap item before workspace initiative - opening? -- Is `initiative next` the right command name, or should this guidance live - inside workspace initiative opening or repo-local status? -- Should the command suggest exactly one next action, or return a ranked set of - possible actions? -- Should it inspect actual work progress, or stay limited to handoff readiness? -- How should it behave when no stores are registered, the initiative is - ambiguous, the local repo is unrelated, or linked changes already exist? -- Should baseline OpenSpec guidance be modeled as a default skill, a profile - member, or a separate managed guide? -- How should the guide skill interact with commands-oriented delivery? -- How should it teach artifact placement: context-store initiative vs - repo-local change vs workspace view? - -## Boundaries - -- Do not add progress/status semantics in the first version. -- Do not create changes, clone repos, or mutate workspace state. -- Do not make workspace opening a prerequisite. -- Prefer agent-readable JSON over broad interactive UX in the first slice. -- Do not turn baseline guidance into a new slash command unless a separate - workflow need emerges. diff --git a/openspec/explorations/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/tasks.md b/openspec/explorations/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/tasks.md deleted file mode 100644 index 188dbe726d..0000000000 --- a/openspec/explorations/context-store-and-initiatives/work-items/proposed-initiative-next-agent-handoff-ux/tasks.md +++ /dev/null @@ -1,18 +0,0 @@ -# Proposed Initiative Next / Agent Handoff UX Tasks - -These are discussion tasks only. Do not implement until the roadmap position and -scope are confirmed. - -- [ ] Decide whether to add this as a numbered roadmap item. -- [ ] Decide whether the command is `initiative next`, workspace initiative - opening guidance, or repo-local status guidance. -- [ ] Decide the minimal JSON output contract for agent handoff. -- [ ] Decide whether the command returns one next action or multiple options. -- [ ] Decide the error and empty-state behavior. -- [ ] Decide whether actual work progress/status is explicitly out of scope. -- [ ] Decide whether to ship `use-openspec` as a managed default skill. -- [ ] Decide whether `use-openspec` is a default-profile member or a separate - always-on guide skill. -- [ ] Decide how `use-openspec` interacts with commands-oriented delivery. -- [ ] Decide the minimal artifact-placement guidance for context-store - initiatives, repo-local changes, and workspace views. From 428b25498a89894562851754fa08b3919944da86 Mon Sep 17 00:00:00 2001 From: Clay Good <hi@claygood.com> Date: Mon, 13 Jul 2026 11:55:09 -0500 Subject: [PATCH 32/45] feat(stores): make the happy path one command deep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh store now defaults to the built-in documentation-only 'requirements' schema (proposal → specs, notes steering agents past the missing implementation phase), 'openspec new schema <name>' scaffolds a team's own workflow as a folder (stages = artifacts, ordered by requires:), setup output teaches the draft → serve → rollup loop, every generated skill knows when to link work with --serves, and the new-change progress line resolves the schema the way createChange does. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- schemas/requirements/instructions/proposal.md | 22 +++ schemas/requirements/instructions/specs.md | 27 +++ schemas/requirements/schema.yaml | 21 +++ schemas/requirements/templates/proposal.md | 23 +++ schemas/requirements/templates/spec.md | 8 + src/cli/index.ts | 17 ++ src/commands/store.ts | 6 +- src/commands/workflow/index.ts | 3 + src/commands/workflow/new-change.ts | 13 +- src/commands/workflow/new-schema.ts | 178 ++++++++++++++++++ src/core/completions/command-registry.ts | 10 + src/core/openspec-root.ts | 22 ++- src/core/store/operations.ts | 4 + .../templates/workflows/store-selection.ts | 2 +- test/commands/new-schema.test.ts | 87 +++++++++ test/commands/store-root-selection.test.ts | 4 +- test/commands/store.test.ts | 4 +- test/core/artifact-graph/resolver.test.ts | 11 ++ .../core/completions/command-registry.test.ts | 1 + .../templates/skill-templates-parity.test.ts | 72 +++---- 20 files changed, 488 insertions(+), 47 deletions(-) create mode 100644 schemas/requirements/instructions/proposal.md create mode 100644 schemas/requirements/instructions/specs.md create mode 100644 schemas/requirements/schema.yaml create mode 100644 schemas/requirements/templates/proposal.md create mode 100644 schemas/requirements/templates/spec.md create mode 100644 src/commands/workflow/new-schema.ts create mode 100644 test/commands/new-schema.test.ts diff --git a/schemas/requirements/instructions/proposal.md b/schemas/requirements/instructions/proposal.md new file mode 100644 index 0000000000..ca301dfb3a --- /dev/null +++ b/schemas/requirements/instructions/proposal.md @@ -0,0 +1,22 @@ +Create the proposal that establishes WHAT is changing and WHY, in plain +language a non-engineer can review. Do NOT describe implementation — no API +names, wire formats, or code structure. That belongs to the repos that will +serve this change. + +Sections: +- **Why**: 1-3 sentences on the problem or opportunity. Who benefits? Why now? +- **What Changes**: bullet list. Be specific about new capabilities, + modifications, or removals. Mark breaking changes with **BREAKING**. +- **Capabilities**: which specs this creates or modifies: + - **New Capabilities**: each becomes a new `specs/<name>/spec.md`. + Use kebab-case names (e.g., `user-auth`, `data-export`). + - **Modified Capabilities**: existing capabilities whose REQUIREMENTS + change. Check `openspec/specs/` for existing names. Leave empty if none. +- **Impact**: affected systems, teams, or downstream repos. + +IMPORTANT: The Capabilities section is the contract with the specs phase — +research existing specs before filling it in. Each capability listed needs a +corresponding spec file. + +Keep it to 1-2 pages. Do NOT proceed to specs until the proposal has been +reviewed and approved. diff --git a/schemas/requirements/instructions/specs.md b/schemas/requirements/instructions/specs.md new file mode 100644 index 0000000000..d648993d1c --- /dev/null +++ b/schemas/requirements/instructions/specs.md @@ -0,0 +1,27 @@ +Create specification files that define WHAT should be true — testable +requirements with scenarios, written from the actor's perspective. Cover the +primary success path AND meaningful edge/failure cases. + +Create one spec file per capability listed in the proposal's Capabilities +section: +- New capabilities: `specs/<capability>/spec.md` with the exact kebab-case + name from the proposal. +- Modified capabilities: use the existing folder name from + `openspec/specs/<capability>/`. + +Delta operations (use `##` headers): **ADDED Requirements**, **MODIFIED +Requirements** (MUST include the full updated requirement block), **REMOVED +Requirements** (MUST include **Reason** and **Migration**), **RENAMED +Requirements** (FROM:/TO:). + +Format: +- Each requirement: `### Requirement: <name>`, SHALL/MUST language. +- Each scenario: `#### Scenario: <name>` with GIVEN/WHEN/THEN. +- **CRITICAL**: scenarios use exactly 4 hashtags (`####`); every requirement + needs at least one scenario. + +Specs should be testable — each scenario is a potential acceptance check for +the downstream work that serves this change. + +This is the final artifact: there is no design or tasks phase here. Once the +specs are approved, archive the change to sync them into `specs/`. diff --git a/schemas/requirements/schema.yaml b/schemas/requirements/schema.yaml new file mode 100644 index 0000000000..358fe31c1a --- /dev/null +++ b/schemas/requirements/schema.yaml @@ -0,0 +1,21 @@ +name: requirements +version: 1 +description: Documentation-only workflow — proposal → specs. Draft and agree on requirements here; implementation happens in the repos that serve this change. +notes: > + This workflow has no implementation phase: there is no design.md or + tasks.md, and nothing to apply. When the specs are approved, archive the + change to sync its delta specs into specs/ — those become the standing + truths that downstream work traces against. Repos implement this change + by linking to it: openspec new change <name> --serves <store-id>/<change>. +artifacts: + - id: proposal + generates: proposal.md + description: Bounded statement of what is changing and why, in plain language + template: proposal.md + requires: [] + - id: specs + generates: specs/**/*.md + description: Testable requirements with scenarios, one spec per capability + template: spec.md + requires: + - proposal diff --git a/schemas/requirements/templates/proposal.md b/schemas/requirements/templates/proposal.md new file mode 100644 index 0000000000..c79b85d44d --- /dev/null +++ b/schemas/requirements/templates/proposal.md @@ -0,0 +1,23 @@ +## Why + +<!-- Explain the motivation for this change. What problem does this solve? Why now? --> + +## What Changes + +<!-- Describe what will change. Be specific about new capabilities, modifications, or removals. --> + +## Capabilities + +### New Capabilities +<!-- Capabilities being introduced. Replace <name> with kebab-case identifier (e.g., user-auth, data-export, api-rate-limiting). Each creates specs/<name>/spec.md --> +- `<name>`: <brief description of what this capability covers> + +### Modified Capabilities +<!-- Existing capabilities whose REQUIREMENTS are changing (not just implementation). + Only list here if spec-level behavior changes. Each needs a delta spec file. + Use existing spec names from openspec/specs/. Leave empty if no requirement changes. --> +- `<existing-name>`: <what requirement is changing> + +## Impact + +<!-- Affected code, APIs, dependencies, systems --> diff --git a/schemas/requirements/templates/spec.md b/schemas/requirements/templates/spec.md new file mode 100644 index 0000000000..095d711c8f --- /dev/null +++ b/schemas/requirements/templates/spec.md @@ -0,0 +1,8 @@ +## ADDED Requirements + +### Requirement: <!-- requirement name --> +<!-- requirement text --> + +#### Scenario: <!-- scenario name --> +- **WHEN** <!-- condition --> +- **THEN** <!-- expected outcome --> diff --git a/src/cli/index.ts b/src/cli/index.ts index 3fe2971eda..9dc09d2b17 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -35,12 +35,14 @@ import { templatesCommand, schemasCommand, newChangeCommand, + newSchemaCommand, DEFAULT_SCHEMA, type StatusOptions, type InstructionsOptions, type TemplatesOptions, type SchemasOptions, type NewChangeOptions, + type NewSchemaOptions, } from '../commands/workflow/index.js'; import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js'; import { COMMON_FLAGS } from '../core/completions/shared-flags.js'; @@ -734,6 +736,21 @@ newCmd } }); +newCmd + .command('schema <name>') + .description('Scaffold a workflow schema (stages, instructions, templates) in this root') + .option('--json', 'Output as JSON') + .option('--store <id>', STORE_OPTION_DESCRIPTION) + .addOption(hiddenStorePathOption()) + .action(async (name: string, options: NewSchemaOptions) => { + try { + await newSchemaCommand(name, options); + } catch (error) { + failWithError(error); + process.exit(1); + } + }); + export { program }; export function runCli(argv = process.argv): void { diff --git a/src/commands/store.ts b/src/commands/store.ts index f7228f1642..6d9fba82f8 100644 --- a/src/commands/store.ts +++ b/src/commands/store.ts @@ -398,8 +398,10 @@ function printMutationHuman( console.log(`${status.severity === 'error' ? 'Issue' : 'Note'}: ${status.message}`); } console.log(''); - console.log('Next: run normal OpenSpec commands against this store, for example:'); - console.log(` openspec new change <change-id> --store ${payload.store.id}`); + console.log('Next:'); + console.log(` openspec new change <name> --store ${payload.store.id} # draft requirements here`); + console.log(` openspec new change <name> --serves ${payload.store.id}/<change> # in any repo: link work to them`); + console.log(` openspec list --downstream --store ${payload.store.id} # where everything stands`); if (payload.git.is_repository) { const shareRemote = remotes?.canonical ?? remotes?.observed; console.log( diff --git a/src/commands/workflow/index.ts b/src/commands/workflow/index.ts index 232b2dbe34..c8a3e94bc7 100644 --- a/src/commands/workflow/index.ts +++ b/src/commands/workflow/index.ts @@ -19,4 +19,7 @@ export type { SchemasOptions } from './schemas.js'; export { newChangeCommand } from './new-change.js'; export type { NewChangeOptions } from './new-change.js'; +export { newSchemaCommand } from './new-schema.js'; +export type { NewSchemaOptions } from './new-schema.js'; + export { DEFAULT_SCHEMA } from './shared.js'; diff --git a/src/commands/workflow/new-change.ts b/src/commands/workflow/new-change.ts index 75ef42167e..2f18e819f0 100644 --- a/src/commands/workflow/new-change.ts +++ b/src/commands/workflow/new-change.ts @@ -11,7 +11,7 @@ import ora from 'ora'; import path from 'path'; import { ServesRefSchema } from '../../core/change-metadata/schema.js'; import { recordLinkedRoot, resolveUpstreamLink } from '../../core/upstream.js'; -import { addReferenceToProjectConfig } from '../../core/project-config.js'; +import { addReferenceToProjectConfig, readProjectConfig } from '../../core/project-config.js'; import { createChange, validateChangeName } from '../../utils/change-utils.js'; import { formatChangeLocation } from '../../core/planning-home.js'; import { @@ -159,7 +159,16 @@ export async function newChangeCommand(name: string | undefined, options: NewCha validateSchemaExists(options.schema, projectRoot); } - const resolvedSchema = options.schema ?? root.defaultSchema; + // Resolve the schema the same way createChange will (option → config → + // default) so the progress line never names the wrong schema. + let resolvedSchema = options.schema; + if (!resolvedSchema) { + try { + resolvedSchema = readProjectConfig(projectRoot)?.schema ?? root.defaultSchema; + } catch { + resolvedSchema = root.defaultSchema; + } + } if (spinner) { spinner.start(`Creating change '${name}' with schema '${resolvedSchema}'...`); } diff --git a/src/commands/workflow/new-schema.ts b/src/commands/workflow/new-schema.ts new file mode 100644 index 0000000000..dd0701e4b3 --- /dev/null +++ b/src/commands/workflow/new-schema.ts @@ -0,0 +1,178 @@ +/** + * New Schema Command + * + * Scaffolds a workflow schema as a folder in the resolved OpenSpec root: + * schema.yaml plus instructions/ and templates/ files. The scaffold is a + * working two-stage workflow (proposal → specs) whose comments teach the + * one move that matters: each artifact is a stage, ordered by `requires:`, + * so a team encodes its own chain by adding artifacts. + */ + +import path from 'path'; +import { promises as fs } from 'fs'; +import { isKebabId } from '../../core/id.js'; +import { getSchemaDir } from '../../core/artifact-graph/index.js'; +import { + resolveRootForCommand, + toRootOutput, + withStoreFlag, + type ResolvedOpenSpecRoot, +} from '../../core/root-selection.js'; +import { printJson, statusFromError } from './shared.js'; + +export interface NewSchemaOptions { + store?: string; + storePath?: string; + json?: boolean; +} + +const SCHEMA_YAML = (name: string) => `name: ${name} +version: 1 +description: > + <one line: what this workflow produces and who reviews it> +notes: > + <how this workflow differs from the default — agents see this verbatim + on every instruction surface. Example: "No implementation phase: archive + the change once specs are approved; downstream repos implement it via + openspec new change <name> --serves <store-id>/<change>."> + +# Each artifact is one stage of the workflow, ordered by \`requires:\`. +# Encode your own chain by adding artifacts — for example a design stage +# between proposal and specs, or an analysis stage before everything. +# Long-form guidance lives in instructions/<artifact-id>.md; the output +# shape lives in templates/. +artifacts: + - id: proposal + generates: proposal.md + description: What is changing and why + template: proposal.md + requires: [] + - id: specs + generates: specs/**/*.md + description: Testable requirements with scenarios + template: spec.md + requires: + - proposal +`; + +const PROPOSAL_INSTRUCTION = `Write the proposal in plain language for whoever reviews this stage. + +<replace with your stage's guidance: required sections, what belongs +here vs. later stages, and any review gate — e.g. "Do NOT proceed to +specs until the proposal is approved."> +`; + +const SPECS_INSTRUCTION = `Create one spec per capability named in the proposal (specs/<name>/spec.md). + +- Each requirement: \`### Requirement: <name>\`, SHALL/MUST language. +- Each scenario: \`#### Scenario: <name>\` with GIVEN/WHEN/THEN (exactly 4 hashtags). +- Cover the success path AND meaningful edge/failure cases. +`; + +const PROPOSAL_TEMPLATE = `## Why + +## What Changes + +## Capabilities +`; + +const SPEC_TEMPLATE = `## ADDED Requirements + +### Requirement: <name> + +#### Scenario: <name> +- **GIVEN** +- **WHEN** +- **THEN** +`; + +function printCreatedSchemaHuman( + name: string, + schemaDir: string, + root: ResolvedOpenSpecRoot +): void { + console.log(`Created schema '${name}' at ${schemaDir}/`); + console.log(' schema.yaml stages and their order (requires:)'); + console.log(' instructions/*.md guidance agents get per stage'); + console.log(' templates/*.md the output shape per stage'); + console.log(''); + console.log('Each artifact is a stage — add one per handoff in your workflow.'); + console.log( + `Try it: ${withStoreFlag(root, `openspec new change <name> --schema ${name}`)}` + ); + console.log( + `Make it the default for this root: set 'schema: ${name}' in openspec/config.yaml` + ); +} + +export async function newSchemaCommand( + name: string | undefined, + options: NewSchemaOptions +): Promise<void> { + try { + if (!name) { + throw new Error('Missing required argument <name>'); + } + if (!isKebabId(name)) { + throw new Error( + 'Schema name must be kebab-case with lowercase letters, numbers, and single hyphen separators' + ); + } + + const root = await resolveRootForCommand(options, { + json: options.json, + failurePayload: { schema: null }, + }); + if (!root) { + return; + } + + const schemaDir = path.join(root.path, 'openspec', 'schemas', name); + try { + await fs.stat(schemaDir); + throw new Error(`Schema '${name}' already exists at ${schemaDir}`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + + await fs.mkdir(path.join(schemaDir, 'instructions'), { recursive: true }); + await fs.mkdir(path.join(schemaDir, 'templates'), { recursive: true }); + const files: Array<[string, string]> = [ + ['schema.yaml', SCHEMA_YAML(name)], + [path.join('instructions', 'proposal.md'), PROPOSAL_INSTRUCTION], + [path.join('instructions', 'specs.md'), SPECS_INSTRUCTION], + [path.join('templates', 'proposal.md'), PROPOSAL_TEMPLATE], + [path.join('templates', 'spec.md'), SPEC_TEMPLATE], + ]; + for (const [relative, content] of files) { + await fs.writeFile(path.join(schemaDir, relative), content, 'utf-8'); + } + + // Sanity: the scaffold must resolve where changes will look for it. + if (getSchemaDir(name, root.path) === null) { + throw new Error(`Scaffolded schema '${name}' did not resolve — this is a bug.`); + } + + if (options.json) { + printJson({ + schema: { + name, + path: schemaDir, + artifacts: ['proposal', 'specs'], + }, + created_files: files.map(([relative]) => relative), + root: toRootOutput(root), + }); + return; + } + + printCreatedSchemaHuman(name, schemaDir, root); + } catch (error) { + if (options.json) { + printJson({ schema: null, status: [statusFromError(error)] }); + process.exitCode = 1; + return; + } + throw error; + } +} diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index f23befd742..dbae14d703 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -255,6 +255,16 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ COMMON_FLAGS.store, ], }, + { + name: 'schema', + description: 'Scaffold a workflow schema (stages, instructions, templates) in this root', + acceptsPositional: true, + positionals: [{ name: 'name' }], + flags: [ + COMMON_FLAGS.json, + COMMON_FLAGS.store, + ], + }, ], }, { diff --git a/src/core/openspec-root.ts b/src/core/openspec-root.ts index d65882ee21..4aa592d9c0 100644 --- a/src/core/openspec-root.ts +++ b/src/core/openspec-root.ts @@ -15,6 +15,19 @@ export const OPENSPEC_SPECS_DIR = 'openspec/specs'; export const OPENSPEC_CHANGES_DIR = 'openspec/changes'; export const OPENSPEC_ARCHIVE_DIR = 'openspec/changes/archive'; export const DEFAULT_OPENSPEC_SCHEMA = 'spec-driven'; + +// A store's changes are shared upstream work — requirements drafted before +// code moves — so a fresh store defaults to the documentation-only workflow. +// One line to change; `structure:` stays a commented example until a team +// wants to declare what its folders are for. +export const STORE_DEFAULT_CONFIG_CONTENT = `schema: requirements + +# Optional: tell agents what this store's folders are for. Surfaced to +# every repo that references this store (openspec context). +# structure: +# research/: raw inputs — interviews, transcripts, meeting notes +# decisions/: standing decisions and their rationale +`; export const DIRECTORY_ANCHOR_FILE_NAME = '.gitkeep'; // Git cannot track empty directories, so setup anchors otherwise-empty @@ -248,7 +261,8 @@ async function ensureDirectory( async function ensureDefaultConfig( storeRoot: string, - ledger: CreatedPathLedgerEntry[] + ledger: CreatedPathLedgerEntry[], + configContent?: string ): Promise<void> { const configYamlPath = path.join(storeRoot, OPENSPEC_CONFIG_YAML); const configYmlPath = path.join(storeRoot, OPENSPEC_CONFIG_YML); @@ -262,7 +276,7 @@ async function ensureDefaultConfig( await FileSystemUtils.writeFile( configYamlPath, - serializeConfig({ schema: DEFAULT_OPENSPEC_SCHEMA }) + configContent ?? serializeConfig({ schema: DEFAULT_OPENSPEC_SCHEMA }) ); ledger.push({ relativePath: relativeArtifact(OPENSPEC_CONFIG_YAML, 'file'), @@ -291,6 +305,8 @@ async function ensureDirectoryAnchor( export interface EnsureOpenSpecRootOptions { anchorEmptyDirectories?: boolean; + /** Seed content for a config that does not exist yet (never overwrites). */ + defaultConfigContent?: string; } export async function ensureOpenSpecRoot( @@ -310,7 +326,7 @@ export async function ensureOpenSpecRoot( await ensureDirectory(storeRoot, OPENSPEC_SPECS_DIR, ledger); await ensureDirectory(storeRoot, OPENSPEC_CHANGES_DIR, ledger); await ensureDirectory(storeRoot, OPENSPEC_ARCHIVE_DIR, ledger); - await ensureDefaultConfig(storeRoot, ledger); + await ensureDefaultConfig(storeRoot, ledger, options.defaultConfigContent); if (options.anchorEmptyDirectories) { for (const relativeDir of ANCHORED_OPENSPEC_DIRS) { diff --git a/src/core/store/operations.ts b/src/core/store/operations.ts index ccdc976377..3f372ed866 100644 --- a/src/core/store/operations.ts +++ b/src/core/store/operations.ts @@ -13,6 +13,7 @@ import { ANCHORED_OPENSPEC_DIRS, DIRECTORY_ANCHOR_FILE_NAME, OPENSPEC_ROOT_DIR, + STORE_DEFAULT_CONFIG_CONTENT, ensureOpenSpecRoot, inspectOpenSpecRoot, rollbackCreatedPaths, @@ -626,6 +627,9 @@ export async function setupPreparedStore( try { const root = await ensureOpenSpecRoot(storeRoot, { anchorEmptyDirectories: !alreadyRegisteredHere, + // Stores hold shared upstream work, so a fresh one defaults to the + // documentation-only workflow instead of the code workflow. + defaultConfigContent: STORE_DEFAULT_CONFIG_CONTENT, }); createdFiles.push(...root.createdArtifacts); createdPaths = root.createdPaths; diff --git a/src/core/templates/workflows/store-selection.ts b/src/core/templates/workflows/store-selection.ts index 1c39b38323..55b3786a16 100644 --- a/src/core/templates/workflows/store-selection.ts +++ b/src/core/templates/workflows/store-selection.ts @@ -4,4 +4,4 @@ * Interpolated into every workflow's instructions so generated skills * consistently teach how to target a registered store with `--store <id>`. */ -export const STORE_SELECTION_GUIDANCE = `**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run \`openspec store list --json\` to discover registered store ids, then pass \`--store <id>\` on the commands that act on a selected root (\`new change\`, \`status\`, \`instructions\`, \`list\`, \`show\`, \`validate\`, \`archive\`, \`doctor\`, \`context\`, \`schemas\`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local \`openspec/\` root.`; +export const STORE_SELECTION_GUIDANCE = `**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run \`openspec store list --json\` to discover registered store ids, then pass \`--store <id>\` on the commands that act on a selected root (\`new change\`, \`new schema\`, \`status\`, \`instructions\`, \`list\`, \`show\`, \`validate\`, \`archive\`, \`doctor\`, \`context\`, \`schemas\`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local \`openspec/\` root. **Upstream links:** when new work implements or serves work tracked in a store (the user names it, or \`openspec context\` lists it under "In motion"), create the change with \`--serves <store-id>/<change>\` — the link wires context and status automatically, and the change's instructions will carry the upstream artifacts to read first.`; diff --git a/test/commands/new-schema.test.ts b/test/commands/new-schema.test.ts new file mode 100644 index 0000000000..6fd4247f66 --- /dev/null +++ b/test/commands/new-schema.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; + +describe('openspec new schema', () => { + let tempDir: string; + let env: NodeJS.ProcessEnv; + + beforeEach(() => { + tempDir = fs.realpathSync.native( + fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-new-schema-')) + ); + env = { + XDG_DATA_HOME: path.join(tempDir, 'data'), + XDG_CONFIG_HOME: path.join(tempDir, 'config'), + OPEN_SPEC_INTERACTIVE: '0', + OPENSPEC_TELEMETRY: '0', + }; + fs.mkdirSync(path.join(tempDir, 'openspec', 'changes'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'openspec', 'config.yaml'), + 'schema: spec-driven\n' + ); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function parseJson(result: RunCLIResult): any { + return JSON.parse(result.stdout); + } + + it('scaffolds a working schema folder that changes can use immediately', async () => { + const result = await runCLI(['new', 'schema', 'team-flow', '--json'], { + cwd: tempDir, + env, + }); + expect(result.exitCode).toBe(0); + const payload = parseJson(result); + expect(payload.schema.name).toBe('team-flow'); + expect(payload.created_files).toEqual([ + 'schema.yaml', + path.join('instructions', 'proposal.md'), + path.join('instructions', 'specs.md'), + path.join('templates', 'proposal.md'), + path.join('templates', 'spec.md'), + ]); + + // The scaffold is a valid, listed schema... + const schemas = await runCLI(['schemas', '--json'], { cwd: tempDir, env }); + const entry = parseJson(schemas).find((s: any) => s.name === 'team-flow'); + expect(entry).toMatchObject({ source: 'project', artifacts: ['proposal', 'specs'] }); + + // ...and a change created with it gets the full artifact graph. + const change = await runCLI( + ['new', 'change', 'try-flow', '--schema', 'team-flow', '--json'], + { cwd: tempDir, env } + ); + expect(change.exitCode).toBe(0); + expect(parseJson(change).change.schema).toBe('team-flow'); + }); + + it('refuses to overwrite an existing schema', async () => { + fs.mkdirSync(path.join(tempDir, 'openspec', 'schemas', 'team-flow'), { + recursive: true, + }); + const result = await runCLI(['new', 'schema', 'team-flow', '--json'], { + cwd: tempDir, + env, + }); + expect(result.exitCode).toBe(1); + expect(parseJson(result).status[0].message).toContain('already exists'); + }); + + it('rejects a non-kebab name', async () => { + const result = await runCLI(['new', 'schema', 'Team Flow', '--json'], { + cwd: tempDir, + env, + }); + expect(result.exitCode).toBe(1); + expect(parseJson(result).status[0].message).toContain('kebab-case'); + }); +}); diff --git a/test/commands/store-root-selection.test.ts b/test/commands/store-root-selection.test.ts index e50147e0d9..bf6f41e6e3 100644 --- a/test/commands/store-root-selection.test.ts +++ b/test/commands/store-root-selection.test.ts @@ -724,7 +724,7 @@ describe('store root selection for normal commands', () => { { cwd: appRepo, env } ); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('openspec new change <change-id> --store fresh-context'); + expect(result.stdout).toContain('openspec new change <name> --store fresh-context'); }); it('shows --store usage after register', async () => { @@ -740,7 +740,7 @@ describe('store root selection for normal commands', () => { env, }); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('openspec new change <change-id> --store register-context'); + expect(result.stdout).toContain('openspec new change <name> --store register-context'); }); }); }); diff --git a/test/commands/store.test.ts b/test/commands/store.test.ts index e07f4e5d51..839037f089 100644 --- a/test/commands/store.test.ts +++ b/test/commands/store.test.ts @@ -164,8 +164,10 @@ describe('store command', () => { ]); expect(payload.status).toEqual([]); expectHealthyOpenSpecRoot(storeRoot); + // A fresh store defaults to the documentation-only workflow: its + // changes are shared upstream requirements, not code changes. expect(fs.readFileSync(path.join(storeRoot, 'openspec', 'config.yaml'), 'utf-8')).toContain( - `schema: ${DEFAULT_OPENSPEC_SCHEMA}` + 'schema: requirements' ); expectNoGeneratedAgentOrBetaArtifacts(storeRoot); await expect(readStoreMetadataState(storeRoot)).resolves.toEqual({ diff --git a/test/core/artifact-graph/resolver.test.ts b/test/core/artifact-graph/resolver.test.ts index b0bb23c38a..6115c3bef4 100644 --- a/test/core/artifact-graph/resolver.test.ts +++ b/test/core/artifact-graph/resolver.test.ts @@ -746,4 +746,15 @@ artifacts: expect(schema.apply?.instruction).toBe('Apply from the file.'); }); }); + describe('built-in requirements schema', () => { + it('resolves as a documentation-only workflow with notes and file instructions', () => { + const schema = resolveSchema('requirements'); + expect(schema.artifacts.map((a) => a.id)).toEqual(['proposal', 'specs']); + expect(schema.notes).toContain('no implementation phase'); + // Guidance ships as instructions/<artifact>.md files beside the schema. + expect(schema.artifacts[0].instruction).toContain('non-engineer'); + expect(schema.artifacts[1].instruction).toContain('#### Scenario'); + expect(schema.apply).toBeUndefined(); + }); + }); }); diff --git a/test/core/completions/command-registry.test.ts b/test/core/completions/command-registry.test.ts index 8c90604917..380200b57d 100644 --- a/test/core/completions/command-registry.test.ts +++ b/test/core/completions/command-registry.test.ts @@ -171,6 +171,7 @@ describe('command completion registry', () => { 'instructions', 'list', 'new change', + 'new schema', 'schemas', 'show', 'status', diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index 249d604b30..f9fdaec264 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -37,46 +37,46 @@ import { import { STORE_SELECTION_GUIDANCE } from '../../../src/core/templates/workflows/store-selection.js'; const EXPECTED_FUNCTION_HASHES: Record<string, string> = { - getExploreSkillTemplate: '26675478b220715bafe3749311db95677043afc85da4dd01c53726a83f198704', - getNewChangeSkillTemplate: 'a647a6602e361cda6a5277ee8195c76cc59c6105c155731bb67ae61bd14c627f', - getContinueChangeSkillTemplate: '03d2a37c9a703a379d4df38633009e63f30f26df78c13e5e259001a4e0391c76', - getApplyChangeSkillTemplate: '31b8da530a8ad1d06562255a5f3b285fb5591c6ddeca9b68b786c0b022196845', - getFfChangeSkillTemplate: 'bf6f4918e96f681922b715338b56dba7a60a46051e3fbaf2ef2d026e51eed07c', - getSyncSpecsSkillTemplate: '8af60d91a626e4751d6a2aef7e47b1522daa1db01c213386fc206a54ce176ab1', - getOnboardSkillTemplate: 'eb746e0f6e720794f2565e487a8bb7e50273e4b8494308b00e7afeed2c31a5e2', - getOpsxExploreCommandTemplate: '1a99984ace5e8ae76a05d357c04a2c6e4b13407451f4545534d4ce95d507bb54', - getOpsxNewCommandTemplate: 'd761624274af2856e60096847dc9fab4beaec0ee55f49ee1e885d3af0496570f', - getOpsxContinueCommandTemplate: 'bd7776b401467c98b07ead76a6965802e99dc150d59804c1014e7ef5f7f89fb3', - getOpsxApplyCommandTemplate: '1382a717a379ba6c6e0b957c59f98df6010a3f10778a73b64af4fc6e0e0b0cc6', - getOpsxFfCommandTemplate: '610a96f70073eb6643f1387723dec01ffbfa75a2e48ddd471845d47fc1183378', - getArchiveChangeSkillTemplate: '6457b47ef91bbb964e434725ca845851ed69497a5f770c4ef9713019fd1378ae', - getBulkArchiveChangeSkillTemplate: '0ce6933682e5f74b8d8b3fd49270957d6c14572eb0490eb65ad63628152e865e', - getOpsxSyncCommandTemplate: 'f50af3e913e32269b5b17bfef71f4f2175c327b3546f848cff9e7f8e017df3bd', - getVerifyChangeSkillTemplate: '9ec56494eac8d7970f985c00938987f784ee3402e407e239004e6da7d0af1896', - getOpsxArchiveCommandTemplate: 'eaa988fb7eacfd8d27c89b0dbac75bdf34ae45f11dccedf5a30caf16dfe0ac80', - getOpsxOnboardCommandTemplate: 'f5d6072c4c6d0c9e2704c469d06f2d56f638c1450088df644f88f013af4074d4', - getOpsxBulkArchiveCommandTemplate: 'be07bc0d7ea413aebaad181ee22dc856bbfc6210fe394310b6c8b552ee891bc0', - getOpsxVerifyCommandTemplate: 'b4a5717c25883ca74dc8da091aef2432492e2a377c8cd9c1a0369b6b854dc32c', - getOpsxProposeSkillTemplate: 'ad11a374aa8f3978a93af3a2d5b4cea736d6a5f7b3f913b38a2b34aeeb2bec21', - getOpsxProposeCommandTemplate: '8aa4d2e0ca201ad8e913e8d490223063cef9c2a7e2b7a464d5aa0452540ccc09', + getExploreSkillTemplate: '7d29c863c7489fccd3696e5a0f797c2a3ceb00e87b2edbb5afa2a7f4c2c1e5f1', + getNewChangeSkillTemplate: '9a77bc2103d174df4074564b1815de3674ad2f4417286747bfac1982cb8a8729', + getContinueChangeSkillTemplate: 'ca14e1fc46f3ae35d46db0b874138a649fdbc0ca497e5d13b8f8717bbd6d2270', + getApplyChangeSkillTemplate: '0c5090a0da5209abd3868f2e6fd8676dc399c9d35175559c9824719aa55bd77f', + getFfChangeSkillTemplate: '418936f7f7d239eb57316e7a7fc16333356ae0c86a19dd1f1eb3df4e6570cf72', + getSyncSpecsSkillTemplate: '7cd72eb2ee6daa5a659b8f39b7f2dad0a7f4b4e0a9d72d6983aa756268b24f5a', + getOnboardSkillTemplate: '468b1acb044688cb5c758afacb9e317f315286a1a896b2160dbd3a3aaaefdc01', + getOpsxExploreCommandTemplate: '377375850502c64771c15dfa5d3a97a94019fb7d1a9bf386082851e84691bcb6', + getOpsxNewCommandTemplate: '8d264704470d555340e63bef518623854783673a827a0b7186b7db785fe3bef0', + getOpsxContinueCommandTemplate: '760fdf65bb7e8a2022ec6b292ed418c810c7110021a14686a0703a7dd45715a4', + getOpsxApplyCommandTemplate: 'fb04979c3953c9a801511d25bf40d433d7f2dc5ebaf8c03089361a5468a895c4', + getOpsxFfCommandTemplate: 'ea3f4cb7c75bf60a862a96673a3a1ee790d152c095d1eaec1bfd947e604c45cf', + getArchiveChangeSkillTemplate: '2685d3afa9c744b71977213b50a07b6f36245b47e850de0837279071ea995622', + getBulkArchiveChangeSkillTemplate: '48da8717605f7ad031eb64a08a1699b193e2b8581d520b0415109323cfe18aa9', + getOpsxSyncCommandTemplate: '657334f9a24012dbbd376b43dd70a0359ab3b33860b42533f2d22614840dd040', + getVerifyChangeSkillTemplate: '694d9de868be5c4efeaba4e5dadabce44bac441083cdf91ed6e9d54101c20f85', + getOpsxArchiveCommandTemplate: '6bedf96a38f737b81873d179af0baa70ba569a9842a53547328b195df94a52cc', + getOpsxOnboardCommandTemplate: '8cfd54812d14d10d6073204f32c29f54d77ae0e9b14767b9ecd4e140a6b1f049', + getOpsxBulkArchiveCommandTemplate: '173f5bceee0980ae065e17ef24a1c0cec4730de017f448238942b26a0fc17247', + getOpsxVerifyCommandTemplate: '172aaf7c3409138be84fce21d49dac206e6d4dfbdc663c9a82ad41663a6e0417', + getOpsxProposeSkillTemplate: '47031581d8855472d6e8743650394c7f91c18a34fd037ffa9eda7b0a521e1b91', + getOpsxProposeCommandTemplate: '3d450bd00350a7f906d421bf3d1a1b02a05912d01499480414f46f28b7ce3af2', getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d', - getUpdateChangeSkillTemplate: 'cb95d9c5c450087b5adf862986a6a81a65d57cce535162e693fbf9aea127c1c3', - getOpsxUpdateCommandTemplate: '1657481ccf1f60c44cba192d51b20f5d7035b3c3a84e237b7323b703496fc149', + getUpdateChangeSkillTemplate: 'edfd7c78c182824d0775c7a9329cc6c2c11a80f0566ce72e0fc0c148aae1db4f', + getOpsxUpdateCommandTemplate: '74ae3775f8a2c89dd418c9128af09becc86175f5268203a1ad8b572d3b1f53ee', }; const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record<string, string> = { - 'openspec-explore': 'e5365b5136fd85b0f3be3e65a834148b32ab05a88df575d8c582a2d048204ba7', - 'openspec-new-change': '491b175943bffded8ec33c1ec2f62dfc83cd078970c0469ceefe21c668192a54', - 'openspec-continue-change': 'ffc8d80dbf2bf39ba1cefbdd7e24aa004f4ff399ec2ff5aa764aa5cf68e6535c', - 'openspec-apply-change': 'af4c1c22e9d1571c41d90933e8dd1763f168e1629595825142978a91b0314033', - 'openspec-ff-change': '20197740266c5fa521eaf4d74a24cb9e5ce270ac8a4aafa92bc47ad56e11b91e', - 'openspec-sync-specs': 'ea1e8d016953775d0f61b4c1813fc0b77ee310dac1bd085722ac99cba7b6f1d1', - 'openspec-archive-change': '51c003b7fc44cab1ddefdd16db4030e971b4504eb9dce99b70a79f691ba2e8b0', - 'openspec-bulk-archive-change': '3fa3a1b31791f74fa8fbc28acfb54626cecca19d9f7434d772873767e6a94f83', - 'openspec-verify-change': 'ba6d6bc6e78d2ab9cf3c41d75961cdd08277ac591ff2454c98b4190a4e43fba6', - 'openspec-onboard': 'd8baa141849b099671376d6928d29e4ed7dfb4be9675dbe3e1ff282548d883a9', - 'openspec-propose': '273e432fd268546de6316520b2d116c1e6d01d5a8d57bd03ad71a47fb5cec112', - 'openspec-update-change': '29c95eefe0df8efb4681dcaa6a90ae965535edda1832501f637a368608e33684', + 'openspec-explore': '7720de626aaecda5f708ff168cc3a2f5ffc192da83ec6330a3f711e511d3ecbf', + 'openspec-new-change': 'acef2777cd19fca67a7f6e0c12f495c8289abe35bf8be6f167a1bf2cb95c3ac7', + 'openspec-continue-change': '723d05abf8826e75a252ec52988fa0ce38a19709eb9126c739e0123a8351477d', + 'openspec-apply-change': 'bbbb5b93d33cdfb20f95774ff1c7e5f939c889b32ee039d6c294e139dd9da293', + 'openspec-ff-change': '28bdc98be47e94b9e718760f620d279818ba408035b8c6a9e2a68e840bda1948', + 'openspec-sync-specs': 'a8c0c437d38cca64c0e1c154810e2c06a4b8675152b616bac74bcd5e9090785a', + 'openspec-archive-change': 'd3f15228a5ec885f09493025e16e4a6763effda37a490bee0684a794b8940eb2', + 'openspec-bulk-archive-change': 'a575d6813df21f21a176d96933d21d0706759cd40d3590f3a3774e58b574bf83', + 'openspec-verify-change': '4e7db9dd5b3b6c3f3f3828c5d5e769b60fe14c264bf947478144cf08a7d69f4e', + 'openspec-onboard': 'a402b00b0dc0e2a6d763cede2ce1e50338a83ca80230ab956cf4a7ec874c13ba', + 'openspec-propose': '7a9ee4bf53dcdb18ff61f87e4dd8c81dd9a0ecd8f1fb684af64b05fcb8cd22f8', + 'openspec-update-change': '129923f75ba75d177acd96db9c893ee85f06101f617e7e22dc11c7017108f978', }; // Intentionally excludes getFeedbackSkillTemplate: this list only models templates From 6d67c21ad9457da8ac15b56eeaafdea31578a617 Mon Sep 17 00:00:00 2001 From: Clay Good <hi@claygood.com> Date: Mon, 13 Jul 2026 11:57:01 -0500 Subject: [PATCH 33/45] docs(stores): teach the happy path in the upstream-work guide and change record Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- docs/stores-beta/upstream-work.md | 14 ++++++++++++++ openspec/changes/add-upstream-links/proposal.md | 4 ++++ openspec/changes/add-upstream-links/tasks.md | 5 +++++ 3 files changed, 23 insertions(+) diff --git a/docs/stores-beta/upstream-work.md b/docs/stores-beta/upstream-work.md index fe62606c6e..61de458e28 100644 --- a/docs/stores-beta/upstream-work.md +++ b/docs/stores-beta/upstream-work.md @@ -18,6 +18,19 @@ All output below is from a real run. ## The store defines the workflow +A fresh store needs no setup: its changes default to the built-in +`requirements` workflow (proposal → specs, documentation-only — its `notes:` +tell agents there is no implementation phase). To encode your own chain, +scaffold a schema and add one artifact per handoff: + +```text +$ openspec new schema product-flow --store product-team +Created schema 'product-flow' at …/openspec/schemas/product-flow/ + schema.yaml stages and their order (requires:) + instructions/*.md guidance agents get per stage + templates/*.md the output shape per stage +``` + A schema is a folder. Long-form guidance lives in files beside it, and a top-level `notes:` tells agents how this workflow differs from the default: @@ -106,6 +119,7 @@ onboarding-revamp 1/2 serving changes complete (archived — its requirements | `schema.yaml` | optional `notes:` (workflow guidance, surfaced verbatim to agents) | | `<schema>/instructions/<artifact>.md` | per-artifact instruction files; a file wins over inline `instruction:` | | `config.yaml` | `structure:` — folder → purpose map, surfaced in `context` and the references index | +| `openspec new schema <name> [--store <id>]` | scaffold a workflow: schema.yaml + instructions/ + templates/ | | `openspec schemas [--store <id>]` | includes inherited schemas with their source store | | `openspec list --downstream [--store <id>]` | the rollup; outside any root it shows every registered store's | | `openspec context` | referenced stores show artifact types, in-motion changes, and layout | diff --git a/openspec/changes/add-upstream-links/proposal.md b/openspec/changes/add-upstream-links/proposal.md index a0f348bf8a..cb27054e86 100644 --- a/openspec/changes/add-upstream-links/proposal.md +++ b/openspec/changes/add-upstream-links/proposal.md @@ -27,6 +27,10 @@ noun; it is the existing machinery working one level up and across repos. schema; schemas inherit through `references:` (project → referenced stores → user → package); `structure:` in config declares folder purposes, surfaced in `context` and the references index. +- **Happy path**: a fresh store defaults to the new built-in `requirements` + schema (proposal → specs, documentation-only); `openspec new schema <name>` + scaffolds a team's own workflow as a folder; store setup output teaches the + draft → serve → rollup loop; generated skills know when to use `--serves`. - **Removed**: the initiatives folder convention, stages, evergreen files, `list --initiatives`, and the initiatives skill. The `initiative` noun returns to retired vocabulary; legacy metadata stays readable. diff --git a/openspec/changes/add-upstream-links/tasks.md b/openspec/changes/add-upstream-links/tasks.md index d5ab77a5a9..26d9053c73 100644 --- a/openspec/changes/add-upstream-links/tasks.md +++ b/openspec/changes/add-upstream-links/tasks.md @@ -15,6 +15,11 @@ - [x] Inheritance through `references:` in resolver, listings, and `schemas --store` labels - [x] `structure:` config declarations surfaced in context and the references index +## Happy path +- [x] Built-in `requirements` schema; fresh stores default to it +- [x] `openspec new schema <name>` scaffolder (schema.yaml + instructions/ + templates/) +- [x] Store setup output teaches the loop; skills carry `--serves` guidance + ## Verification - [x] Unit tests: upstream module, resolver inheritance, structure parsing, references index - [x] CLI tests: --serves validation and wiring, downstream rollup, context enrichment From 3b20c64da3fb13a296fdc63fcbc6bc9581dbba31 Mon Sep 17 00:00:00 2001 From: Clay Good <hi@claygood.com> Date: Mon, 13 Jul 2026 12:14:47 -0500 Subject: [PATCH 34/45] =?UTF-8?q?fix(stores):=20dogfood=20round=20findings?= =?UTF-8?q?=20=E2=80=94=20legacy=20string=20refs=20readable,=20rollup=20no?= =?UTF-8?q?ise=20cut?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 4 on a real multi-repo product surfaced two defects: metadata validation hard-failed on the string-form initiative: refs the beta wrote into real repos (now tolerated read-only alongside the object form), and every serving change earned its own upstream row so busy repos rolled up as noise (downstream work now only gets a row when something serves it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- src/core/change-metadata/schema.ts | 5 ++++- src/core/upstream.ts | 22 ++++++++++++++------ test/commands/change-initiative-link.test.ts | 19 +++++++++++++++-- test/core/upstream.test.ts | 11 ++++++++++ 4 files changed, 48 insertions(+), 9 deletions(-) diff --git a/src/core/change-metadata/schema.ts b/src/core/change-metadata/schema.ts index a58a6ea25c..2086f6e2e7 100644 --- a/src/core/change-metadata/schema.ts +++ b/src/core/change-metadata/schema.ts @@ -50,7 +50,10 @@ export const ChangeMetadataSchema = z.object({ goal: z.string().min(1).optional(), affected_areas: z.array(z.string().min(1)).optional(), serves: ServesRefSchema.optional(), - initiative: InitiativeLinkSchema.optional(), + // Legacy initiative links are tolerated in BOTH shapes the beta wrote — + // the `{store, id}` object and the `<store>/<name>` string — read-only, + // never re-emitted. A change that carries one must never fail to load. + initiative: z.union([InitiativeLinkSchema, z.string().min(1)]).optional(), }); export type ChangeMetadata = z.infer<typeof ChangeMetadataSchema>; diff --git a/src/core/upstream.ts b/src/core/upstream.ts index ba614aa235..b4d6603d33 100644 --- a/src/core/upstream.ts +++ b/src/core/upstream.ts @@ -248,12 +248,14 @@ function mergeChanges( } /** - * Rolls up a root's downstream work: every active change in the root, plus + * Rolls up a root's downstream work: the root's upstream candidates, plus * every change on this machine that serves one of them. Local serving * changes match `serves: <id>` (or `<own-store-id>/<id>`); when the root is * a registered store, other registered roots and linked repos are scanned - * for `<that id>/<id>`. Ids referenced by serving changes but missing on - * disk are included with `exists: false` — a bad reference should be + * for `<that id>/<id>`. A change that itself serves something is downstream + * work — it only gets its own row when something serves IT, so rollups of + * busy repos are not noise. Ids referenced by serving changes but missing + * on disk are included with `exists: false` — a bad reference should be * visible, not silently dropped; archived upstream changes are included * with `archived: true`. Returns null when the root has no changes folder. */ @@ -268,10 +270,16 @@ export async function rollupDownstream( } catch { return null; } - const ownChanges = ownEntries + const allOwnChanges = ownEntries .filter((entry) => entry.isDirectory() && entry.name !== 'archive') .map((entry) => entry.name) .sort(); + const servingOwnChanges = new Set<string>(); + for (const id of allOwnChanges) { + if ((await readServesRef(path.join(changesDir, id))) !== null) { + servingOwnChanges.add(id); + } + } const registry = await readStoreRegistryState( options.globalDataDir ? { globalDataDir: options.globalDataDir } : {} @@ -359,10 +367,12 @@ export async function rollupDownstream( }); }; - for (const id of ownChanges) { + for (const id of allOwnChanges) { + // Downstream work only earns an upstream row when something serves it. + if (servingOwnChanges.has(id) && !byId.has(id)) continue; addUpstream(id, true, false); } - const known = new Set(ownChanges); + const known = new Set(allOwnChanges); for (const id of [...byId.keys()].sort()) { if (known.has(id)) continue; const located = await locateChange(root, id); diff --git a/test/commands/change-initiative-link.test.ts b/test/commands/change-initiative-link.test.ts index c1a7797b57..e22ba2b1eb 100644 --- a/test/commands/change-initiative-link.test.ts +++ b/test/commands/change-initiative-link.test.ts @@ -48,7 +48,7 @@ describe('legacy repo-local change initiative metadata', () => { return path.join(tempDir, 'openspec', 'changes', id); } - function createLegacyLinkedChange(id: string): string { + function createLegacyLinkedChange(id: string, metadataLine?: string): string { const dir = changeDir(id); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync( @@ -57,11 +57,26 @@ describe('legacy repo-local change initiative metadata', () => { ); fs.writeFileSync( path.join(dir, '.openspec.yaml'), - 'schema: spec-driven\ninitiative:\n store: platform\n id: billing-launch\n' + metadataLine ?? + 'schema: spec-driven\ninitiative:\n store: platform\n id: billing-launch\n' ); return dir; } + it('keeps reading the string-form legacy link the beta wrote', async () => { + createLegacyLinkedChange( + 'legacy-string-change', + 'schema: spec-driven\ninitiative: platform/billing-launch\n' + ); + + const status = await runCLI( + ['status', '--change', 'legacy-string-change', '--json'], + { cwd: tempDir, env } + ); + expect(status.exitCode).toBe(0); + expect('initiative' in parseJson(status)).toBe(false); + }); + it('keeps reading existing initiative metadata without modifying it', async () => { const dir = createLegacyLinkedChange('legacy-change'); const metadataPath = path.join(dir, '.openspec.yaml'); diff --git a/test/core/upstream.test.ts b/test/core/upstream.test.ts index 5ea904901a..b7108ac42a 100644 --- a/test/core/upstream.test.ts +++ b/test/core/upstream.test.ts @@ -120,6 +120,17 @@ describe('upstream links', () => { expect(unrelated?.changesTotal).toBe(0); }); + it('hides serving-only changes from upstream rows', async () => { + change('app', 'onboarding-revamp', null, ''); + change('app', 'add-search', 'onboarding-revamp', '- [ ] one\n'); + + const rollup = await rollupDownstream(path.join(tempDir, 'app'), { globalDataDir }); + + // add-search is downstream work: it serves something and nothing + // serves it, so it appears under its upstream, not as its own row. + expect(rollup?.upstream.map((u) => u.id)).toEqual(['onboarding-revamp']); + }); + it('keeps refs to missing changes visible instead of dropping them', async () => { change('app', 'add-search', 'no-such-change', '- [ ] task\n'); From 4684edff21cee826611e90b1d035d4dafdbddf57 Mon Sep 17 00:00:00 2001 From: Clay Good <hi@claygood.com> Date: Mon, 13 Jul 2026 14:41:01 -0500 Subject: [PATCH 35/45] docs(stores): integrate upstream work into the beta guide, CLI reference, and changeset The user guide's walkthrough outputs now match the build (setup teaches the loop, fresh stores default to the requirements workflow), the cross-team story hands off to --serves where read-only references stop, linked-roots state and the per-machine rollup scope are documented, and cli.md covers --serves, --downstream, new schema, schemas --store, and the pathless setup contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .changeset/stores-upstream-work.md | 19 ++++++++++ docs/cli.md | 58 ++++++++++++++++++++++++++---- docs/stores-beta/user-guide.md | 40 ++++++++++++++++----- 3 files changed, 102 insertions(+), 15 deletions(-) create mode 100644 .changeset/stores-upstream-work.md diff --git a/.changeset/stores-upstream-work.md b/.changeset/stores-upstream-work.md new file mode 100644 index 0000000000..8ca490521a --- /dev/null +++ b/.changeset/stores-upstream-work.md @@ -0,0 +1,19 @@ +--- +"@fission-ai/openspec": minor +--- + +### New Features (stores beta) + +- **Upstream links** — `openspec new change <name> --serves <store-id>/<change>` links a change to the store work it implements; the link wires context automatically (the store is referenced in the repo's config, the repo is recorded machine-locally for rollups), and the change's instructions carry the upstream artifacts to read first +- **Downstream rollup** — `openspec list --downstream [--store <id>]` shows each of the root's changes and every change on this machine that serves it, live from task checkboxes; archived upstream work stays visible and resolvable +- **Built-in `requirements` schema** — a documentation-only workflow (proposal → specs); fresh stores default to it, so a store is ready for shared planning with zero configuration +- **`openspec new schema <name>`** — scaffold a workflow schema as a folder (schema.yaml + per-stage `instructions/*.md` + templates); each artifact is a stage ordered by `requires:` +- **Schema inheritance** — repos that declare `references: [<store>]` resolve the store's schemas (project → referenced stores → user → built-in); `openspec schemas` labels inherited entries with their source store and accepts `--store <id>` +- **Schema `notes:` and instruction files** — a schema's top-level `notes:` are surfaced verbatim on every instruction surface; `instructions/<artifact>.md` beside the schema wins over inline `instruction:` +- **`structure:` config** — declare what a root's folders are for; surfaced in `openspec context` and the references index +- **One-command store setup** — `openspec store setup <id>` no longer requires `--path` (defaults to `~/openspec/<id>`) and its output teaches the draft → serve → rollup loop + +### Fixes + +- Legacy `initiative:` change metadata is tolerated in both the object and string forms (read-only, never re-emitted) instead of failing `status` +- `openspec new change` no longer names the wrong schema in its progress line when the root's config sets a default diff --git a/docs/cli.md b/docs/cli.md index fb591f2bcc..bf95c9e881 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -14,7 +14,7 @@ The OpenSpec CLI (`openspec`) provides terminal commands for project setup, vali | **Browsing** | `list`, `view`, `show` | Explore changes and specs | | **Validation** | `validate` | Check changes and specs for issues | | **Lifecycle** | `archive` | Finalize completed changes | -| **Workflow** | `new change`, `status`, `instructions`, `templates`, `schemas` | Artifact-driven workflow support | +| **Workflow** | `new change`, `new schema`, `status`, `instructions`, `templates`, `schemas` | Artifact-driven workflow support | | **Schemas** | `schema init`, `schema fork`, `schema validate`, `schema which` | Create and manage custom workflows | | **Config** | `config` | View and modify settings | | **Utility** | `feedback`, `completion` | Feedback and shell integration | @@ -44,20 +44,21 @@ These commands support `--json` output for programmatic use by AI agents and scr | Command | Human Use | Agent Use | |---------|-----------|-----------| -| `openspec list` | Browse changes/specs | `--json` for structured data | +| `openspec list` | Browse changes/specs; `--downstream` for the upstream rollup | `--json` for structured data | | `openspec show <item>` | Read content | `--json` for parsing | | `openspec validate` | Check for issues | `--all --json` for bulk validation | | `openspec status` | See artifact progress | `--json` for structured status | | `openspec instructions` | Get next steps | `--json` for agent instructions | | `openspec templates` | Find template paths | `--json` for path resolution | -| `openspec schemas` | List available schemas | `--json` for schema discovery | +| `openspec schemas` | List available schemas (including ones inherited from referenced stores) | `--json` for schema discovery | | `openspec store setup <id>` | Create and register a local store | `--json` with explicit inputs for structured setup output | | `openspec store register <path>` | Register an existing store | `--json` for structured registration output | | `openspec store unregister <id>` | Forget a local store registration | `--json` for structured cleanup output | | `openspec store remove <id>` | Delete a registered local store folder | `--yes --json` for non-interactive deletion | | `openspec store list` | Browse registered stores | `--json` for structured registrations | | `openspec store doctor` | Check local store setup | `--json` for structured diagnostics | -| `openspec new change <id>` | Create repo-local change scaffolding | `--json`, plus `--store <id>` to use a registered store as the OpenSpec root | +| `openspec new change <id>` | Create change scaffolding; `--serves <ref>` links it to upstream work | `--json`, plus `--store <id>` to use a registered store as the OpenSpec root | +| `openspec new schema <id>` | Scaffold a workflow schema (stages, instructions, templates) | `--json`, plus `--store <id>` | | `openspec workset create [name]` | Compose a personal working view | `--member <path> --json` for non-interactive composition | | `openspec workset list` | Browse saved worksets | `--json` for structured views | | `openspec workset remove <name>` | Delete a saved view | `--yes --json` for non-interactive removal | @@ -202,15 +203,15 @@ openspec store setup [id] [options] | `--no-init-git` | Skip every Git action: no init, no initial commit | | `--json` | Output JSON | -Non-interactive runs (`--json`, scripts, agents) must pass both the store id and `--path`. In an interactive terminal, setup prompts for the location with an editable suggestion in a visible, user-owned place (for example `~/openspec/<id>`); it never defaults to OpenSpec's managed data directory. +`--path` is optional: setup defaults to `~/openspec/<id>` (interactive runs prompt with that suggestion prefilled). A fresh store's changes default to the built-in `requirements` workflow — see the [upstream work guide](stores-beta/upstream-work.md). Examples: ```bash openspec store setup openspec store setup team-context -openspec store setup team-context --path ~/openspec/team-context --no-init-git -openspec store setup team-context --path ~/openspec/team-context --no-init-git --json +openspec store setup team-context --no-init-git +openspec store setup team-context --path ~/plans/team-context --json ``` ### `openspec store register` @@ -390,7 +391,9 @@ openspec list [options] |--------|-------------| | `--specs` | List specs instead of changes | | `--changes` | List changes (default) | +| `--downstream` | Show the root's changes and every change on this machine that serves them | | `--sort <order>` | Sort by `recent` (default) or `name` | +| `--store <id>` | Store id to use as the OpenSpec root | | `--json` | Output as JSON | **Examples:** @@ -402,6 +405,9 @@ openspec list # List all specs openspec list --specs +# Where does the team's upstream work stand? +openspec list --downstream --store team-plans + # JSON output for scripts openspec list --json ``` @@ -639,14 +645,49 @@ prefix it with a word, for example `ticket-123-add-notifications` instead of | `--description <text>` | Description to add to `README.md` | | `--goal <text>` | Optional goal metadata to store with the change | | `--schema <name>` | Workflow schema to use | +| `--serves <ref>` | Link this change to the work it serves: `<change>` or `<store-id>/<change>` | | `--store <id>` | Store id to use as the OpenSpec root (a store is a standalone OpenSpec repo you've registered) | | `--json` | Output JSON | +`--serves` records the link in the change's metadata and wires everything +else automatically: the repo is recorded (machine-local) so +`openspec list --downstream` finds it, and the store is added to the repo's +`references:` so agents there see its context. + Examples: ```bash openspec new change add-billing-api openspec new change add-billing-api --store team-context --json +openspec new change add-billing-ui --serves team-context/billing-revamp +``` + +### `openspec new schema` + +Scaffold a workflow schema — stages, per-stage instructions, and templates — +in the resolved OpenSpec root. + +```bash +openspec new schema <name> [options] +``` + +Creates `openspec/schemas/<name>/` containing `schema.yaml` (the stages, in +order, via `requires:`), `instructions/*.md` (guidance agents get per +stage), and `templates/*.md` (the output shape per stage). Each artifact is +one stage of the workflow — add one per handoff. Set `schema: <name>` in +`openspec/config.yaml` to make it the root's default. + +**Options:** + +| Option | Description | +|--------|-------------| +| `--store <id>` | Store id to use as the OpenSpec root | +| `--json` | Output JSON | + +Examples: + +```bash +openspec new schema product-flow --store team-context ``` ### `openspec status` @@ -804,6 +845,8 @@ Templates: ### `openspec schemas` List available workflow schemas with their descriptions and artifact flows. +Includes schemas inherited from referenced stores, labeled with their +source store. ``` openspec schemas [options] @@ -813,6 +856,7 @@ openspec schemas [options] | Option | Description | |--------|-------------| +| `--store <id>` | Store id to use as the OpenSpec root | | `--json` | Output as JSON | **Example:** diff --git a/docs/stores-beta/user-guide.md b/docs/stores-beta/user-guide.md index 5e333f9258..bb456074b0 100644 --- a/docs/stores-beta/user-guide.md +++ b/docs/stores-beta/user-guide.md @@ -60,13 +60,14 @@ openspec store setup team-plans # defaults to ~/openspec/team-plans ``` Store ready: team-plans -Location: /Users/you/openspec/team-plans +Location: ~/openspec/team-plans OpenSpec root: ready Registry: registered -Next: run normal OpenSpec commands against this store, for example: - openspec new change <change-id> --store team-plans -Share this store by committing and pushing it like any Git repo. +Next: + openspec new change <name> --store team-plans # draft requirements here + openspec new change <name> --serves team-plans/<change> # in any repo: link work to them + openspec list --downstream --store team-plans # where everything stands ``` ```bash @@ -76,7 +77,7 @@ openspec new change add-login --store team-plans ``` Using OpenSpec root: team-plans (/Users/you/openspec/team-plans) Created change 'add-login' at /Users/you/openspec/team-plans/openspec/changes/add-login/ -Schema: spec-driven +Schema: requirements Next: openspec status --change add-login --store team-plans ``` @@ -85,6 +86,19 @@ That's the whole model. From here the lifecycle is exactly what you know — on each command, and every printed hint carries the flag for you. The `Using OpenSpec root:` line always tells you where a command is acting. +Two defaults worth knowing: + +- A fresh store's changes use the built-in **`requirements`** workflow + (proposal → specs, nothing to implement) — a store holds shared planning, + not code work. One line in the store's `openspec/config.yaml` changes it, + and `openspec new schema <name> --store team-plans` scaffolds a workflow + of your own. +- Any repo can link a change to the store work it implements: + `openspec new change <name> --serves team-plans/<change>`. Context wires + itself, and `openspec list --downstream --store team-plans` shows where + everything stands. The full loop lives in + [Upstream Work](upstream-work.md). + ## Story: one team, one planning repo A team keeps its specs and changes in `team-plans` instead of scattering @@ -184,6 +198,13 @@ summary and the exact fetch command (`openspec show <spec-id> --type spec upstream payment requirements, cite them, and write its low-level design in the repo's own root — without anyone pasting context around. +References are the read-only half. When a product team's change +*implements* something the platform team is drafting, link it — +`openspec new change <name> --serves platform-reqs/<change>` — and the +platform team sees live progress with +`openspec list --downstream --store platform-reqs`. See +[Upstream Work](upstream-work.md). + A reference can carry its clone source, so teammates who don't have the store yet get a complete fix instead of a dead end: @@ -319,9 +340,11 @@ tells you which case you're in. deprecated noun forms (`openspec change show`, ...) act on the current directory only — no `--store`. (`schemas` now accepts `--store` — see [Upstream Work](upstream-work.md).) -- **Per-machine state is per-machine.** The store registry and worksets - are local settings. Nothing about your machine's layout is - ever committed to shared planning. +- **Per-machine state is per-machine.** The store registry, worksets, and + the upstream-link records that power `list --downstream` are local + settings. The rollup sees the checkouts on *this* machine — pull your + teammates' repos to see their work. Nothing about your machine's layout + is ever committed to shared planning. - **Two launch styles for worksets.** A tool that can't be launched with a workspace file or per-folder attach flags can't be added as an opener. - **Agent JSON has a known casing split** (store-family keys are @@ -336,6 +359,7 @@ tells you which case you're in. | A store's planning | `<store>/openspec/` (specs, changes) | Yes — commit and push it | | A store's identity | `<store>/.openspec-store/store.yaml` | Yes — committed with the store | | The store registry | `<data dir>/openspec/stores/registry.yaml` | No — this machine only | +| Repos linked via `--serves` | `<data dir>/openspec/stores/linked-roots.yaml` | No — this machine only | | Worksets | `<data dir>/openspec/worksets/` | No — this machine only | `<data dir>` is `~/.local/share/openspec` on macOS and Linux (or From f4d35c5efa1753ccb83490a70fac9149120c32e0 Mon Sep 17 00:00:00 2001 From: Clay Good <hi@claygood.com> Date: Mon, 13 Jul 2026 15:02:11 -0500 Subject: [PATCH 36/45] refactor(schemas): fold scaffolding into schema init; seed spec Purpose from proposal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openspec new schema duplicated main's existing schema init, so it is gone; schema init instead gains --store, per-stage instruction files seeded from the built-in guidance, and honest next steps — plus two fixes main shipped with (--default wrote a defaultSchema: key nothing reads; the hint printed an invalid command). New specs created by archiving now seed their Purpose from the proposal's Why instead of a TBD placeholder. Docs gain the team-wide status (CI) and schema versioning defaults. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .changeset/stores-upstream-work.md | 3 +- docs/cli.md | 38 +--- docs/customization.md | 2 +- docs/stores-beta/upstream-work.md | 26 ++- docs/stores-beta/user-guide.md | 2 +- .../changes/add-upstream-links/proposal.md | 4 +- openspec/changes/add-upstream-links/tasks.md | 2 +- src/cli/index.ts | 17 -- src/commands/schema.ts | 47 ++++- src/commands/workflow/index.ts | 3 - src/commands/workflow/new-schema.ts | 178 ------------------ src/core/completions/command-registry.ts | 11 +- src/core/specs-apply.ts | 44 ++++- .../templates/workflows/store-selection.ts | 2 +- test/commands/new-schema.test.ts | 87 --------- test/commands/schema-init.test.ts | 108 +++++++++++ test/core/archive.test.ts | 27 +++ .../core/completions/command-registry.test.ts | 2 +- .../templates/skill-templates-parity.test.ts | 72 +++---- 19 files changed, 290 insertions(+), 385 deletions(-) delete mode 100644 src/commands/workflow/new-schema.ts delete mode 100644 test/commands/new-schema.test.ts create mode 100644 test/commands/schema-init.test.ts diff --git a/.changeset/stores-upstream-work.md b/.changeset/stores-upstream-work.md index 8ca490521a..f68cb05f7a 100644 --- a/.changeset/stores-upstream-work.md +++ b/.changeset/stores-upstream-work.md @@ -7,7 +7,7 @@ - **Upstream links** — `openspec new change <name> --serves <store-id>/<change>` links a change to the store work it implements; the link wires context automatically (the store is referenced in the repo's config, the repo is recorded machine-locally for rollups), and the change's instructions carry the upstream artifacts to read first - **Downstream rollup** — `openspec list --downstream [--store <id>]` shows each of the root's changes and every change on this machine that serves it, live from task checkboxes; archived upstream work stays visible and resolvable - **Built-in `requirements` schema** — a documentation-only workflow (proposal → specs); fresh stores default to it, so a store is ready for shared planning with zero configuration -- **`openspec new schema <name>`** — scaffold a workflow schema as a folder (schema.yaml + per-stage `instructions/*.md` + templates); each artifact is a stage ordered by `requires:` +- **`openspec schema init` upgrades** — accepts `--store <id>` to scaffold into a registered store, and seeds per-stage `instructions/<artifact>.md` files (guidance agents receive, editable per stage) alongside templates - **Schema inheritance** — repos that declare `references: [<store>]` resolve the store's schemas (project → referenced stores → user → built-in); `openspec schemas` labels inherited entries with their source store and accepts `--store <id>` - **Schema `notes:` and instruction files** — a schema's top-level `notes:` are surfaced verbatim on every instruction surface; `instructions/<artifact>.md` beside the schema wins over inline `instruction:` - **`structure:` config** — declare what a root's folders are for; surfaced in `openspec context` and the references index @@ -17,3 +17,4 @@ - Legacy `initiative:` change metadata is tolerated in both the object and string forms (read-only, never re-emitted) instead of failing `status` - `openspec new change` no longer names the wrong schema in its progress line when the root's config sets a default +- `openspec schema init --default` now writes the `schema:` key the config reader uses (previously wrote `defaultSchema:`, which nothing read), and its next-step hint prints a valid command diff --git a/docs/cli.md b/docs/cli.md index bf95c9e881..8226a20918 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -14,7 +14,7 @@ The OpenSpec CLI (`openspec`) provides terminal commands for project setup, vali | **Browsing** | `list`, `view`, `show` | Explore changes and specs | | **Validation** | `validate` | Check changes and specs for issues | | **Lifecycle** | `archive` | Finalize completed changes | -| **Workflow** | `new change`, `new schema`, `status`, `instructions`, `templates`, `schemas` | Artifact-driven workflow support | +| **Workflow** | `new change`, `status`, `instructions`, `templates`, `schemas` | Artifact-driven workflow support | | **Schemas** | `schema init`, `schema fork`, `schema validate`, `schema which` | Create and manage custom workflows | | **Config** | `config` | View and modify settings | | **Utility** | `feedback`, `completion` | Feedback and shell integration | @@ -58,7 +58,6 @@ These commands support `--json` output for programmatic use by AI agents and scr | `openspec store list` | Browse registered stores | `--json` for structured registrations | | `openspec store doctor` | Check local store setup | `--json` for structured diagnostics | | `openspec new change <id>` | Create change scaffolding; `--serves <ref>` links it to upstream work | `--json`, plus `--store <id>` to use a registered store as the OpenSpec root | -| `openspec new schema <id>` | Scaffold a workflow schema (stages, instructions, templates) | `--json`, plus `--store <id>` | | `openspec workset create [name]` | Compose a personal working view | `--member <path> --json` for non-interactive composition | | `openspec workset list` | Browse saved worksets | `--json` for structured views | | `openspec workset remove <name>` | Delete a saved view | `--yes --json` for non-interactive removal | @@ -662,34 +661,6 @@ openspec new change add-billing-api --store team-context --json openspec new change add-billing-ui --serves team-context/billing-revamp ``` -### `openspec new schema` - -Scaffold a workflow schema — stages, per-stage instructions, and templates — -in the resolved OpenSpec root. - -```bash -openspec new schema <name> [options] -``` - -Creates `openspec/schemas/<name>/` containing `schema.yaml` (the stages, in -order, via `requires:`), `instructions/*.md` (guidance agents get per -stage), and `templates/*.md` (the output shape per stage). Each artifact is -one stage of the workflow — add one per handoff. Set `schema: <name>` in -`openspec/config.yaml` to make it the root's default. - -**Options:** - -| Option | Description | -|--------|-------------| -| `--store <id>` | Store id to use as the OpenSpec root | -| `--json` | Output JSON | - -Examples: - -```bash -openspec new schema product-flow --store team-context -``` - ### `openspec status` Display artifact completion status for a change. @@ -908,8 +879,15 @@ openspec schema init <name> [options] | `--default` | Set as project default schema | | `--no-default` | Don't prompt to set as default | | `--force` | Overwrite existing schema | +| `--store <id>` | Store id to use as the OpenSpec root (scaffold into a registered store) | | `--json` | Output as JSON | +Each artifact is one stage of the workflow, ordered by `requires:` in +`schema.yaml`. The scaffold includes `instructions/<artifact>.md` files — +per-stage guidance agents receive, seeded from the built-in workflow — and +`templates/` for each stage's output shape. `--default` sets `schema: <name>` +in the root's `openspec/config.yaml`. + **Examples:** ```bash diff --git a/docs/customization.md b/docs/customization.md index 3c20a1d657..e4ea9fb623 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -269,7 +269,7 @@ Path: /path/to/project/openspec/schemas/my-workflow --- -> **Note:** OpenSpec also supports user-level schemas at `~/.local/share/openspec/schemas/` for sharing across projects, but project-level schemas in `openspec/schemas/` are recommended since they're version-controlled with your code. +> **Note:** OpenSpec also supports user-level schemas at `~/.local/share/openspec/schemas/` for sharing across projects, but project-level schemas in `openspec/schemas/` are recommended since they're version-controlled with your code. Repos that declare `references:` to a store additionally inherit the store's schemas (resolution order: project → referenced stores → user → built-in) — see the [upstream work guide](stores-beta/upstream-work.md). --- diff --git a/docs/stores-beta/upstream-work.md b/docs/stores-beta/upstream-work.md index 61de458e28..aa4e6ea837 100644 --- a/docs/stores-beta/upstream-work.md +++ b/docs/stores-beta/upstream-work.md @@ -24,11 +24,11 @@ tell agents there is no implementation phase). To encode your own chain, scaffold a schema and add one artifact per handoff: ```text -$ openspec new schema product-flow --store product-team -Created schema 'product-flow' at …/openspec/schemas/product-flow/ - schema.yaml stages and their order (requires:) - instructions/*.md guidance agents get per stage - templates/*.md the output shape per stage +$ openspec schema init product-flow --artifacts proposal,specs --store product-team +✔ Created schema 'product-flow' + +Each artifact is a stage — order them with requires: in schema.yaml. +Guidance agents get per stage lives in instructions/<artifact>.md. ``` A schema is a folder. Long-form guidance lives in files beside it, and a @@ -111,6 +111,20 @@ the link keeps resolving — marked so downstream agents trace against specs: onboarding-revamp 1/2 serving changes complete (archived — its requirements now live in specs/) ``` +## Team-wide status + +The rollup reads checkouts on the machine it runs on — pull a teammate's +repo and their work appears. For an always-current team view, run the same +command somewhere that already has every repo: a CI job that clones the +store and the code repos, registers the store, and publishes +`openspec list --downstream --store <id> --json` gives the whole team one +answer without any new machinery. + +Schemas version the same way everything in a store does: git. Pulling the +store updates the workflow for every repo that references it; a repo that +needs to pin or diverge defines a project-local schema with the same name, +which wins. + ## Reference | Surface | What it carries | @@ -119,7 +133,7 @@ onboarding-revamp 1/2 serving changes complete (archived — its requirements | `schema.yaml` | optional `notes:` (workflow guidance, surfaced verbatim to agents) | | `<schema>/instructions/<artifact>.md` | per-artifact instruction files; a file wins over inline `instruction:` | | `config.yaml` | `structure:` — folder → purpose map, surfaced in `context` and the references index | -| `openspec new schema <name> [--store <id>]` | scaffold a workflow: schema.yaml + instructions/ + templates/ | +| `openspec schema init <name> [--store <id>]` | scaffold a workflow: schema.yaml + instructions/ + templates/ | | `openspec schemas [--store <id>]` | includes inherited schemas with their source store | | `openspec list --downstream [--store <id>]` | the rollup; outside any root it shows every registered store's | | `openspec context` | referenced stores show artifact types, in-motion changes, and layout | diff --git a/docs/stores-beta/user-guide.md b/docs/stores-beta/user-guide.md index bb456074b0..98af5110f1 100644 --- a/docs/stores-beta/user-guide.md +++ b/docs/stores-beta/user-guide.md @@ -91,7 +91,7 @@ Two defaults worth knowing: - A fresh store's changes use the built-in **`requirements`** workflow (proposal → specs, nothing to implement) — a store holds shared planning, not code work. One line in the store's `openspec/config.yaml` changes it, - and `openspec new schema <name> --store team-plans` scaffolds a workflow + and `openspec schema init <name> --store team-plans` scaffolds a workflow of your own. - Any repo can link a change to the store work it implements: `openspec new change <name> --serves team-plans/<change>`. Context wires diff --git a/openspec/changes/add-upstream-links/proposal.md b/openspec/changes/add-upstream-links/proposal.md index cb27054e86..32d2fe6cb4 100644 --- a/openspec/changes/add-upstream-links/proposal.md +++ b/openspec/changes/add-upstream-links/proposal.md @@ -28,8 +28,8 @@ noun; it is the existing machinery working one level up and across repos. → user → package); `structure:` in config declares folder purposes, surfaced in `context` and the references index. - **Happy path**: a fresh store defaults to the new built-in `requirements` - schema (proposal → specs, documentation-only); `openspec new schema <name>` - scaffolds a team's own workflow as a folder; store setup output teaches the + schema (proposal → specs, documentation-only); `openspec schema init` gains `--store` and + per-stage instruction files, so a team scaffolds its own workflow anywhere; store setup output teaches the draft → serve → rollup loop; generated skills know when to use `--serves`. - **Removed**: the initiatives folder convention, stages, evergreen files, `list --initiatives`, and the initiatives skill. The `initiative` noun diff --git a/openspec/changes/add-upstream-links/tasks.md b/openspec/changes/add-upstream-links/tasks.md index 26d9053c73..ac114c231c 100644 --- a/openspec/changes/add-upstream-links/tasks.md +++ b/openspec/changes/add-upstream-links/tasks.md @@ -17,7 +17,7 @@ ## Happy path - [x] Built-in `requirements` schema; fresh stores default to it -- [x] `openspec new schema <name>` scaffolder (schema.yaml + instructions/ + templates/) +- [x] `schema init`: `--store` support, per-stage instruction files, `--default` key fix - [x] Store setup output teaches the loop; skills carry `--serves` guidance ## Verification diff --git a/src/cli/index.ts b/src/cli/index.ts index 9dc09d2b17..3fe2971eda 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -35,14 +35,12 @@ import { templatesCommand, schemasCommand, newChangeCommand, - newSchemaCommand, DEFAULT_SCHEMA, type StatusOptions, type InstructionsOptions, type TemplatesOptions, type SchemasOptions, type NewChangeOptions, - type NewSchemaOptions, } from '../commands/workflow/index.js'; import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js'; import { COMMON_FLAGS } from '../core/completions/shared-flags.js'; @@ -736,21 +734,6 @@ newCmd } }); -newCmd - .command('schema <name>') - .description('Scaffold a workflow schema (stages, instructions, templates) in this root') - .option('--json', 'Output as JSON') - .option('--store <id>', STORE_OPTION_DESCRIPTION) - .addOption(hiddenStorePathOption()) - .action(async (name: string, options: NewSchemaOptions) => { - try { - await newSchemaCommand(name, options); - } catch (error) { - failWithError(error); - process.exit(1); - } - }); - export { program }; export function runCli(argv = process.argv): void { diff --git a/src/commands/schema.ts b/src/commands/schema.ts index 7f8d0b7888..faae058b7b 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -9,7 +9,9 @@ import { getUserSchemasDir, getPackageSchemasDir, listSchemas, + resolveSchema, } from '../core/artifact-graph/resolver.js'; +import { resolveRootForCommand } from '../core/root-selection.js'; import { parseSchema, SchemaValidationError } from '../core/artifact-graph/schema.js'; import type { SchemaYaml, Artifact } from '../core/artifact-graph/types.js'; @@ -671,6 +673,7 @@ export function registerSchemaCommand(program: Command): void { .option('--default', 'Set as project default schema') .option('--no-default', 'Do not prompt to set as default') .option('--force', 'Overwrite existing schema') + .option('--store <id>', 'Store id to use as the OpenSpec root (a store is a standalone OpenSpec repo you\'ve registered)') .action(async ( name: string, options?: { @@ -679,12 +682,23 @@ export function registerSchemaCommand(program: Command): void { artifacts?: string; default?: boolean; force?: boolean; + store?: string; } ) => { const spinner = options?.json ? null : ora(); try { - const projectRoot = process.cwd(); + // --store scaffolds into a registered store's root; without it the + // command keeps its classic cwd behavior. + let projectRoot = process.cwd(); + if (options?.store) { + const root = await resolveRootForCommand( + { store: options.store }, + { json: options?.json, failurePayload: { created: false } } + ); + if (!root) return; + projectRoot = root.path; + } // Validate name if (!isValidSchemaName(name)) { @@ -866,6 +880,23 @@ export function registerSchemaCommand(program: Command): void { fs.writeFileSync(templatePath, templateContent); } + // Seed per-stage guidance as instructions/<artifact>.md files — + // the built-in workflow's guidance is the starting point, and a + // file beside the schema is what teams edit (it wins over any + // inline instruction). + const builtin = resolveSchema('spec-driven'); + const instructionsDir = path.join(schemaDir, 'instructions'); + fs.mkdirSync(instructionsDir, { recursive: true }); + for (const artifact of selectedArtifacts) { + const seed = builtin.artifacts.find((a) => a.id === artifact.id)?.instruction; + if (seed) { + fs.writeFileSync( + path.join(instructionsDir, `${artifact.id}.md`), + `${seed.trim()}\n` + ); + } + } + // Update config if --default if (options?.default) { const configPath = path.join(projectRoot, 'openspec', 'config.yaml'); @@ -874,7 +905,7 @@ export function registerSchemaCommand(program: Command): void { const { parse: parseYaml, stringify: stringifyYaml2 } = await import('yaml'); const configContent = fs.readFileSync(configPath, 'utf-8'); const config = parseYaml(configContent) || {}; - config.defaultSchema = name; + config.schema = name; fs.writeFileSync(configPath, stringifyYaml2(config)); } else { // Create config file @@ -882,7 +913,7 @@ export function registerSchemaCommand(program: Command): void { if (!fs.existsSync(configDir)) { fs.mkdirSync(configDir, { recursive: true }); } - fs.writeFileSync(configPath, stringifyYaml({ defaultSchema: name })); + fs.writeFileSync(configPath, stringifyYaml({ schema: name })); } } @@ -902,10 +933,14 @@ export function registerSchemaCommand(program: Command): void { if (options?.default) { console.log(`\nSet as project default schema.`); } + console.log(`\nEach artifact is a stage — order them with requires: in schema.yaml.`); + console.log(`Guidance agents get per stage lives in instructions/<artifact>.md.`); console.log(`\nNext steps:`); - console.log(` 1. Edit ${schemaDir}/schema.yaml to customize artifacts`); - console.log(` 2. Modify templates in the schema directory`); - console.log(` 3. Use with: openspec new --schema ${name}`); + console.log(` 1. Edit ${schemaDir}/schema.yaml to customize the stages`); + console.log(` 2. Edit instructions/ and templates/ to match your workflow`); + console.log( + ` 3. Use it: openspec new change <name> --schema ${name}${options?.store ? ` --store ${options.store}` : ''}` + ); } } catch (error) { if (spinner) spinner.fail(`Creation failed`); diff --git a/src/commands/workflow/index.ts b/src/commands/workflow/index.ts index c8a3e94bc7..232b2dbe34 100644 --- a/src/commands/workflow/index.ts +++ b/src/commands/workflow/index.ts @@ -19,7 +19,4 @@ export type { SchemasOptions } from './schemas.js'; export { newChangeCommand } from './new-change.js'; export type { NewChangeOptions } from './new-change.js'; -export { newSchemaCommand } from './new-schema.js'; -export type { NewSchemaOptions } from './new-schema.js'; - export { DEFAULT_SCHEMA } from './shared.js'; diff --git a/src/commands/workflow/new-schema.ts b/src/commands/workflow/new-schema.ts deleted file mode 100644 index dd0701e4b3..0000000000 --- a/src/commands/workflow/new-schema.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * New Schema Command - * - * Scaffolds a workflow schema as a folder in the resolved OpenSpec root: - * schema.yaml plus instructions/ and templates/ files. The scaffold is a - * working two-stage workflow (proposal → specs) whose comments teach the - * one move that matters: each artifact is a stage, ordered by `requires:`, - * so a team encodes its own chain by adding artifacts. - */ - -import path from 'path'; -import { promises as fs } from 'fs'; -import { isKebabId } from '../../core/id.js'; -import { getSchemaDir } from '../../core/artifact-graph/index.js'; -import { - resolveRootForCommand, - toRootOutput, - withStoreFlag, - type ResolvedOpenSpecRoot, -} from '../../core/root-selection.js'; -import { printJson, statusFromError } from './shared.js'; - -export interface NewSchemaOptions { - store?: string; - storePath?: string; - json?: boolean; -} - -const SCHEMA_YAML = (name: string) => `name: ${name} -version: 1 -description: > - <one line: what this workflow produces and who reviews it> -notes: > - <how this workflow differs from the default — agents see this verbatim - on every instruction surface. Example: "No implementation phase: archive - the change once specs are approved; downstream repos implement it via - openspec new change <name> --serves <store-id>/<change>."> - -# Each artifact is one stage of the workflow, ordered by \`requires:\`. -# Encode your own chain by adding artifacts — for example a design stage -# between proposal and specs, or an analysis stage before everything. -# Long-form guidance lives in instructions/<artifact-id>.md; the output -# shape lives in templates/. -artifacts: - - id: proposal - generates: proposal.md - description: What is changing and why - template: proposal.md - requires: [] - - id: specs - generates: specs/**/*.md - description: Testable requirements with scenarios - template: spec.md - requires: - - proposal -`; - -const PROPOSAL_INSTRUCTION = `Write the proposal in plain language for whoever reviews this stage. - -<replace with your stage's guidance: required sections, what belongs -here vs. later stages, and any review gate — e.g. "Do NOT proceed to -specs until the proposal is approved."> -`; - -const SPECS_INSTRUCTION = `Create one spec per capability named in the proposal (specs/<name>/spec.md). - -- Each requirement: \`### Requirement: <name>\`, SHALL/MUST language. -- Each scenario: \`#### Scenario: <name>\` with GIVEN/WHEN/THEN (exactly 4 hashtags). -- Cover the success path AND meaningful edge/failure cases. -`; - -const PROPOSAL_TEMPLATE = `## Why - -## What Changes - -## Capabilities -`; - -const SPEC_TEMPLATE = `## ADDED Requirements - -### Requirement: <name> - -#### Scenario: <name> -- **GIVEN** -- **WHEN** -- **THEN** -`; - -function printCreatedSchemaHuman( - name: string, - schemaDir: string, - root: ResolvedOpenSpecRoot -): void { - console.log(`Created schema '${name}' at ${schemaDir}/`); - console.log(' schema.yaml stages and their order (requires:)'); - console.log(' instructions/*.md guidance agents get per stage'); - console.log(' templates/*.md the output shape per stage'); - console.log(''); - console.log('Each artifact is a stage — add one per handoff in your workflow.'); - console.log( - `Try it: ${withStoreFlag(root, `openspec new change <name> --schema ${name}`)}` - ); - console.log( - `Make it the default for this root: set 'schema: ${name}' in openspec/config.yaml` - ); -} - -export async function newSchemaCommand( - name: string | undefined, - options: NewSchemaOptions -): Promise<void> { - try { - if (!name) { - throw new Error('Missing required argument <name>'); - } - if (!isKebabId(name)) { - throw new Error( - 'Schema name must be kebab-case with lowercase letters, numbers, and single hyphen separators' - ); - } - - const root = await resolveRootForCommand(options, { - json: options.json, - failurePayload: { schema: null }, - }); - if (!root) { - return; - } - - const schemaDir = path.join(root.path, 'openspec', 'schemas', name); - try { - await fs.stat(schemaDir); - throw new Error(`Schema '${name}' already exists at ${schemaDir}`); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; - } - - await fs.mkdir(path.join(schemaDir, 'instructions'), { recursive: true }); - await fs.mkdir(path.join(schemaDir, 'templates'), { recursive: true }); - const files: Array<[string, string]> = [ - ['schema.yaml', SCHEMA_YAML(name)], - [path.join('instructions', 'proposal.md'), PROPOSAL_INSTRUCTION], - [path.join('instructions', 'specs.md'), SPECS_INSTRUCTION], - [path.join('templates', 'proposal.md'), PROPOSAL_TEMPLATE], - [path.join('templates', 'spec.md'), SPEC_TEMPLATE], - ]; - for (const [relative, content] of files) { - await fs.writeFile(path.join(schemaDir, relative), content, 'utf-8'); - } - - // Sanity: the scaffold must resolve where changes will look for it. - if (getSchemaDir(name, root.path) === null) { - throw new Error(`Scaffolded schema '${name}' did not resolve — this is a bug.`); - } - - if (options.json) { - printJson({ - schema: { - name, - path: schemaDir, - artifacts: ['proposal', 'specs'], - }, - created_files: files.map(([relative]) => relative), - root: toRootOutput(root), - }); - return; - } - - printCreatedSchemaHuman(name, schemaDir, root); - } catch (error) { - if (options.json) { - printJson({ schema: null, status: [statusFromError(error)] }); - process.exitCode = 1; - return; - } - throw error; - } -} diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index dbae14d703..50a2233411 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -255,16 +255,6 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ COMMON_FLAGS.store, ], }, - { - name: 'schema', - description: 'Scaffold a workflow schema (stages, instructions, templates) in this root', - acceptsPositional: true, - positionals: [{ name: 'name' }], - flags: [ - COMMON_FLAGS.json, - COMMON_FLAGS.store, - ], - }, ], }, { @@ -775,6 +765,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ name: 'force', description: 'Overwrite existing schema', }, + COMMON_FLAGS.store, ], }, ], diff --git a/src/core/specs-apply.ts b/src/core/specs-apply.ts index 3cf81222af..8ad5918a9f 100644 --- a/src/core/specs-apply.ts +++ b/src/core/specs-apply.ts @@ -227,7 +227,11 @@ export async function buildUpdatedSpec( ); } isNewSpec = true; - targetContent = buildSpecSkeleton(specName, changeName); + targetContent = buildSpecSkeleton( + specName, + changeName, + await readProposalWhy(update.source) + ); } const structureIssues = findMainSpecStructureIssues(targetContent); @@ -385,11 +389,43 @@ export async function writeUpdatedSpec( } /** - * Build a skeleton spec for new capabilities. + * Build a skeleton spec for new capabilities. The Purpose is seeded from + * the change's own proposal when one exists — the intent was already + * written once; a placeholder would just ask for it twice. */ -export function buildSpecSkeleton(specFolderName: string, changeName: string): string { +export function buildSpecSkeleton( + specFolderName: string, + changeName: string, + purpose?: string | null +): string { const titleBase = specFolderName; - return `# ${titleBase} Specification\n\n## Purpose\nTBD - created by archiving change ${changeName}. Update Purpose after archive.\n\n## Requirements\n`; + const purposeText = + purpose ?? + `TBD - created by archiving change ${changeName}. Update Purpose after archive.`; + return `# ${titleBase} Specification\n\n## Purpose\n${purposeText}\n\n## Requirements\n`; +} + +/** + * The first paragraph of the change proposal's `## Why` section, flattened + * to one line — the natural Purpose for a spec this change creates. The + * delta source lives at `<changeDir>/specs/<name>/spec.md`, so the + * proposal sits two directories up. Tolerant: any missing or shapeless + * proposal means "no seed" and the placeholder is used. + */ +async function readProposalWhy(deltaSource: string): Promise<string | null> { + const changeDir = path.dirname(path.dirname(path.dirname(deltaSource))); + try { + const proposal = await fs.readFile(path.join(changeDir, 'proposal.md'), 'utf-8'); + const match = proposal + .replace(/\r\n?/g, '\n') + .match(/(?:^|\n)##\s+Why\s*\n+([\s\S]*?)(?=\n##\s|$)/); + const text = match?.[1]?.trim(); + if (!text) return null; + const paragraph = text.split(/\n\s*\n/)[0].replace(/\s*\n\s*/g, ' ').trim(); + return paragraph.length > 0 ? paragraph : null; + } catch { + return null; + } } function findMissingCurrentScenarios(current: RequirementBlock, incoming: RequirementBlock): string[] { diff --git a/src/core/templates/workflows/store-selection.ts b/src/core/templates/workflows/store-selection.ts index 55b3786a16..3636f6906e 100644 --- a/src/core/templates/workflows/store-selection.ts +++ b/src/core/templates/workflows/store-selection.ts @@ -4,4 +4,4 @@ * Interpolated into every workflow's instructions so generated skills * consistently teach how to target a registered store with `--store <id>`. */ -export const STORE_SELECTION_GUIDANCE = `**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run \`openspec store list --json\` to discover registered store ids, then pass \`--store <id>\` on the commands that act on a selected root (\`new change\`, \`new schema\`, \`status\`, \`instructions\`, \`list\`, \`show\`, \`validate\`, \`archive\`, \`doctor\`, \`context\`, \`schemas\`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local \`openspec/\` root. **Upstream links:** when new work implements or serves work tracked in a store (the user names it, or \`openspec context\` lists it under "In motion"), create the change with \`--serves <store-id>/<change>\` — the link wires context and status automatically, and the change's instructions will carry the upstream artifacts to read first.`; +export const STORE_SELECTION_GUIDANCE = `**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run \`openspec store list --json\` to discover registered store ids, then pass \`--store <id>\` on the commands that act on a selected root (\`new change\`, \`schema init\`, \`status\`, \`instructions\`, \`list\`, \`show\`, \`validate\`, \`archive\`, \`doctor\`, \`context\`, \`schemas\`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local \`openspec/\` root. **Upstream links:** when new work implements or serves work tracked in a store (the user names it, or \`openspec context\` lists it under "In motion"), create the change with \`--serves <store-id>/<change>\` — the link wires context and status automatically, and the change's instructions will carry the upstream artifacts to read first.`; diff --git a/test/commands/new-schema.test.ts b/test/commands/new-schema.test.ts deleted file mode 100644 index 6fd4247f66..0000000000 --- a/test/commands/new-schema.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; - -import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; - -describe('openspec new schema', () => { - let tempDir: string; - let env: NodeJS.ProcessEnv; - - beforeEach(() => { - tempDir = fs.realpathSync.native( - fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-new-schema-')) - ); - env = { - XDG_DATA_HOME: path.join(tempDir, 'data'), - XDG_CONFIG_HOME: path.join(tempDir, 'config'), - OPEN_SPEC_INTERACTIVE: '0', - OPENSPEC_TELEMETRY: '0', - }; - fs.mkdirSync(path.join(tempDir, 'openspec', 'changes'), { recursive: true }); - fs.writeFileSync( - path.join(tempDir, 'openspec', 'config.yaml'), - 'schema: spec-driven\n' - ); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - function parseJson(result: RunCLIResult): any { - return JSON.parse(result.stdout); - } - - it('scaffolds a working schema folder that changes can use immediately', async () => { - const result = await runCLI(['new', 'schema', 'team-flow', '--json'], { - cwd: tempDir, - env, - }); - expect(result.exitCode).toBe(0); - const payload = parseJson(result); - expect(payload.schema.name).toBe('team-flow'); - expect(payload.created_files).toEqual([ - 'schema.yaml', - path.join('instructions', 'proposal.md'), - path.join('instructions', 'specs.md'), - path.join('templates', 'proposal.md'), - path.join('templates', 'spec.md'), - ]); - - // The scaffold is a valid, listed schema... - const schemas = await runCLI(['schemas', '--json'], { cwd: tempDir, env }); - const entry = parseJson(schemas).find((s: any) => s.name === 'team-flow'); - expect(entry).toMatchObject({ source: 'project', artifacts: ['proposal', 'specs'] }); - - // ...and a change created with it gets the full artifact graph. - const change = await runCLI( - ['new', 'change', 'try-flow', '--schema', 'team-flow', '--json'], - { cwd: tempDir, env } - ); - expect(change.exitCode).toBe(0); - expect(parseJson(change).change.schema).toBe('team-flow'); - }); - - it('refuses to overwrite an existing schema', async () => { - fs.mkdirSync(path.join(tempDir, 'openspec', 'schemas', 'team-flow'), { - recursive: true, - }); - const result = await runCLI(['new', 'schema', 'team-flow', '--json'], { - cwd: tempDir, - env, - }); - expect(result.exitCode).toBe(1); - expect(parseJson(result).status[0].message).toContain('already exists'); - }); - - it('rejects a non-kebab name', async () => { - const result = await runCLI(['new', 'schema', 'Team Flow', '--json'], { - cwd: tempDir, - env, - }); - expect(result.exitCode).toBe(1); - expect(parseJson(result).status[0].message).toContain('kebab-case'); - }); -}); diff --git a/test/commands/schema-init.test.ts b/test/commands/schema-init.test.ts new file mode 100644 index 0000000000..974cfe9277 --- /dev/null +++ b/test/commands/schema-init.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; + +describe('openspec schema init (CLI)', () => { + let tempDir: string; + let env: NodeJS.ProcessEnv; + + beforeEach(() => { + tempDir = fs.realpathSync.native( + fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-schema-init-')) + ); + env = { + XDG_DATA_HOME: path.join(tempDir, 'data'), + XDG_CONFIG_HOME: path.join(tempDir, 'config'), + OPEN_SPEC_INTERACTIVE: '0', + OPENSPEC_TELEMETRY: '0', + }; + fs.mkdirSync(path.join(tempDir, 'openspec', 'changes'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'openspec', 'config.yaml'), + 'schema: spec-driven\n' + ); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function parseJson(result: RunCLIResult): any { + return JSON.parse(result.stdout); + } + + it('scaffolds instruction files beside templates, seeded from the built-in guidance', async () => { + const result = await runCLI( + ['schema', 'init', 'team-flow', '--artifacts', 'proposal,specs', '--json'], + { cwd: tempDir, env } + ); + expect(result.exitCode).toBe(0); + expect(parseJson(result)).toMatchObject({ + created: true, + artifacts: ['proposal', 'specs'], + }); + + const schemaDir = path.join(tempDir, 'openspec', 'schemas', 'team-flow'); + for (const file of [ + 'schema.yaml', + path.join('instructions', 'proposal.md'), + path.join('instructions', 'specs.md'), + ]) { + expect(fs.existsSync(path.join(schemaDir, file)), file).toBe(true); + } + // Seeded from the built-in workflow's guidance, not a placeholder. + expect( + fs.readFileSync(path.join(schemaDir, 'instructions', 'proposal.md'), 'utf-8') + ).toContain('Capabilities'); + + // A change created with it resolves the file-based instructions. + const change = await runCLI( + ['new', 'change', 'try-flow', '--schema', 'team-flow', '--json'], + { cwd: tempDir, env } + ); + expect(change.exitCode).toBe(0); + expect(parseJson(change).change.schema).toBe('team-flow'); + }); + + it('writes the schema: key the config reader actually uses for --default', async () => { + const result = await runCLI( + ['schema', 'init', 'team-flow', '--artifacts', 'proposal', '--default', '--json'], + { cwd: tempDir, env } + ); + expect(result.exitCode).toBe(0); + + const config = fs.readFileSync( + path.join(tempDir, 'openspec', 'config.yaml'), + 'utf-8' + ); + expect(config).toContain('schema: team-flow'); + expect(config).not.toContain('defaultSchema'); + }); + + it('scaffolds into a registered store with --store', async () => { + const setup = await runCLI( + ['store', 'setup', 'team-hub', '--path', path.join(tempDir, 'team-hub'), '--no-init-git', '--json'], + { cwd: tempDir, env } + ); + expect(setup.exitCode).toBe(0); + + const result = await runCLI( + ['schema', 'init', 'product-flow', '--artifacts', 'proposal,specs', '--store', 'team-hub', '--json'], + { cwd: tempDir, env } + ); + expect(result.exitCode).toBe(0); + expect(parseJson(result).path).toBe( + path.join(tempDir, 'team-hub', 'openspec', 'schemas', 'product-flow') + ); + + const schemas = await runCLI(['schemas', '--store', 'team-hub', '--json'], { + cwd: tempDir, + env, + }); + const entry = parseJson(schemas).find((s: any) => s.name === 'product-flow'); + expect(entry).toMatchObject({ source: 'project' }); + }); +}); diff --git a/test/core/archive.test.ts b/test/core/archive.test.ts index 0d039f2026..043d94dfbc 100644 --- a/test/core/archive.test.ts +++ b/test/core/archive.test.ts @@ -190,6 +190,33 @@ Then expected result happens`; expect(updatedContent).toContain('#### Scenario: Basic test'); }); + it('seeds a new spec\'s Purpose from the proposal\'s Why section', async () => { + const changeName = 'seeded-purpose'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'seeded-capability'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + await fs.writeFile( + path.join(changeDir, 'proposal.md'), + '## Why\n\nAuditors need one answer for retention.\nToday there is none.\n\nSecond paragraph is not the Purpose.\n\n## What Changes\n- Things.\n' + ); + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + '## ADDED Requirements\n\n### Requirement: The system SHALL retain events\n\n#### Scenario: Basic\nGiven a\nWhen b\nThen c' + ); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'seeded-capability', 'spec.md'); + const updatedContent = await fs.readFile(mainSpecPath, 'utf-8'); + // First Why paragraph, flattened to one line — no TBD placeholder. + expect(updatedContent).toContain( + '## Purpose\nAuditors need one answer for retention. Today there is none.' + ); + expect(updatedContent).not.toContain('TBD'); + expect(updatedContent).not.toContain('Second paragraph'); + }); + it('should allow REMOVED requirements when creating new spec file (issue #403)', async () => { const changeName = 'new-spec-with-removed'; const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); diff --git a/test/core/completions/command-registry.test.ts b/test/core/completions/command-registry.test.ts index 380200b57d..ff169168d1 100644 --- a/test/core/completions/command-registry.test.ts +++ b/test/core/completions/command-registry.test.ts @@ -171,7 +171,7 @@ describe('command completion registry', () => { 'instructions', 'list', 'new change', - 'new schema', + 'schema init', 'schemas', 'show', 'status', diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index f9fdaec264..1f136563e5 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -37,46 +37,46 @@ import { import { STORE_SELECTION_GUIDANCE } from '../../../src/core/templates/workflows/store-selection.js'; const EXPECTED_FUNCTION_HASHES: Record<string, string> = { - getExploreSkillTemplate: '7d29c863c7489fccd3696e5a0f797c2a3ceb00e87b2edbb5afa2a7f4c2c1e5f1', - getNewChangeSkillTemplate: '9a77bc2103d174df4074564b1815de3674ad2f4417286747bfac1982cb8a8729', - getContinueChangeSkillTemplate: 'ca14e1fc46f3ae35d46db0b874138a649fdbc0ca497e5d13b8f8717bbd6d2270', - getApplyChangeSkillTemplate: '0c5090a0da5209abd3868f2e6fd8676dc399c9d35175559c9824719aa55bd77f', - getFfChangeSkillTemplate: '418936f7f7d239eb57316e7a7fc16333356ae0c86a19dd1f1eb3df4e6570cf72', - getSyncSpecsSkillTemplate: '7cd72eb2ee6daa5a659b8f39b7f2dad0a7f4b4e0a9d72d6983aa756268b24f5a', - getOnboardSkillTemplate: '468b1acb044688cb5c758afacb9e317f315286a1a896b2160dbd3a3aaaefdc01', - getOpsxExploreCommandTemplate: '377375850502c64771c15dfa5d3a97a94019fb7d1a9bf386082851e84691bcb6', - getOpsxNewCommandTemplate: '8d264704470d555340e63bef518623854783673a827a0b7186b7db785fe3bef0', - getOpsxContinueCommandTemplate: '760fdf65bb7e8a2022ec6b292ed418c810c7110021a14686a0703a7dd45715a4', - getOpsxApplyCommandTemplate: 'fb04979c3953c9a801511d25bf40d433d7f2dc5ebaf8c03089361a5468a895c4', - getOpsxFfCommandTemplate: 'ea3f4cb7c75bf60a862a96673a3a1ee790d152c095d1eaec1bfd947e604c45cf', - getArchiveChangeSkillTemplate: '2685d3afa9c744b71977213b50a07b6f36245b47e850de0837279071ea995622', - getBulkArchiveChangeSkillTemplate: '48da8717605f7ad031eb64a08a1699b193e2b8581d520b0415109323cfe18aa9', - getOpsxSyncCommandTemplate: '657334f9a24012dbbd376b43dd70a0359ab3b33860b42533f2d22614840dd040', - getVerifyChangeSkillTemplate: '694d9de868be5c4efeaba4e5dadabce44bac441083cdf91ed6e9d54101c20f85', - getOpsxArchiveCommandTemplate: '6bedf96a38f737b81873d179af0baa70ba569a9842a53547328b195df94a52cc', - getOpsxOnboardCommandTemplate: '8cfd54812d14d10d6073204f32c29f54d77ae0e9b14767b9ecd4e140a6b1f049', - getOpsxBulkArchiveCommandTemplate: '173f5bceee0980ae065e17ef24a1c0cec4730de017f448238942b26a0fc17247', - getOpsxVerifyCommandTemplate: '172aaf7c3409138be84fce21d49dac206e6d4dfbdc663c9a82ad41663a6e0417', - getOpsxProposeSkillTemplate: '47031581d8855472d6e8743650394c7f91c18a34fd037ffa9eda7b0a521e1b91', - getOpsxProposeCommandTemplate: '3d450bd00350a7f906d421bf3d1a1b02a05912d01499480414f46f28b7ce3af2', + getExploreSkillTemplate: '76096c95d9a9edf6fcb2c21496f3a9e10c3590c1962546ae4ce403890a3b064e', + getNewChangeSkillTemplate: '9cff7766fae6cbe4b511383620fb631949f68b0701a160cfd742ff314510f894', + getContinueChangeSkillTemplate: 'c40545f2955826dc8ffa7a7e92ab770c05c77a2df65ccc190e43e4257d21abf2', + getApplyChangeSkillTemplate: 'bf28715f9cd68edc34d3010b89fe44969eb1be71a453860197fb5665e2929aaf', + getFfChangeSkillTemplate: '3d5f7c0a5adc13de39b075dbf1055638f451113522aa0f47cb82388972effc0c', + getSyncSpecsSkillTemplate: '2e56dfaca62753eac71673b727e25f5211375c360191aa7e0824a1c8520e6899', + getOnboardSkillTemplate: '497cf9749152c1ea0ad2c5e25173d98a03b531b1593356dadc3174282ee002fd', + getOpsxExploreCommandTemplate: 'cf7e4b7864caeaf0812b7cd66bab114e76ff3291c37620ff8ca953ef9b902e5f', + getOpsxNewCommandTemplate: '1644a002f8e6460e75297bf25af45020e1d6f6f2b0625a85dc694977f67ad880', + getOpsxContinueCommandTemplate: 'c47cebacadd84a3b87415f4833593714916beeadddd1094f4421bdbda9fbddcf', + getOpsxApplyCommandTemplate: '9e6a6341aa4e4af914d03c71dea0461b628af3ef094e08770d109183c7eb37e3', + getOpsxFfCommandTemplate: '6ab0f815943d13d15feeaed6b3c5c31d88a5e39eaeb2e97559084882834eaad0', + getArchiveChangeSkillTemplate: 'beac67ea518ebfcca042b65bf8aaf5580efbc0f2dc401a90b359755a7eed6aec', + getBulkArchiveChangeSkillTemplate: '92ee1a9279a1785fc1096c4b3ff0ea3e7b62d32ede8769667adfc98c3ddab699', + getOpsxSyncCommandTemplate: 'b63ceab2649287341d8d1e9859e28aee43859c0f010842b9cab24d26bc5be9b0', + getVerifyChangeSkillTemplate: '82e177a08924e428b7a49bb0496ebbe3b432a402028ddfc5991791357f2ea323', + getOpsxArchiveCommandTemplate: 'e1ad0e9ba787b547b623b8640384fe313a4a5fa667895d0e057874df4f50c7ca', + getOpsxOnboardCommandTemplate: '0ad961804e729504d05fcabfb2b8dd60af63622cf046fbc3a579b726822b4245', + getOpsxBulkArchiveCommandTemplate: '42ab68ab52596f4d4c35a647d37d676bd391dce357f0802dececc68b38b64f0a', + getOpsxVerifyCommandTemplate: '922bf6a739ce568f05b1a05e08b2bb4b07aa83c9170212db505d269ee4d5bec8', + getOpsxProposeSkillTemplate: 'c3285c04754d98a7d64ae26b761cabb5f498f2e0335b9f61bc9b2a40ad383479', + getOpsxProposeCommandTemplate: 'd9e3aa0bd7a07aa74beff43c274ffe80d2d8e56a5f1658c3c2defb95542363fc', getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d', - getUpdateChangeSkillTemplate: 'edfd7c78c182824d0775c7a9329cc6c2c11a80f0566ce72e0fc0c148aae1db4f', - getOpsxUpdateCommandTemplate: '74ae3775f8a2c89dd418c9128af09becc86175f5268203a1ad8b572d3b1f53ee', + getUpdateChangeSkillTemplate: '7eb4872a29c395679b5630e19d8117bbf98c6f364018f6a6df4860e797736dcb', + getOpsxUpdateCommandTemplate: '2582125a7bfd5620837a5d418bc3a431670b164723fffef63bf83a2893b84ecd', }; const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record<string, string> = { - 'openspec-explore': '7720de626aaecda5f708ff168cc3a2f5ffc192da83ec6330a3f711e511d3ecbf', - 'openspec-new-change': 'acef2777cd19fca67a7f6e0c12f495c8289abe35bf8be6f167a1bf2cb95c3ac7', - 'openspec-continue-change': '723d05abf8826e75a252ec52988fa0ce38a19709eb9126c739e0123a8351477d', - 'openspec-apply-change': 'bbbb5b93d33cdfb20f95774ff1c7e5f939c889b32ee039d6c294e139dd9da293', - 'openspec-ff-change': '28bdc98be47e94b9e718760f620d279818ba408035b8c6a9e2a68e840bda1948', - 'openspec-sync-specs': 'a8c0c437d38cca64c0e1c154810e2c06a4b8675152b616bac74bcd5e9090785a', - 'openspec-archive-change': 'd3f15228a5ec885f09493025e16e4a6763effda37a490bee0684a794b8940eb2', - 'openspec-bulk-archive-change': 'a575d6813df21f21a176d96933d21d0706759cd40d3590f3a3774e58b574bf83', - 'openspec-verify-change': '4e7db9dd5b3b6c3f3f3828c5d5e769b60fe14c264bf947478144cf08a7d69f4e', - 'openspec-onboard': 'a402b00b0dc0e2a6d763cede2ce1e50338a83ca80230ab956cf4a7ec874c13ba', - 'openspec-propose': '7a9ee4bf53dcdb18ff61f87e4dd8c81dd9a0ecd8f1fb684af64b05fcb8cd22f8', - 'openspec-update-change': '129923f75ba75d177acd96db9c893ee85f06101f617e7e22dc11c7017108f978', + 'openspec-explore': 'a4929dd0dba75eae6d9a033bccff7a9659acc9af785663b92b9e24a5612e2bac', + 'openspec-new-change': '4c5d8552d5c0d86a6ab03abb89f0fdc909e3314c4680a1eb093a2a57e6021e72', + 'openspec-continue-change': 'ba24cc84ad60ae5438248d1bb7c60edc87263cf31b60bd1b3ad64920275f90f9', + 'openspec-apply-change': '28e7bd6303d3601b7aeb5ea8aa00d36b6c521f9c463dbd1c0c974299247b9d03', + 'openspec-ff-change': 'b93e1b419277f0f154514128594e37376357eaf78baf2002ed49a91500117e92', + 'openspec-sync-specs': 'cbf42ede20c04ccba5bb850e1f3155483c4c7f2d50ab4a5170bdc82ddfa422c1', + 'openspec-archive-change': 'cd809dc03ee62c8ce1e95c726c838587fd958e9bfc3c18a55a03a9025368e805', + 'openspec-bulk-archive-change': '81d88e43267fd2d351e0d91b1455e1adf1c9f46579c8615bb37701e50a954ea4', + 'openspec-verify-change': '9d4e31dc0913ede7fd64802efde19493604baa910157be75f7e1f8c669eb6d65', + 'openspec-onboard': 'c0e85db9ce32f40b61064ea595fdef0a66e0c702760a5d1782ee6c5053c99b80', + 'openspec-propose': '2e49a22015d65d7b38d892d48ca204cdc60dff075b1af719b75a13582a1ea099', + 'openspec-update-change': '9f7663774774c7df4f2ebff2ca617280381032290b4edaf925d1848441f3e08e', }; // Intentionally excludes getFeedbackSkillTemplate: this list only models templates From 1ec6c8f9ae71826883c668765219590a2ab2fe87 Mon Sep 17 00:00:00 2001 From: Clay Good <hi@claygood.com> Date: Mon, 13 Jul 2026 15:08:55 -0500 Subject: [PATCH 37/45] docs(stores): state the any-format contract and the validate seam honestly Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- docs/stores-beta/upstream-work.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/stores-beta/upstream-work.md b/docs/stores-beta/upstream-work.md index aa4e6ea837..0010923b48 100644 --- a/docs/stores-beta/upstream-work.md +++ b/docs/stores-beta/upstream-work.md @@ -111,6 +111,18 @@ the link keeps resolving — marked so downstream agents trace against specs: onboarding-revamp 1/2 serving changes complete (archived — its requirements now live in specs/) ``` +## Any format, one opinion + +Artifacts are fully open — any filename, any document format, any template. +A team's existing product-spec format is a 15-line schema away, and a +change made of nothing but that document creates, gates, and archives +cleanly. OpenSpec holds exactly one format opinion, at the truth boundary: +deltas you want merged into standing `specs/` at archive time use the +requirement/scenario format. Freeform artifacts archive as-is. (One known +seam: `openspec validate` currently expects delta specs in every change, +so a deliberately spec-less workflow reports a spurious error — making +validation schema-aware is a flagged follow-up.) + ## Team-wide status The rollup reads checkouts on the machine it runs on — pull a teammate's From 0948f020aab68076d50cf762fb743f7f868de1b0 Mon Sep 17 00:00:00 2001 From: Clay Good <hi@claygood.com> Date: Mon, 13 Jul 2026 15:17:05 -0500 Subject: [PATCH 38/45] fix(validate): stop demanding delta specs from spec-less workflow schemas A schema that defines no specs-generating artifact is deliberately documentation-free-form; validating its changes errored with 'must have at least one delta'. Both validation paths now consult the change's resolved schema, with any resolution failure falling back to the strict behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .changeset/stores-upstream-work.md | 2 + docs/stores-beta/upstream-work.md | 7 +- src/core/validation/validator.ts | 32 +++++++- test/commands/validate-schema-aware.test.ts | 84 +++++++++++++++++++++ 4 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 test/commands/validate-schema-aware.test.ts diff --git a/.changeset/stores-upstream-work.md b/.changeset/stores-upstream-work.md index f68cb05f7a..570b0c9833 100644 --- a/.changeset/stores-upstream-work.md +++ b/.changeset/stores-upstream-work.md @@ -18,3 +18,5 @@ - Legacy `initiative:` change metadata is tolerated in both the object and string forms (read-only, never re-emitted) instead of failing `status` - `openspec new change` no longer names the wrong schema in its progress line when the root's config sets a default - `openspec schema init --default` now writes the `schema:` key the config reader uses (previously wrote `defaultSchema:`, which nothing read), and its next-step hint prints a valid command +- `openspec validate` no longer demands delta specs from changes whose workflow schema defines no specs artifact (deliberately spec-less workflows validated with a spurious error) +- New specs created by archiving seed their Purpose from the proposal's Why section instead of a TBD placeholder diff --git a/docs/stores-beta/upstream-work.md b/docs/stores-beta/upstream-work.md index 0010923b48..bb879774df 100644 --- a/docs/stores-beta/upstream-work.md +++ b/docs/stores-beta/upstream-work.md @@ -118,10 +118,9 @@ A team's existing product-spec format is a 15-line schema away, and a change made of nothing but that document creates, gates, and archives cleanly. OpenSpec holds exactly one format opinion, at the truth boundary: deltas you want merged into standing `specs/` at archive time use the -requirement/scenario format. Freeform artifacts archive as-is. (One known -seam: `openspec validate` currently expects delta specs in every change, -so a deliberately spec-less workflow reports a spurious error — making -validation schema-aware is a flagged follow-up.) +requirement/scenario format. Freeform artifacts archive as-is, and +validation is schema-aware: a workflow that defines no specs artifact is +never asked for deltas. ## Team-wide status diff --git a/src/core/validation/validator.ts b/src/core/validation/validator.ts index 511662f294..04eedd2000 100644 --- a/src/core/validation/validator.ts +++ b/src/core/validation/validator.ts @@ -11,6 +11,8 @@ import { VALIDATION_MESSAGES } from './constants.js'; import { parseDeltaSpec, normalizeRequirementName, extractRequirementsSection } from '../parsers/requirement-blocks.js'; +import { resolveSchemaForChange } from '../../utils/change-metadata.js'; +import { resolveSchema } from '../artifact-graph/resolver.js'; import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust as containsShallOrMustShared, @@ -90,7 +92,12 @@ export class Validator { const result = ChangeSchema.safeParse(change); if (!result.success) { - issues.push(...this.convertZodErrors(result.error)); + const converted = this.convertZodErrors(result.error).filter( + (issue) => + !issue.message.startsWith(VALIDATION_MESSAGES.CHANGE_NO_DELTAS) || + this.changeSchemaExpectsDeltas(changeDir) + ); + issues.push(...converted); } issues.push(...this.applyChangeRules(change, content)); @@ -303,13 +310,34 @@ export class Validator { }); } - if (totalDeltas === 0) { + if (totalDeltas === 0 && this.changeSchemaExpectsDeltas(changeDir)) { issues.push({ level: 'ERROR', path: 'file', message: this.enrichTopLevelError('change', VALIDATION_MESSAGES.CHANGE_NO_DELTAS) }); } return this.createReport(issues); } + /** + * Whether the change's workflow schema defines a specs-generating + * artifact. A schema without one is deliberately spec-less (a + * documentation-only or freeform workflow), and demanding deltas from + * it would flag the schema author's intent as an error. Tolerant: any + * resolution failure falls back to requiring deltas, so the guard can + * only ever relax validation for schemas we can actually read. + */ + private changeSchemaExpectsDeltas(changeDir: string): boolean { + try { + const projectRoot = path.dirname(path.dirname(path.dirname(changeDir))); + const schemaName = resolveSchemaForChange(changeDir, undefined, projectRoot); + const schema = resolveSchema(schemaName, projectRoot); + return schema.artifacts.some((artifact) => + artifact.generates.startsWith('specs/') + ); + } catch { + return true; + } + } + /** * Recursively collect every delta `spec.md` under a change's specs directory, * so both the one-level (specs/<capability>/spec.md) and nested multi-area diff --git a/test/commands/validate-schema-aware.test.ts b/test/commands/validate-schema-aware.test.ts new file mode 100644 index 0000000000..2a4609faaf --- /dev/null +++ b/test/commands/validate-schema-aware.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { runCLI } from '../helpers/run-cli.js'; + +describe('schema-aware delta validation', () => { + let tempDir: string; + let env: NodeJS.ProcessEnv; + + beforeEach(() => { + tempDir = fs.realpathSync.native( + fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-validate-aware-')) + ); + env = { + XDG_DATA_HOME: path.join(tempDir, 'data'), + XDG_CONFIG_HOME: path.join(tempDir, 'config'), + OPEN_SPEC_INTERACTIVE: '0', + OPENSPEC_TELEMETRY: '0', + }; + fs.mkdirSync(path.join(tempDir, 'openspec', 'changes'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'openspec', 'config.yaml'), + 'schema: spec-driven\n' + ); + // A deliberately spec-less workflow: one freeform document, no specs/. + const schemaDir = path.join(tempDir, 'openspec', 'schemas', 'doc-only'); + fs.mkdirSync(path.join(schemaDir, 'templates'), { recursive: true }); + fs.writeFileSync( + path.join(schemaDir, 'schema.yaml'), + 'name: doc-only\nversion: 1\nartifacts:\n - id: document\n generates: document.md\n description: The document\n template: document.md\n requires: []\n' + ); + fs.writeFileSync(path.join(schemaDir, 'templates', 'document.md'), '# Doc\n'); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function makeChange(id: string, schema: string): string { + const dir = path.join(tempDir, 'openspec', 'changes', id); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, '.openspec.yaml'), `schema: ${schema}\n`); + fs.writeFileSync( + path.join(dir, 'proposal.md'), + '## Why\nBecause.\n\n## What Changes\n- **thing:** Something\n' + ); + return dir; + } + + it('does not demand deltas from a change whose schema is spec-less', async () => { + const dir = makeChange('write-the-doc', 'doc-only'); + fs.writeFileSync(path.join(dir, 'document.md'), '# The document\n'); + + const result = await runCLI(['validate', 'write-the-doc', '--type', 'change'], { + cwd: tempDir, + env, + }); + expect(result.stdout + result.stderr).not.toContain('at least one delta'); + expect(result.exitCode).toBe(0); + }); + + it('still demands deltas when the schema defines a specs artifact', async () => { + makeChange('needs-deltas', 'spec-driven'); + + const result = await runCLI(['validate', 'needs-deltas', '--type', 'change'], { + cwd: tempDir, + env, + }); + expect(result.exitCode).toBe(1); + expect(result.stdout + result.stderr).toContain('at least one delta'); + }); + + it('falls back to demanding deltas when the schema cannot be resolved', async () => { + makeChange('unknown-schema', 'no-such-schema'); + + const result = await runCLI(['validate', 'unknown-schema', '--type', 'change'], { + cwd: tempDir, + env, + }); + expect(result.exitCode).toBe(1); + }); +}); From 6737f231aefcc142a0aa7f57b0319012e608d430 Mon Sep 17 00:00:00 2001 From: Clay Good <hi@claygood.com> Date: Mon, 13 Jul 2026 15:19:20 -0500 Subject: [PATCH 39/45] =?UTF-8?q?feat(stores):=20stateless=20--scan=20for?= =?UTF-8?q?=20the=20rollup=20=E2=80=94=20the=20CI=20pattern=20now=20runs?= =?UTF-8?q?=20as=20documented?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh CI machine has no linked-root records (linking is recorded on the developer's machine), so the documented team-wide status pattern could not actually find serving changes. list --downstream --scan <dir> rolls up a directory of checkouts with no per-machine state; proven end to end against bare git remotes and a zero-state home. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- docs/agent-contract.md | 2 +- docs/stores-beta/upstream-work.md | 21 ++++++++-- src/cli/index.ts | 21 +++++++--- src/core/completions/command-registry.ts | 5 +++ src/core/upstream.ts | 53 +++++++++++++++++++++--- test/core/upstream.test.ts | 23 ++++++++++ 6 files changed, 110 insertions(+), 15 deletions(-) diff --git a/docs/agent-contract.md b/docs/agent-contract.md index b8f009759f..b8a51e1a39 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -45,7 +45,7 @@ Successful JSON payloads embed the root: ## 4. Command JSON shapes ### 4.1 `list --json` -`{ "changes": [ { "name", "completedTasks", "totalTasks", "lastModified", "status": "no-tasks"|"complete"|"in-progress" } ], "root": RootOutput }` — note the per-change `status` is a string enum here. `--specs`: `{ "specs": [ { "id", "requirementCount" } ], "root" }`. `--downstream`: `{ "downstream": { "path", "upstream": [ { "id", "exists", "archived", "changes": [ { "id", "store"?, "repo"?, "completedTasks", "totalTasks", "state": "complete"|"in-progress"|"no-tasks" } ], "changesComplete", "changesTotal", "tasksComplete", "tasksTotal" } ] } | null, "root" }` — `downstream` is null when the root has no `openspec/changes/` folder; each `upstream` entry is one of the root's changes plus every change on this machine that serves it. Serving changes are discovered by scanning `.openspec.yaml` files for `serves: <change>` (the root's own changes) and `serves: <store-id>/<change>` (changes in other registered roots and linked repos pointing at this store's change); `"exists": false` marks a ref whose target change is not on disk, `"archived": true` one whose target has been archived; `changes[].store`/`changes[].repo` name where a serving change lives, absent = the rolled-up root. Outside any root (and without `--store`), the command answers with registered store rollups instead: `{ "downstream": null, "stores": [ { "store", "rollup": <same shape as downstream> } ], "root": null }`. +`{ "changes": [ { "name", "completedTasks", "totalTasks", "lastModified", "status": "no-tasks"|"complete"|"in-progress" } ], "root": RootOutput }` — note the per-change `status` is a string enum here. `--specs`: `{ "specs": [ { "id", "requirementCount" } ], "root" }`. `--downstream`: `{ "downstream": { "path", "upstream": [ { "id", "exists", "archived", "changes": [ { "id", "store"?, "repo"?, "completedTasks", "totalTasks", "state": "complete"|"in-progress"|"no-tasks" } ], "changesComplete", "changesTotal", "tasksComplete", "tasksTotal" } ] } | null, "root" }` — `downstream` is null when the root has no `openspec/changes/` folder; each `upstream` entry is one of the root's changes plus every change on this machine that serves it. Serving changes are discovered by scanning `.openspec.yaml` files for `serves: <change>` (the root's own changes) and `serves: <store-id>/<change>` (changes in other registered roots and linked repos pointing at this store's change); `"exists": false` marks a ref whose target change is not on disk, `"archived": true` one whose target has been archived; `changes[].store`/`changes[].repo` name where a serving change lives, absent = the rolled-up root. `--scan <dir>` (repeatable, with `--downstream`) additionally scans the directory and its immediate subdirectories for serving changes — stateless, for machines with no linked-root records (CI). Outside any root (and without `--store`), the command answers with registered store rollups instead: `{ "downstream": null, "stores": [ { "store", "rollup": <same shape as downstream> } ], "root": null }`. ### 4.2 `show <item> --json` Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id", "title", "overview", "requirementCount", "requirements": [...], "metadata": { "version", "format", "sourcePath"? }, "root" }`. diff --git a/docs/stores-beta/upstream-work.md b/docs/stores-beta/upstream-work.md index bb879774df..de8dd3a506 100644 --- a/docs/stores-beta/upstream-work.md +++ b/docs/stores-beta/upstream-work.md @@ -126,10 +126,23 @@ never asked for deltas. The rollup reads checkouts on the machine it runs on — pull a teammate's repo and their work appears. For an always-current team view, run the same -command somewhere that already has every repo: a CI job that clones the -store and the code repos, registers the store, and publishes -`openspec list --downstream --store <id> --json` gives the whole team one -answer without any new machinery. +command somewhere that already has every repo. `--scan <dir>` rolls up a +directory of checkouts with no per-machine state at all, so a CI job is +four lines (run against a real fresh environment): + +```text +$ git clone <store-remote> plans +$ git clone <repo-remotes...> checkouts/… +$ openspec store register ./plans --id team-plans +$ openspec list --downstream --store team-plans --scan ./checkouts + +onboarding-revamp 0/2 serving changes complete + · api-implements api 1/2 tasks + · web-app-implements web-app 1/2 tasks +``` + +Publish the `--json` form of that output and the whole team has one answer +without any new machinery. Schemas version the same way everything in a store does: git. Pulling the store updates the workflow for every repo that references it; a repo that diff --git a/src/cli/index.ts b/src/cli/index.ts index 3fe2971eda..73a5cde85f 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -9,6 +9,7 @@ import { AI_TOOLS } from '../core/config.js'; import { UpdateCommand } from '../core/update.js'; import { ListCommand } from '../core/list.js'; import { + expandScanDirs, rollupDownstream, rollupRegisteredStores, type DownstreamRollup, @@ -124,9 +125,13 @@ function printRollupBody( async function renderDownstream( root: ResolvedOpenSpecRoot, - options: { json?: boolean } + options: { json?: boolean; scan?: string[] } ): Promise<void> { - const downstream = await rollupDownstream(root.path); + const downstream = await rollupDownstream(root.path, { + ...(options.scan && options.scan.length > 0 + ? { extraRoots: await expandScanDirs(options.scan) } + : {}), + }); if (options.json) { console.log( @@ -336,11 +341,17 @@ program .option('--specs', 'List specs instead of changes') .option('--changes', 'List changes explicitly (default)') .option('--downstream', "Show the root's changes and every change on this machine that serves them") + .option( + '--scan <dir>', + 'With --downstream: also scan this directory (and its immediate subdirectories) for serving changes — no registration needed (repeatable)', + (value: string, previous: string[]) => [...previous, value], + [] as string[] + ) .option('--sort <order>', 'Sort order: "recent" (default) or "name"', 'recent') .option('--json', 'Output as JSON (for programmatic use)') .option('--store <id>', STORE_OPTION_DESCRIPTION) .addOption(hiddenStorePathOption()) - .action(async (options?: { specs?: boolean; changes?: boolean; downstream?: boolean; sort?: string; json?: boolean; store?: string; storePath?: string }) => { + .action(async (options?: { specs?: boolean; changes?: boolean; downstream?: boolean; scan?: string[]; sort?: string; json?: boolean; store?: string; storePath?: string }) => { try { const failurePayload = options?.downstream ? { downstream: null, root: null } @@ -358,7 +369,7 @@ program try { const root = await resolveRootForCommand(options ?? {}); if (root) { - await renderDownstream(root, { json: options?.json }); + await renderDownstream(root, { json: options?.json, scan: options?.scan }); } return; } catch (error) { @@ -376,7 +387,7 @@ program return; } if (options?.downstream) { - await renderDownstream(root, { json: options?.json }); + await renderDownstream(root, { json: options?.json, scan: options?.scan }); return; } const listCommand = new ListCommand(); diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 50a2233411..dc25e155f6 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -54,6 +54,11 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ name: 'downstream', description: "Show the root's changes and every change on this machine that serves them", }, + { + name: 'scan', + description: 'With --downstream: also scan this directory (and its immediate subdirectories) for serving changes — no registration needed (repeatable)', + takesValue: true, + }, { name: 'sort', description: 'Sort order: "recent" (default) or "name"', diff --git a/src/core/upstream.ts b/src/core/upstream.ts index b4d6603d33..53dc1810b6 100644 --- a/src/core/upstream.ts +++ b/src/core/upstream.ts @@ -261,7 +261,7 @@ function mergeChanges( */ export async function rollupDownstream( root: string, - options: { globalDataDir?: string } = {} + options: { globalDataDir?: string; extraRoots?: string[] } = {} ): Promise<DownstreamRollup | null> { const changesDir = changesDirOf(root); let ownEntries; @@ -329,10 +329,16 @@ export async function rollupDownstream( await collectServingChanges(store.root, toOwnId, { store: store.id }) ); } - // Plain code repos that linked a change here (recorded at link time). - for (const linkedRoot of await readLinkedRoots( - options.globalDataDir ? { globalDataDir: options.globalDataDir } : {} - )) { + // Plain code repos that linked a change here (recorded at link time), + // plus any roots handed in explicitly (--scan on a machine with no + // per-machine state, like CI). + const passedRoots = (options.extraRoots ?? []).map((entry) => path.resolve(entry)); + for (const linkedRoot of [ + ...(await readLinkedRoots( + options.globalDataDir ? { globalDataDir: options.globalDataDir } : {} + )), + ...passedRoots, + ]) { if (scanned.has(linkedRoot)) continue; scanned.add(linkedRoot); mergeChanges( @@ -411,6 +417,43 @@ export async function rollupRegisteredStores( return rollups; } +/** + * Expands `--scan` arguments into candidate roots: the directory itself + * when it is an OpenSpec root, plus every immediate subdirectory that is + * one. Stateless by design — a CI checkout directory needs no + * registration to be rolled up. + */ +export async function expandScanDirs(dirs: string[]): Promise<string[]> { + const roots: string[] = []; + for (const dir of dirs) { + const resolved = path.resolve(dir); + const isRoot = async (candidate: string): Promise<boolean> => { + try { + return (await fs.stat(path.join(candidate, 'openspec', 'changes'))).isDirectory(); + } catch { + return false; + } + }; + if (await isRoot(resolved)) { + roots.push(resolved); + } + let entries; + try { + entries = await fs.readdir(resolved, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith('.')) continue; + const candidate = path.join(resolved, entry.name); + if (await isRoot(candidate)) { + roots.push(candidate); + } + } + } + return roots; +} + export interface ResolvedUpstreamLink { /** The reference as written: `<change>` or `<store-id>/<change>`. */ ref: string; diff --git a/test/core/upstream.test.ts b/test/core/upstream.test.ts index b7108ac42a..e3d3bbc2c7 100644 --- a/test/core/upstream.test.ts +++ b/test/core/upstream.test.ts @@ -203,6 +203,29 @@ describe('upstream links', () => { }); }); + describe('scan roots (stateless CI path)', () => { + it('rolls up serving changes from extraRoots without any linked-root state', async () => { + await registerStore('team-hub', 'team-hub'); + change('team-hub', 'onboarding-revamp', null, ''); + change('checkouts/web-app', 'add-tour', 'team-hub/onboarding-revamp', '- [x] a\n'); + change('checkouts/api', 'add-endpoint', 'team-hub/onboarding-revamp', '- [ ] b\n'); + + const { expandScanDirs } = await import('../../src/core/upstream.js'); + const extraRoots = await expandScanDirs([path.join(tempDir, 'checkouts')]); + expect(extraRoots).toHaveLength(2); + + const rollup = await rollupDownstream(path.join(tempDir, 'team-hub'), { + globalDataDir, + extraRoots, + }); + const entry = rollup?.upstream.find((u) => u.id === 'onboarding-revamp'); + expect(entry?.changes.map((c) => `${c.repo}/${c.id}`)).toEqual([ + 'api/add-endpoint', + 'web-app/add-tour', + ]); + }); + }); + describe('rollupRegisteredStores', () => { it('returns rollups for every registered store with changes', async () => { await registerStore('team-hub', 'team-hub'); From 985d895bea62a26ff27d18a83f41484966390119 Mon Sep 17 00:00:00 2001 From: Clay Good <hi@claygood.com> Date: Mon, 13 Jul 2026 15:33:55 -0500 Subject: [PATCH 40/45] fix(stores): close the fresh-eyes findings from two independent sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cold-start session (CLI + guides only) completed the whole loop first try; a multi-stage session built its own three-stage chain and archived it. Their friction logs drive this commit: rollup 'complete' now means the whole change (tasks AND artifacts — checked-off tasks with no approved proposal no longer read as done); schema init accepts custom stage names wired sequentially in the order given; schema validate/which accept --store; an explicit ## Purpose in a delta spec survives into the standing spec; the empty success_criteria stub is gone; status grows a glyph legend; archive says 'not tracked by this schema' when the schema has no tasks phase. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .changeset/stores-upstream-work.md | 6 +- docs/agent-contract.md | 2 +- docs/cli.md | 3 +- docs/stores-beta/upstream-work.md | 9 +- src/cli/index.ts | 16 ++- src/commands/schema.ts | 103 +++++++++++------- src/commands/workflow/instructions.ts | 6 - src/commands/workflow/status.ts | 5 + src/core/archive.ts | 29 ++++- src/core/completions/command-registry.ts | 4 +- src/core/specs-apply.ts | 21 +++- .../templates/workflows/store-selection.ts | 2 +- src/core/upstream.ts | 56 +++++++++- test/core/archive.test.ts | 27 +++++ .../core/completions/command-registry.test.ts | 2 + .../templates/skill-templates-parity.test.ts | 72 ++++++------ test/core/upstream.test.ts | 28 ++++- 17 files changed, 286 insertions(+), 105 deletions(-) diff --git a/.changeset/stores-upstream-work.md b/.changeset/stores-upstream-work.md index 570b0c9833..042ae6517f 100644 --- a/.changeset/stores-upstream-work.md +++ b/.changeset/stores-upstream-work.md @@ -12,6 +12,9 @@ - **Schema `notes:` and instruction files** — a schema's top-level `notes:` are surfaced verbatim on every instruction surface; `instructions/<artifact>.md` beside the schema wins over inline `instruction:` - **`structure:` config** — declare what a root's folders are for; surfaced in `openspec context` and the references index - **One-command store setup** — `openspec store setup <id>` no longer requires `--path` (defaults to `~/openspec/<id>`) and its output teaches the draft → serve → rollup loop +- **Stateless CI rollups** — `openspec list --downstream --scan <dir>` scans a directory of checkouts with no per-machine state, so a CI job can publish team-wide status +- **Whole-change rollup status** — a serving change only counts as complete when its tasks AND its schema's artifacts are done; half-done work renders both counts +- **Custom stages in `schema init`** — `--artifacts` accepts custom kebab-case stage names in workflow order (each stage requires the previous); `schema validate` and `schema which` accept `--store <id>` ### Fixes @@ -19,4 +22,5 @@ - `openspec new change` no longer names the wrong schema in its progress line when the root's config sets a default - `openspec schema init --default` now writes the `schema:` key the config reader uses (previously wrote `defaultSchema:`, which nothing read), and its next-step hint prints a valid command - `openspec validate` no longer demands delta specs from changes whose workflow schema defines no specs artifact (deliberately spec-less workflows validated with a spurious error) -- New specs created by archiving seed their Purpose from the proposal's Why section instead of a TBD placeholder +- New specs created by archiving seed their Purpose from an explicit `## Purpose` in the delta spec, then the proposal's Why section, instead of a TBD placeholder +- Instruction output no longer ships an empty `<success_criteria>` placeholder; `status` prints a glyph legend when artifacts are blocked; archiving a change whose schema has no tasks phase says "not tracked by this schema" instead of "No tasks" diff --git a/docs/agent-contract.md b/docs/agent-contract.md index b8a51e1a39..0c601af57d 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -45,7 +45,7 @@ Successful JSON payloads embed the root: ## 4. Command JSON shapes ### 4.1 `list --json` -`{ "changes": [ { "name", "completedTasks", "totalTasks", "lastModified", "status": "no-tasks"|"complete"|"in-progress" } ], "root": RootOutput }` — note the per-change `status` is a string enum here. `--specs`: `{ "specs": [ { "id", "requirementCount" } ], "root" }`. `--downstream`: `{ "downstream": { "path", "upstream": [ { "id", "exists", "archived", "changes": [ { "id", "store"?, "repo"?, "completedTasks", "totalTasks", "state": "complete"|"in-progress"|"no-tasks" } ], "changesComplete", "changesTotal", "tasksComplete", "tasksTotal" } ] } | null, "root" }` — `downstream` is null when the root has no `openspec/changes/` folder; each `upstream` entry is one of the root's changes plus every change on this machine that serves it. Serving changes are discovered by scanning `.openspec.yaml` files for `serves: <change>` (the root's own changes) and `serves: <store-id>/<change>` (changes in other registered roots and linked repos pointing at this store's change); `"exists": false` marks a ref whose target change is not on disk, `"archived": true` one whose target has been archived; `changes[].store`/`changes[].repo` name where a serving change lives, absent = the rolled-up root. `--scan <dir>` (repeatable, with `--downstream`) additionally scans the directory and its immediate subdirectories for serving changes — stateless, for machines with no linked-root records (CI). Outside any root (and without `--store`), the command answers with registered store rollups instead: `{ "downstream": null, "stores": [ { "store", "rollup": <same shape as downstream> } ], "root": null }`. +`{ "changes": [ { "name", "completedTasks", "totalTasks", "lastModified", "status": "no-tasks"|"complete"|"in-progress" } ], "root": RootOutput }` — note the per-change `status` is a string enum here. `--specs`: `{ "specs": [ { "id", "requirementCount" } ], "root" }`. `--downstream`: `{ "downstream": { "path", "upstream": [ { "id", "exists", "archived", "changes": [ { "id", "store"?, "repo"?, "completedTasks", "totalTasks", "completedArtifacts"?, "totalArtifacts"?, "state": "complete"|"in-progress"|"no-tasks" } ], "changesComplete", "changesTotal", "tasksComplete", "tasksTotal" } ] } | null, "root" }` — `downstream` is null when the root has no `openspec/changes/` folder; each `upstream` entry is one of the root's changes plus every change on this machine that serves it. Serving changes are discovered by scanning `.openspec.yaml` files for `serves: <change>` (the root's own changes) and `serves: <store-id>/<change>` (changes in other registered roots and linked repos pointing at this store's change); `"exists": false` marks a ref whose target change is not on disk, `"archived": true` one whose target has been archived; `state: "complete"` requires tasks checked off AND (when the serving change's schema is readable) all artifacts present — `completedArtifacts`/`totalArtifacts` carry the artifact-graph progress and are absent when the schema cannot be resolved; `changes[].store`/`changes[].repo` name where a serving change lives, absent = the rolled-up root. `--scan <dir>` (repeatable, with `--downstream`) additionally scans the directory and its immediate subdirectories for serving changes — stateless, for machines with no linked-root records (CI). Outside any root (and without `--store`), the command answers with registered store rollups instead: `{ "downstream": null, "stores": [ { "store", "rollup": <same shape as downstream> } ], "root": null }`. ### 4.2 `show <item> --json` Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id", "title", "overview", "requirementCount", "requirements": [...], "metadata": { "version", "format", "sourcePath"? }, "root" }`. diff --git a/docs/cli.md b/docs/cli.md index 8226a20918..3e97680528 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -391,6 +391,7 @@ openspec list [options] | `--specs` | List specs instead of changes | | `--changes` | List changes (default) | | `--downstream` | Show the root's changes and every change on this machine that serves them | +| `--scan <dir>` | With `--downstream`: also scan a directory of checkouts — stateless, for CI (repeatable) | | `--sort <order>` | Sort by `recent` (default) or `name` | | `--store <id>` | Store id to use as the OpenSpec root | | `--json` | Output as JSON | @@ -875,7 +876,7 @@ openspec schema init <name> [options] | Option | Description | |--------|-------------| | `--description <text>` | Schema description | -| `--artifacts <list>` | Comma-separated artifact IDs (default: `proposal,specs,design,tasks`) | +| `--artifacts <list>` | Comma-separated stage ids **in workflow order** — built-in (`proposal,specs,design,tasks`) or custom kebab-case names; each stage requires the previous | | `--default` | Set as project default schema | | `--no-default` | Don't prompt to set as default | | `--force` | Overwrite existing schema | diff --git a/docs/stores-beta/upstream-work.md b/docs/stores-beta/upstream-work.md index de8dd3a506..620ae1f9e3 100644 --- a/docs/stores-beta/upstream-work.md +++ b/docs/stores-beta/upstream-work.md @@ -94,7 +94,10 @@ Before working, read its artifacts (proposal, specs, and any others its schema d ## Status flows back live Rollup is discovered from facts on disk (one `serves:` line per change, task -checkboxes), never from prose, and works from anywhere: +checkboxes, artifact presence), never from prose, and works from anywhere. +A ✓ means the whole change is done — checked-off tasks with missing +artifacts render as in-progress with both counts, so nobody trusts work +that skipped its own workflow: ```text $ openspec list --downstream --store product-hub @@ -137,8 +140,8 @@ $ openspec store register ./plans --id team-plans $ openspec list --downstream --store team-plans --scan ./checkouts onboarding-revamp 0/2 serving changes complete - · api-implements api 1/2 tasks - · web-app-implements web-app 1/2 tasks + · api-implements api 1/2 tasks (1/4 artifacts) + · web-app-implements web-app 1/2 tasks (1/4 artifacts) ``` Publish the `--json` form of that output and the whole team has one answer diff --git a/src/cli/index.ts b/src/cli/index.ts index 73a5cde85f..38bc5f05bc 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -99,12 +99,22 @@ function printRollupBody( const mark = change.state === 'complete' ? '✓' : change.state === 'no-tasks' ? '–' : '·'; const where = change.store ?? change.repo ?? 'here'; - const tasks = + const artifactsKnown = change.totalArtifacts !== undefined; + const artifactsDone = + artifactsKnown && change.completedArtifacts === change.totalArtifacts; + let progressText = change.totalTasks === 0 - ? 'no tasks' + ? artifactsKnown + ? `${change.completedArtifacts}/${change.totalArtifacts} artifacts` + : 'no tasks' : `${change.completedTasks}/${change.totalTasks} tasks`; + // Checked-off tasks with missing artifacts is the half-done state + // that must not read as done — surface both numbers. + if (change.totalTasks > 0 && artifactsKnown && !artifactsDone) { + progressText += ` (${change.completedArtifacts}/${change.totalArtifacts} artifacts)`; + } console.log( - `${indent} ${mark} ${change.id.padEnd(changeWidth)} ${where.padEnd(14)} ${tasks}` + `${indent} ${mark} ${change.id.padEnd(changeWidth)} ${where.padEnd(14)} ${progressText}` ); } // Truth flows up: upstream work whose serving changes are all complete diff --git a/src/commands/schema.ts b/src/commands/schema.ts index faae058b7b..245b021e18 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -305,9 +305,18 @@ export function registerSchemaCommand(program: Command): void { .description('Show where a schema resolves from') .option('--json', 'Output as JSON') .option('--all', 'List all schemas with their resolution sources') - .action(async (name?: string, options?: { json?: boolean; all?: boolean }) => { + .option('--store <id>', 'Store id to use as the OpenSpec root (a store is a standalone OpenSpec repo you\'ve registered)') + .action(async (name?: string, options?: { json?: boolean; all?: boolean; store?: string }) => { try { - const projectRoot = process.cwd(); + let projectRoot = process.cwd(); + if (options?.store) { + const root = await resolveRootForCommand( + { store: options.store }, + { json: options?.json } + ); + if (!root) return; + projectRoot = root.path; + } if (options?.all) { // List all schemas @@ -407,9 +416,18 @@ export function registerSchemaCommand(program: Command): void { .description('Validate a schema structure and templates') .option('--json', 'Output as JSON') .option('--verbose', 'Show detailed validation steps') - .action(async (name?: string, options?: { json?: boolean; verbose?: boolean }) => { + .option('--store <id>', 'Store id to use as the OpenSpec root (a store is a standalone OpenSpec repo you\'ve registered)') + .action(async (name?: string, options?: { json?: boolean; verbose?: boolean; store?: string }) => { try { - const projectRoot = process.cwd(); + let projectRoot = process.cwd(); + if (options?.store) { + const root = await resolveRootForCommand( + { store: options.store }, + { json: options?.json } + ); + if (!root) return; + projectRoot = root.path; + } if (!name) { // Validate all project schemas @@ -669,7 +687,7 @@ export function registerSchemaCommand(program: Command): void { .description('Create a new project-local schema') .option('--json', 'Output as JSON') .option('--description <text>', 'Schema description') - .option('--artifacts <list>', 'Comma-separated artifact IDs (proposal,specs,design,tasks)') + .option('--artifacts <list>', 'Comma-separated stage ids, in workflow order (built-in or custom kebab-case names)') .option('--default', 'Set as project default schema') .option('--no-default', 'Do not prompt to set as default') .option('--force', 'Overwrite existing schema') @@ -790,19 +808,19 @@ export function registerSchemaCommand(program: Command): void { if (options?.artifacts) { selectedArtifactIds = options.artifacts.split(',').map((a) => a.trim()); - // Validate artifact IDs - const validIds = DEFAULT_ARTIFACTS.map((a) => a.id); + // Any kebab-case id is a valid stage — custom names are the + // point of a custom workflow. Built-in ids (proposal, specs, + // design, tasks) get richer seeded content. for (const id of selectedArtifactIds) { - if (!validIds.includes(id)) { + if (!isValidSchemaName(id)) { if (options?.json) { console.log(JSON.stringify({ created: false, - error: `Unknown artifact '${id}'`, - valid: validIds, + error: `Invalid artifact id '${id}' — use kebab-case (e.g. design-note)`, }, null, 2)); } else { - console.error(`Error: Unknown artifact '${id}'`); - console.error(`Valid artifacts: ${validIds.join(', ')}`); + console.error(`Error: Invalid artifact id '${id}'`); + console.error('Artifact ids must be kebab-case (e.g. design-note)'); } process.exitCode = 1; return; @@ -818,29 +836,29 @@ export function registerSchemaCommand(program: Command): void { if (spinner) spinner.start(`Creating schema '${name}'...`); fs.mkdirSync(schemaDir, { recursive: true }); - // Build artifacts array with proper dependencies - const selectedArtifacts = selectedArtifactIds.map((id) => { - const template = DEFAULT_ARTIFACTS.find((a) => a.id === id)!; - const artifact: Artifact = { - id: template.id, - generates: template.generates, - description: template.description, - template: template.template, - requires: [], - }; - - // Set up dependencies based on typical workflow - if (id === 'specs' && selectedArtifactIds.includes('proposal')) { - artifact.requires = ['proposal']; - } else if (id === 'design' && selectedArtifactIds.includes('specs')) { - artifact.requires = ['specs']; - } else if (id === 'tasks') { - const requires: string[] = []; - if (selectedArtifactIds.includes('design')) requires.push('design'); - else if (selectedArtifactIds.includes('specs')) requires.push('specs'); - artifact.requires = requires; + // Build the artifacts as a sequential chain in the order given — + // the order you pass IS your workflow. Built-in ids keep their + // known shapes; custom ids get a generic single-file shape. + const selectedArtifacts = selectedArtifactIds.map((id, index) => { + const known = DEFAULT_ARTIFACTS.find((a) => a.id === id); + const artifact: Artifact = known + ? { + id: known.id, + generates: known.generates, + description: known.description, + template: known.template, + requires: [], + } + : { + id, + generates: `${id}.md`, + description: `The ${id} stage`, + template: `${id}.md`, + requires: [], + }; + if (index > 0) { + artifact.requires = [selectedArtifactIds[index - 1]]; } - return artifact; }); @@ -875,7 +893,8 @@ export function registerSchemaCommand(program: Command): void { fs.mkdirSync(templateDir, { recursive: true }); } - // Create default template content + // Create default template content (custom stages get a minimal + // heading to replace) const templateContent = createDefaultTemplate(artifact.id); fs.writeFileSync(templatePath, templateContent); } @@ -888,13 +907,13 @@ export function registerSchemaCommand(program: Command): void { const instructionsDir = path.join(schemaDir, 'instructions'); fs.mkdirSync(instructionsDir, { recursive: true }); for (const artifact of selectedArtifacts) { - const seed = builtin.artifacts.find((a) => a.id === artifact.id)?.instruction; - if (seed) { - fs.writeFileSync( - path.join(instructionsDir, `${artifact.id}.md`), - `${seed.trim()}\n` - ); - } + const seed = + builtin.artifacts.find((a) => a.id === artifact.id)?.instruction ?? + `Write the ${artifact.id} stage.\n\n<replace with your guidance: required sections, what belongs here vs. later stages, and any review gate — e.g. "Do NOT proceed to the next stage until this is approved.">`; + fs.writeFileSync( + path.join(instructionsDir, `${artifact.id}.md`), + `${seed.trim()}\n` + ); } // Update config if --default diff --git a/src/commands/workflow/instructions.ts b/src/commands/workflow/instructions.ts index 1e5d02af02..003f4e6c7e 100644 --- a/src/commands/workflow/instructions.ts +++ b/src/commands/workflow/instructions.ts @@ -336,12 +336,6 @@ export function printInstructionsText( console.log('</template>'); console.log(); - // Success criteria placeholder - console.log('<success_criteria>'); - console.log('<!-- To be defined in schema validation rules -->'); - console.log('</success_criteria>'); - console.log(); - // Unlocks if (unlocks.length > 0) { console.log('<unlocks>'); diff --git a/src/commands/workflow/status.ts b/src/commands/workflow/status.ts index 4374744bf5..bd2cca53e3 100644 --- a/src/commands/workflow/status.ts +++ b/src/commands/workflow/status.ts @@ -145,6 +145,11 @@ export function printStatusText(status: ChangeStatus): void { console.log(line); } + if (status.artifacts.some((a) => a.status === 'blocked')) { + console.log(); + console.log(chalk.dim('[x] done · [ ] ready · [-] blocked')); + } + if (status.isComplete) { console.log(); console.log(chalk.green('All artifacts complete!')); diff --git a/src/core/archive.ts b/src/core/archive.ts index 850d4cd5ba..d9d5d96724 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -1,6 +1,8 @@ import { promises as fs } from 'fs'; import path from 'path'; import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js'; +import { resolveSchemaForChange } from '../utils/change-metadata.js'; +import { resolveSchema } from './artifact-graph/resolver.js'; import { Validator } from './validation/validator.js'; import chalk from 'chalk'; import { @@ -345,7 +347,12 @@ export class ArchiveCommand { // Show progress and check for incomplete tasks const progress = await getTaskProgressForChange(changesDir, changeName, path.resolve(changesDir, '..', '..')); if (!json) { - const status = formatTaskStatus(progress); + // "No tasks" reads like a warning when the schema deliberately has + // no tasks phase — say so instead. + const status = + progress.total === 0 && !schemaTracksTasks(path.join(changesDir, changeName)) + ? 'not tracked by this schema' + : formatTaskStatus(progress); console.log(`Task status: ${status}`); } @@ -569,3 +576,23 @@ export class ArchiveCommand { return new Date().toISOString().split('T')[0]; } } + +/** + * Whether the change's workflow schema has a tasks phase at all — an + * artifact generating tasks.md or an apply phase tracking one. Tolerant: + * unresolvable schemas count as tracking, so phrasing can only soften for + * schemas we can actually read. + */ +function schemaTracksTasks(changeDir: string): boolean { + try { + const projectRoot = path.dirname(path.dirname(path.dirname(changeDir))); + const schemaName = resolveSchemaForChange(changeDir, undefined, projectRoot); + const schema = resolveSchema(schemaName, projectRoot); + return ( + schema.artifacts.some((artifact) => artifact.generates.endsWith('tasks.md')) || + Boolean(schema.apply?.tracks) + ); + } catch { + return true; + } +} diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index dc25e155f6..c80c66d839 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -708,6 +708,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ name: 'all', description: 'List all schemas with their resolution sources', }, + COMMON_FLAGS.store, ], }, { @@ -722,6 +723,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ name: 'verbose', description: 'Show detailed validation steps', }, + COMMON_FLAGS.store, ], }, { @@ -755,7 +757,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ }, { name: 'artifacts', - description: 'Comma-separated artifact IDs', + description: 'Comma-separated stage ids, in workflow order (built-in or custom kebab-case names)', takesValue: true, }, { diff --git a/src/core/specs-apply.ts b/src/core/specs-apply.ts index 8ad5918a9f..bf6889f8fe 100644 --- a/src/core/specs-apply.ts +++ b/src/core/specs-apply.ts @@ -230,7 +230,7 @@ export async function buildUpdatedSpec( targetContent = buildSpecSkeleton( specName, changeName, - await readProposalWhy(update.source) + (await readDeltaPurpose(update.source)) ?? (await readProposalWhy(update.source)) ); } @@ -405,6 +405,25 @@ export function buildSpecSkeleton( return `# ${titleBase} Specification\n\n## Purpose\n${purposeText}\n\n## Requirements\n`; } +/** + * An explicit `## Purpose` section in the delta spec itself — the highest- + * fidelity source for a NEW spec's Purpose, written by whoever wrote the + * requirements. Delta operations ignore the section, so without this it + * would be silently dropped. + */ +async function readDeltaPurpose(deltaSource: string): Promise<string | null> { + try { + const content = await fs.readFile(deltaSource, 'utf-8'); + const match = content + .replace(/\r\n?/g, '\n') + .match(/(?:^|\n)##\s+Purpose\s*\n+([\s\S]*?)(?=\n##\s|$)/); + const text = match?.[1]?.trim(); + return text && text.length > 0 ? text : null; + } catch { + return null; + } +} + /** * The first paragraph of the change proposal's `## Why` section, flattened * to one line — the natural Purpose for a spec this change creates. The diff --git a/src/core/templates/workflows/store-selection.ts b/src/core/templates/workflows/store-selection.ts index 3636f6906e..92af634085 100644 --- a/src/core/templates/workflows/store-selection.ts +++ b/src/core/templates/workflows/store-selection.ts @@ -4,4 +4,4 @@ * Interpolated into every workflow's instructions so generated skills * consistently teach how to target a registered store with `--store <id>`. */ -export const STORE_SELECTION_GUIDANCE = `**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run \`openspec store list --json\` to discover registered store ids, then pass \`--store <id>\` on the commands that act on a selected root (\`new change\`, \`schema init\`, \`status\`, \`instructions\`, \`list\`, \`show\`, \`validate\`, \`archive\`, \`doctor\`, \`context\`, \`schemas\`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local \`openspec/\` root. **Upstream links:** when new work implements or serves work tracked in a store (the user names it, or \`openspec context\` lists it under "In motion"), create the change with \`--serves <store-id>/<change>\` — the link wires context and status automatically, and the change's instructions will carry the upstream artifacts to read first.`; +export const STORE_SELECTION_GUIDANCE = `**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run \`openspec store list --json\` to discover registered store ids, then pass \`--store <id>\` on the commands that act on a selected root (\`new change\`, \`schema init\`, \`schema validate\`, \`schema which\`, \`status\`, \`instructions\`, \`list\`, \`show\`, \`validate\`, \`archive\`, \`doctor\`, \`context\`, \`schemas\`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local \`openspec/\` root. **Upstream links:** when new work implements or serves work tracked in a store (the user names it, or \`openspec context\` lists it under "In motion"), create the change with \`--serves <store-id>/<change>\` — the link wires context and status automatically, and the change's instructions will carry the upstream artifacts to read first.`; diff --git a/src/core/upstream.ts b/src/core/upstream.ts index 53dc1810b6..75710b0deb 100644 --- a/src/core/upstream.ts +++ b/src/core/upstream.ts @@ -18,6 +18,10 @@ import path from 'path'; import { parse as parseYaml } from 'yaml'; import { getTaskProgressForChange } from '../utils/task-progress.js'; +import { resolveSchemaForChange } from '../utils/change-metadata.js'; +import { ArtifactGraph } from './artifact-graph/graph.js'; +import { detectCompleted } from './artifact-graph/state.js'; +import { resolveSchema } from './artifact-graph/resolver.js'; import { writeFileAtomically } from './file-state.js'; import { getStoresDir, @@ -35,9 +39,33 @@ export interface ServingChangeStatus { repo?: string; completedTasks: number; totalTasks: number; + /** Artifact-graph progress; absent when the change's schema is unreadable. */ + completedArtifacts?: number; + totalArtifacts?: number; state: 'complete' | 'in-progress' | 'no-tasks'; } +/** + * Artifact-graph progress for one serving change, so the rollup's "done" + * means the whole change — a checked-off tasks.md with no approved + * proposal must not read as complete. Tolerant: an unreadable schema + * yields null and the rollup falls back to task counts alone. + */ +function readArtifactProgress( + changeDir: string, + scanRoot: string +): { completed: number; total: number } | null { + try { + const schemaName = resolveSchemaForChange(changeDir, undefined, scanRoot); + const schema = resolveSchema(schemaName, scanRoot); + const graph = ArtifactGraph.fromSchema(schema); + const completed = detectCompleted(graph, changeDir); + return { completed: completed.size, total: schema.artifacts.length }; + } catch { + return null; + } +} + export interface UpstreamChangeInfo { id: string; /** False when serving changes reference this id but no change exists on disk. */ @@ -210,18 +238,34 @@ async function collectServingChanges( if (id === null) continue; const progress = await getTaskProgressForChange(changesDir, entry.name, root); + const artifacts = readArtifactProgress(path.join(changesDir, entry.name), root); + const tasksDone = progress.total > 0 && progress.completed === progress.total; + const artifactsDone = artifacts !== null && artifacts.completed === artifacts.total; + // Done means the WHOLE change: tasks checked off AND (when the schema + // is readable) every artifact present. Without tasks, artifacts alone + // can carry it; without either signal, the state stays 'no-tasks'. + const state: ServingChangeStatus['state'] = + artifacts === null + ? progress.total === 0 + ? 'no-tasks' + : tasksDone + ? 'complete' + : 'in-progress' + : (progress.total === 0 ? artifactsDone : tasksDone && artifactsDone) + ? 'complete' + : progress.total === 0 && artifacts.total === 0 + ? 'no-tasks' + : 'in-progress'; const status: ServingChangeStatus = { id: entry.name, ...(label?.store ? { store: label.store } : {}), ...(label?.repo ? { repo: label.repo } : {}), completedTasks: progress.completed, totalTasks: progress.total, - state: - progress.total === 0 - ? 'no-tasks' - : progress.completed === progress.total - ? 'complete' - : 'in-progress', + ...(artifacts !== null + ? { completedArtifacts: artifacts.completed, totalArtifacts: artifacts.total } + : {}), + state, }; const bucket = found.get(id); if (bucket) { diff --git a/test/core/archive.test.ts b/test/core/archive.test.ts index 043d94dfbc..2eb3df9fdf 100644 --- a/test/core/archive.test.ts +++ b/test/core/archive.test.ts @@ -217,6 +217,33 @@ Then expected result happens`; expect(updatedContent).not.toContain('Second paragraph'); }); + it('prefers an explicit Purpose section in the delta over the proposal Why', async () => { + const changeName = 'delta-purpose'; + const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); + const changeSpecDir = path.join(changeDir, 'specs', 'purposeful'); + await fs.mkdir(changeSpecDir, { recursive: true }); + + await fs.writeFile( + path.join(changeDir, 'proposal.md'), + '## Why\n\nProposal-level why.\n' + ); + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + '## Purpose\nThe authoritative purpose, written with the requirements.\n\n## ADDED Requirements\n\n### Requirement: The system SHALL work\n\n#### Scenario: Basic\nGiven a\nWhen b\nThen c' + ); + + await archiveCommand.execute(changeName, { yes: true, noValidate: true }); + + const updatedContent = await fs.readFile( + path.join(tempDir, 'openspec', 'specs', 'purposeful', 'spec.md'), + 'utf-8' + ); + expect(updatedContent).toContain( + '## Purpose\nThe authoritative purpose, written with the requirements.' + ); + expect(updatedContent).not.toContain('Proposal-level why'); + }); + it('should allow REMOVED requirements when creating new spec file (issue #403)', async () => { const changeName = 'new-spec-with-removed'; const changeDir = path.join(tempDir, 'openspec', 'changes', changeName); diff --git a/test/core/completions/command-registry.test.ts b/test/core/completions/command-registry.test.ts index ff169168d1..0299845af5 100644 --- a/test/core/completions/command-registry.test.ts +++ b/test/core/completions/command-registry.test.ts @@ -172,6 +172,8 @@ describe('command completion registry', () => { 'list', 'new change', 'schema init', + 'schema validate', + 'schema which', 'schemas', 'show', 'status', diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index 1f136563e5..ea23ea3ab8 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -37,46 +37,46 @@ import { import { STORE_SELECTION_GUIDANCE } from '../../../src/core/templates/workflows/store-selection.js'; const EXPECTED_FUNCTION_HASHES: Record<string, string> = { - getExploreSkillTemplate: '76096c95d9a9edf6fcb2c21496f3a9e10c3590c1962546ae4ce403890a3b064e', - getNewChangeSkillTemplate: '9cff7766fae6cbe4b511383620fb631949f68b0701a160cfd742ff314510f894', - getContinueChangeSkillTemplate: 'c40545f2955826dc8ffa7a7e92ab770c05c77a2df65ccc190e43e4257d21abf2', - getApplyChangeSkillTemplate: 'bf28715f9cd68edc34d3010b89fe44969eb1be71a453860197fb5665e2929aaf', - getFfChangeSkillTemplate: '3d5f7c0a5adc13de39b075dbf1055638f451113522aa0f47cb82388972effc0c', - getSyncSpecsSkillTemplate: '2e56dfaca62753eac71673b727e25f5211375c360191aa7e0824a1c8520e6899', - getOnboardSkillTemplate: '497cf9749152c1ea0ad2c5e25173d98a03b531b1593356dadc3174282ee002fd', - getOpsxExploreCommandTemplate: 'cf7e4b7864caeaf0812b7cd66bab114e76ff3291c37620ff8ca953ef9b902e5f', - getOpsxNewCommandTemplate: '1644a002f8e6460e75297bf25af45020e1d6f6f2b0625a85dc694977f67ad880', - getOpsxContinueCommandTemplate: 'c47cebacadd84a3b87415f4833593714916beeadddd1094f4421bdbda9fbddcf', - getOpsxApplyCommandTemplate: '9e6a6341aa4e4af914d03c71dea0461b628af3ef094e08770d109183c7eb37e3', - getOpsxFfCommandTemplate: '6ab0f815943d13d15feeaed6b3c5c31d88a5e39eaeb2e97559084882834eaad0', - getArchiveChangeSkillTemplate: 'beac67ea518ebfcca042b65bf8aaf5580efbc0f2dc401a90b359755a7eed6aec', - getBulkArchiveChangeSkillTemplate: '92ee1a9279a1785fc1096c4b3ff0ea3e7b62d32ede8769667adfc98c3ddab699', - getOpsxSyncCommandTemplate: 'b63ceab2649287341d8d1e9859e28aee43859c0f010842b9cab24d26bc5be9b0', - getVerifyChangeSkillTemplate: '82e177a08924e428b7a49bb0496ebbe3b432a402028ddfc5991791357f2ea323', - getOpsxArchiveCommandTemplate: 'e1ad0e9ba787b547b623b8640384fe313a4a5fa667895d0e057874df4f50c7ca', - getOpsxOnboardCommandTemplate: '0ad961804e729504d05fcabfb2b8dd60af63622cf046fbc3a579b726822b4245', - getOpsxBulkArchiveCommandTemplate: '42ab68ab52596f4d4c35a647d37d676bd391dce357f0802dececc68b38b64f0a', - getOpsxVerifyCommandTemplate: '922bf6a739ce568f05b1a05e08b2bb4b07aa83c9170212db505d269ee4d5bec8', - getOpsxProposeSkillTemplate: 'c3285c04754d98a7d64ae26b761cabb5f498f2e0335b9f61bc9b2a40ad383479', - getOpsxProposeCommandTemplate: 'd9e3aa0bd7a07aa74beff43c274ffe80d2d8e56a5f1658c3c2defb95542363fc', + getExploreSkillTemplate: '3bfeae699013c46c0ad0018afd85b0ca931c5a7ddf8ce3c18aab22f06d1d354d', + getNewChangeSkillTemplate: '63d2eb80b6e746a562c9273df4048d228d3aa025f9a921632a5855f751e727ba', + getContinueChangeSkillTemplate: '09a58562acebd99b6738c45095bc423c3ce48388335e349aa59c7cb83ffab426', + getApplyChangeSkillTemplate: '76cc8caf6a58e946b03d14eb81dd02af45a209d040e249b8e12811e2423012e8', + getFfChangeSkillTemplate: '61389a5c3b9aa7405ac9e355269bd35f6a48415c041a3abd5cda1210afb4a7f7', + getSyncSpecsSkillTemplate: 'd8a19a90678d84ddfbcb3cd98e06806efe3e32f2965a7a29fd970e29f50f0522', + getOnboardSkillTemplate: 'fd10e5d08562888fbb95eec3bf1b765c90d24bbadfd886b9ae0eb821423eb4ab', + getOpsxExploreCommandTemplate: '152372aee423777e9d5637199d194bdba224fc3caff6dc3643c0d9b6ad4fbf3d', + getOpsxNewCommandTemplate: '14a3027294b0117b653c3d03f04ec89b951b5fe59153be3ae00377cf7cc6d6f1', + getOpsxContinueCommandTemplate: '332a4d8c47f1d477bab725946454551de5031e3c4d6551f040b6806c81a53b9e', + getOpsxApplyCommandTemplate: 'ab2efb2a9c3e47cfd12cf777575af93971f424343e8a318cbcfc4ed465d6e3ea', + getOpsxFfCommandTemplate: 'edb5e1a9ecff5be9eb7f952356531fb899a3c88fa70663037ed4bdeeb30aa0a8', + getArchiveChangeSkillTemplate: 'a630a79be1c563d313f75ed3f038b157cd9ad33e28b2805dd7fbb7dc766e2f86', + getBulkArchiveChangeSkillTemplate: 'b251e738bc06d7e8e9e55e8c3e7591c452e3ca42a185965c5e7beeb2c6870bec', + getOpsxSyncCommandTemplate: 'b25f62a21ad9b3eda0af79f3e8730814a44b9baa0ea8da6276125e845f59e823', + getVerifyChangeSkillTemplate: '72d800b35725c65e1d11144a4000b74bb2c6c1809c351958b193e0502b83d3f4', + getOpsxArchiveCommandTemplate: 'dc8c8ada7e068986bd83c4ba3755f111fb7abf2b234d3c42b744e552276407b3', + getOpsxOnboardCommandTemplate: 'a0a3a0bd3005757cf8c64f2a4f5b80e90aca6179723ae793843baaab822ebe7f', + getOpsxBulkArchiveCommandTemplate: 'fa10bb635ad5df4a42963b333a9d42d5da098b2577321a08c96de75bb83fb0eb', + getOpsxVerifyCommandTemplate: 'dd00c7601f579e65968035776b28e75193d713300e9776f7bcdd14dbecd2fb63', + getOpsxProposeSkillTemplate: 'a96cb461f59a562cfda0acecd6361d5e70497a1800d6a3c73afc35f2b18dae53', + getOpsxProposeCommandTemplate: '64d236912ec56432c34b7ee810bca47e20a4cc3befbf1c9570909163f4f05fcd', getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d', - getUpdateChangeSkillTemplate: '7eb4872a29c395679b5630e19d8117bbf98c6f364018f6a6df4860e797736dcb', - getOpsxUpdateCommandTemplate: '2582125a7bfd5620837a5d418bc3a431670b164723fffef63bf83a2893b84ecd', + getUpdateChangeSkillTemplate: '84cf42664f863b89447c4fadeb666f5b16936d10f143139e7bbbe6846da87f2e', + getOpsxUpdateCommandTemplate: 'c58d3534fa477e67bd35400848620784a2a69749c2e1703ab11cf7dca28b211a', }; const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record<string, string> = { - 'openspec-explore': 'a4929dd0dba75eae6d9a033bccff7a9659acc9af785663b92b9e24a5612e2bac', - 'openspec-new-change': '4c5d8552d5c0d86a6ab03abb89f0fdc909e3314c4680a1eb093a2a57e6021e72', - 'openspec-continue-change': 'ba24cc84ad60ae5438248d1bb7c60edc87263cf31b60bd1b3ad64920275f90f9', - 'openspec-apply-change': '28e7bd6303d3601b7aeb5ea8aa00d36b6c521f9c463dbd1c0c974299247b9d03', - 'openspec-ff-change': 'b93e1b419277f0f154514128594e37376357eaf78baf2002ed49a91500117e92', - 'openspec-sync-specs': 'cbf42ede20c04ccba5bb850e1f3155483c4c7f2d50ab4a5170bdc82ddfa422c1', - 'openspec-archive-change': 'cd809dc03ee62c8ce1e95c726c838587fd958e9bfc3c18a55a03a9025368e805', - 'openspec-bulk-archive-change': '81d88e43267fd2d351e0d91b1455e1adf1c9f46579c8615bb37701e50a954ea4', - 'openspec-verify-change': '9d4e31dc0913ede7fd64802efde19493604baa910157be75f7e1f8c669eb6d65', - 'openspec-onboard': 'c0e85db9ce32f40b61064ea595fdef0a66e0c702760a5d1782ee6c5053c99b80', - 'openspec-propose': '2e49a22015d65d7b38d892d48ca204cdc60dff075b1af719b75a13582a1ea099', - 'openspec-update-change': '9f7663774774c7df4f2ebff2ca617280381032290b4edaf925d1848441f3e08e', + 'openspec-explore': '74bd0a2abc09559af858195ddc187800e70686569a8a9e283cd72a0607442b35', + 'openspec-new-change': '986235a54f883526a3cd876348f30c5c4929371f257b84e1370dfafc910713c1', + 'openspec-continue-change': 'e82cf91e7833c21de29e537c256ee7acca9d92fb29275a3e126975cf2d8e2b91', + 'openspec-apply-change': '3f8eae937f9fc87967fbed44a9f20aabc6668ddad2aa53762a5621591cdaec8f', + 'openspec-ff-change': '9e997859748f54b75399c21c0896792912f719b9ddd1eabae3746153d31d311c', + 'openspec-sync-specs': 'be314dea2f1e26aca3d8fbc37dad1077b6d83fdb9cb6f59b2415f5eb91e9eca3', + 'openspec-archive-change': 'f99716c8c02a0dcdbc148e5cba64cb188952f1a0933ee4ebd8fb1aba701e2d8f', + 'openspec-bulk-archive-change': 'a2518c0cd3eebe817338be87743a1be07ab28f17e183c7eb499f705af0c863c2', + 'openspec-verify-change': 'c8e32ad379f5ab120ee2df54d17b1fb2d24ffde4b774e06e66e2eaafb2ffc150', + 'openspec-onboard': '34ca708adb63d6b011d65021b2807ca0f6b5078d10026fd4606df043a9e1d608', + 'openspec-propose': '21bc7e1756fd7a58a9776ea272a46510361e99b84ba5435956e74700ea25c8ac', + 'openspec-update-change': '9507860650a2f3fa13d3eab4299e2b56bdf90263f49b457dc5d641e28afdaa75', }; // Intentionally excludes getFeedbackSkillTemplate: this list only models templates diff --git a/test/core/upstream.test.ts b/test/core/upstream.test.ts index e3d3bbc2c7..b466e714ae 100644 --- a/test/core/upstream.test.ts +++ b/test/core/upstream.test.ts @@ -43,11 +43,14 @@ describe('upstream links', () => { fs.writeFileSync(full, content); } + // Fixtures use a schema name that resolves nowhere on purpose: artifact + // progress degrades to null and the aggregation tests stay about tasks. + // Artifact-aware completion has its own test below. function change(root: string, id: string, ref: string | null, tasks: string): void { const metadata = ref === null - ? 'schema: spec-driven\n' - : `schema: spec-driven\nserves: ${ref}\n`; + ? 'schema: fixture-workflow\n' + : `schema: fixture-workflow\nserves: ${ref}\n`; write(`${root}/openspec/changes/${id}/.openspec.yaml`, metadata); write(`${root}/openspec/changes/${id}/tasks.md`, tasks); } @@ -120,6 +123,27 @@ describe('upstream links', () => { expect(unrelated?.changesTotal).toBe(0); }); + it('does not call a change complete on tasks alone when artifacts are missing', async () => { + change('app', 'onboarding-revamp', null, ''); + // spec-driven resolves (built-in): tasks done, but only tasks.md of + // four artifacts exists — the whole change is not done. + write( + 'app/openspec/changes/half-done/.openspec.yaml', + 'schema: spec-driven\nserves: onboarding-revamp\n' + ); + write('app/openspec/changes/half-done/tasks.md', '- [x] all checked\n'); + + const rollup = await rollupDownstream(path.join(tempDir, 'app'), { globalDataDir }); + const entry = rollup?.upstream.find((u) => u.id === 'onboarding-revamp'); + const serving = entry?.changes.find((c) => c.id === 'half-done'); + + expect(serving?.state).toBe('in-progress'); + expect(serving?.completedTasks).toBe(1); + expect(serving?.totalArtifacts).toBe(4); + expect(serving?.completedArtifacts).toBe(1); + expect(entry?.changesComplete).toBe(0); + }); + it('hides serving-only changes from upstream rows', async () => { change('app', 'onboarding-revamp', null, ''); change('app', 'add-search', 'onboarding-revamp', '- [ ] one\n'); From f7c1d65ad84e9244954082615a6940f809c394b8 Mon Sep 17 00:00:00 2001 From: Clay Good <hi@claygood.com> Date: Mon, 13 Jul 2026 20:36:20 -0500 Subject: [PATCH 41/45] feat(schemas): sharpen the requirements workflow with deferrals, signals, and traceability The proposal stage now asks for explicit non-goals (the deferral list that stops downstream work from building the wrong thing) and 1-3 measurable success signals; the specs stage requires every requirement to trace to something the proposal states, flagging untraceable ones as scope creep instead of writing them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- schemas/requirements/instructions/proposal.md | 5 +++++ schemas/requirements/instructions/specs.md | 4 +++- schemas/requirements/templates/proposal.md | 8 ++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/schemas/requirements/instructions/proposal.md b/schemas/requirements/instructions/proposal.md index ca301dfb3a..0bd121d0aa 100644 --- a/schemas/requirements/instructions/proposal.md +++ b/schemas/requirements/instructions/proposal.md @@ -12,6 +12,11 @@ Sections: Use kebab-case names (e.g., `user-auth`, `data-export`). - **Modified Capabilities**: existing capabilities whose REQUIREMENTS change. Check `openspec/specs/` for existing names. Leave empty if none. +- **Non-goals**: what this deliberately does NOT include or defers. The + explicit deferral list is the single best guard against downstream work + building the wrong thing. +- **Success signals**: 1-3 measurable outcomes — numbers and time bounds, + not adjectives. - **Impact**: affected systems, teams, or downstream repos. IMPORTANT: The Capabilities section is the contract with the specs phase — diff --git a/schemas/requirements/instructions/specs.md b/schemas/requirements/instructions/specs.md index d648993d1c..d141565553 100644 --- a/schemas/requirements/instructions/specs.md +++ b/schemas/requirements/instructions/specs.md @@ -21,7 +21,9 @@ Format: needs at least one scenario. Specs should be testable — each scenario is a potential acceptance check for -the downstream work that serves this change. +the downstream work that serves this change. Every requirement MUST trace to +something the proposal states; a requirement that traces to nothing is scope +creep — flag it instead of writing it. This is the final artifact: there is no design or tasks phase here. Once the specs are approved, archive the change to sync them into `specs/`. diff --git a/schemas/requirements/templates/proposal.md b/schemas/requirements/templates/proposal.md index c79b85d44d..503f23da0a 100644 --- a/schemas/requirements/templates/proposal.md +++ b/schemas/requirements/templates/proposal.md @@ -18,6 +18,14 @@ Use existing spec names from openspec/specs/. Leave empty if no requirement changes. --> - `<existing-name>`: <what requirement is changing> +## Non-goals + +<!-- What this deliberately does not include or defers --> + +## Success Signals + +<!-- 1-3 measurable outcomes: numbers and time bounds, not adjectives --> + ## Impact <!-- Affected code, APIs, dependencies, systems --> From 5ebeb86bd8c2876f5c183382874ca22ce8d48b2d Mon Sep 17 00:00:00 2001 From: Clay Good <hi@claygood.com> Date: Mon, 13 Jul 2026 20:42:51 -0500 Subject: [PATCH 42/45] =?UTF-8?q?feat(stores):=20divergence=20rule=20in=20?= =?UTF-8?q?the=20upstream=20block=20=E2=80=94=20intent=20stays=20honest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A serving change's instructions now tell the agent what to do when implementation disagrees with the upstream requirement: flag it upstream (or propose a change against the synced spec, once archived) instead of silently diverging. Truth flowed up at archive time; now drift is caught while the work is still in motion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .changeset/stores-upstream-work.md | 2 ++ docs/stores-beta/upstream-work.md | 4 +++- src/commands/workflow/instructions.ts | 6 ++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.changeset/stores-upstream-work.md b/.changeset/stores-upstream-work.md index 042ae6517f..ec2afa411d 100644 --- a/.changeset/stores-upstream-work.md +++ b/.changeset/stores-upstream-work.md @@ -16,6 +16,8 @@ - **Whole-change rollup status** — a serving change only counts as complete when its tasks AND its schema's artifacts are done; half-done work renders both counts - **Custom stages in `schema init`** — `--artifacts` accepts custom kebab-case stage names in workflow order (each stage requires the previous); `schema validate` and `schema which` accept `--store <id>` +- **Intent stays honest** — a serving change's instructions carry a divergence rule (flag upstream instead of silently diverging), and the built-in `requirements` workflow now asks for explicit non-goals and measurable success signals, with requirement-traceability enforced in its specs guidance + ### Fixes - Legacy `initiative:` change metadata is tolerated in both the object and string forms (read-only, never re-emitted) instead of failing `status` diff --git a/docs/stores-beta/upstream-work.md b/docs/stores-beta/upstream-work.md index 620ae1f9e3..3da9b84726 100644 --- a/docs/stores-beta/upstream-work.md +++ b/docs/stores-beta/upstream-work.md @@ -81,7 +81,9 @@ Referenced store 'product-hub' in openspec/config.yaml — agents here now see i ``` The serving change's instructions open with the upstream context on disk — -the intent travels without anyone pasting it: +the intent travels without anyone pasting it, along with one standing rule: +if implementation shows an upstream requirement is wrong, flag it upstream +instead of silently diverging, so intent stays honest: ```text <upstream ref="product-hub/onboarding-revamp"> diff --git a/src/commands/workflow/instructions.ts b/src/commands/workflow/instructions.ts index 003f4e6c7e..434da6871c 100644 --- a/src/commands/workflow/instructions.ts +++ b/src/commands/workflow/instructions.ts @@ -116,11 +116,17 @@ function renderUpstreamBlock(link: ResolvedUpstreamLink): string { lines.push( "The upstream change has been archived — its requirements were synced into that root's openspec/specs/, which is now the standing truth to trace against." ); + lines.push( + 'If implementation shows a synced requirement is wrong or incomplete, do not silently diverge — propose a change against that spec upstream so the divergence is recorded before it becomes drift.' + ); } else if (link.path) { lines.push(`Upstream context: ${link.path}`); lines.push( "Before working, read its artifacts (proposal, specs, and any others its schema defines); this change should trace to a requirement there. Standing truths live beside it in that root's openspec/specs/." ); + lines.push( + 'If implementation shows an upstream requirement is wrong or incomplete, do not silently diverge — flag it upstream (or edit the upstream change, if that is your team\'s call to make) so intent stays honest.' + ); } else { lines.push( 'The upstream change was not found on disk — the link may be stale, or the store may not be registered on this machine.' From 714adade18c9a6e45a9ef1b9efdb6103d639bbd9 Mon Sep 17 00:00:00 2001 From: Clay Good <hi@claygood.com> Date: Tue, 14 Jul 2026 08:45:04 -0500 Subject: [PATCH 43/45] feat(stores): executable structure + one door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit structure: is now a filesystem definition, not documentation — keys ending / are folders, other keys files, store setup materializes whatever is missing (markdown seeded with its purpose, nothing ever overwritten, paths confined to openspec/), and store doctor flags drift with the one-command fix. And the whole loop gets a single entry point: /opsx:store, a generated core-profile skill that reads machine state, routes to the one situation that applies, runs the deterministic commands itself, and ends every reply with numbered next moves — zero mechanics of its own, so agents stay on the rails while users just talk. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .changeset/stores-upstream-work.md | 3 +- docs/agent-contract.md | 2 +- docs/stores-beta/upstream-work.md | 9 +++- docs/stores-beta/user-guide.md | 4 ++ src/core/init.ts | 1 + src/core/openspec-root.ts | 52 ++++++++++++++++++++ src/core/profile-sync-drift.ts | 1 + src/core/profiles.ts | 3 +- src/core/shared/skill-generation.ts | 4 ++ src/core/shared/tool-detection.ts | 1 + src/core/store/operations.ts | 40 +++++++++++++++ src/core/templates/skill-templates.ts | 1 + src/core/templates/workflows/store.ts | 60 +++++++++++++++++++++++ test/commands/config-profile.test.ts | 7 +-- test/commands/config.test.ts | 2 +- test/core/openspec-root.test.ts | 30 ++++++++++++ test/core/profiles.test.ts | 8 +-- test/core/shared/skill-generation.test.ts | 12 ++--- 18 files changed, 222 insertions(+), 18 deletions(-) create mode 100644 src/core/templates/workflows/store.ts diff --git a/.changeset/stores-upstream-work.md b/.changeset/stores-upstream-work.md index ec2afa411d..d29ba5e6c7 100644 --- a/.changeset/stores-upstream-work.md +++ b/.changeset/stores-upstream-work.md @@ -10,7 +10,8 @@ - **`openspec schema init` upgrades** — accepts `--store <id>` to scaffold into a registered store, and seeds per-stage `instructions/<artifact>.md` files (guidance agents receive, editable per stage) alongside templates - **Schema inheritance** — repos that declare `references: [<store>]` resolve the store's schemas (project → referenced stores → user → built-in); `openspec schemas` labels inherited entries with their source store and accepts `--store <id>` - **Schema `notes:` and instruction files** — a schema's top-level `notes:` are surfaced verbatim on every instruction surface; `instructions/<artifact>.md` beside the schema wins over inline `instruction:` -- **`structure:` config** — declare what a root's folders are for; surfaced in `openspec context` and the references index +- **`structure:` config, now executable** — declare any folders or files in a root's layout (keys ending `/` are folders, other keys files); surfaced in `openspec context` and the references index, materialized by `store setup <id>` (markdown files seeded with their purpose), and drift is flagged by `store doctor` +- **`/opsx:store` — one door** — a generated skill (core profile) that reads machine state and routes the whole loop conversationally: set up, draft, link with `--serves`, show status, archive; every mechanic stays a deterministic CLI command underneath - **One-command store setup** — `openspec store setup <id>` no longer requires `--path` (defaults to `~/openspec/<id>`) and its output teaches the draft → serve → rollup loop - **Stateless CI rollups** — `openspec list --downstream --scan <dir>` scans a directory of checkouts with no per-machine state, so a CI job can publish team-wide status - **Whole-change rollup status** — a serving change only counts as complete when its tasks AND its schema's artifacts are done; half-done work renders both counts diff --git a/docs/agent-contract.md b/docs/agent-contract.md index 0c601af57d..98ea381d73 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -74,7 +74,7 @@ Success: `{ "archive": { "change", "archivedAs": "YYYY-MM-DD-name", "path", "spe `{ "root": { "path", "source", "store_id"?, "healthy", "status": [] }, "store": { "id", "metadata": {present,valid,remote?}, "origin_url"?, "status": [] } | null, "references": [...], "status": [] }`. Health findings of any severity exit 0. Failure payload: `{ "root": null, "store": null, "references": [], "status": [d] }`, exit 1. ### 4.10 `context --json` -`{ "root": { "path", "source", "store_id"?, "role": "openspec_root" }, "members": [ { "role": "referenced_store", "id", "path"?, "remote"?, "fetch"?, "artifactTypes"?: [string], "changes"?: [string], "structure"?: {"<folder>": "<purpose>"}, "status": [] } ], "status": [] }`. AVAILABLE = path present AND status empty. `artifactTypes` (the store's own project-local schema names), `changes` (its in-motion change ids), and `structure` (its config-declared folder purposes) are present only on available members whose store defines them. `--code-workspace <path>` writes `{folders:[{name,path}]}` (available referenced stores only, `ref:` prefixes); in JSON mode the write runs before printing so stdout holds exactly one document even on write failure. Failure: `{ "root": null, "members": [], "status": [d] }`, exit 1. +`{ "root": { "path", "source", "store_id"?, "role": "openspec_root" }, "members": [ { "role": "referenced_store", "id", "path"?, "remote"?, "fetch"?, "artifactTypes"?: [string], "changes"?: [string], "structure"?: {"<folder>": "<purpose>"}, "status": [] } ], "status": [] }`. AVAILABLE = path present AND status empty. `artifactTypes` (the store's own project-local schema names), `changes` (its in-motion change ids), and `structure` (its config-declared layout — keys ending `/` are folders, other keys files) are present only on available members whose store defines them. `--code-workspace <path>` writes `{folders:[{name,path}]}` (available referenced stores only, `ref:` prefixes); in JSON mode the write runs before printing so stdout holds exactly one document even on write failure. Failure: `{ "root": null, "members": [], "status": [d] }`, exit 1. ### 4.11 `store ... --json` setup/register: `{ "store": {id, root, metadata_path?}, "registry": {path, registered, already_registered}, "git": {is_repository, initialized, committed}, "created_files": [], "status": [] }`. unregister/remove: `{ "store", "registry": {path, removed}, "files": {deleted, deleted_path, left_on_disk}, "status": [] }`. list: `{ "stores": [{id, root}], "status": [] }`. doctor: `{ "stores": [ { id, root, metadata_path?, openspec_root: {...healthy, status}, metadata: {present, valid, id?, remote}, git: {is_repository, has_commits, has_uncommitted_changes, has_remote, origin_url}, status } ], "status": [] }` (`null` = unknown/not probed). Health findings exit 0; failures exit 1 with the matching null-shape. Prompt cancellation exits 130. diff --git a/docs/stores-beta/upstream-work.md b/docs/stores-beta/upstream-work.md index 3da9b84726..674ce218a2 100644 --- a/docs/stores-beta/upstream-work.md +++ b/docs/stores-beta/upstream-work.md @@ -14,6 +14,13 @@ two primitives OpenSpec already has, working one level up: | "What is true, standing, forever" | the store's **specs/** — archive syncs them, as always | | "Where does everything stand" | `openspec list --downstream --store <id>` | +**And there is one door for all of it:** repos with OpenSpec skills get +`/opsx:store` — a conversational entry point that reads the machine's +state, routes to the one thing that makes sense (set up, draft, link, +status, archive), runs the commands itself, and ends every reply with +numbered next moves. Users talk; the agent drives; the commands below are +what it drives. + All output below is from a real run. ## The store defines the workflow @@ -161,7 +168,7 @@ which wins. | `.openspec.yaml` | `serves: <change>` or `serves: <store-id>/<change>` | | `schema.yaml` | optional `notes:` (workflow guidance, surfaced verbatim to agents) | | `<schema>/instructions/<artifact>.md` | per-artifact instruction files; a file wins over inline `instruction:` | -| `config.yaml` | `structure:` — folder → purpose map, surfaced in `context` and the references index | +| `config.yaml` | `structure:` — layout map, surfaced in `context` and the references index; keys ending `/` are folders, other keys are files; `store setup <id>` materializes anything missing (markdown files are seeded with their purpose) and `store doctor` flags drift | | `openspec schema init <name> [--store <id>]` | scaffold a workflow: schema.yaml + instructions/ + templates/ | | `openspec schemas [--store <id>]` | includes inherited schemas with their source store | | `openspec list --downstream [--store <id>]` | the rollup; outside any root it shows every registered store's | diff --git a/docs/stores-beta/user-guide.md b/docs/stores-beta/user-guide.md index 98af5110f1..747f090bf1 100644 --- a/docs/stores-beta/user-guide.md +++ b/docs/stores-beta/user-guide.md @@ -86,6 +86,10 @@ That's the whole model. From here the lifecycle is exactly what you know — on each command, and every printed hint carries the flag for you. The `Using OpenSpec root:` line always tells you where a command is acting. +Prefer to skip the commands entirely? Repos with OpenSpec skills get +`/opsx:store` — one conversational door that sets up, drafts, links, and +archives by running all of this for you. + Two defaults worth knowing: - A fresh store's changes use the built-in **`requirements`** workflow diff --git a/src/core/init.ts b/src/core/init.ts index b6ab31ab77..acd9ffffb6 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -75,6 +75,7 @@ const WORKFLOW_TO_SKILL_DIR: Record<string, string> = { 'verify': 'openspec-verify-change', 'onboard': 'openspec-onboard', 'propose': 'openspec-propose', + 'store': 'openspec-store', }; // ----------------------------------------------------------------------------- diff --git a/src/core/openspec-root.ts b/src/core/openspec-root.ts index 4aa592d9c0..25f89fd44d 100644 --- a/src/core/openspec-root.ts +++ b/src/core/openspec-root.ts @@ -2,6 +2,7 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { FileSystemUtils } from '../utils/file-system.js'; +import { readProjectConfig } from './project-config.js'; import { serializeConfig } from './config-prompts.js'; import { makeStoreDiagnostic, @@ -303,6 +304,56 @@ async function ensureDirectoryAnchor( }); } +/** + * Materialize the root's declared layout: every `structure:` entry in + * config becomes real. Keys ending in `/` are folders (created, anchored + * for git when requested); other keys are files (seeded with their purpose + * when markdown, created empty otherwise). Never overwrites anything that + * exists — declaring is safe, and re-running `store setup <id>` heals a + * layout that drifted. Keys are confined to the root; anything escaping it + * is skipped. + */ +async function ensureDeclaredStructure( + storeRoot: string, + ledger: CreatedPathLedgerEntry[], + anchor?: boolean +): Promise<void> { + let structure; + try { + structure = readProjectConfig(storeRoot)?.structure; + } catch { + return; + } + if (!structure) return; + + const openspecDir = path.resolve(storeRoot, OPENSPEC_ROOT_DIR); + for (const [key, purpose] of Object.entries(structure)) { + const target = path.resolve(openspecDir, key); + if (target !== openspecDir && !target.startsWith(openspecDir + path.sep)) { + continue; // Escapes the root — declared paths must stay inside openspec/. + } + const relative = `${OPENSPEC_ROOT_DIR}/${path.relative(openspecDir, target)}`; + if (key.endsWith('/')) { + await ensureDirectory(storeRoot, relative, ledger); + if (anchor) { + await ensureDirectoryAnchor(storeRoot, relative, ledger); + } + } else { + if ((await pathKind(target)) !== 'missing') continue; + await fs.mkdir(path.dirname(target), { recursive: true }); + const seed = key.endsWith('.md') + ? `# ${path.basename(key, '.md')}\n\n<!-- ${purpose} -->\n` + : ''; + await fs.writeFile(target, seed, 'utf-8'); + ledger.push({ + relativePath: relativeArtifact(relative, 'file'), + absolutePath: target, + kind: 'file', + }); + } + } +} + export interface EnsureOpenSpecRootOptions { anchorEmptyDirectories?: boolean; /** Seed content for a config that does not exist yet (never overwrites). */ @@ -327,6 +378,7 @@ export async function ensureOpenSpecRoot( await ensureDirectory(storeRoot, OPENSPEC_CHANGES_DIR, ledger); await ensureDirectory(storeRoot, OPENSPEC_ARCHIVE_DIR, ledger); await ensureDefaultConfig(storeRoot, ledger, options.defaultConfigContent); + await ensureDeclaredStructure(storeRoot, ledger, options.anchorEmptyDirectories); if (options.anchorEmptyDirectories) { for (const relativeDir of ANCHORED_OPENSPEC_DIRS) { diff --git a/src/core/profile-sync-drift.ts b/src/core/profile-sync-drift.ts index 488d16cfdc..4d81b6660f 100644 --- a/src/core/profile-sync-drift.ts +++ b/src/core/profile-sync-drift.ts @@ -24,6 +24,7 @@ export const WORKFLOW_TO_SKILL_DIR: Record<WorkflowId, string> = { 'verify': 'openspec-verify-change', 'onboard': 'openspec-onboard', 'propose': 'openspec-propose', + 'store': 'openspec-store', }; function toKnownWorkflows(workflows: readonly string[]): WorkflowId[] { diff --git a/src/core/profiles.ts b/src/core/profiles.ts index acdc3ec953..b1fcaee19f 100644 --- a/src/core/profiles.ts +++ b/src/core/profiles.ts @@ -11,7 +11,7 @@ import type { Profile } from './global-config.js'; * Core workflows included in the 'core' profile. * These provide the streamlined experience for new users. */ -export const CORE_WORKFLOWS = ['propose', 'explore', 'apply', 'update', 'sync', 'archive'] as const; +export const CORE_WORKFLOWS = ['propose', 'explore', 'apply', 'update', 'sync', 'archive', 'store'] as const; /** * All available workflows in the system. @@ -29,6 +29,7 @@ export const ALL_WORKFLOWS = [ 'bulk-archive', 'verify', 'onboard', + 'store', ] as const; export type WorkflowId = (typeof ALL_WORKFLOWS)[number]; diff --git a/src/core/shared/skill-generation.ts b/src/core/shared/skill-generation.ts index f671b4de73..682b50b50e 100644 --- a/src/core/shared/skill-generation.ts +++ b/src/core/shared/skill-generation.ts @@ -17,6 +17,8 @@ import { getVerifyChangeSkillTemplate, getOnboardSkillTemplate, getOpsxProposeSkillTemplate, + getStoreSkillTemplate, + getOpsxStoreCommandTemplate, getOpsxExploreCommandTemplate, getOpsxNewCommandTemplate, getOpsxContinueCommandTemplate, @@ -70,6 +72,7 @@ export function getSkillTemplates(workflowFilter?: readonly string[]): SkillTemp { template: getVerifyChangeSkillTemplate(), dirName: 'openspec-verify-change', workflowId: 'verify' }, { template: getOnboardSkillTemplate(), dirName: 'openspec-onboard', workflowId: 'onboard' }, { template: getOpsxProposeSkillTemplate(), dirName: 'openspec-propose', workflowId: 'propose' }, + { template: getStoreSkillTemplate(), dirName: 'openspec-store', workflowId: 'store' }, ]; if (!workflowFilter) return all; @@ -97,6 +100,7 @@ export function getCommandTemplates(workflowFilter?: readonly string[]): Command { template: getOpsxVerifyCommandTemplate(), id: 'verify' }, { template: getOpsxOnboardCommandTemplate(), id: 'onboard' }, { template: getOpsxProposeCommandTemplate(), id: 'propose' }, + { template: getOpsxStoreCommandTemplate(), id: 'store' }, ]; if (!workflowFilter) return all; diff --git a/src/core/shared/tool-detection.ts b/src/core/shared/tool-detection.ts index 30622209dc..71066d5e59 100644 --- a/src/core/shared/tool-detection.ts +++ b/src/core/shared/tool-detection.ts @@ -44,6 +44,7 @@ export const COMMAND_IDS = [ 'verify', 'onboard', 'propose', + 'store', ] as const; export type CommandId = (typeof COMMAND_IDS)[number]; diff --git a/src/core/store/operations.ts b/src/core/store/operations.ts index 3f372ed866..e65575352c 100644 --- a/src/core/store/operations.ts +++ b/src/core/store/operations.ts @@ -35,6 +35,7 @@ import { type StorePathOptions, type StoreRegistryState, } from './foundation.js'; +import { readProjectConfig } from '../project-config.js'; import { StoreError, type StoreDiagnostic, makeStoreDiagnostic } from './errors.js'; import { assertGitCommitIdentity, @@ -1091,6 +1092,20 @@ async function inspectStore(entry: { openspecRoot = await inspectOpenSpecRoot(root); diagnostics.push(...openspecRoot.diagnostics); + // Declared layout drift: every `structure:` entry should exist on + // disk. Setup materializes them, so the fix is one re-run. + for (const missing of await findMissingDeclaredStructure(root)) { + diagnostics.push(makeStoreDiagnostic( + 'warning', + 'store_structure_missing', + `Declared structure entry 'openspec/${missing}' is missing on disk.`, + { + target: 'store.structure', + fix: `Run openspec store setup ${entry.id} to materialize the declared layout.`, + } + )); + } + try { const parsed = await readOptionalStoreMetadataState(root); if (!parsed) { @@ -1195,6 +1210,31 @@ async function inspectStore(entry: { }; } +/** Declared `structure:` entries (relative to openspec/) missing on disk. */ +async function findMissingDeclaredStructure(root: string): Promise<string[]> { + let structure; + try { + structure = readProjectConfig(root)?.structure; + } catch { + return []; + } + if (!structure) return []; + const openspecDir = path.resolve(root, OPENSPEC_ROOT_DIR); + const missing: string[] = []; + for (const key of Object.keys(structure)) { + const target = path.resolve(openspecDir, key); + if (target !== openspecDir && !target.startsWith(openspecDir + path.sep)) continue; + try { + const stat = await nodeFs.promises.stat(target); + const wantDir = key.endsWith('/'); + if (wantDir !== stat.isDirectory()) missing.push(key); + } catch { + missing.push(key); + } + } + return missing.sort(); +} + export async function doctorStores(id?: string): Promise<StoreDoctorResult> { const selectedId = id !== undefined ? validateStoreId(id) : undefined; const registry = await readStoreRegistryState(); diff --git a/src/core/templates/skill-templates.ts b/src/core/templates/skill-templates.ts index 598fcc4465..ffb84e942f 100644 --- a/src/core/templates/skill-templates.ts +++ b/src/core/templates/skill-templates.ts @@ -18,4 +18,5 @@ export { getBulkArchiveChangeSkillTemplate, getOpsxBulkArchiveCommandTemplate } export { getVerifyChangeSkillTemplate, getOpsxVerifyCommandTemplate } from './workflows/verify-change.js'; export { getOnboardSkillTemplate, getOpsxOnboardCommandTemplate } from './workflows/onboard.js'; export { getOpsxProposeSkillTemplate, getOpsxProposeCommandTemplate } from './workflows/propose.js'; +export { getStoreSkillTemplate, getOpsxStoreCommandTemplate } from './workflows/store.js'; export { getFeedbackSkillTemplate } from './workflows/feedback.js'; diff --git a/src/core/templates/workflows/store.ts b/src/core/templates/workflows/store.ts new file mode 100644 index 0000000000..b7d42679ea --- /dev/null +++ b/src/core/templates/workflows/store.ts @@ -0,0 +1,60 @@ +/** + * Skill Template Workflow Modules + * + * The one door to shared upstream work: a thin, state-routed skill. Every + * mechanic lives in deterministic CLI commands with JSON output; this + * skill only reads the world, routes to exactly one situation, and ends + * every reply with numbered next moves computed from disk state. + */ +import type { SkillTemplate, CommandTemplate } from '../types.js'; +import { STORE_SELECTION_GUIDANCE } from './store-selection.js'; + +const STORE_DOOR_INSTRUCTIONS = `The one door to shared upstream work: set it up, draft requirements, link implementing work, see status, archive into standing truth. The user should never need to know the commands — you run them. + +--- + +${STORE_SELECTION_GUIDANCE} + +**Read the world FIRST — never ask the user anything a command can answer:** + +1. \`openspec store list --json\` — which stores exist on this machine +2. \`openspec context --json\` — what the current repo references and what is in motion upstream +3. If a store is relevant: \`openspec list --downstream --store <id> --json\` — where everything stands + +**Route to exactly ONE situation:** + +- **No store registered** → offer to create one: \`openspec store setup <kebab-id>\` (no other flags needed — it picks ~/openspec/<id>, seeds a working requirements workflow, and prints the whole loop). Derive the id from the team or product name. +- **Store exists, nothing drafted** → capture: ask ONE question at most ("what's the work?"), derive a kebab-case name, run \`openspec new change <name> --store <id>\`, then follow \`openspec instructions <artifact> --change <name> --store <id>\` stage by stage — read the schema_notes and instruction blocks, author each artifact from the conversation, and STOP at each review gate the instructions declare. +- **Upstream change in motion** → continue its next incomplete artifact via \`openspec status --change <name> --store <id>\`, then \`openspec instructions <next-artifact> ...\`. +- **User is implementing upstream work in a code repo** → \`openspec new change <impl-name> --serves <store-id>/<change>\` — this wires context and status automatically; their instructions will open with the upstream block. +- **The rollup shows "all serving changes complete — archive it"** → confirm with the user, then \`openspec archive <change> --store <id> --yes\`, and show the standing spec it produced (\`openspec list --specs --store <id>\`). +- **The team wants its own workflow stages** → \`openspec schema init <name> --artifacts <their,stage,names> --store <id>\` (their order IS the chain), then edit the generated instructions/ and templates/ files with them. +- **Anything else / just checking in** → show the rollup and read it back plainly. + +**Rules:** + +- Quote real command output for any status claim — never estimate progress. +- Ask at most ONE question per message, and only when no command can answer it. +- Respect the schema: read \`schema_notes\` and per-artifact instructions verbatim; do not assume proposal/design/tasks exist — the workflow is whatever the schema says. +- If implementation shows an upstream requirement is wrong, flag it upstream instead of silently diverging. +- End EVERY reply with 2-4 numbered next moves computed from the current disk state, exactly one marked **(Recommended)**, each naming the real command it runs.`; + +export function getStoreSkillTemplate(): SkillTemplate { + return { + name: 'openspec-store', + description: + "One door to shared upstream work (stores): set up a team store, draft requirements under the team's workflow, link implementing changes with --serves, show live cross-repo status, and archive approved work into standing specs. Use when the user mentions team requirements, shared or upstream work, a store, cross-repo status, or handing work to other repos.", + instructions: STORE_DOOR_INSTRUCTIONS, + }; +} + +export function getOpsxStoreCommandTemplate(): CommandTemplate { + return { + name: 'OPSX: Store', + description: + 'One door to shared upstream work: set up, draft, link, status, archive', + category: 'Workflow', + tags: ['workflow', 'stores', 'experimental'], + content: STORE_DOOR_INSTRUCTIONS, + }; +} diff --git a/test/commands/config-profile.test.ts b/test/commands/config-profile.test.ts index 679e89a547..cda3a04dce 100644 --- a/test/commands/config-profile.test.ts +++ b/test/commands/config-profile.test.ts @@ -78,7 +78,7 @@ describe('deriveProfileFromWorkflowSelection', () => { it('returns core when selection has exactly core workflows in different order', async () => { const { deriveProfileFromWorkflowSelection } = await import('../../src/commands/config.js'); - expect(deriveProfileFromWorkflowSelection(['archive', 'sync', 'update', 'apply', 'explore', 'propose'])).toBe('core'); + expect(deriveProfileFromWorkflowSelection(['store', 'archive', 'sync', 'update', 'apply', 'explore', 'propose'])).toBe('core'); }); }); @@ -107,6 +107,7 @@ describe('config profile interactive flow', () => { 'openspec-update-change', 'openspec-sync-specs', 'openspec-archive-change', + 'openspec-store', ]; for (const dirName of coreSkillDirs) { const skillPath = path.join(projectDir, '.claude', 'skills', dirName, 'SKILL.md'); @@ -114,7 +115,7 @@ describe('config profile interactive flow', () => { fs.writeFileSync(skillPath, `name: ${dirName}\n`, 'utf-8'); } - const coreCommands = ['propose', 'explore', 'apply', 'update', 'sync', 'archive']; + const coreCommands = ['propose', 'explore', 'apply', 'update', 'sync', 'archive', 'store']; for (const commandId of coreCommands) { const commandPath = path.join(projectDir, '.claude', 'commands', 'opsx', `${commandId}.md`); fs.mkdirSync(path.dirname(commandPath), { recursive: true }); @@ -408,7 +409,7 @@ describe('config profile interactive flow', () => { const config = getGlobalConfig(); expect(config.profile).toBe('core'); expect(config.delivery).toBe('skills'); - expect(config.workflows).toEqual(['propose', 'explore', 'apply', 'update', 'sync', 'archive']); + expect(config.workflows).toEqual(['propose', 'explore', 'apply', 'update', 'sync', 'archive', 'store']); expect(select).not.toHaveBeenCalled(); expect(checkbox).not.toHaveBeenCalled(); expect(confirm).not.toHaveBeenCalled(); diff --git a/test/commands/config.test.ts b/test/commands/config.test.ts index 9d3541b686..381a6a499b 100644 --- a/test/commands/config.test.ts +++ b/test/commands/config.test.ts @@ -250,7 +250,7 @@ describe('config profile command', () => { const result = getGlobalConfig(); expect(result.profile).toBe('core'); expect(result.delivery).toBe('skills'); // preserved - expect(result.workflows).toEqual(['propose', 'explore', 'apply', 'update', 'sync', 'archive']); + expect(result.workflows).toEqual(['propose', 'explore', 'apply', 'update', 'sync', 'archive', 'store']); }); it('custom workflow selection should set profile to custom', async () => { diff --git a/test/core/openspec-root.test.ts b/test/core/openspec-root.test.ts index d2059f31e0..b41e157fc3 100644 --- a/test/core/openspec-root.test.ts +++ b/test/core/openspec-root.test.ts @@ -11,6 +11,36 @@ import { } from '../../src/core/index.js'; describe('OpenSpec root helper', () => { + it('materializes declared structure: folders and seeded files, never overwriting', async () => { + const root = path.join(tempDir, 'structured-store'); + fs.mkdirSync(path.join(root, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'openspec', 'config.yaml'), + [ + 'schema: spec-driven', + 'structure:', + ' research/: raw inputs', + ' decisions/adr-template.md: the shape of a decision record', + ' ../escape/: must be skipped', + ].join('\n') + '\n' + ); + fs.mkdirSync(path.join(root, 'openspec', 'research'), { recursive: true }); + fs.writeFileSync(path.join(root, 'openspec', 'research', 'kept.md'), 'existing\n'); + + const result = await ensureOpenSpecRoot(root, { anchorEmptyDirectories: true }); + + // Declared file seeded with its purpose; existing content untouched; + // escaping keys ignored. + expect( + fs.readFileSync(path.join(root, 'openspec', 'decisions', 'adr-template.md'), 'utf-8') + ).toContain('the shape of a decision record'); + expect(fs.readFileSync(path.join(root, 'openspec', 'research', 'kept.md'), 'utf-8')).toBe( + 'existing\n' + ); + expect(fs.existsSync(path.join(root, 'escape'))).toBe(false); + expect(result.createdArtifacts).toContain('openspec/decisions/adr-template.md'); + }); + let tempDir: string; beforeEach(() => { diff --git a/test/core/profiles.test.ts b/test/core/profiles.test.ts index b06456e016..a0badab354 100644 --- a/test/core/profiles.test.ts +++ b/test/core/profiles.test.ts @@ -9,7 +9,7 @@ import { describe('profiles', () => { describe('CORE_WORKFLOWS', () => { it('should contain the default core workflows', () => { - expect(CORE_WORKFLOWS).toEqual(['propose', 'explore', 'apply', 'update', 'sync', 'archive']); + expect(CORE_WORKFLOWS).toEqual(['propose', 'explore', 'apply', 'update', 'sync', 'archive', 'store']); }); it('should include update in the core profile (default install, not expanded-only)', () => { @@ -24,14 +24,14 @@ describe('profiles', () => { }); describe('ALL_WORKFLOWS', () => { - it('should contain all 12 workflows', () => { - expect(ALL_WORKFLOWS).toHaveLength(12); + it('should contain all 13 workflows', () => { + expect(ALL_WORKFLOWS).toHaveLength(13); }); it('should contain expected workflow IDs', () => { const expected = [ 'propose', 'explore', 'new', 'continue', 'apply', 'update', - 'ff', 'sync', 'archive', 'bulk-archive', 'verify', 'onboard', + 'ff', 'sync', 'archive', 'bulk-archive', 'verify', 'onboard', 'store', ]; expect([...ALL_WORKFLOWS]).toEqual(expected); }); diff --git a/test/core/shared/skill-generation.test.ts b/test/core/shared/skill-generation.test.ts index 5f4bba9d55..c806c906b3 100644 --- a/test/core/shared/skill-generation.test.ts +++ b/test/core/shared/skill-generation.test.ts @@ -8,9 +8,9 @@ import { describe('skill-generation', () => { describe('getSkillTemplates', () => { - it('should return all 12 skill templates', () => { + it('should return all 13 skill templates', () => { const templates = getSkillTemplates(); - expect(templates).toHaveLength(12); + expect(templates).toHaveLength(13); }); it('should have unique directory names', () => { @@ -89,9 +89,9 @@ describe('skill-generation', () => { }); describe('getCommandTemplates', () => { - it('should return all 12 command templates', () => { + it('should return all 13 command templates', () => { const templates = getCommandTemplates(); - expect(templates).toHaveLength(12); + expect(templates).toHaveLength(13); }); it('should have unique IDs', () => { @@ -144,9 +144,9 @@ describe('skill-generation', () => { }); describe('getCommandContents', () => { - it('should return all 12 command contents', () => { + it('should return all 13 command contents', () => { const contents = getCommandContents(); - expect(contents).toHaveLength(12); + expect(contents).toHaveLength(13); }); it('should have valid content structure', () => { From 981835a769c6122c9fcd405fd94faacc1a94c020 Mon Sep 17 00:00:00 2001 From: Clay Good <hi@claygood.com> Date: Tue, 14 Jul 2026 08:52:16 -0500 Subject: [PATCH 44/45] docs(stores): bring the change record and every guide current with the final state Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- docs/agent-contract.md | 2 +- docs/opsx.md | 1 + docs/stores-beta/user-guide.md | 5 +- openspec/changes/add-upstream-links/design.md | 48 +++++++--- .../changes/add-upstream-links/proposal.md | 88 ++++++++++++------- .../specs/schema-openness/spec.md | 16 +++- .../specs/upstream-links/spec.md | 22 +++++ openspec/changes/add-upstream-links/tasks.md | 18 +++- 8 files changed, 144 insertions(+), 56 deletions(-) diff --git a/docs/agent-contract.md b/docs/agent-contract.md index 98ea381d73..90acd56a32 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -65,7 +65,7 @@ Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id `{ "changeName", "changeDir", "schemaName", "contextFiles": { "<artifactId>": ["/abs", ...] }, "progress": {total,complete,remaining}, "tasks": [{id,description,done}], "state": "blocked"|"all_done"|"ready", "missingArtifacts"?, "instruction", "schemaNotes"?, "references"?, "upstream"?, "root" }` — `schemaNotes` and `upstream` as in 4.5. ### 4.7 `new change <name> --json` -Success: `{ "change": { "id", "path", "metadataPath", "schema" }, "root" }`. Failure: `{ "change": null, "status": [d] }`, exit 1. +Success: `{ "change": { "id", "path", "metadataPath", "schema" }, "serves"?, "root" }` — `serves` is present when `--serves <ref>` was passed and carries `{ "ref", "resolved", "reference_wiring"?: "added"|"already"|"skipped" }` (`resolved` = the upstream change was found on disk at link time; `reference_wiring` reports the automatic `references:` config edit). Failure: `{ "change": null, "status": [d] }`, exit 1. ### 4.8 `archive <name> --json` Success: `{ "archive": { "change", "archivedAs": "YYYY-MM-DD-name", "path", "specsUpdated", "totals"? }, "root" }`. Failure: `{ "archive": null, "root"?, "status": [d] }`, exit 1. JSON mode is strictly non-interactive: every prompt point becomes an `archive_*` code. diff --git a/docs/opsx.md b/docs/opsx.md index e396890add..87159087f1 100644 --- a/docs/opsx.md +++ b/docs/opsx.md @@ -158,6 +158,7 @@ rules: | Command | What it does | |---------|--------------| | `/opsx:propose` | Create a change and generate planning artifacts in one step (default quick path) | +| `/opsx:store` | One door to shared upstream work: set up a store, draft, link with `--serves`, status, archive (stores beta) | | `/opsx:explore` | Think through ideas, investigate problems, clarify requirements | | `/opsx:new` | Start a new change scaffold (expanded workflow) | | `/opsx:continue` | Create the next artifact (expanded workflow) | diff --git a/docs/stores-beta/user-guide.md b/docs/stores-beta/user-guide.md index 747f090bf1..b946fdd72b 100644 --- a/docs/stores-beta/user-guide.md +++ b/docs/stores-beta/user-guide.md @@ -347,8 +347,9 @@ tells you which case you're in. - **Per-machine state is per-machine.** The store registry, worksets, and the upstream-link records that power `list --downstream` are local settings. The rollup sees the checkouts on *this* machine — pull your - teammates' repos to see their work. Nothing about your machine's layout - is ever committed to shared planning. + teammates' repos to see their work, or point `list --downstream --scan + <dir>` at a directory of clones (stateless; how CI gets the team view). + Nothing about your machine's layout is ever committed to shared work. - **Two launch styles for worksets.** A tool that can't be launched with a workspace file or per-folder attach flags can't be added as an opener. - **Agent JSON has a known casing split** (store-family keys are diff --git a/openspec/changes/add-upstream-links/design.md b/openspec/changes/add-upstream-links/design.md index 6412f45b5f..19d5374e74 100644 --- a/openspec/changes/add-upstream-links/design.md +++ b/openspec/changes/add-upstream-links/design.md @@ -6,39 +6,59 @@ Upstream work is not a new artifact kind. It is a change in a store, typed by the store's schema. Everything else follows from refusing the parallel ontology: -| Planning need | Not this (initiatives experiment) | This | +| Need | Not this (initiatives experiment) | This | |---|---|---| | A finite piece of work above code | `initiatives/<name>/` folder | a change in the store | | An ordered workflow with handoffs | numbered stage folders | schema artifacts with `requires:` | | Guidance per stage | (none — freeform) | `instructions/<artifact>.md` + review gates | | Standing truths | unnumbered "evergreen" files | the store's `specs/`, synced by archive | +| The store's own layout | (nothing) | `structure:` map — materialized by setup, checked by doctor | | Traceability | `initiative:` line to a folder | `serves:` line to a change | -| Status | folder scan + task counts | same rollup, grouped by upstream change | +| Status | folder scan + task counts | rollup grouped by upstream change; done = tasks AND artifacts | +| Entry point | a skill that owned the mechanics | a thin skill routing deterministic commands | The pull-based rule the experiment liked ("read everything lower-numbered, produce what your stage owes the next") is the artifact dependency graph: your artifact's `requires` are satisfied → read them → draft yours. -## Mechanics kept from the experiment (retargeted) +## Deterministic rails, judgment in agents -Linking does all the wiring, exactly as before: `--serves <store>/<change>` -records the repo in `linked-roots.yaml` (machine-local; nothing committed) -and auto-adds the store to `references:` with comment-preserving YAML edits. -Rollup scans registered stores plus linked roots for one metadata line — no -manifest. A ref that resolves to nothing renders visibly, never silently. +Every mechanic is a CLI command computed from disk state: refs validated +before anything is written, status derived from checkboxes and artifact +presence (never recorded), rollup discovered from one metadata line (no +manifest), missing/archived targets rendered visibly. The `/opsx:store` +skill is deliberately mechanics-free — it reads three JSON commands, +routes to one situation, and ends with numbered next moves — so agents +cannot drift and humans/CI can run the same rails directly. `--scan <dir>` +exists because linked-root records are per-machine: a stateless CI clone +directory gets the identical rollup. ## Schema resolution order `project → referenced stores (declaration order, one hop) → user → package`. The resolver reads the registry synchronously and degrades to "no inherited schemas" on any unreadable state, because resolution runs deep in sync call -chains and must never turn a broken registry into a failed command. +chains and must never turn a broken registry into a failed command. The +same tolerance pattern governs everything schema-aware added here +(whole-change rollup status, delta-less validation, archive phrasing): an +unresolvable schema always falls back to the stricter, older behavior. + +## The truth boundary + +Archive remains the only place OpenSpec holds a format opinion: deltas +merged into standing `specs/` use the requirement/scenario format; +everything else archives as-is. New specs seed Purpose from the best +available intent (delta `## Purpose` → proposal `## Why` → placeholder), +and the divergence rule in the upstream block catches drift while work is +still in motion instead of only at archive time. ## Compatibility -- Legacy `initiative:` metadata (object form) stays readable and is never - re-emitted, matching the existing vocabulary contract. -- `--initiative` stays registered as a hidden removed option with a - deliberate error pointing at `--serves`. +- Legacy `initiative:` metadata stays readable in both shapes the beta + wrote (object and string), never re-emitted; `--initiative` stays + registered as a hidden removed option pointing at `--serves`. - `schemas --json` stays a bare array; entries gain `source: 'store'` + - `store` for inherited schemas. + `store` for inherited schemas. `structure:` JSON surfaces stay + folder/file → purpose strings. +- Existing commands keep their shapes; every addition is a new key, flag, + or command. diff --git a/openspec/changes/add-upstream-links/proposal.md b/openspec/changes/add-upstream-links/proposal.md index 32d2fe6cb4..ffbaa4679b 100644 --- a/openspec/changes/add-upstream-links/proposal.md +++ b/openspec/changes/add-upstream-links/proposal.md @@ -1,48 +1,68 @@ -# Add upstream links and open the schema system upward +# Upstream work in stores: links, rollups, workflows, one door ## Why -Teams that plan before code moves (product briefs, platform contracts) need -a home for that work and a traceable handoff to the repos that implement it. -The initiatives experiment answered this with a new untyped layer — folders, -stages, evergreen files — while everything else in OpenSpec is typed. Users -with custom schemas showed the better shape: upstream work is a change in a -store, typed by a schema the store defines. What is missing is not a new -noun; it is the existing machinery working one level up and across repos. +Teams decide what to build before code exists, and that work spans repos. +Stores gave it a home; nothing gave it motion — no link from implementing +changes to the upstream work they serve, no cross-repo status, no way for +a store to define its own workflow or layout, and no simple entry point. +The initiatives experiment answered with a new untyped layer; users with +custom schemas showed the better shape: upstream work is a change in a +store, and everything else is the existing machinery working one level up. ## What Changes - **Serves links**: `serves: <change>` / `<store-id>/<change>` in change - metadata, written by `openspec new change --serves <ref>`. Linking records - the checkout machine-locally and auto-adds `references:` to the repo's - config, so context surfaces immediately. -- **Rollup**: `openspec list --downstream [--store <id>]` shows each of the - root's changes and every change on this machine that serves it, live from - task lists. Refs to missing changes stay visible; archived upstream - changes are marked (their requirements now live in specs). -- **Upstream context block**: instructions for a linked change open with - `<upstream>` — the upstream change's on-disk path and how to trace to it. -- **Schema system opens up**: top-level `notes:` surfaced verbatim on every - instruction surface; per-artifact `instructions/<id>.md` files beside the - schema; schemas inherit through `references:` (project → referenced stores - → user → package); `structure:` in config declares folder purposes, - surfaced in `context` and the references index. -- **Happy path**: a fresh store defaults to the new built-in `requirements` - schema (proposal → specs, documentation-only); `openspec schema init` gains `--store` and - per-stage instruction files, so a team scaffolds its own workflow anywhere; store setup output teaches the - draft → serve → rollup loop; generated skills know when to use `--serves`. -- **Removed**: the initiatives folder convention, stages, evergreen files, - `list --initiatives`, and the initiatives skill. The `initiative` noun - returns to retired vocabulary; legacy metadata stays readable. + metadata via `new change --serves <ref>` — validated up front, and the + one flag wires everything: records the checkout for rollups + (machine-local) and auto-adds `references:` to the repo's config. +- **Live rollup**: `list --downstream [--store <id>]` groups every change + on the machine under the upstream change it serves; complete means the + WHOLE change (tasks and schema artifacts); missing refs stay visible, + archived targets resolve and are marked; `--scan <dir>` rolls up a + directory of checkouts statelessly (CI needs no linked-root records). +- **Context flows down**: instructions for a linked change open with an + `<upstream>` block — the upstream change's on-disk path, a directive to + trace to a requirement, and the divergence rule (flag upstream instead + of silently diverging). +- **Workflows are data**: schema `notes:` surfaced verbatim to agents; + per-stage `instructions/<artifact>.md` files; inheritance through + `references:` (project → referenced stores → user → built-in); + `schema init` scaffolds custom stage names in the order given, into a + store via `--store`; a built-in `requirements` workflow (proposal → + specs with non-goals, success signals, and requirement traceability) + is the default for fresh stores. +- **Layout is data**: `structure:` in config declares any folders or + files (keys ending `/` are folders); `store setup` materializes what is + missing (markdown seeded with its purpose, never overwriting) and + `store doctor` flags drift. +- **One door**: a generated core-profile skill (`/opsx:store`) reads + machine state via JSON commands, routes to the one applicable move + (set up / draft / continue / link / status / archive / custom schema), + and ends every reply with numbered next steps — zero mechanics of its + own. +- **Truth boundary made schema-aware**: `validate` requires deltas only + from schemas that define a specs artifact; new specs seed Purpose from + the delta's own Purpose section or the change's stated intent; archive + says "not tracked by this schema" instead of implying a missing + tasks.md is a defect. +- **Removed**: the initiatives folder convention, stages, evergreen + files, `list --initiatives`, and the initiatives skill; the noun + returns to retired vocabulary, legacy metadata stays readable in both + shapes it ever had. ## Capabilities ### New Capabilities -- `upstream-links`: the serves link, downstream rollup, and upstream context block -- `schema-openness`: notes, instruction files, inheritance through references, structure declarations +- `upstream-links`: the serves link, downstream rollup (including `--scan` + and whole-change completion), and upstream context block +- `schema-openness`: notes, instruction files, inheritance, custom-stage + scaffolding, executable structure declarations ## Impact -- CLI: `new change --serves`, `list --downstream`, `schemas --store` source labels -- Core: `src/core/upstream.ts` (new), artifact-graph resolver, project config, references index -- Docs: `docs/stores-beta/upstream-work.md`, agent contract JSON shapes +CLI (`new change --serves`, `list --downstream --scan`, `schema init/ +validate/which --store`, store setup/doctor), core (`src/core/upstream.ts` +new; resolver, project config, references, archive, validate), generated +skills (new `store` workflow in the core profile), stores-beta docs and +the agent contract. diff --git a/openspec/changes/add-upstream-links/specs/schema-openness/spec.md b/openspec/changes/add-upstream-links/specs/schema-openness/spec.md index fde53818a4..11b7465765 100644 --- a/openspec/changes/add-upstream-links/specs/schema-openness/spec.md +++ b/openspec/changes/add-upstream-links/specs/schema-openness/spec.md @@ -26,13 +26,21 @@ for the apply phase); a file SHALL win over an inline `instruction:` value. - **WHEN** any artifact's instructions render - **THEN** the notes appear in a `<schema_notes>` block before the artifact guidance -### Requirement: A store can declare its layout +### Requirement: A store can declare its layout, and the layout is real -Config MAY carry `structure:` — a folder → purpose map. `openspec context` -and the references index SHALL surface it for the root and for referenced -stores, so agents learn what non-reserved folders are for without guessing. +Config MAY carry `structure:` — a map of folders (keys ending `/`) and +files to their purpose. `openspec context` and the references index SHALL +surface it; `openspec store setup <id>` SHALL materialize declared entries +that are missing (seeding markdown files with their purpose, never +overwriting, confined to `openspec/`); `openspec store doctor` SHALL flag +declared entries missing on disk. #### Scenario: Downstream agents see the store's layout - **GIVEN** a store whose config declares `structure: { research/: raw inputs }` - **WHEN** a referencing repo runs `openspec context` - **THEN** the store's member entry lists `research/` with its purpose + +#### Scenario: Declared layout materializes on setup +- **GIVEN** a store whose config declares a folder and a markdown file that do not exist +- **WHEN** `openspec store setup <id>` runs again +- **THEN** the folder exists, the file exists seeded with its purpose, and nothing pre-existing was overwritten diff --git a/openspec/changes/add-upstream-links/specs/upstream-links/spec.md b/openspec/changes/add-upstream-links/specs/upstream-links/spec.md index a18c89c383..e3a88ee878 100644 --- a/openspec/changes/add-upstream-links/specs/upstream-links/spec.md +++ b/openspec/changes/add-upstream-links/specs/upstream-links/spec.md @@ -37,6 +37,28 @@ missing SHALL render visibly; archived targets SHALL be marked as archived. - **WHEN** the rollup or a serving change's instructions render - **THEN** the link resolves to the archived copy and states that its requirements now live in specs +### Requirement: Rollups work without per-machine state + +`openspec list --downstream --scan <dir>` SHALL also scan the directory +and its immediate subdirectories for serving changes, so a machine with no +linked-root records (CI) can produce the team rollup from clones alone. + +#### Scenario: A CI machine rolls up from fresh clones +- **GIVEN** a machine with no OpenSpec state, a cloned store registered by path, and code repos cloned under `./checkouts` +- **WHEN** `openspec list --downstream --store <id> --scan ./checkouts` runs +- **THEN** serving changes from the scanned clones appear in the rollup + +### Requirement: Complete means the whole change + +A serving change SHALL count as complete only when its tasks are checked +off AND (when its schema is resolvable) all of its schema's artifacts +exist; half-done work SHALL render both counts. + +#### Scenario: Checked tasks with missing artifacts is not done +- **GIVEN** a serving change with all tasks checked but only one of four schema artifacts present +- **WHEN** the rollup renders +- **THEN** the change shows as in-progress with both task and artifact counts + ### Requirement: Instructions carry upstream context Instruction surfaces for a serving change SHALL open with an `<upstream>` diff --git a/openspec/changes/add-upstream-links/tasks.md b/openspec/changes/add-upstream-links/tasks.md index ac114c231c..db4a814e23 100644 --- a/openspec/changes/add-upstream-links/tasks.md +++ b/openspec/changes/add-upstream-links/tasks.md @@ -20,8 +20,24 @@ - [x] `schema init`: `--store` support, per-stage instruction files, `--default` key fix - [x] Store setup output teaches the loop; skills carry `--serves` guidance +## Hardening from independent sessions +- [x] Whole-change rollup status: complete = tasks AND schema artifacts (half-done shows both counts) +- [x] `--scan <dir>`: stateless rollup for CI (no linked-root records needed) +- [x] `schema init` accepts custom stage names, wired sequentially in the order given +- [x] `schema validate` / `schema which` accept `--store` +- [x] Divergence rule in the `<upstream>` block (flag upstream, never silently diverge) +- [x] Schema-aware validate (spec-less workflows exempt from delta requirement) +- [x] Purpose seeding: delta `## Purpose` → proposal `## Why` → placeholder +- [x] Requirements workflow sharpened: non-goals, success signals, requirement traceability +- [x] Legacy string-form `initiative:` refs readable; rollup noise cut + +## Layout and the one door +- [x] `structure:` executable: folders and files materialized by `store setup`, drift flagged by `store doctor` +- [x] `/opsx:store` skill (core profile): thin state-routed entry point, generated on plain init + ## Verification - [x] Unit tests: upstream module, resolver inheritance, structure parsing, references index - [x] CLI tests: --serves validation and wiring, downstream rollup, context enrichment - [x] Full suite green; golden skill hashes regenerated -- [x] Real end-to-end run captured for docs and PR body +- [x] Real end-to-end runs captured for docs and PR body (single-repo, cross-repo on a real product, CI-machine rollup) +- [x] Cold-start session (help + guides only) completed the loop first attempt; multi-stage custom chain driven through every gate From a77d6975cba5e2eef32049eca931121195663f34 Mon Sep 17 00:00:00 2001 From: Clay Good <hi@claygood.com> Date: Tue, 14 Jul 2026 09:02:46 -0500 Subject: [PATCH 45/45] docs(stores): align the upstream-work guide vocabulary with the PR framing Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- docs/README.md | 2 +- docs/stores-beta/upstream-work.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/README.md b/docs/README.md index 32c70be272..1d7c93a7c9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -90,7 +90,7 @@ That second one matters more than it looks. OpenSpec has two halves: a command l | Doc | What it gives you | |-----|-------------------| | [Stores: User Guide](stores-beta/user-guide.md) | Plan in its own repo when your work spans repos or teams | -| [Upstream Work](stores-beta/upstream-work.md) | Typed planning in a store: your schemas, serves links, live status | +| [Upstream Work](stores-beta/upstream-work.md) | Upstream work in a store: one door, your workflows, serves links, live status | | [Agent Contract](agent-contract.md) | The machine-readable CLI surfaces agents drive | ## The thirty-second version diff --git a/docs/stores-beta/upstream-work.md b/docs/stores-beta/upstream-work.md index 674ce218a2..98e95ff854 100644 --- a/docs/stores-beta/upstream-work.md +++ b/docs/stores-beta/upstream-work.md @@ -1,4 +1,4 @@ -# Upstream work: typed planning in a store +# Upstream work: typed, linked, and live in a store > Specs are what is true. Changes are what is in motion. **Upstream work is > just a change in the store — typed by a schema the store defines.** @@ -9,7 +9,7 @@ two primitives OpenSpec already has, working one level up: | Need | Mechanism | |------|-----------| -| "Our planning work has its own shape" | a **schema in the store** (`openspec/schemas/<name>/`) | +| "Our upstream work has its own shape" | a **schema in the store** (`openspec/schemas/<name>/`) | | "Engineers' work should trace to ours" | a **serves link** (`openspec new change <name> --serves <store>/<change>`) | | "What is true, standing, forever" | the store's **specs/** — archive syncs them, as always | | "Where does everything stand" | `openspec list --downstream --store <id>` |