clone

a hermes agent plugin for openspec: tools for spec workflows and a dashboard tab for browsing changes and specs

Merge branch 'dev'

dami etoile committed 25b88e7
commit
25b88e7b751975a35cdc53c8c832ccbf60bdf7d5
parent
d185b4abdb80c4e198658c2518b5bc35ab0ce3ab
parent
8003cb287e3bcb6344f8f4ba1ae00dbad676fcd2
author
dami etoile <dami.etoile@gmail.com>
13 files changed, +1437 -15
MREADME.md37+-
M__init__.py20+
Aopenspec/changes/archive/add-idea-tools/.openspec.yaml2+
Aopenspec/changes/archive/add-idea-tools/README.md3+
Aopenspec/changes/archive/add-idea-tools/design.md73+++
Aopenspec/changes/archive/add-idea-tools/proposal.md45++
Aopenspec/changes/archive/add-idea-tools/specs/agent-tools/spec.md83++++
Aopenspec/changes/archive/add-idea-tools/specs/dashboard-sources/spec.md26+
Aopenspec/changes/archive/add-idea-tools/tasks.md36++
Mplugin.yaml9+
Mschemas.py135++++++
Atests/test_openspec_idea_tools.py276++++++++++++
Mtools.py707++++++++++++++++++++++++++++++-
M README.md +23 -14
@@ -8,23 +8,32 @@ OpenSpec keeps spec files (`openspec/changes/`, `openspec/specs/`, `openspec/ide
88 99 This plugin closes both gaps:1010 11-- **Agent tools** — six tools that wrap the `openspec` CLI so the agent can list, read, validate, and track OpenSpec changes and specs without shelling out manually. The tools are gated behind the CLI binary's availability, so they stay invisible if OpenSpec isn't installed.11+- **Agent tools** — tools to resolve OpenSpec identifiers, run OpenSpec CLI workflows, and manage idea lifecycle artifacts without shelling out manually. CLI-backed tools are gated behind the OpenSpec binary's availability; filesystem-backed context/idea tools remain available without the CLI.1212 - **Dashboard tab** — a `/openspec` tab in the Hermes dashboard where you register repos, drill into change proposals (tasks, designs, specs, deltas), browse current specs, and compare branch diffs — all without leaving the dashboard.1313 1414 ## What you get1515 16-**Agent tools** (gated on `openspec` binary):16+**Agent tools** — 15 tools covering the full OpenSpec lifecycle:1717 1818 | Tool | Does |1919 |---|---|20-| `openspec_context` | Resolve a copyable `os_*` identifier (e.g. `puzzletea/os_a1b2c3`) into repo path + change/spec content. Entry point — call this first. |21-| `openspec_list` | List changes or specs in a project, sorted by recent or name. |22-| `openspec_show` | Show a change or spec as JSON — proposals, tasks, designs, deltas, requirements. |23-| `openspec_validate` | Validate OpenSpec changes or specs in a project. |24-| `openspec_status` | Show artifact completion status for a change (turns into kanban updates). |25-| `openspec_instructions` | Enriched instructions for creating artifacts or applying tasks. |26-27-`openspec_context` is always available (reads the registry DB + files only); the other five require the `openspec` CLI binary.20+| `openspec_context` | Resolve a copyable identifier (`puzzletea`, `puzzletea/os_a1b2c3`) into repo path and change/spec content. Always call this first when the user gives you an `os_` identifier — it returns the `workdir` the other tools need. |21+| `openspec_list` | List changes or specs in a project (sorted by recent or name). Call before picking a change or spec to inspect, validate, or coordinate with kanban. |22+| `openspec_show` | Show a change or spec as JSON — proposal, tasks, design, spec deltas, requirements, and scenarios. Call to read the full content of a specific change or spec. |23+| `openspec_validate` | Validate OpenSpec artifacts (a single target, or all changes/specs). Call after editing specs/proposals/tasks or before completing kanban tasks tied to OpenSpec work. |24+| `openspec_status` | Show artifact completion status for a change as JSON (which artifacts exist, which are missing, readiness). Call to turn proposal/task progress into kanban updates. |25+| `openspec_instructions` | Return enriched instructions for creating an artifact (proposal, design, tasks, specs) or applying a change. Call before implementing a spec-driven change to get the authoring guide. |26+| `openspec_idea_create` | Create a markdown idea under `openspec/ideas/` from a title and raw prompt text. Call to capture a feature idea before it's been scoped or evaluated. |27+| `openspec_idea_enrich` | Write or update a structured enrichment report for an existing idea — problem statement, proposed direction, feasibility, T-shirt size, risks, key questions, and next step. Call to evaluate whether an idea is worth promoting. |28+| `openspec_idea_promote` | Promote an idea into a new OpenSpec change scaffold with proposal/tasks/spec traceability. Call when an enriched idea is ready to become an actionable change. |29+| `openspec_task_list` | List checklist tasks for a change — task ids, text, status, and completion counts. Call before marking tasks done or to see what's left. |30+| `openspec_task_set_status` | Set selected task ids in a change to `todo` or `done`. Call to update progress as you complete work items. |31+| `openspec_change_create` | Create a draft OpenSpec change scaffold directly from a title/summary (optional tasks and spec placeholder). Call to start a new change without going through the idea pipeline. |32+| `openspec_change_promote` | Promote a draft change to `todo` by ensuring tasks and a valid spec placeholder exist. Call when a draft is ready for implementation. |33+| `openspec_change_archive` | Archive a completed change, refusing if tasks are incomplete unless `force` is set. Call when a change is done and should be moved out of the active board. |34+| `openspec_change_unarchive` | Move an archived change back to the active changes directory. Call to reopen a change that was archived prematurely. |35+36+`openspec_context` and the lifecycle write tools (idea/change/task) are filesystem-backed and always available. `openspec_list`, `openspec_show`, `openspec_validate`, `openspec_status`, and `openspec_instructions` require the `openspec` CLI binary.2837 2938 **Dashboard tab** (`/openspec`):3039
@@ -41,7 +50,7 @@ This plugin closes both gaps:
4150 ## Requirements4251 4352 - **Hermes Agent** — any recent build that supports the plugin system (`hermes plugins install`).44-- **OpenSpec CLI** (optional) — needed for five of the six agent tools. The dashboard tab works without it. Install via `npm install -g @fission-ai/openspec@latest` or set `OPENSPEC_BIN` to the binary path.53+- **OpenSpec CLI** (optional) — needed for CLI-backed agent tools: list, show, validate, status, and instructions. The dashboard tab and filesystem-backed context/idea tools work without it. Install via `npm install -g @fission-ai/openspec@latest` or set `OPENSPEC_BIN` to the binary path.4554 - **Git** — used for branch diffs and spec commit-date sorting.4655 4756 ## Quickstart
@@ -96,10 +105,10 @@ This pulls the latest from the remote and reloads. If the backend API routes cha
96105 97106 ```98107 hermes-openspec/99-├── plugin.yaml # Tool plugin manifest — declares the six agent tools108+├── plugin.yaml # Tool plugin manifest — declares agent tools100109 ├── __init__.py # Plugin registration — wires tools, sets check_fn gating101110 ├── schemas.py # Tool parameter schemas (JSON Schema for each tool)102-├── tools.py # Tool handlers — wraps the openspec CLI binary111+├── tools.py # Tool handlers — wraps OpenSpec CLI and filesystem-backed idea workflows103112 ├── registry.py # SQLite registry of sources at <hermes_home>/openspec.db104113 ├── dashboard/105114 │ ├── manifest.json # Dashboard tab manifest — tab path, position, entry/css/api
@@ -112,7 +121,7 @@ hermes-openspec/
112121 | File | What to read it for |113122 |---|---|114123 | `plugin.yaml` | Which tools the plugin provides |115-| `tools.py` | How CLI commands are wrapped and how the binary is resolved |124+| `tools.py` | Tool handlers for CLI-backed commands and filesystem-backed idea workflows |116125 | `registry.py` | Source registry schema, token derivation, DB path |117126 | `dashboard/plugin_api.py` | All backend API routes and the spec-browser logic |118127 | `dashboard/manifest.json` | Tab registration, entry points |
M __init__.py +20 -0
A openspec/changes/archive/add-idea-tools/.openspec.yaml +2 -0
A openspec/changes/archive/add-idea-tools/README.md +3 -0
A openspec/changes/archive/add-idea-tools/design.md +73 -0
@@ -0,0 +1,73 @@
1+## Context2+3+The plugin already has two classes of agent tools: CLI-backed wrappers (`openspec_list`, `openspec_show`, `openspec_validate`, `openspec_status`, `openspec_instructions`) and filesystem-backed context resolution (`openspec_context`). Ideas are already part of the dashboard scan model under `openspec/ideas/`, but agents had no write-side primitive for capturing, enriching, or promoting ideas.4+5+The new idea tools are filesystem-backed because OpenSpec CLI currently has no `openspec new idea` command. They still follow existing plugin conventions: JSON string responses, `ok: true/false`, explicit validation errors, `workdir` support, and registered-source identifier support where possible.6+7+## Goals / Non-Goals8+9+**Goals:**10+11+- Create idea markdown files deterministically under `openspec/ideas/`.12+- Let agents update a structured enrichment section without duplicating old reports.13+- Promote reviewed ideas into valid OpenSpec change scaffolds that preserve traceability.14+- Keep the tools usable even when the OpenSpec CLI binary is unavailable.15+- Keep generated artifacts simple enough for humans to review and refine.16+17+**Non-Goals:**18+19+- Automatically spawning subagents from `openspec_idea_create`.20+- Inferring final capability boundaries during promotion.21+- Replacing human review before a promoted idea becomes implementation work.22+- Adding dashboard write controls in this change.23+24+## Decisions25+26+### Filesystem-backed write tools27+28+`openspec_idea_create`, `openspec_idea_enrich`, `openspec_idea_promote`, and the task/change lifecycle tools write repo-local markdown directly instead of calling the OpenSpec CLI.29+30+Alternative considered: shell out to hypothetical CLI commands. Rejected because the current OpenSpec CLI exposes `openspec new change`, not `openspec new idea`, and direct filesystem writes match the existing dashboard scan contract.31+32+### Explicit lifecycle steps33+34+Creation, enrichment, and promotion are separate tools. Creation does not automatically enrich or promote.35+36+Alternative considered: one orchestration tool that creates and enriches in a single call. Rejected because agent/human orchestration needs pause points for review and feedback.37+38+### Deterministic slugging and collision suffixes39+40+Idea file names are safe slugs derived from title or an explicit slug. Collisions append `-2`, `-3`, etc. rather than overwriting.41+42+Alternative considered: timestamps in every slug. Rejected because deterministic human-readable names are easier to inspect and copy.43+44+### Replaceable enrichment block45+46+Enrichment uses sentinel comments around a single `## Enrichment Report` block. Re-enriching replaces that block instead of appending duplicates.47+48+Alternative considered: append every enrichment as history. Rejected for now because the first workflow needs the current report more than audit history.49+50+### Promotion creates a valid placeholder spec delta51+52+Promoted ideas create proposal, tasks, metadata, and a placeholder `specs/<change>/spec.md` delta. The placeholder is valid OpenSpec and explicitly says it must be refined before implementation.53+54+Alternative considered: proposal/tasks only. Rejected because strict OpenSpec validation requires at least one delta, and generated scaffolds should validate immediately.55+56+## Risks / Trade-offs57+58+- Placeholder specs can be mistaken for final requirements → Mitigation: wording says they must be refined before implementation.59+- Direct filesystem writes bypass future CLI validation rules → Mitigation: outputs are covered by tests and strict OpenSpec validation.60+- Registered-source resolution depends on registry availability → Mitigation: explicit `workdir` remains the primary and simplest path.61+- Enrichment replacement discards prior report history → Mitigation: acceptable for the first version; history can be added later if needed.62+63+## Migration Plan64+65+- Existing OpenSpec repos need no migration.66+- New tools create missing `openspec/changes`, `openspec/changes/archive`, `openspec/specs`, and `openspec/ideas` roots as needed.67+- Deployment is a normal plugin update/reload; no database migration is required.68+69+## Open Questions70+71+- Whether future dashboard UI should expose create/enrich/promote actions directly.72+- Whether enrichment should eventually support durable history instead of one replaceable current report.73+- Whether promotion should accept explicit capability names once human review has selected affected specs.
A openspec/changes/archive/add-idea-tools/proposal.md +45 -0
@@ -0,0 +1,45 @@
1+## Why2+3+Agents and humans can currently read OpenSpec artifacts, but they need a uniform write-side lifecycle surface to capture ideas, enrich them, create/promote changes, move task work toward done, and archive/unarchive completed work without manually editing files. A noun-action tool naming convention makes this surface easier for agents and humans to discover and use consistently.4+5+## What Changes6+7+- Rename the idea lifecycle tools to noun-action names:8+ - `openspec_idea_create`9+ - `openspec_idea_enrich`10+ - `openspec_idea_promote`11+- Add task lifecycle tools:12+ - `openspec_task_list`13+ - `openspec_task_set_status`14+- Add change lifecycle tools:15+ - `openspec_change_create`16+ - `openspec_change_promote`17+ - `openspec_change_archive`18+ - `openspec_change_unarchive`19+- Keep lifecycle operations explicit: creating ideas, enriching ideas, promoting ideas, creating changes, promoting changes, updating tasks, and archiving/unarchiving changes remain separate reviewable steps.20+- Use deterministic filesystem behavior, safe slug/change-id generation, collision handling, and structured JSON responses so agents can chain the tools reliably.21+22+## Capabilities23+24+### New Capabilities25+- None.26+27+### Modified Capabilities28+- `agent-tools`: Add write-side idea, task, and change lifecycle tools using uniform noun-action naming.29+- `dashboard-sources`: Ensure dashboard/source scans can reflect ideas and changes created or moved by agent tools without extra manual refresh assumptions.30+31+## Impact32+33+- Affected tool registration and schemas:34+ - `plugin.yaml`35+ - `__init__.py`36+ - `schemas.py`37+ - `tools.py`38+- Affected OpenSpec filesystem conventions:39+ - `openspec/ideas/<slug>.md`40+ - `openspec/changes/<change-id>/proposal.md`41+ - `openspec/changes/<change-id>/tasks.md`42+ - `openspec/changes/<change-id>/specs/**/spec.md`43+ - `openspec/changes/archive/<change-id>/`44+- Affected dashboard behavior only insofar as tool-created and tool-moved artifacts must appear in existing source scans.45+- Tests should cover naming exposure, slug/collision handling, invalid workdirs, idea creation/enrichment/promotion, change creation/promotion/archive/unarchive, task listing/status updates, validation of promoted scaffolds, and refusal to overwrite existing artifacts unless explicitly allowed.
A openspec/changes/archive/add-idea-tools/specs/agent-tools/spec.md +83 -0
@@ -0,0 +1,83 @@
1+# Agent OpenSpec Tools Delta2+3+## MODIFIED Requirements4+5+### Requirement: Artifact listing and inspection6+The plugin SHALL provide tools that map directly to OpenSpec list/show/status/instructions workflows while smoothing known CLI rough edges that would otherwise block fresh-repository agent workflows and SHALL expose lifecycle write tools with noun-action naming.7+8+#### Scenario: List changes or specs9+- **WHEN** an agent calls `openspec_list` with kind `changes` or `specs`10+- **THEN** the tool invokes the OpenSpec CLI JSON list command for that kind and respects supported sort options11+12+#### Scenario: Show a change or spec13+- **WHEN** an agent calls `openspec_show` with a target name and optional flags14+- **THEN** the tool invokes the OpenSpec CLI JSON show command with the requested type, delta, requirements, scenario, or requirement filters15+16+#### Scenario: Validate artifacts17+- **WHEN** an agent calls `openspec_validate`18+- **THEN** the tool invokes the OpenSpec CLI validation command with strict JSON non-interactive output and the requested target or scope19+20+#### Scenario: Retrieve implementation instructions21+- **WHEN** an agent calls `openspec_instructions`22+- **THEN** the tool returns OpenSpec's enriched instructions for the requested artifact, schema, and optional change23+24+#### Scenario: Accept singular spec artifact alias25+- **WHEN** an agent calls `openspec_instructions` with artifact `spec`26+- **THEN** the tool treats it as the OpenSpec CLI artifact `specs`27+28+#### Scenario: Fresh repository instructions fallback29+- **GIVEN** a repository has been initialized but contains no changes30+- **WHEN** an agent calls `openspec_instructions` for a known artifact31+- **THEN** the tool returns `ok: true` with template-backed guidance for that artifact instead of failing only because no change exists32+33+#### Scenario: Create an idea artifact34+- **WHEN** an agent calls `openspec_idea_create` with a valid project/workdir, title, prompt, and optional origin/tags/notes35+- **THEN** the tool creates a markdown idea under `openspec/ideas/`, uses a safe deterministic slug with collision handling, and returns `ok: true` with the idea slug, relative path, absolute path, and parsed metadata36+37+#### Scenario: Refuse invalid idea creation38+- **WHEN** an agent calls `openspec_idea_create` with a missing project, empty title, empty prompt, or unsafe path input39+- **THEN** the tool returns `ok: false` with a direct validation error and does not write an idea file40+41+#### Scenario: Enrich an existing idea42+- **GIVEN** an idea exists under `openspec/ideas/`43+- **WHEN** an agent calls `openspec_idea_enrich` with structured enrichment fields44+- **THEN** the tool writes or updates an enrichment report containing problem, proposed direction, key questions, feasibility, T-shirt size, size justification, risks, and suggested next step45+46+#### Scenario: Promote an idea into a change47+- **GIVEN** an idea exists under `openspec/ideas/`48+- **WHEN** an agent calls `openspec_idea_promote` with a valid change id49+- **THEN** the tool creates an OpenSpec change scaffold that includes proposal, tasks, metadata, and a valid placeholder spec delta, preserves traceability to the source idea, and returns the created artifact paths50+51+#### Scenario: Create a change directly52+- **WHEN** an agent calls `openspec_change_create` with a valid change id, title, and summary53+- **THEN** the tool creates a draft OpenSpec change scaffold with proposal metadata and optional tasks/spec placeholder artifacts according to the supplied inputs54+55+#### Scenario: Promote a draft change to todo56+- **GIVEN** an active change has proposal content but no task checklist57+- **WHEN** an agent calls `openspec_change_promote` with task descriptions58+- **THEN** the tool creates or updates `tasks.md`, ensures at least one valid spec delta exists, and returns the change status as `todo`59+60+#### Scenario: List tasks for a change61+- **GIVEN** a change has a `tasks.md` checklist62+- **WHEN** an agent calls `openspec_task_list`63+- **THEN** the tool returns ordered task ids, text, status, and aggregate completion counts64+65+#### Scenario: Set task status66+- **GIVEN** a change has a `tasks.md` checklist67+- **WHEN** an agent calls `openspec_task_set_status` with task ids and status `todo` or `done`68+- **THEN** the tool updates only the requested checklist items and returns refreshed task counts and derived change status69+70+#### Scenario: Archive a completed change71+- **GIVEN** an active change has all tasks complete72+- **WHEN** an agent calls `openspec_change_archive`73+- **THEN** the tool moves the change under `openspec/changes/archive/`, refuses incomplete tasks unless forced, and returns the archived path74+75+#### Scenario: Unarchive a change76+- **GIVEN** a change exists under `openspec/changes/archive/`77+- **WHEN** an agent calls `openspec_change_unarchive`78+- **THEN** the tool moves the change back under `openspec/changes/` and refuses to overwrite an active change unless forced79+80+#### Scenario: Refuse unsafe lifecycle overwrites81+- **GIVEN** a target idea, change, task, or archive path would overwrite existing data without explicit force82+- **WHEN** an agent calls any lifecycle write tool83+- **THEN** the tool returns `ok: false` and preserves existing files
A openspec/changes/archive/add-idea-tools/specs/dashboard-sources/spec.md +26 -0
A openspec/changes/archive/add-idea-tools/tasks.md +36 -0
@@ -0,0 +1,36 @@
1+## 1. Tool Contracts2+3+- [x] 1.1 Rename idea tools to `openspec_idea_create`, `openspec_idea_enrich`, and `openspec_idea_promote`.4+- [x] 1.2 Define schemas for task lifecycle tools: `openspec_task_list` and `openspec_task_set_status`.5+- [x] 1.3 Define schemas for change lifecycle tools: `openspec_change_create`, `openspec_change_promote`, `openspec_change_archive`, and `openspec_change_unarchive`.6+- [x] 1.4 Register the uniform noun-action tool surface in the plugin manifest and Python registration layer.7+8+## 2. Idea Lifecycle9+10+- [x] 2.1 Implement source/workdir resolution and `openspec/ideas/` layout creation for idea writes.11+- [x] 2.2 Implement safe idea markdown generation from title, prompt, origin/source, tags, and optional notes.12+- [x] 2.3 Implement idea enrichment report writing/updating.13+- [x] 2.4 Implement idea promotion to a valid change scaffold with source traceability.14+- [x] 2.5 Add regression tests for idea creation, slug collision handling, enrichment replacement, promotion validation, and old-name removal.15+16+## 3. Task Lifecycle17+18+- [x] 3.1 Implement checklist parsing for ordered task ids, text, status, and aggregate counts.19+- [x] 3.2 Implement status updates for selected task ids with `todo` and `done` states.20+- [x] 3.3 Return refreshed derived change status after task updates.21+- [x] 3.4 Add tests for task listing, status updates, missing task errors, and no-accidental-edit behavior.22+23+## 4. Change Lifecycle24+25+- [x] 4.1 Implement direct change creation with proposal, optional tasks, metadata, and optional placeholder spec delta.26+- [x] 4.2 Implement change promotion from draft to todo by ensuring tasks and a valid spec delta exist.27+- [x] 4.3 Implement archive of completed changes with incomplete-task refusal unless forced.28+- [x] 4.4 Implement unarchive with active-change collision refusal unless forced.29+- [x] 4.5 Add tests for change creation, promotion, archive/unarchive, collision refusal, and strict validation of generated scaffolds.30+31+## 5. Documentation and Verification32+33+- [x] 5.1 Update README tool surface documentation.34+- [x] 5.2 Run Python syntax checks for modified plugin files.35+- [x] 5.3 Run targeted and full pytest suites.36+- [x] 5.4 Run `openspec validate --all --strict --json --no-interactive`.
M plugin.yaml +9 -0
M schemas.py +135 -0
@@ -161,3 +161,138 @@ OPENSPEC_INSTRUCTIONS = {
161161 },162162 },163163 }164+165+PROJECT_PROPS = {166+ "workdir": WORKDIR_PROP,167+ "identifier": {168+ "type": "string",169+ "description": "Optional registered OpenSpec source identifier/name. Used when workdir is not supplied.",170+ },171+ "project": {172+ "type": "string",173+ "description": "Alias for identifier: a registered OpenSpec source name or token.",174+ },175+}176+177+OPENSPEC_IDEA_CREATE = {178+ "name": "openspec_idea_create",179+ "description": "Create a markdown idea under openspec/ideas from a title and prompt.",180+ "parameters": {181+ "type": "object",182+ "properties": {183+ **PROJECT_PROPS,184+ "title": {"type": "string", "description": "Idea title."},185+ "prompt": {"type": "string", "description": "Raw human/agent idea text to preserve."},186+ "origin": {"type": "string", "description": "Idea origin, e.g. human, agent, or mixed."},187+ "source": {"type": "string", "description": "Alias for origin."},188+ "tags": {"type": "array", "items": {"type": "string"}},189+ "notes": {"type": "string"},190+ "slug": {"type": "string", "description": "Optional preferred file slug."},191+ },192+ "required": ["title", "prompt"],193+ },194+}195+196+OPENSPEC_IDEA_ENRICH = {197+ "name": "openspec_idea_enrich",198+ "description": "Write or update a structured enrichment report for an existing OpenSpec idea.",199+ "parameters": {200+ "type": "object",201+ "properties": {202+ **PROJECT_PROPS,203+ "idea": {"type": "string", "description": "Idea slug/name under openspec/ideas without .md."},204+ "slug": {"type": "string", "description": "Alias for idea."},205+ "problem": {"type": "string"},206+ "proposed_direction": {"type": "string"},207+ "key_questions": {"type": "array", "items": {"type": "string"}},208+ "feasibility": {"type": "string", "enum": ["Low", "Medium", "High"]},209+ "tshirt_size": {"type": "string", "enum": ["XS", "S", "M", "L", "XL"]},210+ "size_justification": {"type": "string"},211+ "risks": {"type": "array", "items": {"type": "string"}},212+ "suggested_next_step": {"type": "string"},213+ },214+ "required": ["idea"],215+ },216+}217+218+OPENSPEC_IDEA_PROMOTE = {219+ "name": "openspec_idea_promote",220+ "description": "Promote an existing OpenSpec idea into a new change scaffold with traceability.",221+ "parameters": {222+ "type": "object",223+ "properties": {224+ **PROJECT_PROPS,225+ "idea": {"type": "string"},226+ "slug": {"type": "string", "description": "Alias for idea."},227+ "change": {"type": "string", "description": "Kebab-case OpenSpec change id to create."},228+ "change_id": {"type": "string", "description": "Alias for change."},229+ "summary": {"type": "string"},230+ "force": {"type": "boolean"},231+ },232+ "required": ["idea", "change"],233+ },234+}235+236+OPENSPEC_TASK_LIST = {237+ "name": "openspec_task_list",238+ "description": "List checklist tasks for an OpenSpec change with ids, text, status, and counts.",239+ "parameters": {"type": "object", "properties": {**PROJECT_PROPS, "change": {"type": "string"}, "change_id": {"type": "string"}}, "required": ["change"]},240+}241+242+OPENSPEC_TASK_SET_STATUS = {243+ "name": "openspec_task_set_status",244+ "description": "Set selected checklist task ids in an OpenSpec change to todo or done.",245+ "parameters": {246+ "type": "object",247+ "properties": {248+ **PROJECT_PROPS,249+ "change": {"type": "string"},250+ "change_id": {"type": "string"},251+ "tasks": {"type": "array", "items": {"type": "string"}},252+ "task_ids": {"type": "array", "items": {"type": "string"}},253+ "status": {"type": "string", "enum": ["todo", "done"]},254+ },255+ "required": ["change", "tasks", "status"],256+ },257+}258+259+OPENSPEC_CHANGE_CREATE = {260+ "name": "openspec_change_create",261+ "description": "Create a draft OpenSpec change scaffold directly from title/summary and optional tasks/spec placeholder.",262+ "parameters": {263+ "type": "object",264+ "properties": {265+ **PROJECT_PROPS,266+ "change": {"type": "string"},267+ "change_id": {"type": "string"},268+ "title": {"type": "string"},269+ "summary": {"type": "string"},270+ "tasks": {"type": "array", "items": {"type": "string"}},271+ "with_spec": {"type": "boolean"},272+ "force": {"type": "boolean"},273+ },274+ "required": ["change", "title", "summary"],275+ },276+}277+278+OPENSPEC_CHANGE_PROMOTE = {279+ "name": "openspec_change_promote",280+ "description": "Promote a draft OpenSpec change to todo by ensuring tasks and a valid spec placeholder exist.",281+ "parameters": {282+ "type": "object",283+ "properties": {**PROJECT_PROPS, "change": {"type": "string"}, "change_id": {"type": "string"}, "tasks": {"type": "array", "items": {"type": "string"}}, "replace_tasks": {"type": "boolean"}},284+ "required": ["change"],285+ },286+}287+288+OPENSPEC_CHANGE_ARCHIVE = {289+ "name": "openspec_change_archive",290+ "description": "Archive a completed OpenSpec change, refusing incomplete tasks unless force is true.",291+ "parameters": {"type": "object", "properties": {**PROJECT_PROPS, "change": {"type": "string"}, "change_id": {"type": "string"}, "force": {"type": "boolean"}}, "required": ["change"]},292+}293+294+OPENSPEC_CHANGE_UNARCHIVE = {295+ "name": "openspec_change_unarchive",296+ "description": "Move an archived OpenSpec change back to the active changes directory.",297+ "parameters": {"type": "object", "properties": {**PROJECT_PROPS, "change": {"type": "string"}, "change_id": {"type": "string"}, "force": {"type": "boolean"}}, "required": ["change"]},298+}
A tests/test_openspec_idea_tools.py +276 -0
@@ -0,0 +1,276 @@
1+import json2+import subprocess3+from pathlib import Path4+5+import pytest6+import tools7+8+9+def parse(payload):10+ return json.loads(payload)11+12+13+def test_uniform_lifecycle_tool_names_are_exposed_and_old_idea_names_removed():14+ for name in (15+ "openspec_idea_create",16+ "openspec_idea_enrich",17+ "openspec_idea_promote",18+ "openspec_task_list",19+ "openspec_task_set_status",20+ "openspec_change_create",21+ "openspec_change_promote",22+ "openspec_change_archive",23+ "openspec_change_unarchive",24+ ):25+ assert hasattr(tools, name)26+27+ for old_name in (28+ "openspec_create_idea",29+ "openspec_enrich_idea",30+ "openspec_promote_idea",31+ ):32+ assert not hasattr(tools, old_name)33+34+35+def test_idea_create_writes_markdown_with_safe_slug_and_collision_suffix(tmp_path):36+ first = parse(tools.openspec_idea_create({37+ "workdir": str(tmp_path),38+ "title": "Agent + Human Feedback Loop!",39+ "prompt": "Capture raw user and agent ideas in one lane.",40+ "origin": "human",41+ "tags": ["orchestration", "feedback"],42+ "notes": "Start small.",43+ }))44+ second = parse(tools.openspec_idea_create({45+ "workdir": str(tmp_path),46+ "title": "Agent + Human Feedback Loop!",47+ "prompt": "A second prompt should not overwrite the first.",48+ }))49+50+ assert first["ok"] is True51+ assert first["slug"] == "agent-human-feedback-loop"52+ assert second["ok"] is True53+ assert second["slug"] == "agent-human-feedback-loop-2"54+55+ first_path = Path(first["path"])56+ second_path = Path(second["path"])57+ assert first_path.is_file()58+ assert second_path.is_file()59+ assert first_path.read_text(encoding="utf-8").startswith("# Agent + Human Feedback Loop!")60+ assert "Origin: human" in first_path.read_text(encoding="utf-8")61+ assert "Tags: orchestration, feedback" in first_path.read_text(encoding="utf-8")62+ assert "Capture raw user and agent ideas" in first_path.read_text(encoding="utf-8")63+ assert "A second prompt" in second_path.read_text(encoding="utf-8")64+65+66+def test_idea_create_refuses_empty_title_or_prompt_without_writing(tmp_path):67+ result = parse(tools.openspec_idea_create({68+ "workdir": str(tmp_path),69+ "title": " ",70+ "prompt": "Something",71+ }))72+73+ assert result["ok"] is False74+ assert "title is required" in result["error"]75+ assert not (tmp_path / "openspec" / "ideas").exists()76+77+78+def test_idea_enrich_inserts_and_replaces_structured_report(tmp_path):79+ created = parse(tools.openspec_idea_create({80+ "workdir": str(tmp_path),81+ "title": "Idea enrichment",82+ "prompt": "Assess ideas consistently.",83+ }))84+85+ enriched = parse(tools.openspec_idea_enrich({86+ "workdir": str(tmp_path),87+ "idea": created["slug"],88+ "problem": "Ideas lack consistent evaluation.",89+ "proposed_direction": "Use a rubric-backed report.",90+ "key_questions": ["Who reviews it?", "When promote?"],91+ "feasibility": "High",92+ "tshirt_size": "M",93+ "size_justification": "Three small tools plus tests.",94+ "risks": ["Over-automation"],95+ "suggested_next_step": "Promote after review",96+ }))97+ updated = parse(tools.openspec_idea_enrich({98+ "workdir": str(tmp_path),99+ "idea": created["slug"],100+ "problem": "Updated problem.",101+ "feasibility": "Medium",102+ "tshirt_size": "S",103+ "size_justification": "Reduced scope.",104+ "suggested_next_step": "Keep as idea",105+ }))106+107+ assert enriched["ok"] is True108+ assert updated["ok"] is True109+ content = Path(created["path"]).read_text(encoding="utf-8")110+ assert content.count("## Enrichment Report") == 1111+ assert "Updated problem." in content112+ assert "Ideas lack consistent evaluation." not in content113+ assert "Feasibility: Medium" in content114+ assert "T-Shirt Size: S" in content115+116+117+def test_idea_promote_creates_change_scaffold_with_traceability_and_refuses_collision(tmp_path):118+ created = parse(tools.openspec_idea_create({119+ "workdir": str(tmp_path),120+ "title": "Promote me",121+ "prompt": "Turn a reviewed idea into a change.",122+ }))123+124+ promoted = parse(tools.openspec_idea_promote({125+ "workdir": str(tmp_path),126+ "idea": created["slug"],127+ "change": "promote-reviewed-idea",128+ "summary": "Add promotion workflow.",129+ }))130+ collision = parse(tools.openspec_idea_promote({131+ "workdir": str(tmp_path),132+ "idea": created["slug"],133+ "change": "promote-reviewed-idea",134+ }))135+136+ assert promoted["ok"] is True137+ change_dir = Path(promoted["change_path"])138+ assert (change_dir / "proposal.md").is_file()139+ assert (change_dir / "tasks.md").is_file()140+ assert (change_dir / "specs" / "promote-reviewed-idea" / "spec.md").is_file()141+ assert "## ADDED Requirements" in (change_dir / "specs" / "promote-reviewed-idea" / "spec.md").read_text(encoding="utf-8")142+ assert "Source Idea" in (change_dir / "proposal.md").read_text(encoding="utf-8")143+ assert created["relative_path"] in (change_dir / "proposal.md").read_text(encoding="utf-8")144+ assert "Turn a reviewed idea into a change." in (change_dir / "proposal.md").read_text(encoding="utf-8")145+ assert collision["ok"] is False146+ assert "already exists" in collision["error"]147+148+149+def test_change_create_and_promote_support_draft_to_todo_flow(tmp_path):150+ created = parse(tools.openspec_change_create({151+ "workdir": str(tmp_path),152+ "change": "direct-change",153+ "title": "Direct Change",154+ "summary": "Create a change without an idea first.",155+ }))156+ assert created["ok"] is True157+ change_dir = Path(created["change_path"])158+ assert (change_dir / "proposal.md").is_file()159+ assert not (change_dir / "tasks.md").exists()160+161+ promoted = parse(tools.openspec_change_promote({162+ "workdir": str(tmp_path),163+ "change": "direct-change",164+ "tasks": ["Refine scope", "Implement behavior"],165+ }))166+167+ assert promoted["ok"] is True168+ assert promoted["status"] == "todo"169+ assert (change_dir / "tasks.md").is_file()170+ assert (change_dir / "specs" / "direct-change" / "spec.md").is_file()171+ assert "- [ ] 1.1 Refine scope" in (change_dir / "tasks.md").read_text(encoding="utf-8")172+173+174+def test_task_list_and_set_status_update_requested_checklist_items(tmp_path):175+ parse(tools.openspec_change_create({176+ "workdir": str(tmp_path),177+ "change": "task-change",178+ "title": "Task Change",179+ "summary": "Test task lifecycle.",180+ "tasks": ["First task", "Second task"],181+ "with_spec": True,182+ }))183+184+ listed = parse(tools.openspec_task_list({"workdir": str(tmp_path), "change": "task-change"}))185+ assert listed["ok"] is True186+ assert [task["id"] for task in listed["tasks"]] == ["1.1", "1.2"]187+ assert listed["counts"] == {"total": 2, "done": 0, "todo": 2}188+ assert listed["status"] == "todo"189+190+ updated = parse(tools.openspec_task_set_status({191+ "workdir": str(tmp_path),192+ "change": "task-change",193+ "tasks": ["1.1"],194+ "status": "done",195+ }))196+ assert updated["ok"] is True197+ assert updated["counts"] == {"total": 2, "done": 1, "todo": 1}198+ assert updated["status"] == "in_progress"199+200+ done = parse(tools.openspec_task_set_status({201+ "workdir": str(tmp_path),202+ "change": "task-change",203+ "tasks": ["1.2"],204+ "status": "done",205+ }))206+ assert done["counts"] == {"total": 2, "done": 2, "todo": 0}207+ assert done["status"] == "done"208+209+210+def test_change_archive_refuses_incomplete_tasks_then_unarchive_restores_active_change(tmp_path):211+ parse(tools.openspec_change_create({212+ "workdir": str(tmp_path),213+ "change": "archive-me",214+ "title": "Archive Me",215+ "summary": "Archive lifecycle.",216+ "tasks": ["Finish this"],217+ "with_spec": True,218+ }))219+220+ refused = parse(tools.openspec_change_archive({"workdir": str(tmp_path), "change": "archive-me"}))221+ assert refused["ok"] is False222+ assert "not complete" in refused["error"]223+224+ parse(tools.openspec_task_set_status({225+ "workdir": str(tmp_path),226+ "change": "archive-me",227+ "tasks": ["1.1"],228+ "status": "done",229+ }))230+ archived = parse(tools.openspec_change_archive({"workdir": str(tmp_path), "change": "archive-me"}))231+ assert archived["ok"] is True232+ assert not (tmp_path / "openspec" / "changes" / "archive-me").exists()233+ assert (tmp_path / "openspec" / "changes" / "archive" / "archive-me").is_dir()234+235+ unarchived = parse(tools.openspec_change_unarchive({"workdir": str(tmp_path), "change": "archive-me"}))236+ assert unarchived["ok"] is True237+ assert (tmp_path / "openspec" / "changes" / "archive-me").is_dir()238+ assert not (tmp_path / "openspec" / "changes" / "archive" / "archive-me").exists()239+240+241+def test_generated_scaffolds_validate_with_openspec_cli(tmp_path):242+ exe = tools._openspec_bin()243+ if not exe:244+ pytest.skip("openspec CLI is not available")245+246+ created = parse(tools.openspec_idea_create({247+ "workdir": str(tmp_path),248+ "title": "Valid promoted scaffold",249+ "prompt": "Generated changes should validate before refinement.",250+ }))251+ idea_promoted = parse(tools.openspec_idea_promote({252+ "workdir": str(tmp_path),253+ "idea": created["slug"],254+ "change": "valid-promoted-scaffold",255+ }))256+ change_created = parse(tools.openspec_change_create({257+ "workdir": str(tmp_path),258+ "change": "valid-created-scaffold",259+ "title": "Valid Created Scaffold",260+ "summary": "Generated direct changes should validate.",261+ "tasks": ["Do the thing"],262+ "with_spec": True,263+ }))264+265+ for change in (idea_promoted["change"], change_created["change"]):266+ proc = subprocess.run(267+ [exe, "validate", change, "--strict", "--json", "--no-interactive"],268+ cwd=str(tmp_path),269+ text=True,270+ stdout=subprocess.PIPE,271+ stderr=subprocess.PIPE,272+ check=False,273+ )274+ assert proc.returncode == 0, proc.stdout + proc.stderr275+ payload = json.loads(proc.stdout)276+ assert payload["summary"]["totals"]["failed"] == 0
M tools.py +706 -1
@@ -1,11 +1,13 @@
1-"""OpenSpec CLI-backed Hermes tool handlers."""1+"""OpenSpec Hermes tool handlers."""22 33 from __future__ import annotations44 55 import json66 import os7+import re78 import shutil89 import subprocess10+from datetime import datetime, timezone911 from pathlib import Path1012 from typing import Any1113
@@ -317,6 +319,709 @@ def openspec_context(args: dict, **kwargs) -> str:
317319 return json.dumps(result)318320 319321 322+_SLUG_RE = re.compile(r"[^a-z0-9]+")323+_VALID_CHANGE_RE = re.compile(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$")324+_ENRICHMENT_START = "<!-- OPENSPEC_IDEA_ENRICHMENT_START -->"325+_ENRICHMENT_END = "<!-- OPENSPEC_IDEA_ENRICHMENT_END -->"326+_VALID_FEASIBILITY = {"low", "medium", "high"}327+_VALID_TSHIRT = {"xs", "s", "m", "l", "xl"}328+329+330+def _error(message: str, **extra: Any) -> str:331+ payload = {"ok": False, "error": message}332+ payload.update(extra)333+ return json.dumps(payload)334+335+336+def _now_iso() -> str:337+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")338+339+340+def _slugify(value: str, fallback: str = "idea") -> str:341+ slug = _SLUG_RE.sub("-", value.strip().lower()).strip("-")342+ return slug or fallback343+344+345+def _resolve_project(args: dict) -> tuple[Path | None, str | None, dict[str, Any]]:346+ """Resolve a tool call to a project root.347+348+ Prefer explicit workdir. Also accept a registered OpenSpec identifier/source349+ name so agents can call write tools with the same project handles used by350+ ``openspec_context``.351+ """352+ workdir = args.get("workdir")353+ if workdir:354+ root, err = _resolve_workdir(workdir)355+ return root, err, {}356+357+ identifier = str(args.get("identifier") or args.get("project") or "").strip()358+ if identifier:359+ registry = _registry_module()360+ if registry is None:361+ return None, "OpenSpec registry unavailable (plugins.openspec.registry not importable).", {}362+ source_ref, _artifact_ref = registry.parse_identifier(identifier)363+ source = registry.get_source_by_name(source_ref) or registry.get_source(source_ref)364+ if source is None:365+ return None, f"No registered OpenSpec source for '{source_ref}'.", {}366+ root = Path(source["path"]).expanduser()367+ if not root.is_absolute():368+ root = root.resolve()369+ if not root.exists() or not root.is_dir():370+ return None, f"registered source path does not exist or is not a directory: {root}", {}371+ return root, None, {"source": source, "name": registry.effective_name(source)}372+373+ root, err = _resolve_workdir(None)374+ return root, err, {}375+376+377+def _ensure_openspec_layout(root: Path) -> None:378+ openspec_root = root / "openspec"379+ (openspec_root / "changes" / "archive").mkdir(parents=True, exist_ok=True)380+ (openspec_root / "specs").mkdir(parents=True, exist_ok=True)381+ (openspec_root / "ideas").mkdir(parents=True, exist_ok=True)382+383+384+def _unique_idea_path(ideas_root: Path, slug: str) -> tuple[str, Path]:385+ candidate = ideas_root / f"{slug}.md"386+ if not candidate.exists():387+ return slug, candidate388+ index = 2389+ while True:390+ suffixed = f"{slug}-{index}"391+ candidate = ideas_root / f"{suffixed}.md"392+ if not candidate.exists():393+ return suffixed, candidate394+ index += 1395+396+397+def _coerce_string_list(value: Any) -> list[str]:398+ if value is None or value == "":399+ return []400+ if isinstance(value, str):401+ return [line.strip() for line in value.splitlines() if line.strip()]402+ if isinstance(value, list):403+ return [str(item).strip() for item in value if str(item).strip()]404+ return [str(value).strip()]405+406+407+def _idea_path(root: Path, idea_ref: str) -> Path:408+ slug = _slugify(idea_ref)409+ return root / "openspec" / "ideas" / f"{slug}.md"410+411+412+def _relative(path: Path, root: Path) -> str:413+ try:414+ return path.relative_to(root).as_posix()415+ except ValueError:416+ return path.as_posix()417+418+419+def _markdown_list(items: list[str]) -> str:420+ if not items:421+ return "- None"422+ return "\n".join(f"- {item}" for item in items)423+424+425+_TASK_LINE_RE = re.compile(r"^(?P<prefix>\s*- \[)(?P<mark>[ xX])(?P<suffix>\]\s*(?P<id>\d+(?:\.\d+)*)\s+(?P<text>.*?))\s*$")426+427+428+def _change_path(root: Path, change: str, *, archived: bool = False) -> Path:429+ base = root / "openspec" / "changes"430+ if archived:431+ base = base / "archive"432+ return base / change433+434+435+def _resolve_change_path(root: Path, change_ref: str) -> tuple[Path | None, bool]:436+ change = _slugify(change_ref, fallback="")437+ if not change:438+ return None, False439+ active = _change_path(root, change)440+ if active.is_dir():441+ return active, False442+ archived = _change_path(root, change, archived=True)443+ if archived.is_dir():444+ return archived, True445+ return None, False446+447+448+def _derive_status(tasks_path: Path, *, archived: bool = False) -> str:449+ if archived:450+ return "archived"451+ if not tasks_path.is_file():452+ return "draft"453+ tasks = _parse_tasks(tasks_path)454+ if not tasks:455+ return "draft"456+ done = sum(1 for task in tasks if task["status"] == "done")457+ if done == 0:458+ return "todo"459+ if done == len(tasks):460+ return "done"461+ return "in_progress"462+463+464+def _parse_tasks(tasks_path: Path) -> list[dict[str, Any]]:465+ if not tasks_path.is_file():466+ return []467+ tasks: list[dict[str, Any]] = []468+ for line_no, line in enumerate(tasks_path.read_text(encoding="utf-8", errors="replace").splitlines(), start=1):469+ match = _TASK_LINE_RE.match(line)470+ if not match:471+ continue472+ tasks.append({473+ "id": match.group("id"),474+ "text": match.group("text").strip(),475+ "status": "done" if match.group("mark").lower() == "x" else "todo",476+ "line": line_no,477+ })478+ return tasks479+480+481+def _task_counts(tasks: list[dict[str, Any]]) -> dict[str, int]:482+ done = sum(1 for task in tasks if task["status"] == "done")483+ total = len(tasks)484+ return {"total": total, "done": done, "todo": total - done}485+486+487+def _format_tasks(items: list[str]) -> str:488+ lines = ["## 1. Tasks", ""]489+ for index, text in enumerate(items, start=1):490+ lines.append(f"- [ ] 1.{index} {text}")491+ lines.append("")492+ return "\n".join(lines)493+494+495+def _default_tasks() -> list[str]:496+ return ["Refine scope", "Implement behavior", "Validate OpenSpec artifacts and tests"]497+498+499+def _write_placeholder_spec(change_dir: Path, change: str, title: str, source: str = "change") -> Path:500+ spec_dir = change_dir / "specs" / change501+ spec_dir.mkdir(parents=True, exist_ok=True)502+ spec_path = spec_dir / "spec.md"503+ if spec_path.exists():504+ return spec_path505+ spec = f"""# {title}506+507+## ADDED Requirements508+509+### Requirement: {title} scope510+The system SHALL implement the approved behavior described by this {source} after this placeholder is refined into concrete requirements.511+512+#### Scenario: Placeholder refinement513+- **GIVEN** `{change}` has been created as an OpenSpec change514+- **WHEN** implementation begins515+- **THEN** this placeholder requirement is replaced or refined with concrete, testable behavior before the change is completed516+"""517+ spec_path.write_text(spec, encoding="utf-8")518+ return spec_path519+520+521+def _write_change_scaffold(522+ root: Path,523+ change: str,524+ title: str,525+ summary: str,526+ *,527+ source_section: str = "",528+ tasks: list[str] | None = None,529+ with_spec: bool = False,530+ force: bool = False,531+) -> tuple[dict[str, Any] | None, str | None]:532+ _ensure_openspec_layout(root)533+ change_dir = _change_path(root, change)534+ if change_dir.exists() and not force:535+ return None, f"change already exists: {change}"536+ change_dir.mkdir(parents=True, exist_ok=True)537+ created = _now_iso()538+ source_block = f"\n## Source\n\n{source_section}\n" if source_section else ""539+ proposal = f"""## Why540+541+{summary}542+543+## What Changes544+545+- Create the `{change}` OpenSpec change scaffold for review and refinement.546+547+{source_block}## Capabilities548+549+### New Capabilities550+- `{change}`: Initial capability placeholder. Refine before implementation.551+552+### Modified Capabilities553+- None yet.554+555+## Impact556+557+- TBD: Fill in affected code, APIs, dependencies, or systems before implementation.558+"""559+ (change_dir / "proposal.md").write_text(proposal, encoding="utf-8")560+ task_items = tasks or []561+ if task_items:562+ (change_dir / "tasks.md").write_text(_format_tasks(task_items), encoding="utf-8")563+ spec_path = None564+ if with_spec:565+ spec_path = _write_placeholder_spec(change_dir, change, title)566+ (change_dir / ".openspec.yaml").write_text(567+ "schema: spec-driven\ncreatedAt: " + json.dumps(created) + "\n",568+ encoding="utf-8",569+ )570+ paths = {571+ "proposal": str(change_dir / "proposal.md"),572+ "metadata": str(change_dir / ".openspec.yaml"),573+ }574+ if task_items:575+ paths["tasks"] = str(change_dir / "tasks.md")576+ if spec_path:577+ paths["spec"] = str(spec_path)578+ return {579+ "ok": True,580+ "change": change,581+ "change_path": str(change_dir),582+ "relative_change_path": _relative(change_dir, root),583+ "workdir": str(root),584+ "status": _derive_status(change_dir / "tasks.md"),585+ "paths": paths,586+ }, None587+588+589+def openspec_idea_create(args: dict, **kwargs) -> str:590+ root, err, context = _resolve_project(args)591+ if err or root is None:592+ return _error(err or "workdir could not be resolved")593+594+ title = str(args.get("title") or "").strip()595+ prompt = str(args.get("prompt") or "").strip()596+ if not title:597+ return _error("title is required")598+ if not prompt:599+ return _error("prompt is required")600+601+ origin = str(args.get("origin") or args.get("source") or "unspecified").strip() or "unspecified"602+ notes = str(args.get("notes") or "").strip()603+ tags = _coerce_string_list(args.get("tags"))604+605+ _ensure_openspec_layout(root)606+ ideas_root = root / "openspec" / "ideas"607+ slug, path = _unique_idea_path(ideas_root, _slugify(str(args.get("slug") or title)))608+ created = _now_iso()609+ tags_text = ", ".join(tags) if tags else "None"610+611+ sections = [612+ f"# {title}",613+ "",614+ "## Source",615+ f"- Origin: {origin}",616+ f"- Created: {created}",617+ f"- Tags: {tags_text}",618+ ]619+ if context.get("name"):620+ sections.append(f"- Project: {context['name']}")621+ sections.extend([622+ "",623+ "## Prompt",624+ prompt,625+ ])626+ if notes:627+ sections.extend(["", "## Notes", notes])628+ sections.append("")629+630+ path.write_text("\n".join(sections), encoding="utf-8")631+ return json.dumps({632+ "ok": True,633+ "slug": slug,634+ "title": title,635+ "path": str(path),636+ "relative_path": _relative(path, root),637+ "workdir": str(root),638+ "metadata": {"origin": origin, "created": created, "tags": tags},639+ })640+641+642+def openspec_idea_enrich(args: dict, **kwargs) -> str:643+ root, err, _context = _resolve_project(args)644+ if err or root is None:645+ return _error(err or "workdir could not be resolved")646+ idea = str(args.get("idea") or args.get("slug") or "").strip()647+ if not idea:648+ return _error("idea is required")649+650+ path = _idea_path(root, idea)651+ if not path.is_file():652+ return _error(f"idea not found: {idea}")653+654+ feasibility = str(args.get("feasibility") or "").strip()655+ tshirt_size = str(args.get("tshirt_size") or args.get("t_shirt_size") or "").strip()656+ if feasibility and feasibility.lower() not in _VALID_FEASIBILITY:657+ return _error("feasibility must be one of: Low, Medium, High")658+ if tshirt_size and tshirt_size.lower() not in _VALID_TSHIRT:659+ return _error("tshirt_size must be one of: XS, S, M, L, XL")660+661+ problem = str(args.get("problem") or "TBD").strip() or "TBD"662+ proposed_direction = str(args.get("proposed_direction") or args.get("proposedDirection") or "TBD").strip() or "TBD"663+ key_questions = _coerce_string_list(args.get("key_questions"))664+ risks = _coerce_string_list(args.get("risks"))665+ size_justification = str(args.get("size_justification") or "TBD").strip() or "TBD"666+ suggested_next_step = str(args.get("suggested_next_step") or "TBD").strip() or "TBD"667+ generated = _now_iso()668+669+ report = "\n".join([670+ _ENRICHMENT_START,671+ "## Enrichment Report",672+ "",673+ f"Generated: {generated}",674+ "",675+ "### Problem",676+ problem,677+ "",678+ "### Proposed Direction",679+ proposed_direction,680+ "",681+ "### Key Questions",682+ _markdown_list(key_questions),683+ "",684+ "### Feasibility",685+ f"Feasibility: {feasibility or 'TBD'}",686+ "",687+ "### T-Shirt Size",688+ f"T-Shirt Size: {tshirt_size or 'TBD'}",689+ "",690+ "### Size Justification",691+ size_justification,692+ "",693+ "### Risks",694+ _markdown_list(risks),695+ "",696+ "### Suggested Next Step",697+ suggested_next_step,698+ _ENRICHMENT_END,699+ "",700+ ])701+702+ content = path.read_text(encoding="utf-8", errors="replace")703+ if _ENRICHMENT_START in content and _ENRICHMENT_END in content:704+ before, rest = content.split(_ENRICHMENT_START, 1)705+ _old, after = rest.split(_ENRICHMENT_END, 1)706+ content = before.rstrip() + "\n\n" + report + after.lstrip("\n")707+ else:708+ content = content.rstrip() + "\n\n" + report709+ path.write_text(content, encoding="utf-8")710+711+ return json.dumps({712+ "ok": True,713+ "idea": _slugify(idea),714+ "path": str(path),715+ "relative_path": _relative(path, root),716+ "workdir": str(root),717+ "metadata": {"generated": generated, "feasibility": feasibility, "tshirt_size": tshirt_size},718+ })719+720+721+def openspec_idea_promote(args: dict, **kwargs) -> str:722+ root, err, _context = _resolve_project(args)723+ if err or root is None:724+ return _error(err or "workdir could not be resolved")725+ idea = str(args.get("idea") or args.get("slug") or "").strip()726+ change = _slugify(str(args.get("change") or args.get("change_id") or ""), fallback="")727+ if not idea:728+ return _error("idea is required")729+ if not change or not _VALID_CHANGE_RE.match(change):730+ return _error("change must be a kebab-case id")731+732+ idea_path = _idea_path(root, idea)733+ if not idea_path.is_file():734+ return _error(f"idea not found: {idea}")735+736+ _ensure_openspec_layout(root)737+ change_dir = root / "openspec" / "changes" / change738+ if change_dir.exists() and not args.get("force"):739+ return _error(f"change already exists: {change}", change=change, change_path=str(change_dir))740+ change_dir.mkdir(parents=True, exist_ok=True)741+742+ idea_content = idea_path.read_text(encoding="utf-8", errors="replace")743+ title = idea_content.splitlines()[0].lstrip("# ").strip() if idea_content.strip() else idea744+ summary = str(args.get("summary") or f"Promote idea: {title}").strip()745+ rel_idea = _relative(idea_path, root)746+ created = _now_iso()747+748+ proposal = f"""## Why749+750+{summary}751+752+## What Changes753+754+- Promote the reviewed idea `{idea_path.stem}` into an implementation-ready OpenSpec change.755+- Preserve traceability to the source idea while proposal, specs, design, and tasks are refined.756+757+## Source Idea758+759+- Path: `{rel_idea}`760+- Promoted: {created}761+762+### Idea Content763+764+```md765+{idea_content.rstrip()}766+```767+768+## Capabilities769+770+### New Capabilities771+- `{change}`: Initial capability placeholder created from the promoted idea. Refine before implementation.772+773+### Modified Capabilities774+- None yet.775+776+## Impact777+778+- TBD: Fill in affected code, APIs, dependencies, or systems before implementation.779+"""780+ tasks = """## 1. Proposal Refinement781+782+- [ ] 1.1 Review the source idea and clarify scope.783+- [ ] 1.2 Identify affected capabilities and write spec deltas.784+- [ ] 1.3 Add design notes if implementation trade-offs are non-trivial.785+786+## 2. Implementation787+788+- [ ] 2.1 Implement the approved behavior.789+- [ ] 2.2 Add or update tests.790+- [ ] 2.3 Validate OpenSpec artifacts and test suite.791+"""792+ spec_dir = change_dir / "specs" / change793+ spec_dir.mkdir(parents=True, exist_ok=True)794+ spec = f"""# {title}795+796+## ADDED Requirements797+798+### Requirement: Promoted idea scope799+The system SHALL implement the approved behavior described by the promoted idea after this placeholder is refined into concrete requirements.800+801+#### Scenario: Placeholder refinement802+- **GIVEN** the idea `{idea_path.stem}` has been promoted into this change803+- **WHEN** implementation begins804+- **THEN** this placeholder requirement is replaced or refined with concrete, testable behavior before the change is completed805+"""806+ (change_dir / "proposal.md").write_text(proposal, encoding="utf-8")807+ (change_dir / "tasks.md").write_text(tasks, encoding="utf-8")808+ (spec_dir / "spec.md").write_text(spec, encoding="utf-8")809+ metadata = {810+ "schema": "spec-driven",811+ "sourceIdea": rel_idea,812+ "promotedAt": created,813+ }814+ (change_dir / ".openspec.yaml").write_text("schema: spec-driven\nsourceIdea: " + json.dumps(rel_idea) + "\npromotedAt: " + json.dumps(created) + "\n", encoding="utf-8")815+816+ return json.dumps({817+ "ok": True,818+ "idea": idea_path.stem,819+ "change": change,820+ "change_path": str(change_dir),821+ "relative_change_path": _relative(change_dir, root),822+ "workdir": str(root),823+ "paths": {824+ "proposal": str(change_dir / "proposal.md"),825+ "tasks": str(change_dir / "tasks.md"),826+ "spec": str(spec_dir / "spec.md"),827+ "metadata": str(change_dir / ".openspec.yaml"),828+ },829+ "metadata": metadata,830+ })831+832+833+def openspec_change_create(args: dict, **kwargs) -> str:834+ root, err, _context = _resolve_project(args)835+ if err or root is None:836+ return _error(err or "workdir could not be resolved")837+ change = _slugify(str(args.get("change") or args.get("change_id") or ""), fallback="")838+ title = str(args.get("title") or change.replace("-", " ").title()).strip()839+ summary = str(args.get("summary") or f"Create change: {title}").strip()840+ if not change or not _VALID_CHANGE_RE.match(change):841+ return _error("change must be a kebab-case id")842+ tasks = _coerce_string_list(args.get("tasks"))843+ result, error = _write_change_scaffold(844+ root,845+ change,846+ title,847+ summary,848+ tasks=tasks,849+ with_spec=bool(args.get("with_spec")) or bool(tasks),850+ force=bool(args.get("force")),851+ )852+ if error:853+ return _error(error, change=change, change_path=str(_change_path(root, change)))854+ return json.dumps(result)855+856+857+def openspec_change_promote(args: dict, **kwargs) -> str:858+ root, err, _context = _resolve_project(args)859+ if err or root is None:860+ return _error(err or "workdir could not be resolved")861+ change = _slugify(str(args.get("change") or args.get("change_id") or ""), fallback="")862+ if not change:863+ return _error("change is required")864+ change_dir, archived = _resolve_change_path(root, change)865+ if change_dir is None or archived:866+ return _error(f"active change not found: {change}")867+ tasks_path = change_dir / "tasks.md"868+ if not tasks_path.exists() or bool(args.get("replace_tasks")):869+ task_items = _coerce_string_list(args.get("tasks")) or _default_tasks()870+ tasks_path.write_text(_format_tasks(task_items), encoding="utf-8")871+ title = change.replace("-", " ").title()872+ proposal = _read_doc(change_dir / "proposal.md")873+ for line in proposal.splitlines():874+ if line.startswith("# "):875+ title = line.lstrip("# ").strip() or title876+ break877+ spec_path = _write_placeholder_spec(change_dir, change, title)878+ tasks = _parse_tasks(tasks_path)879+ return json.dumps({880+ "ok": True,881+ "change": change,882+ "change_path": str(change_dir),883+ "relative_change_path": _relative(change_dir, root),884+ "workdir": str(root),885+ "status": _derive_status(tasks_path),886+ "counts": _task_counts(tasks),887+ "paths": {"tasks": str(tasks_path), "spec": str(spec_path)},888+ })889+890+891+def openspec_task_list(args: dict, **kwargs) -> str:892+ root, err, _context = _resolve_project(args)893+ if err or root is None:894+ return _error(err or "workdir could not be resolved")895+ change = _slugify(str(args.get("change") or args.get("change_id") or ""), fallback="")896+ if not change:897+ return _error("change is required")898+ change_dir, archived = _resolve_change_path(root, change)899+ if change_dir is None:900+ return _error(f"change not found: {change}")901+ tasks_path = change_dir / "tasks.md"902+ tasks = _parse_tasks(tasks_path)903+ if not tasks:904+ return _error(f"tasks not found for change: {change}", change=change, tasks=[])905+ return json.dumps({906+ "ok": True,907+ "change": change,908+ "archived": archived,909+ "path": str(tasks_path),910+ "relative_path": _relative(tasks_path, root),911+ "tasks": tasks,912+ "counts": _task_counts(tasks),913+ "status": _derive_status(tasks_path, archived=archived),914+ })915+916+917+def openspec_task_set_status(args: dict, **kwargs) -> str:918+ root, err, _context = _resolve_project(args)919+ if err or root is None:920+ return _error(err or "workdir could not be resolved")921+ change = _slugify(str(args.get("change") or args.get("change_id") or ""), fallback="")922+ status = str(args.get("status") or "").strip().lower()923+ if status in {"open", "todo", "pending"}:924+ mark = " "925+ normalized = "todo"926+ elif status in {"done", "complete", "completed"}:927+ mark = "x"928+ normalized = "done"929+ else:930+ return _error("status must be 'todo' or 'done'")931+ task_ids = set(_coerce_string_list(args.get("tasks") or args.get("task_ids")))932+ if not task_ids:933+ return _error("tasks is required")934+ change_dir, archived = _resolve_change_path(root, change)935+ if change_dir is None or archived:936+ return _error(f"active change not found: {change}")937+ tasks_path = change_dir / "tasks.md"938+ if not tasks_path.is_file():939+ return _error(f"tasks not found for change: {change}")940+ lines = tasks_path.read_text(encoding="utf-8", errors="replace").splitlines()941+ found: set[str] = set()942+ updated_lines = []943+ for line in lines:944+ match = _TASK_LINE_RE.match(line)945+ if match and match.group("id") in task_ids:946+ found.add(match.group("id"))947+ line = f"{match.group('prefix')}{mark}{match.group('suffix')}"948+ updated_lines.append(line)949+ missing = sorted(task_ids - found)950+ if missing:951+ return _error("task ids not found: " + ", ".join(missing), missing=missing)952+ tasks_path.write_text("\n".join(updated_lines) + "\n", encoding="utf-8")953+ tasks = _parse_tasks(tasks_path)954+ return json.dumps({955+ "ok": True,956+ "change": change,957+ "updated": sorted(found),958+ "set_status": normalized,959+ "path": str(tasks_path),960+ "relative_path": _relative(tasks_path, root),961+ "tasks": tasks,962+ "counts": _task_counts(tasks),963+ "status": _derive_status(tasks_path),964+ })965+966+967+def openspec_change_archive(args: dict, **kwargs) -> str:968+ root, err, _context = _resolve_project(args)969+ if err or root is None:970+ return _error(err or "workdir could not be resolved")971+ change = _slugify(str(args.get("change") or args.get("change_id") or ""), fallback="")972+ if not change:973+ return _error("change is required")974+ change_dir = _change_path(root, change)975+ if not change_dir.is_dir():976+ return _error(f"active change not found: {change}")977+ tasks_path = change_dir / "tasks.md"978+ status = _derive_status(tasks_path)979+ if status != "done" and not args.get("force"):980+ return _error(f"change is not complete: {change}", status=status)981+ archive_dir = _change_path(root, change, archived=True)982+ archive_dir.parent.mkdir(parents=True, exist_ok=True)983+ if archive_dir.exists() and not args.get("force"):984+ return _error(f"archived change already exists: {change}")985+ if archive_dir.exists():986+ shutil.rmtree(archive_dir)987+ shutil.move(str(change_dir), str(archive_dir))988+ return json.dumps({989+ "ok": True,990+ "change": change,991+ "status": "archived",992+ "archived_path": str(archive_dir),993+ "relative_archived_path": _relative(archive_dir, root),994+ "workdir": str(root),995+ })996+997+998+def openspec_change_unarchive(args: dict, **kwargs) -> str:999+ root, err, _context = _resolve_project(args)1000+ if err or root is None:1001+ return _error(err or "workdir could not be resolved")1002+ change = _slugify(str(args.get("change") or args.get("change_id") or ""), fallback="")1003+ if not change:1004+ return _error("change is required")1005+ archive_dir = _change_path(root, change, archived=True)1006+ active_dir = _change_path(root, change)1007+ if not archive_dir.is_dir():1008+ return _error(f"archived change not found: {change}")1009+ if active_dir.exists() and not args.get("force"):1010+ return _error(f"active change already exists: {change}")1011+ if active_dir.exists():1012+ shutil.rmtree(active_dir)1013+ shutil.move(str(archive_dir), str(active_dir))1014+ status = _derive_status(active_dir / "tasks.md")1015+ return json.dumps({1016+ "ok": True,1017+ "change": change,1018+ "status": status,1019+ "change_path": str(active_dir),1020+ "relative_change_path": _relative(active_dir, root),1021+ "workdir": str(root),1022+ })1023+1024+3201025 def openspec_list(args: dict, **kwargs) -> str:3211026 cmd = ["list", "--json"]3221027 kind = str(args.get("kind") or "changes").strip().lower()