src / policy / planDocument.ts
src / policy / planDocument.ts
import { basename } from "node:path";
import { AgenticError } from "../core/errors";
export type PlanStepKind = "edit" | "command" | "vcs" | "research" | "other";
export type PlanChange = "create" | "modify" | "delete" | "move";
export interface PlanStep {
n: number;
text: string;
kind?: PlanStepKind;
}
export interface PlanFileChange {
path: string;
change: PlanChange;
}
export interface PlanCommand {
executable: string;
args: string[];
}
export interface PlanDocument {
title: string;
objective: string;
steps: PlanStep[];
files: PlanFileChange[];
commands: PlanCommand[];
risks: string[];
questions: string[];
revision: number;
supersedes?: string;
}
export interface PlanInput {
title: string;
objective: string;
steps: Array<string | { text: string; kind?: PlanStepKind }>;
files?: Array<{ path: string; change: PlanChange }>;
commands?: Array<{ executable: string; args?: string[] }>;
risks?: string[];
questions?: string[];
}
const MAX_STEPS = 40;
const MAX_FILES = 100;
const MAX_COMMANDS = 40;
const MAX_TEXT = 2000;
function text(value: unknown, field: string, required = true): string {
const normalized = typeof value === "string" ? value.trim() : "";
if (required && !normalized) {
throw new AgenticError("INVALID_INPUT", `Plan ${field} may not be empty.`);
}
if (normalized.length > MAX_TEXT) {
throw new AgenticError("INVALID_INPUT", `Plan ${field} is limited to ${MAX_TEXT} characters.`);
}
return normalized;
}
function textList(values: string[] | undefined, field: string, max: number): string[] {
const list = (values ?? []).map((value, index) => text(value, `${field}[${index}]`));
if (list.length > max) {
throw new AgenticError("INVALID_INPUT", `Plan ${field} is limited to ${max} entries.`);
}
return list;
}
export function validatePlan(
input: PlanInput,
options: { revision?: number; supersedes?: string } = {},
): PlanDocument {
const steps = (input.steps ?? []).map((step, index) => {
const raw = typeof step === "string" ? { text: step } : step;
return {
n: index + 1,
text: text(raw.text, `steps[${index}]`),
...(raw.kind ? { kind: raw.kind } : {}),
};
});
if (steps.length === 0) {
throw new AgenticError("INVALID_INPUT", "A plan needs at least one step.");
}
if (steps.length > MAX_STEPS) {
throw new AgenticError("INVALID_INPUT", `A plan is limited to ${MAX_STEPS} steps.`);
}
const files = (input.files ?? []).map((file, index) => ({
path: text(file.path, `files[${index}].path`),
change: file.change,
}));
if (files.length > MAX_FILES) {
throw new AgenticError("INVALID_INPUT", `A plan is limited to ${MAX_FILES} files.`);
}
const commands = (input.commands ?? []).map((command, index) => {
const executable = text(command.executable, `commands[${index}].executable`);
if (executable !== basename(executable) || /[\\/]/.test(executable)) {
throw new AgenticError(
"INVALID_INPUT",
`Plan command ${index + 1} must name a bare allowlisted executable.`,
);
}
return { executable, args: (command.args ?? []).map((arg) => String(arg)) };
});
if (commands.length > MAX_COMMANDS) {
throw new AgenticError("INVALID_INPUT", `A plan is limited to ${MAX_COMMANDS} commands.`);
}
return {
title: text(input.title, "title"),
objective: text(input.objective, "objective"),
steps,
files,
commands,
risks: textList(input.risks, "risks", MAX_STEPS),
questions: textList(input.questions, "questions", MAX_STEPS),
revision: options.revision ?? 1,
...(options.supersedes ? { supersedes: options.supersedes } : {}),
};
}
export function renderPlanMarkdown(plan: PlanDocument, id: string): string {
const heading = plan.supersedes
? `# Plan ${id}: ${plan.title} (revision ${plan.revision}, supersedes ${plan.supersedes})`
: `# Plan ${id}: ${plan.title} (revision ${plan.revision})`;
const sections: string[] = [heading, "", "## Objective", plan.objective, ""];
sections.push("## Steps");
for (const step of plan.steps) {
sections.push(`${step.n}. ${step.text}${step.kind ? ` _(${step.kind})_` : ""}`);
}
sections.push("");
if (plan.files.length > 0) {
sections.push("## Files");
for (const file of plan.files) sections.push(`- ${file.change}: ${file.path}`);
sections.push("");
}
if (plan.commands.length > 0) {
sections.push("## Commands");
for (const command of plan.commands) {
sections.push(`- ${[command.executable, ...command.args].join(" ")}`);
}
sections.push("");
}
if (plan.risks.length > 0) {
sections.push("## Risks");
for (const risk of plan.risks) sections.push(`- ${risk}`);
sections.push("");
}
if (plan.questions.length > 0) {
sections.push("## Open questions");
for (const question of plan.questions) sections.push(`- ${question}`);
sections.push("");
}
return `${sections.join("\n").replace(/\n+$/, "")}\n`;
}
import { basename } from "node:path";
import { AgenticError } from "../core/errors";
export type PlanStepKind = "edit" | "command" | "vcs" | "research" | "other";
export type PlanChange = "create" | "modify" | "delete" | "move";
export interface PlanStep {
n: number;
text: string;
kind?: PlanStepKind;
}
export interface PlanFileChange {
path: string;
change: PlanChange;
}
export interface PlanCommand {
executable: string;
args: string[];
}
export interface PlanDocument {
title: string;
objective: string;
steps: PlanStep[];
files: PlanFileChange[];
commands: PlanCommand[];
risks: string[];
questions: string[];
revision: number;
supersedes?: string;
}
export interface PlanInput {
title: string;
objective: string;
steps: Array<string | { text: string; kind?: PlanStepKind }>;
files?: Array<{ path: string; change: PlanChange }>;
commands?: Array<{ executable: string; args?: string[] }>;
risks?: string[];
questions?: string[];
}
const MAX_STEPS = 40;
const MAX_FILES = 100;
const MAX_COMMANDS = 40;
const MAX_TEXT = 2000;
function text(value: unknown, field: string, required = true): string {
const normalized = typeof value === "string" ? value.trim() : "";
if (required && !normalized) {
throw new AgenticError("INVALID_INPUT", `Plan ${field} may not be empty.`);
}
if (normalized.length > MAX_TEXT) {
throw new AgenticError("INVALID_INPUT", `Plan ${field} is limited to ${MAX_TEXT} characters.`);
}
return normalized;
}
function textList(values: string[] | undefined, field: string, max: number): string[] {
const list = (values ?? []).map((value, index) => text(value, `${field}[${index}]`));
if (list.length > max) {
throw new AgenticError("INVALID_INPUT", `Plan ${field} is limited to ${max} entries.`);
}
return list;
}
export function validatePlan(
input: PlanInput,
options: { revision?: number; supersedes?: string } = {},
): PlanDocument {
const steps = (input.steps ?? []).map((step, index) => {
const raw = typeof step === "string" ? { text: step } : step;
return {
n: index + 1,
text: text(raw.text, `steps[${index}]`),
...(raw.kind ? { kind: raw.kind } : {}),
};
});
if (steps.length === 0) {
throw new AgenticError("INVALID_INPUT", "A plan needs at least one step.");
}
if (steps.length > MAX_STEPS) {
throw new AgenticError("INVALID_INPUT", `A plan is limited to ${MAX_STEPS} steps.`);
}
const files = (input.files ?? []).map((file, index) => ({
path: text(file.path, `files[${index}].path`),
change: file.change,
}));
if (files.length > MAX_FILES) {
throw new AgenticError("INVALID_INPUT", `A plan is limited to ${MAX_FILES} files.`);
}
const commands = (input.commands ?? []).map((command, index) => {
const executable = text(command.executable, `commands[${index}].executable`);
if (executable !== basename(executable) || /[\\/]/.test(executable)) {
throw new AgenticError(
"INVALID_INPUT",
`Plan command ${index + 1} must name a bare allowlisted executable.`,
);
}
return { executable, args: (command.args ?? []).map((arg) => String(arg)) };
});
if (commands.length > MAX_COMMANDS) {
throw new AgenticError("INVALID_INPUT", `A plan is limited to ${MAX_COMMANDS} commands.`);
}
return {
title: text(input.title, "title"),
objective: text(input.objective, "objective"),
steps,
files,
commands,
risks: textList(input.risks, "risks", MAX_STEPS),
questions: textList(input.questions, "questions", MAX_STEPS),
revision: options.revision ?? 1,
...(options.supersedes ? { supersedes: options.supersedes } : {}),
};
}
export function renderPlanMarkdown(plan: PlanDocument, id: string): string {
const heading = plan.supersedes
? `# Plan ${id}: ${plan.title} (revision ${plan.revision}, supersedes ${plan.supersedes})`
: `# Plan ${id}: ${plan.title} (revision ${plan.revision})`;
const sections: string[] = [heading, "", "## Objective", plan.objective, ""];
sections.push("## Steps");
for (const step of plan.steps) {
sections.push(`${step.n}. ${step.text}${step.kind ? ` _(${step.kind})_` : ""}`);
}
sections.push("");
if (plan.files.length > 0) {
sections.push("## Files");
for (const file of plan.files) sections.push(`- ${file.change}: ${file.path}`);
sections.push("");
}
if (plan.commands.length > 0) {
sections.push("## Commands");
for (const command of plan.commands) {
sections.push(`- ${[command.executable, ...command.args].join(" ")}`);
}
sections.push("");
}
if (plan.risks.length > 0) {
sections.push("## Risks");
for (const risk of plan.risks) sections.push(`- ${risk}`);
sections.push("");
}
if (plan.questions.length > 0) {
sections.push("## Open questions");
for (const question of plan.questions) sections.push(`- ${question}`);
sections.push("");
}
return `${sections.join("\n").replace(/\n+$/, "")}\n`;
}