diff --git a/docs/README.md b/docs/README.md index 2e3df67b1c..b64f91cb45 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 | +| [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 dae386b9f7..ceb1ef7d09 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": { "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}], "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"?: [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,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"?, "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` (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. ### 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 new file mode 100644 index 0000000000..e9b427215b --- /dev/null +++ b/docs/stores-beta/initiatives.md @@ -0,0 +1,182 @@ +# 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/docs/stores-beta/user-guide.md b/docs/stores-beta/user-guide.md index 3711777e15..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 ``` @@ -316,9 +315,10 @@ tells you which case you're in. `openspec/config.yaml` declares `store: ` is treated as externalized planning, not as a store checkout to register. Remove the `store:` line first if you intentionally want to convert that repo into a local store root. -- **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/add-global-install-scope/.openspec.yaml b/openspec/changes/add-global-install-scope/.openspec.yaml index 910badd983..3d698c574e 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 +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..e6ff9118c5 --- /dev/null +++ b/openspec/changes/add-initiatives/design.md @@ -0,0 +1,102 @@ +## 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 new file mode 100644 index 0000000000..02d40c2c16 --- /dev/null +++ b/openspec/changes/add-initiatives/proposal.md @@ -0,0 +1,63 @@ +## 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 new file mode 100644 index 0000000000..aa654b14ef --- /dev/null +++ b/openspec/changes/add-initiatives/specs/initiatives/spec.md @@ -0,0 +1,117 @@ +## 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 new file mode 100644 index 0000000000..368aff1f0e --- /dev/null +++ b/openspec/changes/add-initiatives/tasks.md @@ -0,0 +1,39 @@ +## 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 e331c975d9..5fcdfb2ae1 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 +initiative: smoother-setup diff --git a/openspec/changes/schema-alias-support/.openspec.yaml b/openspec/changes/schema-alias-support/.openspec.yaml index 749f7518cb..c9c46f60b9 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 +initiative: smoother-setup diff --git a/openspec/changes/simplify-skill-installation/.openspec.yaml b/openspec/changes/simplify-skill-installation/.openspec.yaml index c8d3976aee..42e8161e7c 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 +initiative: smoother-setup diff --git a/openspec/config.yaml b/openspec/config.yaml index 0b7ad5176a..138c6501d9 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -1,6 +1,10 @@ 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/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/initiatives/smoother-setup/01_who/personas.md b/openspec/initiatives/smoother-setup/01_who/personas.md new file mode 100644 index 0000000000..cdccc50ec9 --- /dev/null +++ b/openspec/initiatives/smoother-setup/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/initiatives/smoother-setup/02_decisions/decisions.md b/openspec/initiatives/smoother-setup/02_decisions/decisions.md new file mode 100644 index 0000000000..b3f0572d51 --- /dev/null +++ b/openspec/initiatives/smoother-setup/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/initiatives/smoother-setup/goal.md b/openspec/initiatives/smoother-setup/goal.md new file mode 100644 index 0000000000..7383c710cb --- /dev/null +++ b/openspec/initiatives/smoother-setup/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/src/cli/index.ts b/src/cli/index.ts index 98505b02fe..4375180243 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -8,9 +8,14 @@ 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 { + 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 } 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'; @@ -53,6 +58,126 @@ function hiddenStorePathOption(): Option { ).hideHelp(); } +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(', ')}`); + } + 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)` + ); + } + 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 ?? change.repo ?? '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}` + ); + } + // 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 ${refPrefix}` + ); + } +} + +async function renderInitiatives( + root: ResolvedOpenSpecRoot, + options: { json?: boolean } +): Promise { + const initiatives = await rollupInitiatives(root.path); + + if (options.json) { + console.log( + JSON.stringify({ initiatives, 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).' + ); + return; + } + + console.log(`Initiatives: ${initiatives.path}`); + printPortfolioBody(initiatives, '', isStoreSelectedRoot(root) ? `${root.storeId}/` : ''); +} + +/** + * 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 (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('No local OpenSpec root — showing registered store portfolios.'); + for (const { store, portfolio } of stores) { + console.log(''); + console.log(`${store} (${portfolio.path})`); + printPortfolioBody(portfolio, ' ', `${store}/`); + } +} + function failWithError( error: unknown, json?: { enabled: boolean | undefined; payload?: Record; fallbackCode?: string } @@ -214,22 +339,53 @@ 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 see the portfolio 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('--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: 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: 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'; @@ -241,7 +397,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); @@ -544,6 +704,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); @@ -562,12 +724,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('--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 1a4b4a8312..d1c0734c76 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 { listInitiativeNames } 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 { + // 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 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/commands/store.ts b/src/commands/store.ts index 1a91d89984..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 } : {}), }; } @@ -673,7 +666,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..77443a06a7 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)); @@ -393,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.'; @@ -462,28 +520,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(); @@ -494,7 +572,9 @@ export function printApplyInstructionsText(instructions: ApplyInstructions): voi 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 3e059242dc..c96294280b 100644 --- a/src/commands/workflow/new-change.ts +++ b/src/commands/workflow/new-change.ts @@ -2,13 +2,16 @@ * 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 { recordLinkedRoot } from '../../core/initiatives.js'; +import { addReferenceToProjectConfig } from '../../core/project-config.js'; import { createChange, validateChangeName } from '../../utils/change-utils.js'; import { formatChangeLocation } from '../../core/planning-home.js'; import { @@ -31,9 +34,10 @@ export interface NewChangeOptions { description?: string; goal?: string; schema?: string; + /** Link the change to an initiative: `` or `/`. */ + initiative?: string; store?: string; storePath?: string; - initiative?: string; areas?: string; json?: boolean; } @@ -45,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; } @@ -53,14 +63,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.', @@ -72,7 +74,9 @@ function assertRemovedOptionsAbsent(options: NewChangeOptions): void { function printCreatedChangeHuman( payload: NewChangeOutput, - root: ResolvedOpenSpecRoot + root: ResolvedOpenSpecRoot, + 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. @@ -82,6 +86,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 (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}`)}`); } @@ -100,6 +120,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 }, @@ -126,6 +155,7 @@ export async function newChangeCommand(name: string | undefined, options: NewCha changesDir: root.changesDir, metadata: { ...(options.goal ? { goal: options.goal } : {}), + ...(options.initiative ? { initiative: options.initiative } : {}), }, }); @@ -136,6 +166,21 @@ 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. + // 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 = { change: { id: name, @@ -143,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), }; @@ -152,7 +205,7 @@ export async function newChangeCommand(name: string | undefined, options: NewCha } spinner?.stop(); - printCreatedChangeHuman(payload, root); + printCreatedChangeHuman(payload, root, options.initiative, referenceWiring); } catch (error) { spinner?.stop(); if (options.json) { 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/change-metadata/schema.ts b/src/core/change-metadata/schema.ts index d97d9a9a3f..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,7 +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(), + 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 76f2a28587..407f455a93 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: "Show the root's portfolio: every initiative, and every change pointing at it", + }, { 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, ], }, { @@ -241,6 +246,11 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Workflow schema to use', takesValue: true, }, + { + name: 'initiative', + description: 'Link this change to an initiative: or /', + takesValue: true, + }, COMMON_FLAGS.json, COMMON_FLAGS.store, ], diff --git a/src/core/init.ts b/src/core/init.ts index b6ab31ab77..4e791bd8a4 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -75,6 +75,7 @@ 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 new file mode 100644 index 0000000000..873fbef8d8 --- /dev/null +++ b/src/core/initiatives.ts @@ -0,0 +1,529 @@ +/** + * 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 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/profile-sync-drift.ts b/src/core/profile-sync-drift.ts index 488d16cfdc..3c4c10ebc7 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 = { '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 acdc3ec953..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. @@ -29,6 +37,7 @@ 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 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/references.ts b/src/core/references.ts index 7edb8a225b..249d85c510 100644 --- a/src/core/references.ts +++ b/src/core/references.ts @@ -23,16 +23,29 @@ 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 { listInitiativeNames } 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[]; +} + export interface ReferenceIndexEntry { store_id: string; 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[]; fetch?: string; status: StoreDiagnostic[]; } @@ -140,10 +153,52 @@ 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)), + })); +} + +/** + * A store's planning layer for the index: initiative names in order, or — + * with no initiatives yet — its evergreen artifact names. + */ +async function collectInitiativeNames(referencedRoot: string): Promise { + try { + const names = await listInitiativeNames(referencedRoot); + return names.map((name) => sanitizeInline(name, 60)); + } catch { + return []; + } +} + 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 +260,24 @@ 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: ${entry.initiatives.join(', ')} (${initiativesFetchRecipe(entry.store_id)})` + ); + } if (entry.fetch) { lines.push(` Fetch: ${entry.fetch}`); } @@ -368,10 +441,14 @@ export async function assembleReferenceIndex( } const specs = await collectSpecEntries(inspection.canonicalRoot); + const schemas = collectSchemaEntries(inspection.canonicalRoot); + const initiatives = await collectInitiativeNames(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/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/shared/skill-generation.ts b/src/core/shared/skill-generation.ts index f671b4de73..c966e77afd 100644 --- a/src/core/shared/skill-generation.ts +++ b/src/core/shared/skill-generation.ts @@ -17,6 +17,8 @@ import { getVerifyChangeSkillTemplate, getOnboardSkillTemplate, getOpsxProposeSkillTemplate, + getInitiativesSkillTemplate, + getOpsxInitiativesCommandTemplate, 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: getInitiativesSkillTemplate(), dirName: 'openspec-initiatives', workflowId: 'initiatives' }, ]; 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: getOpsxInitiativesCommandTemplate(), id: 'initiatives' }, ]; if (!workflowFilter) return all; diff --git a/src/core/shared/tool-detection.ts b/src/core/shared/tool-detection.ts index 30622209dc..6e03753551 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', + '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 598fcc4465..9a90e8fba2 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 { getInitiativesSkillTemplate, getOpsxInitiativesCommandTemplate } from './workflows/initiatives.js'; export { getFeedbackSkillTemplate } from './workflows/feedback.js'; 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 new file mode 100644 index 0000000000..e264ac1d56 --- /dev/null +++ b/src/core/templates/workflows/initiatives.ts @@ -0,0 +1,102 @@ +/** + * 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/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/src/core/working-set.ts b/src/core/working-set.ts index 007c336801..e025835fac 100644 --- a/src/core/working-set.ts +++ b/src/core/working-set.ts @@ -18,6 +18,12 @@ 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 names — or, with none yet, its + * evergreen artifact names. */ + initiatives?: string[]; status: StoreDiagnostic[]; } 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/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/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/context.test.ts b/test/commands/context.test.ts index 14471afadc..b5a68103be 100644 --- a/test/commands/context.test.ts +++ b/test/commands/context.test.ts @@ -106,6 +106,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 initiatives. + 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'); + fs.mkdirSync(path.join(upstream, 'openspec', 'initiatives', 'smoother-setup'), { + recursive: true, + }); + fs.mkdirSync(path.join(upstream, 'openspec', 'initiatives', 'q3-payments'), { + recursive: true, + }); + + 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(['q3-payments', 'smoother-setup']); + + 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: q3-payments, smoother-setup (openspec list --initiatives --store upstream-context)' + ); + }); + it('distinguishes self-reference omission from nothing declared', async () => { fs.writeFileSync( path.join(storeRoot, 'openspec', 'config.yaml'), 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-root-selection.test.ts b/test/commands/store-root-selection.test.ts index e50147e0d9..974a2a906c 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 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); @@ -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(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/commands/store.test.ts b/test/commands/store.test.ts index 41a17a6377..c1a59b2349 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([]); @@ -229,18 +231,23 @@ 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'], + // Both spellings: os.homedir() reads HOME on POSIX, USERPROFILE on Windows. + { cwd: tempDir, env: { ...env, HOME: tempDir, USERPROFILE: 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); @@ -291,9 +298,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 +322,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/completions/command-registry.test.ts b/test/core/completions/command-registry.test.ts index 8ac1e0775b..cb6e573d52 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', @@ -206,13 +207,13 @@ describe('command completion registry', () => { 'description', 'goal', 'schema', + '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..478b2a3358 --- /dev/null +++ b/test/core/initiatives.test.ts @@ -0,0 +1,306 @@ +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/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 b06456e016..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)', () => { @@ -24,14 +32,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', 'initiatives', ]; expect([...ALL_WORKFLOWS]).toEqual(expected); }); 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/references.test.ts b/test/core/references.test.ts index c129e9e420..bbdc119df7 100644 --- a/test/core/references.test.ts +++ b/test/core/references.test.ts @@ -130,6 +130,57 @@ 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 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']); + + expect(entry.schemas).toEqual([ + { + id: 'team-brief', + summary: 'Our own planning artifacts.', + artifacts: ['brief'], + }, + ]); + expect(entry.initiatives).toEqual(['q3-payments', '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: q3-payments, smoother-setup (openspec list --initiatives --store team-context)' + ); + }); + + 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/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', () => { diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index 65202e9e6e..083b32412a 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -25,6 +25,8 @@ import { getOpsxProposeSkillTemplate, getOpsxUpdateCommandTemplate, getOpsxVerifyCommandTemplate, + getInitiativesSkillTemplate, + getOpsxInitiativesCommandTemplate, getSyncSpecsSkillTemplate, getUpdateChangeSkillTemplate, getVerifyChangeSkillTemplate, @@ -37,46 +39,49 @@ 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: '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', + getInitiativesSkillTemplate: '6a8da2ed910e732a56dc7f280cb26b90b487247af323f8059d3bda707e02d168', + getOpsxInitiativesCommandTemplate: '4297c838493ca508f7a08748be4f48393d220b59981f6276db6b704864c470c8', getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d', - getUpdateChangeSkillTemplate: 'fe2e8edaf973d42dc7fc7dfd846105c4c3cfec0437606e582ec644985cd4e81d', - getOpsxUpdateCommandTemplate: 'e55ac5774203a7d9037d2d588889c97c53f3f930da49497cc79e865375920da7', + getUpdateChangeSkillTemplate: 'cb95d9c5c450087b5adf862986a6a81a65d57cce535162e693fbf9aea127c1c3', + getOpsxUpdateCommandTemplate: '1657481ccf1f60c44cba192d51b20f5d7035b3c3a84e237b7323b703496fc149', }; const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { - 'openspec-explore': 'ba099821631ce75ee70af370917bbddbc88d0882ad0e50e91ed687d2185102ef', - 'openspec-new-change': 'd5b8909bea70a33b7a312b38ce204a91f40b6bb2bff12c4c06b3e11641b6a689', - 'openspec-continue-change': '39b4467a4873cde7c97d52c80d53ac647b220bf7c9d96f4e6505f3188e1a1642', - 'openspec-apply-change': '09c0e1cdf5ccc82416d0969d6bd715cc70616bdbc3531358a5c36057f78be55a', - 'openspec-ff-change': '8d5a8890eccbd97d714fbab1d73472f79ad9104b519e000264ae43d752cdf631', - 'openspec-sync-specs': 'f6a1581eb11a30061795c42582db6fa4f5e1f213b4b7cad9f3cbfbe3e9fb2d97', - 'openspec-archive-change': '1821aee5a06afd895d59d1e1d16495e484b6087ecf59ec93460d7d5e7851e772', - 'openspec-bulk-archive-change': '7b09b04a440809dd7dbf0b1d7b695cbb8c41184d8d104eb32e82d7cdfb476d18', - 'openspec-verify-change': '9a8735eaaa34c278d2193eb32fa736f4b111d1c47e675971c8df40f81d20c8c3', - 'openspec-onboard': 'b1b6fc9a1b3ff64dafe9b8c39a761ee1bd001b542d47b4e4deaf058e0aa21256', - 'openspec-propose': '0cfc9278123d973929cb4da3ea7ac8ae1b6c84b472eed4fb753657b8347eaeb9', - 'openspec-update-change': '77ff4d1f1cd08a57649cce1f25e0ebc4f55d6d032dfde5c301d1b479561b72fa', + '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-initiatives': '9f40e9c1e485186912e3fd88aedbbe8d4106ff2a4c3b45114047afacaf2a65da', + 'openspec-update-change': '29c95eefe0df8efb4681dcaa6a90ae965535edda1832501f637a368608e33684', }; // Intentionally excludes getFeedbackSkillTemplate: this list only models templates @@ -93,6 +98,7 @@ const GENERATED_SKILL_FACTORIES: Array<[string, () => SkillTemplate]> = [ ['openspec-verify-change', getVerifyChangeSkillTemplate], ['openspec-onboard', getOnboardSkillTemplate], ['openspec-propose', getOpsxProposeSkillTemplate], + ['openspec-initiatives', getInitiativesSkillTemplate], ['openspec-update-change', getUpdateChangeSkillTemplate], ]; @@ -141,6 +147,8 @@ describe('skill templates split parity', () => { getOpsxVerifyCommandTemplate, getOpsxProposeSkillTemplate, getOpsxProposeCommandTemplate, + getInitiativesSkillTemplate, + getOpsxInitiativesCommandTemplate, getFeedbackSkillTemplate, getUpdateChangeSkillTemplate, getOpsxUpdateCommandTemplate, 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');