init: @trentuna/pi-openspec — 13-tool OpenSpec extension for pi
- 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
This commit is contained in:
commit
9e1f99a28f
6 changed files with 808 additions and 0 deletions
438
extensions/openspec.ts
Normal file
438
extensions/openspec.ts
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
/**
|
||||
* 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.`);
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue