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:
Vigilio Desto 2026-04-08 17:53:24 +02:00
commit 9e1f99a28f
Signed by: vigilio
GPG key ID: 159D6AD58C8E55E9
6 changed files with 808 additions and 0 deletions

438
extensions/openspec.ts Normal file
View 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.`);
},
});
}