@howaboua/opencode-planning-toolkit
Advanced tools
| /** | ||
| * hooks/index.ts | ||
| * Exposes hook registration for system prompt augmentation. | ||
| * Keeps hook modules encapsulated behind a single import. | ||
| */ | ||
| export { systemHooks } from "./system"; |
| /** | ||
| * hooks/system.ts | ||
| * Injects available plan names and descriptions into the system prompt. | ||
| * Keeps the prompt in sync with current plan files. | ||
| */ | ||
| import { basename } from "path"; | ||
| import { listPlans } from "../utils"; | ||
| const formatAvailablePlans = (plans) => { | ||
| if (plans.length === 0) | ||
| return "<available_plans></available_plans>"; | ||
| return `<available_plans>${plans | ||
| .map((plan) => `<plan><name>${plan.name}</name><description>${plan.description}</description></plan>`) | ||
| .join("")}</available_plans>`; | ||
| }; | ||
| const stripExtension = (name) => (name.endsWith(".md") ? name.slice(0, -3) : name); | ||
| const extractDescription = (text) => { | ||
| const head = text.split("\n").slice(0, 8).join("\n"); | ||
| const match = head.match(/^plan description:\s*(.+)$/m); | ||
| return match?.[1]?.trim() || ""; | ||
| }; | ||
| export const systemHooks = (ctx) => ({ | ||
| "experimental.chat.system.transform": async (_input, output) => { | ||
| const files = await listPlans(ctx.directory); | ||
| const plans = await Promise.all(files.map(async (file) => ({ | ||
| name: stripExtension(basename(file)), | ||
| description: extractDescription(await Bun.file(file).text()), | ||
| }))); | ||
| output.system.push(formatAvailablePlans(plans)); | ||
| }, | ||
| }); |
| import { createBundledSkillsHook } from "./skills"; | ||
| import { createTools } from "./tools"; | ||
| import { systemHooks } from "./hooks"; | ||
| export const PlanPlugin = async (ctx) => { | ||
| const skillsHook = createBundledSkillsHook(); | ||
| const config = async (value) => { | ||
| await skillsHook.config?.(value); | ||
| }; | ||
| return { | ||
| config, | ||
| tool: createTools(ctx), | ||
| ...systemHooks(ctx), | ||
| }; | ||
| }; | ||
| export default PlanPlugin; |
| /** | ||
| * bundled.ts | ||
| * Registers bundled skills that ship with this plugin package. | ||
| * Resolves skill paths relative to the compiled entry point without writing files. | ||
| */ | ||
| import { access } from "fs/promises"; | ||
| import path from "path"; | ||
| import { fileURLToPath } from "node:url"; | ||
| const resolveBundledSkillPaths = async () => { | ||
| const baseDir = path.dirname(fileURLToPath(import.meta.url)); | ||
| const candidates = [path.join(baseDir, "skills"), path.join(baseDir, "..", "skills")]; | ||
| const resolved = []; | ||
| for (const candidate of candidates) { | ||
| try { | ||
| await access(candidate); | ||
| resolved.push(candidate); | ||
| } | ||
| catch { | ||
| // Ignore missing paths | ||
| } | ||
| } | ||
| return resolved; | ||
| }; | ||
| const registerSkillPaths = (config, paths) => { | ||
| if (paths.length === 0) | ||
| return; | ||
| config.skills ??= {}; | ||
| config.skills.paths ??= []; | ||
| config.skill ??= {}; | ||
| config.skill.paths ??= []; | ||
| for (const skillPath of paths) { | ||
| if (!config.skills.paths.includes(skillPath)) | ||
| config.skills.paths.push(skillPath); | ||
| if (!config.skill.paths.includes(skillPath)) | ||
| config.skill.paths.push(skillPath); | ||
| } | ||
| }; | ||
| export const createBundledSkillsHook = () => { | ||
| return { | ||
| config: async (config) => { | ||
| const paths = await resolveBundledSkillPaths(); | ||
| registerSkillPaths(config, paths); | ||
| }, | ||
| }; | ||
| }; |
| /** | ||
| * bundled.ts | ||
| * Registers bundled skills that ship with this plugin package. | ||
| * Resolves skill paths relative to the compiled entry point without writing files. | ||
| */ | ||
| import { access } from "fs/promises" | ||
| import path from "path" | ||
| import { fileURLToPath } from "node:url" | ||
| import type { Hooks } from "@opencode-ai/plugin" | ||
| type SkillsConfig = { | ||
| skills?: { | ||
| paths?: string[] | ||
| } | ||
| skill?: { | ||
| paths?: string[] | ||
| } | ||
| } | ||
| type Config = Parameters<NonNullable<Hooks["config"]>>[0] & SkillsConfig | ||
| const resolveBundledSkillPaths = async () => { | ||
| const baseDir = path.dirname(fileURLToPath(import.meta.url)) | ||
| const candidates = [path.join(baseDir, "skills"), path.join(baseDir, "..", "skills")] | ||
| const resolved: string[] = [] | ||
| for (const candidate of candidates) { | ||
| try { | ||
| await access(candidate) | ||
| resolved.push(candidate) | ||
| } catch { | ||
| // Ignore missing paths | ||
| } | ||
| } | ||
| return resolved | ||
| } | ||
| const registerSkillPaths = (config: Config, paths: string[]) => { | ||
| if (paths.length === 0) return | ||
| config.skills ??= {} | ||
| config.skills.paths ??= [] | ||
| config.skill ??= {} | ||
| config.skill.paths ??= [] | ||
| for (const skillPath of paths) { | ||
| if (!config.skills.paths.includes(skillPath)) config.skills.paths.push(skillPath) | ||
| if (!config.skill.paths.includes(skillPath)) config.skill.paths.push(skillPath) | ||
| } | ||
| } | ||
| export const createBundledSkillsHook = (): Pick<Hooks, "config"> => { | ||
| return { | ||
| config: async (config) => { | ||
| const paths = await resolveBundledSkillPaths() | ||
| registerSkillPaths(config, paths) | ||
| }, | ||
| } | ||
| } |
| /** | ||
| * index.ts | ||
| * Exposes the skill helpers for the plugin entry point. | ||
| * Keeps skill exports explicit and centralized for maintenance. | ||
| */ | ||
| export { createBundledSkillsHook } from "./bundled"; |
| /** | ||
| * index.ts | ||
| * Exposes the skill helpers for the plugin entry point. | ||
| * Keeps skill exports explicit and centralized for maintenance. | ||
| */ | ||
| export { createBundledSkillsHook } from "./bundled" |
| --- | ||
| name: planning-toolkit | ||
| description: |- | ||
| Use the planning toolkit tools to create plans and specs. Load this skill before running planning workflows in a repository. | ||
| Examples: | ||
| - user: "Create a plan" -> use createPlan then readPlan | ||
| - user: "Write a spec" -> use createSpec then appendSpec | ||
| --- | ||
| # Planning Toolkit Usage | ||
| <objective> | ||
| Use the planning toolkit tools to create and maintain plans and specs consistently. | ||
| </objective> | ||
| <rules> | ||
| - You MUST load this skill before using planning toolkit tools. | ||
| - Always validate plan/spec names against the allowed format. | ||
| - Follow existing plan/spec templates and status fields. | ||
| </rules> | ||
| <procedure> | ||
| ## 1. Identify the required artifact | ||
| Decide whether the task needs a plan or a spec. | ||
| ## 2. Create the artifact | ||
| Use the appropriate tool: | ||
| ```text | ||
| Use createPlan with the required fields. | ||
| ``` | ||
| ```text | ||
| Use createSpec with the required fields. | ||
| ``` | ||
| ## 3. Review or update | ||
| Use readPlan or appendSpec to refine the artifact as needed. | ||
| </procedure> |
| /** | ||
| * tools/append-spec.ts | ||
| * Links a spec to a plan by updating the spec block. | ||
| * Uses merge logic to reduce race losses and keeps markers stable. | ||
| */ | ||
| import { tool } from "@opencode-ai/plugin"; | ||
| import { getPlanPath, getSpecPath, validateName } from "../utils"; | ||
| import { endToken, findSpecBlock, parseSpecLines, startToken, writeWithMerge } from "./shared"; | ||
| export const appendSpecTool = (ctx) => tool({ | ||
| description: "Link spec to plan. Spec MUST exist. MUST NOT call in batch/parallel; use sequential calls.", | ||
| args: { | ||
| planName: tool.schema.string().describe("Target plan name (REQUIRED)"), | ||
| specName: tool.schema.string().describe("Spec name to link (REQUIRED)"), | ||
| }, | ||
| async execute(args) { | ||
| if (!args.planName) | ||
| return "Error: 'planName' parameter is REQUIRED."; | ||
| if (!args.specName) | ||
| return "Error: 'specName' parameter is REQUIRED."; | ||
| const planCheck = validateName(args.planName); | ||
| if (!planCheck.ok) | ||
| return `Error: Invalid plan name '${args.planName}': ${planCheck.reason}.`; | ||
| const specCheck = validateName(args.specName); | ||
| if (!specCheck.ok) | ||
| return `Error: Invalid spec name '${args.specName}': ${specCheck.reason}.`; | ||
| const planPath = getPlanPath(ctx.directory, args.planName); | ||
| const planFile = Bun.file(planPath); | ||
| if (!(await planFile.exists())) | ||
| return `Plan '${args.planName}' not found.`; | ||
| const specPath = getSpecPath(ctx.directory, args.specName); | ||
| if (!(await Bun.file(specPath).exists())) | ||
| return `Spec '${args.specName}' not found. Please create it first.`; | ||
| const content = await planFile.text(); | ||
| const block = findSpecBlock(content); | ||
| const specLink = `- ${args.specName}`; | ||
| if (!block) { | ||
| const appended = `${content}\n\n## Required Specs\n${startToken}\n${specLink}\n${endToken}`; | ||
| const bytes = await Bun.write(planPath, appended); | ||
| if (bytes === 0) | ||
| return `Error: Failed to update plan '${args.planName}'.`; | ||
| return `Linked spec '${args.specName}' to plan '${args.planName}'`; | ||
| } | ||
| const existing = parseSpecLines(block.middle); | ||
| if (existing.includes(args.specName)) { | ||
| return `Spec '${args.specName}' is already linked to plan '${args.planName}'.`; | ||
| } | ||
| const result = await writeWithMerge(planPath, args.specName); | ||
| if (!result.ok) { | ||
| return `Error: Failed to update plan '${args.planName}': ${result.reason}.`; | ||
| } | ||
| return `Linked spec '${args.specName}' to plan '${args.planName}'`; | ||
| }, | ||
| }); |
| /** | ||
| * tools/create-plan.ts | ||
| * Implements plan creation with validation and frontmatter formatting. | ||
| * Returns guidance about global specs after writing the plan. | ||
| */ | ||
| import { tool } from "@opencode-ai/plugin"; | ||
| import { ensureDirectory, formatPlan, getPlanPath, validateName } from "../utils"; | ||
| import { listRepoSpecs } from "./shared"; | ||
| export const createPlanTool = (ctx) => tool({ | ||
| description: "Create a plan. Name MUST be [A-Za-z0-9-], max 3 words. Idea REQUIRED and detailed. SHORT description REQUIRED (3-5 words) and MUST NOT overlap with the plan name. Steps REQUIRED (min 5), specific and actionable. You SHOULD ask clarifying questions before creating a plan. After creating: (1) you MUST use appendSpec for all REPO scope specs listed, (2) you MUST ask user if they want a FEATURE spec for this plan. Other agents MUST read the plan before major work.", | ||
| args: { | ||
| name: tool.schema.string().describe("Plan name MUST be [A-Za-z0-9-], max 3 words."), | ||
| idea: tool.schema.string().describe("Plan idea (REQUIRED, detailed)"), | ||
| description: tool.schema.string().describe("Plan SHORT description (REQUIRED, 3-5 words, NOT overlapping name)"), | ||
| steps: tool.schema.array(tool.schema.string()).describe("Implementation steps (REQUIRED, min 5, specific)"), | ||
| }, | ||
| async execute(args) { | ||
| if (!args.name) | ||
| return "Error: 'name' parameter is REQUIRED."; | ||
| if (!args.description) | ||
| return "Error: 'description' parameter is REQUIRED."; | ||
| const words = args.description.trim().split(/\s+/).filter(Boolean).length; | ||
| if (words < 3 || words > 10) { | ||
| return "Error: 'description' parameter must be between 3 and 10 words."; | ||
| } | ||
| if (!args.steps || args.steps.length < 5) { | ||
| return "Error: 'steps' parameter is REQUIRED and must include at least 5 steps."; | ||
| } | ||
| const nameCheck = validateName(args.name); | ||
| if (!nameCheck.ok) | ||
| return `Error: Invalid plan name '${args.name}': ${nameCheck.reason}.`; | ||
| const path = getPlanPath(ctx.directory, args.name); | ||
| if (await Bun.file(path).exists()) | ||
| return `Error: Plan '${args.name}' already exists. Use a unique name.`; | ||
| await ensureDirectory(path, ctx.$); | ||
| const content = formatPlan(args.idea || "", args.name, args.description, args.steps || []); | ||
| const bytes = await Bun.write(path, content); | ||
| if (bytes === 0 || !(await Bun.file(path).exists())) { | ||
| return `Error: Failed to write plan '${args.name}' to disk. Please check permissions.`; | ||
| } | ||
| const repoSpecs = await listRepoSpecs(ctx.directory); | ||
| const featurePrompt = `You MUST ask the user if they want to create a FEATURE spec for plan '${args.name}'.`; | ||
| if (repoSpecs.length === 0) { | ||
| return `Plan '${args.name}' created successfully. No global specs detected. ${featurePrompt}`; | ||
| } | ||
| return `Plan '${args.name}' created successfully. REQUIRED: (1) Call appendSpec for each REPO spec: ${repoSpecs.join(", ")}. (2) ${featurePrompt}`; | ||
| }, | ||
| }); |
| /** | ||
| * tools/create-spec.ts | ||
| * Implements spec creation with scope validation and formatted output. | ||
| * Encourages reuse while keeping names constrained. | ||
| */ | ||
| import { tool } from "@opencode-ai/plugin"; | ||
| import { ensureDirectory, formatSpec, getSpecPath, validateName } from "../utils"; | ||
| export const createSpecTool = (ctx) => tool({ | ||
| description: "Create a spec. Specs SHOULD be reusable. Name MUST be [A-Za-z0-9-], max 3 words. You SHOULD ask clarifying questions before creating a spec.", | ||
| args: { | ||
| name: tool.schema.string().describe("Spec name MUST be [A-Za-z0-9-], max 3 words."), | ||
| scope: tool.schema.enum(["repo", "feature"]).describe("Scope MUST be repo or feature."), | ||
| content: tool.schema.string().describe("Spec content (REUSABLE, markdown OK)"), | ||
| }, | ||
| async execute(args) { | ||
| if (!args.name) | ||
| return "Error: 'name' parameter is REQUIRED."; | ||
| if (!args.content) | ||
| return "Error: 'content' parameter is REQUIRED."; | ||
| if (args.scope !== "repo" && args.scope !== "feature") { | ||
| return "Error: 'scope' parameter is REQUIRED and must be 'repo' or 'feature'."; | ||
| } | ||
| const nameCheck = validateName(args.name); | ||
| if (!nameCheck.ok) | ||
| return `Error: Invalid spec name '${args.name}': ${nameCheck.reason}.`; | ||
| const path = getSpecPath(ctx.directory, args.name); | ||
| if (await Bun.file(path).exists()) | ||
| return `Error: Spec '${args.name}' already exists. Use a unique name.`; | ||
| await ensureDirectory(path, ctx.$); | ||
| const content = formatSpec(args.name, args.scope, args.content); | ||
| const bytes = await Bun.write(path, content); | ||
| if (bytes === 0 || !(await Bun.file(path).exists())) { | ||
| return `Error: Failed to write spec '${args.name}' to disk. Please check permissions.`; | ||
| } | ||
| return `Spec '${args.name}' created successfully.`; | ||
| }, | ||
| }); |
| import { appendSpecTool } from "./append-spec"; | ||
| import { createPlanTool } from "./create-plan"; | ||
| import { createSpecTool } from "./create-spec"; | ||
| import { markPlanDoneTool } from "./mark-plan-done"; | ||
| import { readPlanTool } from "./read-plan"; | ||
| export const createTools = (ctx) => ({ | ||
| createPlan: createPlanTool(ctx), | ||
| createSpec: createSpecTool(ctx), | ||
| readPlan: readPlanTool(ctx), | ||
| appendSpec: appendSpecTool(ctx), | ||
| markPlanDone: markPlanDoneTool(ctx), | ||
| }); |
| /** | ||
| * tools/mark-plan-done.ts | ||
| * Marks a plan as done by normalizing frontmatter and updating status. | ||
| * Expects completion to be verified before invoking this tool. | ||
| */ | ||
| import { tool } from "@opencode-ai/plugin"; | ||
| import { getPlanPath, normalizePlanFrontmatter, validateName } from "../utils"; | ||
| export const markPlanDoneTool = (ctx) => tool({ | ||
| description: "Mark plan status as done. MUST ensure the plan is fully completed before calling.", | ||
| args: { | ||
| name: tool.schema.string().describe("Target plan name (REQUIRED)"), | ||
| }, | ||
| async execute(args) { | ||
| if (!args.name) | ||
| return "Error: 'name' parameter is REQUIRED."; | ||
| const nameCheck = validateName(args.name); | ||
| if (!nameCheck.ok) | ||
| return `Error: Invalid plan name '${args.name}': ${nameCheck.reason}.`; | ||
| const planPath = getPlanPath(ctx.directory, args.name); | ||
| const planFile = Bun.file(planPath); | ||
| if (!(await planFile.exists())) | ||
| return `Plan '${args.name}' not found.`; | ||
| const content = await planFile.text(); | ||
| const statusMatch = content.match(/^plan status:\s*(\w+)\b/m); | ||
| const status = statusMatch?.[1] ?? "active"; | ||
| if (status === "done") | ||
| return `Plan '${args.name}' is already done.`; | ||
| const nameMatch = content.match(/^plan name:\s*(.+)$/m); | ||
| const descMatch = content.match(/^plan description:\s*(.+)$/m); | ||
| const planName = nameMatch?.[1]?.trim() || args.name; | ||
| const description = descMatch?.[1]?.trim() || ""; | ||
| const normalized = normalizePlanFrontmatter(content, planName, description, "done"); | ||
| const bytes = await Bun.write(planPath, normalized); | ||
| if (bytes === 0) | ||
| return `Error: Failed to update plan '${args.name}'.`; | ||
| return `Plan '${args.name}' marked as done.`; | ||
| }, | ||
| }); |
| /** | ||
| * tools/read-plan.ts | ||
| * Reads a plan and expands linked specs into the output. | ||
| * Normalizes frontmatter before parsing the spec block. | ||
| */ | ||
| import { tool } from "@opencode-ai/plugin"; | ||
| import { getPlanPath, getSpecPath, normalizePlanFrontmatter, validateName } from "../utils"; | ||
| import { findSpecBlock, parseSpecLines } from "./shared"; | ||
| export const readPlanTool = (ctx) => tool({ | ||
| description: "Read a plan with linked spec content. MUST be read before major work.", | ||
| args: { | ||
| name: tool.schema.string().describe("Existing plan name (REQUIRED)"), | ||
| }, | ||
| async execute(args) { | ||
| if (!args.name) | ||
| return "Error: 'name' parameter is REQUIRED."; | ||
| const nameCheck = validateName(args.name); | ||
| if (!nameCheck.ok) | ||
| return `Error: Invalid plan name '${args.name}': ${nameCheck.reason}.`; | ||
| const planPath = getPlanPath(ctx.directory, args.name); | ||
| const planFile = Bun.file(planPath); | ||
| if (!(await planFile.exists())) | ||
| return `Plan '${args.name}' not found.`; | ||
| const content = await planFile.text(); | ||
| const nameMatch = content.match(/^plan name:\s*(.+)$/m); | ||
| const descMatch = content.match(/^plan description:\s*(.+)$/m); | ||
| const statusMatch = content.match(/^plan status:\s*(\w+)\b/m); | ||
| const planName = nameMatch?.[1]?.trim() || args.name; | ||
| const description = descMatch?.[1]?.trim() || ""; | ||
| const status = statusMatch?.[1] ?? "active"; | ||
| const normalized = normalizePlanFrontmatter(content, planName, description, status); | ||
| const block = findSpecBlock(normalized); | ||
| if (!block) | ||
| return normalized; | ||
| const specs = parseSpecLines(block.middle); | ||
| if (specs.length === 0) | ||
| return normalized; | ||
| const specChunks = await Promise.all(specs.map(async (specName) => { | ||
| const specPath = getSpecPath(ctx.directory, specName); | ||
| const specFile = Bun.file(specPath); | ||
| if (await specFile.exists()) | ||
| return `\n### Spec: ${specName}\n${await specFile.text()}\n`; | ||
| return `\n### Spec: ${specName} (NOT FOUND)\n`; | ||
| })); | ||
| const specContent = "\n\n## Associated Specs\n" + specChunks.join(""); | ||
| return normalized + specContent; | ||
| }, | ||
| }); |
| /** | ||
| * tools/shared.ts | ||
| * Holds spec block parsing and merge helpers shared by plan tools. | ||
| * Keeps append and read logic consistent across tool implementations. | ||
| */ | ||
| import { basename, join } from "path"; | ||
| const startToken = "<!-- SPECS_START -->"; | ||
| const endToken = "<!-- SPECS_END -->"; | ||
| const parseSpecLines = (block) => block | ||
| .split("\n") | ||
| .map((line) => line.trim()) | ||
| .filter((line) => line.startsWith("- ")) | ||
| .map((line) => line.replace(/^- /, "").trim()) | ||
| .filter(Boolean); | ||
| const findSpecBlock = (content) => { | ||
| const startIndex = content.indexOf(startToken); | ||
| const endIndex = content.indexOf(endToken, startIndex + startToken.length); | ||
| const hasStart = startIndex !== -1; | ||
| const hasEnd = endIndex !== -1 && endIndex > startIndex; | ||
| if (!hasStart) | ||
| return null; | ||
| if (hasEnd) { | ||
| const before = content.slice(0, startIndex + startToken.length); | ||
| const middle = content.slice(startIndex + startToken.length, endIndex); | ||
| const after = content.slice(endIndex); | ||
| return { before, middle, after }; | ||
| } | ||
| const afterStart = content.slice(startIndex + startToken.length); | ||
| const headingMatch = afterStart.match(/\n##\s/); | ||
| const headingIndex = headingMatch?.index ?? -1; | ||
| const recoverIndex = headingIndex >= 0 ? startIndex + startToken.length + headingIndex : content.length; | ||
| const before = content.slice(0, startIndex + startToken.length); | ||
| const middle = content.slice(startIndex + startToken.length, recoverIndex); | ||
| const after = content.slice(recoverIndex); | ||
| return { before, middle, after }; | ||
| }; | ||
| const stripExtraEndTokens = (after) => { | ||
| if (!after.startsWith(endToken)) | ||
| return after; | ||
| const tail = after.slice(endToken.length); | ||
| const escaped = endToken.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | ||
| const trimmed = tail.replace(new RegExp(`^(?:${escaped})+`), ""); | ||
| return endToken + trimmed; | ||
| }; | ||
| const writeWithMerge = async (path, spec) => { | ||
| const attempts = 5; | ||
| for (const _ of Array.from({ length: attempts })) { | ||
| const current = await Bun.file(path).text(); | ||
| const block = findSpecBlock(current); | ||
| if (!block) | ||
| return { ok: false, reason: "missing spec block" }; | ||
| const existing = parseSpecLines(block.middle); | ||
| if (existing.includes(spec)) | ||
| return { ok: true }; | ||
| const merged = [...existing, spec]; | ||
| const newMiddle = merged.map((name) => `- ${name}`).join("\n"); | ||
| const normalizedAfter = stripExtraEndTokens(block.after); | ||
| const tail = normalizedAfter.startsWith(endToken) ? normalizedAfter.slice(endToken.length) : normalizedAfter; | ||
| const next = `${block.before}\n${newMiddle}\n${endToken}${tail}`; | ||
| const bytes = await Bun.write(path, next); | ||
| if (bytes === 0) | ||
| return { ok: false, reason: "write failed" }; | ||
| const verify = await Bun.file(path).text(); | ||
| const verifyBlock = findSpecBlock(verify); | ||
| if (!verifyBlock) | ||
| return { ok: false, reason: "missing spec block" }; | ||
| const verifySpecs = parseSpecLines(verifyBlock.middle); | ||
| if (verifySpecs.includes(spec)) | ||
| return { ok: true }; | ||
| } | ||
| return { ok: false, reason: "concurrent updates" }; | ||
| }; | ||
| const listRepoSpecs = async (directory) => { | ||
| const specsDir = join(directory, "docs/specs"); | ||
| const glob = new Bun.Glob("*.md"); | ||
| const files = []; | ||
| try { | ||
| for await (const file of glob.scan({ cwd: specsDir, absolute: true })) { | ||
| files.push(file); | ||
| } | ||
| } | ||
| catch { | ||
| return []; | ||
| } | ||
| const repoSpecs = []; | ||
| for (const file of files) { | ||
| const text = await Bun.file(file).text(); | ||
| const head = text.split("\n").slice(0, 8).join("\n"); | ||
| if (!/^Scope:\s*repo\b/m.test(head)) | ||
| continue; | ||
| repoSpecs.push(basename(file).replace(/\.md$/, "")); | ||
| } | ||
| return repoSpecs; | ||
| }; | ||
| export { endToken, findSpecBlock, listRepoSpecs, parseSpecLines, startToken, writeWithMerge }; |
| export {}; |
| import { resolve, join, normalize } from "path"; | ||
| const namePattern = /^[A-Za-z0-9-]+$/; | ||
| export const validateName = (name) => { | ||
| if (!name || typeof name !== "string") | ||
| return { ok: false, reason: "name is required" }; | ||
| if (!namePattern.test(name)) | ||
| return { ok: false, reason: "use only letters, numbers, and hyphens" }; | ||
| const parts = name.split("-").filter(Boolean); | ||
| if (parts.length === 0) | ||
| return { ok: false, reason: "name cannot be empty" }; | ||
| if (parts.length > 3) | ||
| return { ok: false, reason: "use max 3 hyphen-separated words" }; | ||
| return { ok: true }; | ||
| }; | ||
| export const sanitizeFilename = (name) => { | ||
| if (!name || typeof name !== "string") | ||
| return "untitled"; | ||
| const sanitized = name | ||
| .replace(/[\0-\x1f\x7f]/g, "") | ||
| .replace(/[\\/:\*\?"<>\|]/g, "_") | ||
| .trim(); | ||
| return sanitized.length === 0 ? "untitled" : sanitized; | ||
| }; | ||
| export const getSecurePath = (baseDir, name) => { | ||
| const sanitized = sanitizeFilename(name); | ||
| const fullBase = resolve(baseDir); | ||
| const target = resolve(join(fullBase, `${sanitized}.md`)); | ||
| if (!target.startsWith(fullBase)) { | ||
| throw new Error(`Security violation: Path ${target} is outside of ${fullBase}`); | ||
| } | ||
| return target; | ||
| }; | ||
| export const getPlanPath = (directory, name) => getSecurePath(join(directory, "docs/plans"), name); | ||
| export const getSpecPath = (directory, name) => getSecurePath(join(directory, "docs/specs"), name); | ||
| export const listPlans = async (directory) => { | ||
| const plansDir = join(directory, "docs/plans"); | ||
| const glob = new Bun.Glob("*.md"); | ||
| const files = []; | ||
| try { | ||
| for await (const file of glob.scan({ cwd: plansDir, absolute: true })) { | ||
| files.push(file); | ||
| } | ||
| } | ||
| catch { | ||
| return []; | ||
| } | ||
| return files; | ||
| }; | ||
| export async function ensureDirectory(path, $) { | ||
| const dir = normalize(join(path, "..")); | ||
| await $ `mkdir -p ${dir}`; | ||
| } | ||
| export const formatPlan = (idea, name, description, implementation) => { | ||
| const implementationSection = implementation.length > 0 ? `\n## Implementation\n${implementation.map((item) => `- ${item}`).join("\n")}\n` : ""; | ||
| return ` | ||
| --- | ||
| plan name: ${name} | ||
| plan description: ${description} | ||
| plan status: active | ||
| --- | ||
| ## Idea | ||
| ${idea} | ||
| ${implementationSection} | ||
| ## Required Specs | ||
| <!-- SPECS_START --> | ||
| <!-- SPECS_END --> | ||
| `.trim(); | ||
| }; | ||
| export const normalizePlanFrontmatter = (content, name, description, status) => { | ||
| const header = `---\nplan name: ${name}\nplan description: ${description}\nplan status: ${status}\n---\n\n`; | ||
| const rest = content.replace(/^---[\s\S]*?---\n\n?/, ""); | ||
| return header + rest; | ||
| }; | ||
| export const formatSpec = (name, scope, content) => ` | ||
| # Spec: ${name} | ||
| Scope: ${scope} | ||
| ${content} | ||
| `.trim(); |
| /** | ||
| * bundled.ts | ||
| * Registers bundled skills that ship with this plugin package. | ||
| * Resolves skill paths relative to the compiled entry point without writing files. | ||
| */ | ||
| import { access } from "fs/promises" | ||
| import path from "path" | ||
| import { fileURLToPath } from "node:url" | ||
| import type { Hooks } from "@opencode-ai/plugin" | ||
| type SkillsConfig = { | ||
| skills?: { | ||
| paths?: string[] | ||
| } | ||
| skill?: { | ||
| paths?: string[] | ||
| } | ||
| } | ||
| type Config = Parameters<NonNullable<Hooks["config"]>>[0] & SkillsConfig | ||
| const resolveBundledSkillPaths = async () => { | ||
| const baseDir = path.dirname(fileURLToPath(import.meta.url)) | ||
| const candidates = [path.join(baseDir, "skills"), path.join(baseDir, "..", "skills")] | ||
| const resolved: string[] = [] | ||
| for (const candidate of candidates) { | ||
| try { | ||
| await access(candidate) | ||
| resolved.push(candidate) | ||
| } catch { | ||
| // Ignore missing paths | ||
| } | ||
| } | ||
| return resolved | ||
| } | ||
| const registerSkillPaths = (config: Config, paths: string[]) => { | ||
| if (paths.length === 0) return | ||
| config.skills ??= {} | ||
| config.skills.paths ??= [] | ||
| config.skill ??= {} | ||
| config.skill.paths ??= [] | ||
| config.skills.paths = [...new Set([...config.skills.paths, ...paths])] | ||
| config.skill.paths = [...new Set([...config.skill.paths, ...paths])] | ||
| } | ||
| export const createBundledSkillsHook = (): Pick<Hooks, "config"> => { | ||
| return { | ||
| config: async (config) => { | ||
| const paths = await resolveBundledSkillPaths() | ||
| registerSkillPaths(config, paths) | ||
| }, | ||
| } | ||
| } |
| /** | ||
| * index.ts | ||
| * Exposes the skill helpers for the plugin entry point. | ||
| * Keeps skill exports explicit and centralized for maintenance. | ||
| */ | ||
| export { createBundledSkillsHook } from "./bundled" |
| --- | ||
| name: plans-and-specs | ||
| description: |- | ||
| This SKILL provides detailed instructions on how to use the planning plugin tools. MUST be loaded when: | ||
| Situations: | ||
| - User asks to create a plan, roadmap, or break down work into steps | ||
| - User mentions specs, requirements, or standards that need to be documented | ||
| - User references an existing plan that needs to be read or updated | ||
| - User asks to mark work as complete or done | ||
| - User wants to link requirements to a plan | ||
| - User references a task that matches <available_plans> | ||
| --- | ||
| # Plans and Specs | ||
| <objective> | ||
| Plans are actionable work breakdowns stored as markdown with frontmatter. Specs are reusable requirements/documents. Plans link to specs via appendSpec. readPlan expands linked specs inline. | ||
| </objective> | ||
| <rules> | ||
| After createPlan: MUST call appendSpec for each REPO scope spec (sequential), then ask about FEATURE specs. | ||
| Before major work: MUST use readPlan to get plan + all linked specs. | ||
| appendSpec: MUST be sequential calls, never batch/parallel. | ||
| Specs MUST exist before appendSpec. | ||
| markPlanDone: MUST ensure plan fully completed first. | ||
| </rules> | ||
| <procedure> | ||
| ## Planning Workflow | ||
| 1. Check if plan already exists - if exact match, execute instead of creating | ||
| 2. createPlan with name, idea, description (3-5 words), steps (min 5) | ||
| 3. For each REPO spec returned: appendSpec(planName, specName) - sequential | ||
| 4. Ask user: "Want a FEATURE spec for this plan?" | ||
| 5. If yes: createSpec, then appendSpec | ||
| 6. Before work: readPlan to get full context | ||
| ## Spec Creation | ||
| createSpec with name, scope (repo/feature), reusable content. | ||
| ## Completion | ||
| markPlanDone only after all work verified complete. | ||
| </procedure> | ||
| <errors> | ||
| Invalid name: Use [A-Za-z0-9-], max 3 words. | ||
| Description error: 3-5 words, not overlapping name. | ||
| Steps error: Need min 5 specific steps. | ||
| Already exists: Use different name. | ||
| Spec not found: Create spec first. | ||
| Concurrent updates: appendSpec must be sequential. | ||
| </errors> |
+10
-3
| { | ||
| "name": "@howaboua/opencode-planning-toolkit", | ||
| "version": "0.0.4", | ||
| "version": "0.0.5", | ||
| "description": "Comprehensive planning toolkit for OpenCode: manage specifications, track study plans, and automate roadmaps.", | ||
| "main": "index.ts", | ||
| "main": "dist/index.js", | ||
| "type": "module", | ||
| "types": "dist/index.d.ts", | ||
| "scripts": { | ||
| "build": "tsc", | ||
| "build": "tsc && cp -r skills dist/", | ||
| "watch": "tsc --watch", | ||
@@ -27,2 +28,8 @@ "clean": "find . -name '*.js' ! -path './node_modules/*' -delete && find . -name '*.d.ts' ! -path './node_modules/*' -delete", | ||
| }, | ||
| "files": [ | ||
| "dist", | ||
| "skills", | ||
| "README.md", | ||
| "LICENSE" | ||
| ], | ||
| "dependencies": { | ||
@@ -29,0 +36,0 @@ "@opencode-ai/plugin": "latest" |
+23
-1
@@ -14,2 +14,3 @@ # OpenCode Planning Toolkit | ||
| - Automatically receive `<available_plans>` in the system prompt with all plan names and descriptions | ||
| - **Bundled skill** automatically loads to guide agents through proper planning workflow | ||
@@ -26,2 +27,19 @@ ## Tools | ||
| ## Bundled Skill | ||
| This plugin includes a **bundled skill** (`plans-and-specs`) that automatically loads with the plugin. The skill provides agents with: | ||
| - Clear workflow instructions for creating plans and specs | ||
| - Proper order of operations (create plan → append REPO specs → ask about FEATURE specs) | ||
| - Instructions to check for existing plans before creating new ones | ||
| - Guidance on when to use each tool | ||
| **When agents use it:** | ||
| - User asks to create a plan, roadmap, or break down work | ||
| - User mentions specs, requirements, or standards documentation | ||
| - User references existing plans that need reading or updating | ||
| - User asks to mark work complete or link requirements | ||
| The skill ensures agents follow best practices automatically without manual prompting. | ||
| ## Installation | ||
@@ -81,3 +99,3 @@ | ||
| plan name: user-auth | ||
| plan description: Add JWT-based authentication | ||
| plan description: JWT authentication for API | ||
| plan status: active | ||
@@ -101,2 +119,6 @@ --- | ||
| **Agent automatically:** | ||
| 1. Links any existing REPO scope specs | ||
| 2. Asks if you want a FEATURE spec for this plan | ||
| ### 3. Link Specs to Plans | ||
@@ -103,0 +125,0 @@ |
| { | ||
| // Enable or disable the plugin | ||
| "enabled": true, | ||
| // Where to store memories: "global", "project", or "both" | ||
| // - "global": ~/.config/opencode/memory/memories.sqlite (shared across projects) | ||
| // - "project": .opencode/memory/memories.sqlite (project-specific) | ||
| // - "both": search both, save to project | ||
| "scope": "project", | ||
| // Memory injection settings | ||
| "inject": { | ||
| // Number of memories to inject after user messages (default: 5) | ||
| "count": 5, | ||
| // Score threshold for [important] vs [related] tag (default: 0.6) | ||
| "highThreshold": 0.6 | ||
| } | ||
| } |
-60
| ## Repository Overview | ||
| OpenCode plugin for project planning and roadmap management. Provides tools for creating specifications (`spec.md`), generating study plans (`plan.md`), and tracking progress. Built with TypeScript and the OpenCode Plugin SDK. | ||
| <instructions> | ||
| ## Build & Type Check | ||
| - **Type check**: `npm run lint` - Runs `tsc --noEmit` to verify types without emitting files (STRONGLY PREFERRED) | ||
| - **Build**: `npm run build` - Compiles TypeScript to JavaScript | ||
| - **Clean**: `npm run clean` - Removes generated `.js` and `.d.ts` files from source tree | ||
| ## Plugin Architecture | ||
| - **Entry point**: `index.ts` - Registers tools and system hooks via `PlanPlugin` | ||
| - **Tool Implementation**: `tools/` - Contains logic for plan and spec management tools | ||
| - **System Hooks**: `hooks/` - Core plugin event handlers | ||
| - **Security Utilities**: `utils.ts` - Mandatory helpers for path validation and file formatting | ||
| </instructions> | ||
| <rules> | ||
| ## Process Constraints | ||
| - MUST NOT run long-running/blocking processes (e.g., `npm run watch`) | ||
| - MUST use one-shot commands only (`npm run build`, `npm run lint`) | ||
| - Dev servers and watch modes are the USER's responsibility | ||
| ## Coding Conventions | ||
| - **TypeScript strict mode**: All code MUST pass `tsc --noEmit` with strict type checking | ||
| - **Path security**: MUST use `getSecurePath`, `getPlanPath`, or `getSpecPath` from `utils.ts` for all file operations | ||
| - **Name validation**: MUST use `validateName` for user-provided names (alphanumeric + hyphens, max 3 words) | ||
| - **File formats**: | ||
| - Plans: MUST use frontmatter with `plan name`, `plan description`, `plan status` | ||
| - Specs: MUST use `# Spec: {name}` header and `Scope:` field | ||
| - **Utility imports**: Import shell and context types from `@opencode-ai/plugin` | ||
| </rules> | ||
| <routing> | ||
| ## Task Navigation | ||
| | Task | Entry Point | Key Files | | ||
| |------|-------------|-----------| | ||
| | Add/Modify tool | `tools/` | Tool implementation modules | | ||
| | Change hooks | `hooks/` | Plugin hook definitions | | ||
| | File formatting | `utils.ts` | `formatPlan` and `formatSpec` functions | | ||
| | Path security | `utils.ts` | `getSecurePath` logic | | ||
| </routing> | ||
| <context_hints> | ||
| - **Restricted**: `dist/` and `node_modules/` MUST be ignored by agents | ||
| - **Critical**: `utils.ts` contains security-critical path validation that MUST NOT be bypassed | ||
| </context_hints> |
| /** | ||
| * hooks/index.ts | ||
| * Exposes hook registration for system prompt augmentation. | ||
| * Keeps hook modules encapsulated behind a single import. | ||
| */ | ||
| export { systemHooks } from "./system" |
| /** | ||
| * hooks/system.ts | ||
| * Injects available plan names and descriptions into the system prompt. | ||
| * Keeps the prompt in sync with current plan files. | ||
| */ | ||
| import { basename } from "path" | ||
| import { listPlans } from "../utils" | ||
| import type { Ctx } from "../types" | ||
| const formatAvailablePlans = (plans: { name: string; description: string }[]) => { | ||
| if (plans.length === 0) return "<available_plans></available_plans>" | ||
| return `<available_plans>${plans | ||
| .map((plan) => `<plan><name>${plan.name}</name><description>${plan.description}</description></plan>`) | ||
| .join("")}</available_plans>` | ||
| } | ||
| const stripExtension = (name: string) => (name.endsWith(".md") ? name.slice(0, -3) : name) | ||
| const extractDescription = (text: string) => { | ||
| const head = text.split("\n").slice(0, 8).join("\n") | ||
| const match = head.match(/^plan description:\s*(.+)$/m) | ||
| return match?.[1]?.trim() || "" | ||
| } | ||
| export const systemHooks = (ctx: Ctx) => ({ | ||
| "experimental.chat.system.transform": async (_input: { sessionID: string }, output: { system: string[] }) => { | ||
| const files = await listPlans(ctx.directory) | ||
| const plans = await Promise.all( | ||
| files.map(async (file) => ({ | ||
| name: stripExtension(basename(file)), | ||
| description: extractDescription(await Bun.file(file).text()), | ||
| })), | ||
| ) | ||
| output.system.push(formatAvailablePlans(plans)) | ||
| }, | ||
| }) |
-17
| /** | ||
| * index.ts | ||
| * Wires plugin hooks and tool registrations for the plan/spec workflow. | ||
| * Keeps entrypoint minimal and delegates behavior to modules. | ||
| */ | ||
| import type { Plugin } from "@opencode-ai/plugin" | ||
| import { createTools } from "./tools" | ||
| import { systemHooks } from "./hooks" | ||
| export const PlanPlugin: Plugin = async (ctx) => { | ||
| return { | ||
| tool: createTools(ctx), | ||
| ...systemHooks(ctx), | ||
| } | ||
| } | ||
| export default PlanPlugin |
| /** | ||
| * tools/append-spec.ts | ||
| * Links a spec to a plan by updating the spec block. | ||
| * Uses merge logic to reduce race losses and keeps markers stable. | ||
| */ | ||
| import { tool } from "@opencode-ai/plugin" | ||
| import { getPlanPath, getSpecPath, validateName } from "../utils" | ||
| import type { Ctx } from "../types" | ||
| import { endToken, findSpecBlock, parseSpecLines, startToken, writeWithMerge } from "./shared" | ||
| export const appendSpecTool = (ctx: Ctx) => | ||
| tool({ | ||
| description: "Link spec to plan. Spec MUST exist. MUST NOT call in batch/parallel; use sequential calls.", | ||
| args: { | ||
| planName: tool.schema.string().describe("Target plan name (REQUIRED)"), | ||
| specName: tool.schema.string().describe("Spec name to link (REQUIRED)"), | ||
| }, | ||
| async execute(args) { | ||
| if (!args.planName) return "Error: 'planName' parameter is REQUIRED." | ||
| if (!args.specName) return "Error: 'specName' parameter is REQUIRED." | ||
| const planCheck = validateName(args.planName) | ||
| if (!planCheck.ok) return `Error: Invalid plan name '${args.planName}': ${planCheck.reason}.` | ||
| const specCheck = validateName(args.specName) | ||
| if (!specCheck.ok) return `Error: Invalid spec name '${args.specName}': ${specCheck.reason}.` | ||
| const planPath = getPlanPath(ctx.directory, args.planName) | ||
| const planFile = Bun.file(planPath) | ||
| if (!(await planFile.exists())) return `Plan '${args.planName}' not found.` | ||
| const specPath = getSpecPath(ctx.directory, args.specName) | ||
| if (!(await Bun.file(specPath).exists())) return `Spec '${args.specName}' not found. Please create it first.` | ||
| const content = await planFile.text() | ||
| const block = findSpecBlock(content) | ||
| const specLink = `- ${args.specName}` | ||
| if (!block) { | ||
| const appended = `${content}\n\n## Required Specs\n${startToken}\n${specLink}\n${endToken}` | ||
| const bytes = await Bun.write(planPath, appended) | ||
| if (bytes === 0) return `Error: Failed to update plan '${args.planName}'.` | ||
| return `Linked spec '${args.specName}' to plan '${args.planName}'` | ||
| } | ||
| const existing = parseSpecLines(block.middle) | ||
| if (existing.includes(args.specName)) { | ||
| return `Spec '${args.specName}' is already linked to plan '${args.planName}'.` | ||
| } | ||
| const result = await writeWithMerge(planPath, args.specName) | ||
| if (!result.ok) { | ||
| return `Error: Failed to update plan '${args.planName}': ${result.reason}.` | ||
| } | ||
| return `Linked spec '${args.specName}' to plan '${args.planName}'` | ||
| }, | ||
| }) |
| /** | ||
| * tools/create-plan.ts | ||
| * Implements plan creation with validation and frontmatter formatting. | ||
| * Returns guidance about global specs after writing the plan. | ||
| */ | ||
| import { tool } from "@opencode-ai/plugin" | ||
| import { ensureDirectory, formatPlan, getPlanPath, validateName } from "../utils" | ||
| import type { Ctx } from "../types" | ||
| import { listRepoSpecs } from "./shared" | ||
| export const createPlanTool = (ctx: Ctx) => | ||
| tool({ | ||
| description: | ||
| "Create a plan. Name MUST be [A-Za-z0-9-], max 3 words. Idea REQUIRED and detailed. SHORT description REQUIRED (3-5 words) and MUST NOT overlap with the plan name. Steps REQUIRED (min 5), specific and actionable. You SHOULD ask clarifying questions before creating a plan. After creating: (1) you MUST use appendSpec for all REPO scope specs listed, (2) you MUST ask user if they want a FEATURE spec for this plan. Other agents MUST read the plan before major work.", | ||
| args: { | ||
| name: tool.schema.string().describe("Plan name MUST be [A-Za-z0-9-], max 3 words."), | ||
| idea: tool.schema.string().describe("Plan idea (REQUIRED, detailed)"), | ||
| description: tool.schema.string().describe("Plan SHORT description (REQUIRED, 3-5 words, NOT overlapping name)"), | ||
| steps: tool.schema.array(tool.schema.string()).describe("Implementation steps (REQUIRED, min 5, specific)"), | ||
| }, | ||
| async execute(args) { | ||
| if (!args.name) return "Error: 'name' parameter is REQUIRED." | ||
| if (!args.description) return "Error: 'description' parameter is REQUIRED." | ||
| const words = args.description.trim().split(/\s+/).filter(Boolean).length | ||
| if (words < 3 || words > 10) { | ||
| return "Error: 'description' parameter must be between 3 and 10 words." | ||
| } | ||
| if (!args.steps || args.steps.length < 5) { | ||
| return "Error: 'steps' parameter is REQUIRED and must include at least 5 steps." | ||
| } | ||
| const nameCheck = validateName(args.name) | ||
| if (!nameCheck.ok) return `Error: Invalid plan name '${args.name}': ${nameCheck.reason}.` | ||
| const path = getPlanPath(ctx.directory, args.name) | ||
| if (await Bun.file(path).exists()) return `Error: Plan '${args.name}' already exists. Use a unique name.` | ||
| await ensureDirectory(path, ctx.$) | ||
| const content = formatPlan(args.idea || "", args.name, args.description, args.steps || []) | ||
| const bytes = await Bun.write(path, content) | ||
| if (bytes === 0 || !(await Bun.file(path).exists())) { | ||
| return `Error: Failed to write plan '${args.name}' to disk. Please check permissions.` | ||
| } | ||
| const repoSpecs = await listRepoSpecs(ctx.directory) | ||
| const featurePrompt = `You MUST ask the user if they want to create a FEATURE spec for plan '${args.name}'.` | ||
| if (repoSpecs.length === 0) { | ||
| return `Plan '${args.name}' created successfully. No global specs detected. ${featurePrompt}` | ||
| } | ||
| return `Plan '${args.name}' created successfully. REQUIRED: (1) Call appendSpec for each REPO spec: ${repoSpecs.join(", ")}. (2) ${featurePrompt}` | ||
| }, | ||
| }) |
| /** | ||
| * tools/create-spec.ts | ||
| * Implements spec creation with scope validation and formatted output. | ||
| * Encourages reuse while keeping names constrained. | ||
| */ | ||
| import { tool } from "@opencode-ai/plugin" | ||
| import { ensureDirectory, formatSpec, getSpecPath, validateName } from "../utils" | ||
| import type { Ctx } from "../types" | ||
| export const createSpecTool = (ctx: Ctx) => | ||
| tool({ | ||
| description: | ||
| "Create a spec. Specs SHOULD be reusable. Name MUST be [A-Za-z0-9-], max 3 words. You SHOULD ask clarifying questions before creating a spec.", | ||
| args: { | ||
| name: tool.schema.string().describe("Spec name MUST be [A-Za-z0-9-], max 3 words."), | ||
| scope: tool.schema.enum(["repo", "feature"]).describe("Scope MUST be repo or feature."), | ||
| content: tool.schema.string().describe("Spec content (REUSABLE, markdown OK)"), | ||
| }, | ||
| async execute(args) { | ||
| if (!args.name) return "Error: 'name' parameter is REQUIRED." | ||
| if (!args.content) return "Error: 'content' parameter is REQUIRED." | ||
| if (args.scope !== "repo" && args.scope !== "feature") { | ||
| return "Error: 'scope' parameter is REQUIRED and must be 'repo' or 'feature'." | ||
| } | ||
| const nameCheck = validateName(args.name) | ||
| if (!nameCheck.ok) return `Error: Invalid spec name '${args.name}': ${nameCheck.reason}.` | ||
| const path = getSpecPath(ctx.directory, args.name) | ||
| if (await Bun.file(path).exists()) return `Error: Spec '${args.name}' already exists. Use a unique name.` | ||
| await ensureDirectory(path, ctx.$) | ||
| const content = formatSpec(args.name, args.scope, args.content) | ||
| const bytes = await Bun.write(path, content) | ||
| if (bytes === 0 || !(await Bun.file(path).exists())) { | ||
| return `Error: Failed to write spec '${args.name}' to disk. Please check permissions.` | ||
| } | ||
| return `Spec '${args.name}' created successfully.` | ||
| }, | ||
| }) |
| /** | ||
| * tools/index.ts | ||
| * Assembles tool definitions into a single registry for the plugin. | ||
| * Keeps tool wiring separate from tool implementations. | ||
| */ | ||
| import type { Ctx } from "../types" | ||
| import { appendSpecTool } from "./append-spec" | ||
| import { createPlanTool } from "./create-plan" | ||
| import { createSpecTool } from "./create-spec" | ||
| import { markPlanDoneTool } from "./mark-plan-done" | ||
| import { readPlanTool } from "./read-plan" | ||
| export const createTools = (ctx: Ctx) => ({ | ||
| createPlan: createPlanTool(ctx), | ||
| createSpec: createSpecTool(ctx), | ||
| readPlan: readPlanTool(ctx), | ||
| appendSpec: appendSpecTool(ctx), | ||
| markPlanDone: markPlanDoneTool(ctx), | ||
| }) |
| /** | ||
| * tools/mark-plan-done.ts | ||
| * Marks a plan as done by normalizing frontmatter and updating status. | ||
| * Expects completion to be verified before invoking this tool. | ||
| */ | ||
| import { tool } from "@opencode-ai/plugin" | ||
| import { getPlanPath, normalizePlanFrontmatter, validateName } from "../utils" | ||
| import type { Ctx } from "../types" | ||
| export const markPlanDoneTool = (ctx: Ctx) => | ||
| tool({ | ||
| description: "Mark plan status as done. MUST ensure the plan is fully completed before calling.", | ||
| args: { | ||
| name: tool.schema.string().describe("Target plan name (REQUIRED)"), | ||
| }, | ||
| async execute(args) { | ||
| if (!args.name) return "Error: 'name' parameter is REQUIRED." | ||
| const nameCheck = validateName(args.name) | ||
| if (!nameCheck.ok) return `Error: Invalid plan name '${args.name}': ${nameCheck.reason}.` | ||
| const planPath = getPlanPath(ctx.directory, args.name) | ||
| const planFile = Bun.file(planPath) | ||
| if (!(await planFile.exists())) return `Plan '${args.name}' not found.` | ||
| const content = await planFile.text() | ||
| const statusMatch = content.match(/^plan status:\s*(\w+)\b/m) | ||
| const status = statusMatch?.[1] ?? "active" | ||
| if (status === "done") return `Plan '${args.name}' is already done.` | ||
| const nameMatch = content.match(/^plan name:\s*(.+)$/m) | ||
| const descMatch = content.match(/^plan description:\s*(.+)$/m) | ||
| const planName = nameMatch?.[1]?.trim() || args.name | ||
| const description = descMatch?.[1]?.trim() || "" | ||
| const normalized = normalizePlanFrontmatter(content, planName, description, "done") | ||
| const bytes = await Bun.write(planPath, normalized) | ||
| if (bytes === 0) return `Error: Failed to update plan '${args.name}'.` | ||
| return `Plan '${args.name}' marked as done.` | ||
| }, | ||
| }) |
| /** | ||
| * tools/read-plan.ts | ||
| * Reads a plan and expands linked specs into the output. | ||
| * Normalizes frontmatter before parsing the spec block. | ||
| */ | ||
| import { tool } from "@opencode-ai/plugin" | ||
| import { getPlanPath, getSpecPath, normalizePlanFrontmatter, validateName } from "../utils" | ||
| import type { Ctx } from "../types" | ||
| import { findSpecBlock, parseSpecLines } from "./shared" | ||
| export const readPlanTool = (ctx: Ctx) => | ||
| tool({ | ||
| description: "Read a plan with linked spec content. MUST be read before major work.", | ||
| args: { | ||
| name: tool.schema.string().describe("Existing plan name (REQUIRED)"), | ||
| }, | ||
| async execute(args) { | ||
| if (!args.name) return "Error: 'name' parameter is REQUIRED." | ||
| const nameCheck = validateName(args.name) | ||
| if (!nameCheck.ok) return `Error: Invalid plan name '${args.name}': ${nameCheck.reason}.` | ||
| const planPath = getPlanPath(ctx.directory, args.name) | ||
| const planFile = Bun.file(planPath) | ||
| if (!(await planFile.exists())) return `Plan '${args.name}' not found.` | ||
| const content = await planFile.text() | ||
| const nameMatch = content.match(/^plan name:\s*(.+)$/m) | ||
| const descMatch = content.match(/^plan description:\s*(.+)$/m) | ||
| const statusMatch = content.match(/^plan status:\s*(\w+)\b/m) | ||
| const planName = nameMatch?.[1]?.trim() || args.name | ||
| const description = descMatch?.[1]?.trim() || "" | ||
| const status = statusMatch?.[1] ?? "active" | ||
| const normalized = normalizePlanFrontmatter(content, planName, description, status) | ||
| const block = findSpecBlock(normalized) | ||
| if (!block) return normalized | ||
| const specs = parseSpecLines(block.middle) | ||
| if (specs.length === 0) return normalized | ||
| const specChunks = await Promise.all( | ||
| specs.map(async (specName) => { | ||
| const specPath = getSpecPath(ctx.directory, specName) | ||
| const specFile = Bun.file(specPath) | ||
| if (await specFile.exists()) return `\n### Spec: ${specName}\n${await specFile.text()}\n` | ||
| return `\n### Spec: ${specName} (NOT FOUND)\n` | ||
| }), | ||
| ) | ||
| const specContent = "\n\n## Associated Specs\n" + specChunks.join("") | ||
| return normalized + specContent | ||
| }, | ||
| }) |
-102
| /** | ||
| * tools/shared.ts | ||
| * Holds spec block parsing and merge helpers shared by plan tools. | ||
| * Keeps append and read logic consistent across tool implementations. | ||
| */ | ||
| import { basename, join } from "path" | ||
| const startToken = "<!-- SPECS_START -->" | ||
| const endToken = "<!-- SPECS_END -->" | ||
| const parseSpecLines = (block: string) => | ||
| block | ||
| .split("\n") | ||
| .map((line) => line.trim()) | ||
| .filter((line) => line.startsWith("- ")) | ||
| .map((line) => line.replace(/^- /, "").trim()) | ||
| .filter(Boolean) | ||
| const findSpecBlock = (content: string) => { | ||
| const startIndex = content.indexOf(startToken) | ||
| const endIndex = content.indexOf(endToken, startIndex + startToken.length) | ||
| const hasStart = startIndex !== -1 | ||
| const hasEnd = endIndex !== -1 && endIndex > startIndex | ||
| if (!hasStart) return null | ||
| if (hasEnd) { | ||
| const before = content.slice(0, startIndex + startToken.length) | ||
| const middle = content.slice(startIndex + startToken.length, endIndex) | ||
| const after = content.slice(endIndex) | ||
| return { before, middle, after } | ||
| } | ||
| const afterStart = content.slice(startIndex + startToken.length) | ||
| const headingMatch = afterStart.match(/\n##\s/) | ||
| const headingIndex = headingMatch?.index ?? -1 | ||
| const recoverIndex = headingIndex >= 0 ? startIndex + startToken.length + headingIndex : content.length | ||
| const before = content.slice(0, startIndex + startToken.length) | ||
| const middle = content.slice(startIndex + startToken.length, recoverIndex) | ||
| const after = content.slice(recoverIndex) | ||
| return { before, middle, after } | ||
| } | ||
| const stripExtraEndTokens = (after: string) => { | ||
| if (!after.startsWith(endToken)) return after | ||
| const tail = after.slice(endToken.length) | ||
| const escaped = endToken.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") | ||
| const trimmed = tail.replace(new RegExp(`^(?:${escaped})+`), "") | ||
| return endToken + trimmed | ||
| } | ||
| const writeWithMerge = async (path: string, spec: string) => { | ||
| const attempts = 5 | ||
| for (const _ of Array.from({ length: attempts })) { | ||
| const current = await Bun.file(path).text() | ||
| const block = findSpecBlock(current) | ||
| if (!block) return { ok: false, reason: "missing spec block" } | ||
| const existing = parseSpecLines(block.middle) | ||
| if (existing.includes(spec)) return { ok: true } | ||
| const merged = [...existing, spec] | ||
| const newMiddle = merged.map((name) => `- ${name}`).join("\n") | ||
| const normalizedAfter = stripExtraEndTokens(block.after) | ||
| const tail = normalizedAfter.startsWith(endToken) ? normalizedAfter.slice(endToken.length) : normalizedAfter | ||
| const next = `${block.before}\n${newMiddle}\n${endToken}${tail}` | ||
| const bytes = await Bun.write(path, next) | ||
| if (bytes === 0) return { ok: false, reason: "write failed" } | ||
| const verify = await Bun.file(path).text() | ||
| const verifyBlock = findSpecBlock(verify) | ||
| if (!verifyBlock) return { ok: false, reason: "missing spec block" } | ||
| const verifySpecs = parseSpecLines(verifyBlock.middle) | ||
| if (verifySpecs.includes(spec)) return { ok: true } | ||
| } | ||
| return { ok: false, reason: "concurrent updates" } | ||
| } | ||
| const listRepoSpecs = async (directory: string) => { | ||
| const specsDir = join(directory, "docs/specs") | ||
| const glob = new Bun.Glob("*.md") | ||
| const files: string[] = [] | ||
| try { | ||
| for await (const file of glob.scan({ cwd: specsDir, absolute: true })) { | ||
| files.push(file) | ||
| } | ||
| } catch { | ||
| return [] | ||
| } | ||
| const repoSpecs: string[] = [] | ||
| for (const file of files) { | ||
| const text = await Bun.file(file).text() | ||
| const head = text.split("\n").slice(0, 8).join("\n") | ||
| if (!/^Scope:\s*repo\b/m.test(head)) continue | ||
| repoSpecs.push(basename(file).replace(/\.md$/, "")) | ||
| } | ||
| return repoSpecs | ||
| } | ||
| export { endToken, findSpecBlock, listRepoSpecs, parseSpecLines, startToken, writeWithMerge } |
| { | ||
| "compilerOptions": { | ||
| "target": "ESNext", | ||
| "module": "ESNext", | ||
| "moduleResolution": "bundler", | ||
| "strict": true, | ||
| "skipLibCheck": true, | ||
| "types": ["bun"], | ||
| "outDir": "./dist" | ||
| }, | ||
| "include": ["**/*.ts"], | ||
| "exclude": ["node_modules", "dist"] | ||
| } |
-11
| /** | ||
| * types.ts | ||
| * Defines shared plugin context types for tool and hook modules. | ||
| * Keeps runtime signatures consistent without using any. | ||
| */ | ||
| import type { PluginInput } from "@opencode-ai/plugin" | ||
| export type Ctx = { | ||
| directory: string | ||
| $: PluginInput["$"] | ||
| } |
-98
| /** | ||
| * utils.ts | ||
| * Provides path safety, directory creation, and file formatting helpers for plans and specs. | ||
| * Centralizes plan frontmatter normalization to keep files consistent. | ||
| */ | ||
| import type { PluginInput } from "@opencode-ai/plugin" | ||
| import { resolve, join, normalize } from "path" | ||
| type BunShell = PluginInput["$"] | ||
| const namePattern = /^[A-Za-z0-9-]+$/ | ||
| export const validateName = (name: string) => { | ||
| if (!name || typeof name !== "string") return { ok: false, reason: "name is required" } | ||
| if (!namePattern.test(name)) return { ok: false, reason: "use only letters, numbers, and hyphens" } | ||
| const parts = name.split("-").filter(Boolean) | ||
| if (parts.length === 0) return { ok: false, reason: "name cannot be empty" } | ||
| if (parts.length > 3) return { ok: false, reason: "use max 3 hyphen-separated words" } | ||
| return { ok: true } | ||
| } | ||
| export const sanitizeFilename = (name: string) => { | ||
| if (!name || typeof name !== "string") return "untitled" | ||
| const sanitized = name | ||
| .replace(/[\0-\x1f\x7f]/g, "") | ||
| .replace(/[\\/:\*\?"<>\|]/g, "_") | ||
| .trim() | ||
| return sanitized.length === 0 ? "untitled" : sanitized | ||
| } | ||
| export const getSecurePath = (baseDir: string, name: string) => { | ||
| const sanitized = sanitizeFilename(name) | ||
| const fullBase = resolve(baseDir) | ||
| const target = resolve(join(fullBase, `${sanitized}.md`)) | ||
| if (!target.startsWith(fullBase)) { | ||
| throw new Error(`Security violation: Path ${target} is outside of ${fullBase}`) | ||
| } | ||
| return target | ||
| } | ||
| export const getPlanPath = (directory: string, name: string) => getSecurePath(join(directory, "docs/plans"), name) | ||
| export const getSpecPath = (directory: string, name: string) => getSecurePath(join(directory, "docs/specs"), name) | ||
| export const listPlans = async (directory: string) => { | ||
| const plansDir = join(directory, "docs/plans") | ||
| const glob = new Bun.Glob("*.md") | ||
| const files: string[] = [] | ||
| try { | ||
| for await (const file of glob.scan({ cwd: plansDir, absolute: true })) { | ||
| files.push(file) | ||
| } | ||
| } catch { | ||
| return [] | ||
| } | ||
| return files | ||
| } | ||
| export async function ensureDirectory(path: string, $: BunShell) { | ||
| const dir = normalize(join(path, "..")) | ||
| await $`mkdir -p ${dir}` | ||
| } | ||
| export const formatPlan = (idea: string, name: string, description: string, implementation: string[]) => { | ||
| const implementationSection = | ||
| implementation.length > 0 ? `\n## Implementation\n${implementation.map((item) => `- ${item}`).join("\n")}\n` : "" | ||
| return ` | ||
| --- | ||
| plan name: ${name} | ||
| plan description: ${description} | ||
| plan status: active | ||
| --- | ||
| ## Idea | ||
| ${idea} | ||
| ${implementationSection} | ||
| ## Required Specs | ||
| <!-- SPECS_START --> | ||
| <!-- SPECS_END --> | ||
| `.trim() | ||
| } | ||
| export const normalizePlanFrontmatter = (content: string, name: string, description: string, status: string) => { | ||
| const header = `---\nplan name: ${name}\nplan description: ${description}\nplan status: ${status}\n---\n\n` | ||
| const rest = content.replace(/^---[\s\S]*?---\n\n?/, "") | ||
| return header + rest | ||
| } | ||
| export const formatSpec = (name: string, scope: string, content: string) => | ||
| ` | ||
| # Spec: ${name} | ||
| Scope: ${scope} | ||
| ${content} | ||
| `.trim() |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
34449
20.14%22
29.41%625
31.58%166
15.28%3
Infinity%1
Infinity%