/** * OpenSpec Pi Extension * * Native OpenSpec integration for pi — spec-driven development workflow, * change management, artifact creation, spec browsing, and project setup. * * Wraps the openspec CLI (https://github.com/Fission-AI/OpenSpec) as pi tools, * making spec-driven workflow a first-class operation in any agent session. * * Package: @trentuna/pi-openspec * Source: https://github.com/trentuna/pi-openspec (canonical) * Origin: https://github.com/Fission-AI/OpenSpec (openspec CLI upstream) * Docs: ~/.napkin/docs/fission-ai_openspec/ (octopus-adopted) * * --- * * WORKFLOW OVERVIEW * * OpenSpec organizes work into specs (source of truth) and changes (proposed * modifications). The standard workflow: * * 1. openspec_new_change → create a change folder with scaffolded artifacts * 2. openspec_status → see which artifacts need to be created * 3. openspec_instructions → get enriched instructions for each artifact * [Write artifact files using Write/Edit tools] * 4. openspec_instructions apply → get task implementation instructions * [Implement the tasks] * 5. openspec_validate → check implementation matches artifacts * 6. openspec_archive → finalize: merge delta specs + move to archive * * For new projects: * openspec_init → initialize OpenSpec (creates openspec/ directory, skills) * openspec_update → regenerate skill files after config changes * * --- * * CORE CONCEPTS * * Specs — describe how the system CURRENTLY behaves (openspec/specs/) * Contain requirements and scenarios (Given/When/Then). * Source of truth for the project. * * Changes — proposed modifications, each a self-contained folder * (openspec/changes//) containing: * proposal.md — why and what (intent, scope, approach) * design.md — how (technical decisions, architecture) * tasks.md — implementation checklist with [ ] checkboxes * specs/ — delta specs (ADDED/MODIFIED/REMOVED requirements) * * Delta specs — describe what's CHANGING, not the full spec. On archive, * deltas merge into main specs. Enables parallel work on same spec. * * Schemas — define artifact types and dependencies. Default: spec-driven * (proposal → specs → design → tasks → implement → archive). * * --- * * Install: * ln -sf $(pwd)/extensions/openspec.ts ~/.pi/agent/extensions/openspec.ts * # or: run install/pi/install.sh * * Requirements: * openspec CLI on PATH: npm install -g @fission-ai/openspec * Source: ~/upstream/openspec */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; // ── CLI helper ───────────────────────────────────────────────────────────────── async function openspecCli( args: string[], exec: (command: string, args: string[]) => Promise<{ stdout: string; stderr: string; code: number | null; killed: boolean }>, cwd?: string ): Promise<{ output: string; exitCode: number; cancelled: boolean }> { const result = await exec("openspec", args); return { output: result.stdout || result.stderr || "", exitCode: result.code ?? 1, cancelled: result.killed ?? false, }; } function ok(text: string) { return { content: [{ type: "text" as const, text: text }], details: { success: true } }; } function err(text: string) { return { content: [{ type: "text" as const, text: text }], details: { success: false } }; } // ── Extension ────────────────────────────────────────────────────────────────── export default function (pi: ExtensionAPI) { const exec = pi.exec.bind(pi); // ═══════════════════════ PROJECT SETUP ════════════════════════════════════ pi.registerTool({ name: "openspec_init", label: "Initialize OpenSpec", description: "Initialize OpenSpec in the current project. Creates openspec/ directory, " + "config.yaml, and skill files for AI tools. " + "Run once per project before using any other openspec tools. " + "Use tool='pi' to generate pi-compatible skill files (recommended for this environment). " + "Use tool='claude' for Claude Code compatibility.", parameters: Type.Object({ path: Type.Optional( Type.String({ description: "Project path to initialize (default: current directory)" }) ), tools: Type.Optional( Type.String({ description: "AI tools to configure skill files for. " + "Options: 'pi' (recommended), 'claude', 'all', 'none', " + "or comma-separated list (pi,claude,cursor). Default: pi.", }) ), }), async execute(_toolCallId, params) { const args = ["init"]; if (params.path) args.push(params.path); if (params.tools) args.push("--tools", params.tools); const result = await openspecCli(args, exec); if (result.exitCode !== 0) return err(`Error initializing: ${result.output}`); return ok(result.output || "OpenSpec initialized. Run openspec_list to see active changes."); }, }); pi.registerTool({ name: "openspec_update", label: "Update OpenSpec Skill Files", description: "Regenerate OpenSpec skill/instruction files for AI tools. " + "Run after changing config.yaml, switching schemas, or adding custom workflow commands. " + "This keeps the AI-tool integrations in sync with your OpenSpec configuration.", parameters: Type.Object({ path: Type.Optional( Type.String({ description: "Project path to update (default: current directory)" }) ), force: Type.Optional( Type.Boolean({ description: "Force update even when tools appear up to date" }) ), }), async execute(_toolCallId, params) { const args = ["update"]; if (params.path) args.push(params.path); if (params.force) args.push("--force"); const result = await openspecCli(args, exec); if (result.exitCode !== 0) return err(`Error updating: ${result.output}`); return ok(result.output || "OpenSpec skills updated."); }, }); // ═══════════════════════ DISCOVERY ════════════════════════════════════════ pi.registerTool({ name: "openspec_list", label: "List Changes or Specs", description: "List active changes or specs in the current project. " + "Default: lists active changes (work in progress). " + "Use listType='specs' to list the stable spec domains instead. " + "Run at the start of a session to orient on what's in flight. " + "JSON output includes change names, schemas, and artifact completion state.", parameters: Type.Object({ listType: Type.Optional( Type.Union([Type.Literal("changes"), Type.Literal("specs")], { description: "'changes' (default) — work in progress | 'specs' — spec domains", }) ), }), async execute(_toolCallId, params) { const args = ["list", "--json"]; if (params.listType === "specs") args.push("--specs"); const result = await openspecCli(args, exec); if (result.exitCode !== 0) return err(`Error listing: ${result.output}`); return ok(result.output); }, }); pi.registerTool({ name: "openspec_show", label: "Show Change or Spec", description: "Show the full content of a change or spec. " + "For changes: displays proposal, design summary, tasks, and delta spec overview. " + "For specs: displays requirements and scenarios. " + "Use this to read existing work before continuing or before creating new artifacts.", parameters: Type.Object({ name: Type.String({ description: "Change name (kebab-case) or spec ID" }), itemType: Type.Optional( Type.Union([Type.Literal("change"), Type.Literal("spec")], { description: "Item type. Auto-detected from name if omitted.", }) ), }), async execute(_toolCallId, params) { const args = ["show", params.name, "--json", "--no-interactive"]; if (params.itemType) args.push("--type", params.itemType); const result = await openspecCli(args, exec); if (result.exitCode !== 0) return err(`Error: ${result.output}`); return ok(result.output); }, }); // ═══════════════════════ CHANGE LIFECYCLE ═════════════════════════════════ pi.registerTool({ name: "openspec_new_change", label: "Create New Change", description: "Create a new OpenSpec change directory with scaffolded artifacts. " + "A change packages everything needed for one piece of work: " + "proposal (why + what), specs (delta requirements), design (how), tasks (checklist). " + "\n\nAfter creating: call openspec_status to see the artifact dependency graph, " + "then openspec_instructions for each artifact before writing it. " + "\n\nNaming: use kebab-case ('add-user-auth', 'fix-pagination', 'refactor-api'). " + "Avoid generic names like 'update' or 'wip'.", parameters: Type.Object({ name: Type.String({ description: "Change name in kebab-case" }), description: Type.Optional( Type.String({ description: "Short description added to the change README" }) ), schema: Type.Optional( Type.String({ description: "Workflow schema. Default: 'spec-driven' (proposal → specs → design → tasks). " + "Other schemas available if configured in openspec/schemas/.", }) ), }), async execute(_toolCallId, params) { const args = ["new", "change", params.name]; if (params.description) args.push("--description", params.description); if (params.schema) args.push("--schema", params.schema); const result = await openspecCli(args, exec); if (result.exitCode !== 0) return err(`Error creating change '${params.name}': ${result.output}`); return ok( result.output || `Change '${params.name}' created at openspec/changes/${params.name}/. ` + `Run openspec_status to see artifact build order.` ); }, }); pi.registerTool({ name: "openspec_status", label: "Change Status", description: "Get artifact completion status for a change. " + "Shows the dependency graph: which artifacts exist (done), " + "which are ready to create (dependencies satisfied), and which are blocked. " + "The 'applyRequires' field lists which artifacts must be complete before implementation. " + "\n\nCall this after openspec_new_change to see the artifact build order. " + "Call again after writing each artifact to see what becomes available next.", parameters: Type.Object({ changeName: Type.String({ description: "Change name (kebab-case)" }), }), async execute(_toolCallId, params) { const result = await openspecCli( ["status", "--change", params.changeName, "--json"], exec ); if (result.exitCode !== 0) return err(`Error getting status: ${result.output}`); return ok(result.output); }, }); pi.registerTool({ name: "openspec_instructions", label: "Get Artifact Instructions", description: "Get enriched instructions for creating a specific artifact or for implementing tasks. " + "\n\nReturns: context (project background), rules (constraints), template (structure), " + "outputPath (where to write the file), and dependencies (files to read first). " + "\n\nIMPORTANT: 'context' and 'rules' guide what YOU write — do NOT copy them into the artifact file. " + "Use 'template' as the structure for the output file. " + "\n\nArtifact IDs (spec-driven schema): proposal, specs, design, tasks. " + "Use artifact='apply' to get implementation instructions (task list + approach).", parameters: Type.Object({ changeName: Type.String({ description: "Change name (kebab-case)" }), artifact: Type.Optional( Type.String({ description: "Artifact ID: 'proposal', 'specs', 'design', 'tasks', or 'apply' (for implementation). " + "If omitted, returns instructions for the next pending artifact.", }) ), }), async execute(_toolCallId, params) { const args = ["instructions", "--change", params.changeName, "--json"]; if (params.artifact) args.push(params.artifact); const result = await openspecCli(args, exec); if (result.exitCode !== 0) return err(`Error getting instructions: ${result.output}`); return ok(result.output); }, }); pi.registerTool({ name: "openspec_validate", label: "Validate Change or Spec", description: "Validate a change or spec for structural correctness. " + "Checks that artifacts follow the expected format and that requirements are well-formed. " + "\n\nFor changes: checks all artifacts are present and valid. " + "For specs: checks requirement and scenario structure. " + "\n\nRun before archiving to catch missing or malformed artifacts. " + "Reports issues as CRITICAL, WARNING, or INFO.", parameters: Type.Object({ name: Type.Optional( Type.String({ description: "Change or spec name to validate. If omitted, validates all active changes.", }) ), }), async execute(_toolCallId, params) { const args = ["validate"]; if (params.name) args.push(params.name); const result = await openspecCli(args, exec); // Non-zero may mean validation issues (report them, don't error) return ok(result.output || (result.exitCode === 0 ? "Validation passed." : "Validation issues found.")); }, }); pi.registerTool({ name: "openspec_archive", label: "Archive Completed Change", description: "Archive a completed change after implementation. " + "\n\nWhat happens: " + "(1) Delta specs merge into main openspec/specs/ — the source of truth updates. " + "(2) Change folder moves to openspec/changes/archive/YYYY-MM-DD-/ for history. " + "(3) All artifacts preserved for audit trail. " + "\n\nCall openspec_validate first to catch issues. " + "The archive will warn on incomplete tasks but won't block. " + "Delta spec sync is offered if not already done.", parameters: Type.Object({ changeName: Type.Optional( Type.String({ description: "Change name to archive. Provide explicitly for non-interactive use. " + "If omitted, openspec selects interactively (may not work in agent context).", }) ), }), async execute(_toolCallId, params) { const args = ["archive", "--no-interactive"]; if (params.changeName) args.push(params.changeName); const result = await openspecCli(args, exec); if (result.exitCode !== 0) return err(`Error archiving: ${result.output}`); return ok(result.output || "Change archived. Delta specs merged into main specs."); }, }); // ═══════════════════════ SPEC BROWSING ════════════════════════════════════ pi.registerTool({ name: "openspec_spec_list", label: "List Specs", description: "List all spec domains in the current project. " + "Specs (openspec/specs/) are the stable source of truth — they describe " + "how the system CURRENTLY behaves. " + "Browse them before creating a change to understand what's already specified " + "and which domain your change touches.", parameters: Type.Object({}), async execute(_toolCallId, _params) { const result = await openspecCli(["spec", "list", "--json"], exec); if (result.exitCode !== 0) return err(`Error listing specs: ${result.output}`); return ok(result.output); }, }); pi.registerTool({ name: "openspec_spec_show", label: "View Spec", description: "Display a specific spec by ID. " + "Specs contain requirements (what the system must do) and scenarios (concrete examples). " + "Read specs before implementing to understand what behavior is already defined " + "and what your change's delta specs must satisfy or extend.", parameters: Type.Object({ specId: Type.String({ description: "Spec ID (e.g. 'auth', 'payments', 'ui') — from openspec_spec_list", }), }), async execute(_toolCallId, params) { const result = await openspecCli(["spec", "show", params.specId, "--no-interactive"], exec); if (result.exitCode !== 0) return err(`Error showing spec: ${result.output}`); return ok(result.output); }, }); // ═══════════════════════ SCHEMA MANAGEMENT ════════════════════════════════ pi.registerTool({ name: "openspec_schema_list", label: "List Available Schemas", description: "List available workflow schemas. " + "Schemas define artifact types and their dependencies. " + "Default: 'spec-driven' (proposal → specs → design → tasks). " + "Custom schemas can be created for specialized workflows " + "(e.g. research-first, rapid, security-review).", parameters: Type.Object({}), async execute(_toolCallId, _params) { // 'openspec schema which' lists the schema resolution path // Try listing via schema which with no name const result = await openspecCli(["schema", "which"], exec); if (result.exitCode !== 0) return err(`Error: ${result.output}`); return ok(result.output); }, }); pi.registerTool({ name: "openspec_schema_fork", label: "Fork a Schema", description: "Copy an existing schema to this project for customization. " + "Forked schemas live at openspec/schemas// and can be modified " + "to define custom artifact types, dependencies, and instruction templates. " + "\n\nExample: fork 'spec-driven' as 'rapid' to create a minimal workflow " + "with just proposal + tasks (skipping design for small changes).", parameters: Type.Object({ source: Type.String({ description: "Source schema name to fork (e.g. 'spec-driven')" }), name: Type.String({ description: "New schema name (kebab-case)" }), }), async execute(_toolCallId, params) { const result = await openspecCli(["schema", "fork", params.source, params.name], exec); if (result.exitCode !== 0) return err(`Error forking schema: ${result.output}`); return ok(result.output || `Schema '${params.source}' forked as '${params.name}'. Edit openspec/schemas/${params.name}/schema.yaml to customize.`); }, }); }