- extensions/openspec.ts — 13 tools wrapping openspec CLI - skills/openspec/SKILL.md — full workflow guidance for agents - package.json — pi manifest with pi-package keyword, @trentuna scope - README.md — comprehensive: install, all 13 tools, quick-start, workflow - LICENSE — MIT - .gitignore Adapted from a-team/extensions/openspec.ts (Hannibal, session 135). Standalone package so any trentuna member can: pi install git:http://localhost:3001/trentuna/pi-openspec.git
438 lines
19 KiB
TypeScript
438 lines
19 KiB
TypeScript
/**
|
|
* 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/<name>/) 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-<name>/ 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/<name>/ 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.`);
|
|
},
|
|
});
|
|
}
|