You're Invited:Meet the Socket Team at RSAC and BSidesSF 2026, March 23–26.RSVP
Socket
Book a DemoSign in
Socket

@howaboua/opencode-planning-toolkit

Package Overview
Dependencies
Maintainers
1
Versions
6
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@howaboua/opencode-planning-toolkit - npm Package Compare versions

Comparing version
0.0.4
to
0.0.5
+6
dist/hooks/index.js
/**
* 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"

@@ -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
}
}
## 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))
},
})
/**
* 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
},
})
/**
* 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"]
}
/**
* 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["$"]
}
/**
* 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()