import { AlwaysOnError } from "../protocol/errors.js";
* PlanContract enforces the markdown shape described in
* `docs/always-on/02-pilotdeck-always-on-rewrite-plan.md` §7.
*
* Required sections, in order:
* 1. ## Summary single short paragraph (≤ 200 chars)
* 2. ## Rationale non-empty
* 3. ## Context Signals ≥ 1 unordered list item
* 4. ## Proposed Change non-empty, no fuzzy "TODO" wording
* 5. ## Execution Steps ≥ 1 ordered list item; ordered-only
* 6. ## Verification ≥ 1 unordered list item
*/
export type PlanMetadata = {
id: string;
sourceRunId: string;
createdAt: string;
projectRoot: string;
dedupeKey: string;
};
export type PlanParseResult = {
title: string;
metadata: PlanMetadata;
sections: Record<string, string[]>;
rawContent: string;
};
export const PLAN_REQUIRED_SECTIONS: ReadonlyArray<string> = [
"Summary",
"Rationale",
"Context Signals",
"Proposed Change",
"Execution Steps",
"Verification",
];
export const PLAN_METADATA_FIRST_LINE = "Always-On Discovery Plan";
export const PLAN_METADATA_KEYS: ReadonlyArray<keyof PlanMetadata> = [
"id",
"sourceRunId",
"createdAt",
"projectRoot",
"dedupeKey",
];
export type PlanContractOptions = {
maxResultSizeChars?: number;
fuzzyTodoPatterns?: RegExp[];
summaryMaxChars?: number;
};
const DEFAULT_MAX_RESULT_SIZE_CHARS = 100_000;
const DEFAULT_SUMMARY_MAX = 200;
const DEFAULT_FUZZY_TODOS: RegExp[] = [/^\s*TODO\b/i, /^\s*待补充\b/];
export function parsePlanMarkdown(
content: string,
options: PlanContractOptions = {},
): PlanParseResult {
const max = options.maxResultSizeChars ?? DEFAULT_MAX_RESULT_SIZE_CHARS;
const summaryMax = options.summaryMaxChars ?? DEFAULT_SUMMARY_MAX;
const fuzzyPatterns = options.fuzzyTodoPatterns ?? DEFAULT_FUZZY_TODOS;
if (typeof content !== "string") {
throw new AlwaysOnError("plan_invalid", "plan content must be a string.");
}
const normalized = content.replace(/\r\n/g, "\n").replace(/[\u00a0]/g, " ");
if (normalized.length === 0) {
throw new AlwaysOnError("plan_invalid", "plan content is empty.");
}
if (normalized.length > max) {
throw new AlwaysOnError(
"plan_invalid",
`plan content exceeds maxResultSizeChars (${max}).`,
);
}
const lines = normalized.split("\n");
let cursor = 0;
while (cursor < lines.length && lines[cursor].trim().length === 0) {
cursor += 1;
}
const titleLine = lines[cursor] ?? "";
if (!titleLine.startsWith("# ")) {
throw new AlwaysOnError("plan_invalid", "plan must start with a level-1 markdown heading.");
}
const title = titleLine.slice(2).trim();
if (title.length === 0) {
throw new AlwaysOnError("plan_invalid", "plan title must not be empty.");
}
cursor += 1;
while (cursor < lines.length && lines[cursor].trim().length === 0) {
cursor += 1;
}
const metadataLines: string[] = [];
while (cursor < lines.length && lines[cursor].startsWith(">")) {
metadataLines.push(lines[cursor].replace(/^>\s?/, ""));
cursor += 1;
}
if (metadataLines.length === 0) {
throw new AlwaysOnError(
"plan_invalid",
"plan must include a metadata blockquote immediately after the title.",
);
}
if (metadataLines[0].trim() !== PLAN_METADATA_FIRST_LINE) {
throw new AlwaysOnError(
"plan_invalid",
`plan metadata blockquote first line must be "${PLAN_METADATA_FIRST_LINE}".`,
);
}
const metadata = parseMetadataLines(metadataLines.slice(1));
for (const key of PLAN_METADATA_KEYS) {
if (metadata[key].length === 0) {
throw new AlwaysOnError(
"plan_invalid",
`plan metadata is missing required key "${key}".`,
);
}
}
while (cursor < lines.length && lines[cursor].trim().length === 0) {
cursor += 1;
}
const sections: Record<string, string[]> = {};
const seenSections = new Set<string>();
let currentSection: string | null = null;
let currentLines: string[] = [];
const flush = (): void => {
if (currentSection !== null) {
sections[currentSection] = currentLines;
}
currentSection = null;
currentLines = [];
};
for (; cursor < lines.length; cursor += 1) {
const line = lines[cursor];
if (line.startsWith("## ")) {
flush();
const sectionName = line.slice(3).trim();
if (sectionName.length === 0) {
throw new AlwaysOnError("plan_invalid", "plan section heading must not be empty.");
}
if (seenSections.has(sectionName)) {
throw new AlwaysOnError(
"plan_invalid",
`plan contains duplicate section "${sectionName}".`,
);
}
seenSections.add(sectionName);
currentSection = sectionName;
currentLines = [];
continue;
}
if (line.startsWith("# ")) {
throw new AlwaysOnError(
"plan_invalid",
"plan must contain exactly one level-1 heading at the top.",
);
}
if (currentSection !== null) {
currentLines.push(line);
}
}
flush();
const orderedSections = Object.keys(sections);
if (orderedSections.length !== PLAN_REQUIRED_SECTIONS.length) {
throw new AlwaysOnError(
"plan_invalid",
`plan must contain exactly the required sections: ${PLAN_REQUIRED_SECTIONS.join(", ")}.`,
);
}
for (let index = 0; index < PLAN_REQUIRED_SECTIONS.length; index += 1) {
if (orderedSections[index] !== PLAN_REQUIRED_SECTIONS[index]) {
throw new AlwaysOnError(
"plan_invalid",
`plan section #${index + 1} must be "${PLAN_REQUIRED_SECTIONS[index]}", got "${orderedSections[index]}".`,
);
}
}
validateSummary(sections.Summary, summaryMax);
validateRationale(sections.Rationale);
validateContextSignals(sections["Context Signals"]);
validateProposedChange(sections["Proposed Change"], fuzzyPatterns);
validateExecutionSteps(sections["Execution Steps"]);
validateVerification(sections.Verification);
return {
title,
metadata: metadata as PlanMetadata,
sections,
rawContent: normalized.replace(/\s+$/, "") + "\n",
};
}
function parseMetadataLines(lines: string[]): Record<keyof PlanMetadata, string> {
const result: Record<string, string> = {
id: "",
sourceRunId: "",
createdAt: "",
projectRoot: "",
dedupeKey: "",
};
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.length === 0) continue;
const colonIndex = trimmed.indexOf(":");
if (colonIndex === -1) {
throw new AlwaysOnError(
"plan_invalid",
`plan metadata line "${line}" is not in "key: value" form.`,
);
}
const key = trimmed.slice(0, colonIndex).trim();
const value = trimmed.slice(colonIndex + 1).trim();
if (!(key in result)) {
throw new AlwaysOnError(
"plan_invalid",
`plan metadata contains unknown key "${key}".`,
);
}
result[key] = value;
}
return result as Record<keyof PlanMetadata, string>;
}
function nonEmptyTextOrThrow(lines: string[], section: string): string {
const text = lines.join("\n").trim();
if (text.length === 0) {
throw new AlwaysOnError("plan_invalid", `plan section "${section}" must not be empty.`);
}
return text;
}
function validateSummary(lines: string[], summaryMax: number): void {
const text = nonEmptyTextOrThrow(lines, "Summary");
if (text.length > summaryMax) {
throw new AlwaysOnError(
"plan_invalid",
`plan Summary exceeds ${summaryMax} characters.`,
);
}
if (text.split(/\n\s*\n/).length > 1) {
throw new AlwaysOnError("plan_invalid", "plan Summary must be a single paragraph.");
}
}
function validateRationale(lines: string[]): void {
nonEmptyTextOrThrow(lines, "Rationale");
}
function validateContextSignals(lines: string[]): void {
const items = lines.filter((line) => /^\s*-\s+/.test(line));
if (items.length === 0) {
throw new AlwaysOnError(
"plan_invalid",
"plan Context Signals must contain at least one unordered list item.",
);
}
}
function validateProposedChange(lines: string[], fuzzyPatterns: RegExp[]): void {
const text = nonEmptyTextOrThrow(lines, "Proposed Change");
for (const pattern of fuzzyPatterns) {
if (pattern.test(text)) {
throw new AlwaysOnError(
"plan_invalid",
`plan Proposed Change must not be fuzzy ("${pattern.source}").`,
);
}
}
}
function validateExecutionSteps(lines: string[]): void {
const stripped = lines.map((line) => line.trim()).filter((line) => line.length > 0);
if (stripped.length === 0) {
throw new AlwaysOnError(
"plan_invalid",
"plan Execution Steps must contain at least one ordered list item.",
);
}
let ordered = 0;
for (const line of stripped) {
if (/^\d+\.\s+/.test(line)) {
ordered += 1;
continue;
}
if (/^[-*+]\s+/.test(line)) {
throw new AlwaysOnError(
"plan_invalid",
"plan Execution Steps must use an ordered list (no unordered items).",
);
}
}
if (ordered === 0) {
throw new AlwaysOnError(
"plan_invalid",
"plan Execution Steps must contain at least one ordered list item.",
);
}
}
function validateVerification(lines: string[]): void {
const items = lines.filter((line) => /^\s*-\s+/.test(line));
if (items.length === 0) {
throw new AlwaysOnError(
"plan_invalid",
"plan Verification must contain at least one unordered list item.",
);
}
}