/**
* Plan-document structure (pure).
*
* This module owns the shape of the markdown plan: the outline the model is
* guided to write, the top-level steps that seed `todo` and drive the review
* gauge, the heading sections the review screen navigates, and the in-place
* checkbox edit that keeps the file current after approval. All of it is pure so
* the tools, the store, and the renderer agree on one parser.
*
* Only top-level list items become steps: nested sub-bullets are notes, and the
* shallowest list level is treated as the plan. When the plan has a `Steps`
* section (the canonical outline's heading), only that section's list items are
* steps, so prose bullets elsewhere never seed the todo list or consume the
* step cap. List items inside fenced code blocks are ignored. A `- [ ]` / `- [x]`
* checkbox or a `[DONE:n]` marker sets the status.
*/
/** Most steps seeded from one plan. */
export const MAX_STEPS = 50;
/** Longest seeded step text; longer lines are elided. */
export const MAX_STEP_LENGTH = 200;
/**
* The outline a plan is guided to follow. Guidance only: `write_plan` never
* rejects or rewrites a plan that omits a section.
*/
export const PLAN_OUTLINE = [
"#
",
"",
"## Goal",
"What this change achieves, in one or two sentences.",
"",
"## Context",
"The relevant code, constraints, and prior art.",
"",
"## Approach",
"The chosen approach and why; alternatives considered.",
"",
"## Steps",
"- [ ] First concrete step",
"- [ ] Next step",
"",
"## Risks",
"What could go wrong and how it is mitigated.",
"",
"## Verification",
"How to prove it works: commands and checks.",
].join("\n");
/** The outline's section titles, in order, for documentation and tests. */
export const PLAN_SECTIONS = ["Goal", "Context", "Approach", "Steps", "Risks", "Verification"] as const;
export interface PlanStep {
content: string;
status: "pending" | "completed";
}
/** A heading-delimited chunk of the plan, for the review screen's navigation. */
export interface PlanSection {
/** Plain heading text, or `""` for the content before the first heading. */
title: string;
/** The section's markdown, including its heading line. */
text: string;
}
/** A top-level list item located in the source. */
interface PlanListItem {
/** Zero-based source line index. */
line: number;
indent: number;
/** The list marker with its trailing whitespace, e.g. `1. ` or `- `. */
lead: string;
/** Item text after the marker, right-trimmed (an optional checkbox included). */
raw: string;
step: PlanStep;
}
/** A top-level numbered or bulleted list item, capturing its parts. */
const LIST_ITEM = /^([ \t]*)((?:\d+[.)]|[-*+])\s+)(.*\S)\s*$/;
const CHECKBOX = /^\[([ xX])\]\s*(.*)$/;
/** A markdown heading outside a fence, capturing its level and text. */
const HEADING = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;
/** A markdown code-fence opener or closer (backticks or tildes). */
const FENCE = /^\s{0,3}(`{3,}|~{3,})/;
const DONE_MARKER = /\[DONE:\d+\]/gi;
/**
* A heading or step label as the review surfaces compare it: inline emphasis and
* code removed, whitespace collapsed. Exported so `blocks.ts` labels an outline
* entry the same way `extractPlanSections` labels its section, which is what
* lets the browser mark a changed section.
*/
export function normalizeHeadingText(text: string): string {
return text
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\*([^*]+)\*/g, "$1")
.replace(/(? {
const fenceMatch = line.match(FENCE);
if (fenceMatch) {
if (fence === undefined) fence = fenceMatch[1][0];
else if (fence === fenceMatch[1][0]) fence = undefined;
return;
}
if (fence !== undefined) return;
const match = line.match(LIST_ITEM);
if (!match) return;
const step = parseStep(match[3]);
if (!step.content) return;
items.push({ line: index, indent: match[1].length, lead: match[2], raw: match[3], step });
});
return items;
}
/**
* Source range of the plan's `Steps` section: the first heading named `Steps`
* (any level, outside a fence) through the next heading of the same or a higher
* level. Undefined when the plan has no such heading, which keeps the
* whole-document fallback for plans written before the outline.
*/
function stepsSection(plan: string): { start: number; end: number } | undefined {
const lines = plan.split("\n");
let fence: string | undefined;
let start = -1;
let level = 0;
for (let index = 0; index < lines.length; index++) {
const line = lines[index];
const fenceMatch = line.match(FENCE);
if (fenceMatch) {
if (fence === undefined) fence = fenceMatch[1][0];
else if (fence === fenceMatch[1][0]) fence = undefined;
continue;
}
if (fence !== undefined) continue;
const heading = line.match(HEADING);
if (!heading) continue;
const depth = heading[1].length;
if (start < 0) {
if (plainText(heading[2]).toLowerCase() !== "steps") continue;
start = index;
level = depth;
continue;
}
// A deeper heading is a subsection of Steps; same or higher ends it.
if (depth <= level) return { start, end: index };
}
return start < 0 ? undefined : { start, end: lines.length };
}
/**
* The plan's top-level steps in order: the shallowest list level of the `Steps`
* section (or of the whole plan when there is none), de-duplicated, capped.
* Extraction, the review gauge, and checkbox edits all index into this, so they
* agree on what counts as a step.
*/
function topLevelItems(plan: string, cap: number = MAX_STEPS): PlanListItem[] {
const section = stepsSection(plan);
const items = scanListItems(plan).filter(
(item) => section === undefined || (item.line > section.start && item.line < section.end),
);
// The shallowest list is the plan; deeper items are nested notes.
let baseIndent = Number.POSITIVE_INFINITY;
for (const item of items) baseIndent = Math.min(baseIndent, item.indent);
const seen = new Set();
const top: PlanListItem[] = [];
for (const item of items) {
if (item.indent !== baseIndent) continue;
const key = item.step.content.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
top.push(item);
if (top.length >= cap) break;
}
return top;
}
function elide(content: string): string {
return content.length > MAX_STEP_LENGTH ? `${content.slice(0, MAX_STEP_LENGTH - 1).trimEnd()}…` : content;
}
/** Top-level steps from a plan, in order. */
export function extractPlanSteps(plan: string): PlanStep[] {
return topLevelItems(plan).map((item) => ({ content: elide(item.step.content), status: item.step.status }));
}
/** The full cost of a plan's step list, for the warning `write_plan` reports. */
export interface PlanStepSummary {
/** Top-level items in the step list, before the {@link MAX_STEPS} cap. */
total: number;
/** Items longer than {@link MAX_STEP_LENGTH}, whose text is elided in the todo list. */
elided: number;
}
/** Uncapped step count and elision count; `extractPlanSteps` is the capped view. */
export function planStepSummary(plan: string): PlanStepSummary {
const items = topLevelItems(plan, Number.POSITIVE_INFINITY);
return {
total: items.length,
elided: items.filter((item) => item.step.content.length > MAX_STEP_LENGTH).length,
};
}
/** One checkbox change: a 1-based step number and the state to set. */
export interface StepUpdate {
step: number;
status: PlanStep["status"];
/**
* The step's text when the plan was approved. When given, the update is
* refused if the step at that number no longer carries this text, so a
* reordered or rewritten plan is caught instead of ticking the wrong step.
*/
expected?: string;
}
/** Whether the step text now at a number still matches what the caller expected. */
export function matchesStepText(actual: string, expected: string): boolean {
const normalize = (text: string): string => text.trim().replace(/\s+/g, " ").toLowerCase();
return normalize(actual) === normalize(expected);
}
/**
* Apply a batch of checkbox changes to the given top-level steps (1-based,
* matching {@link extractPlanSteps}) and return the rewritten plan, or
* undefined when any step number is out of range. All-or-nothing: every step is
* validated before any edit, so one bad entry changes nothing. Only the marker
* changes: a plain `1.`/`-` item gains a checkbox, an existing one is set, a
* `[DONE:n]` marker is dropped when the step is reopened, and the step text is
* otherwise left exactly as written. When a step appears twice, the last entry
* wins.
*/
export function setStepStatuses(plan: string, updates: StepUpdate[]): string | undefined {
const items = topLevelItems(plan);
for (const update of updates) {
if (!Number.isInteger(update.step) || update.step < 1 || !items[update.step - 1]) return undefined;
}
const lines = plan.split("\n");
for (const update of updates) {
const target = items[update.step - 1];
const existing = target.raw.match(CHECKBOX);
let text = existing ? existing[2] : target.raw;
// A reopened step must lose its marker too: the marker alone reads as
// completed, so keeping it would contradict the checkbox.
if (update.status === "pending") text = text.replace(DONE_MARKER, "").trimEnd();
const checkbox = update.status === "completed" ? "[x]" : "[ ]";
lines[target.line] = `${" ".repeat(target.indent)}${target.lead}${checkbox} ${text}`;
}
return lines.join("\n");
}
/**
* Titles (normalized) whose section text differs between two plan revisions, or
* that are new in `current`. Revision snapshots are the caller's to provide;
* strip stored feedback first so a review comment does not look like an edit.
* Duplicate headings are compared by occurrence, and a changed title marks every
* section with that title — plans following the canonical outline have none.
*/
export function changedSections(previous: string, current: string): Set {
const before = new Map();
for (const section of extractPlanSections(previous)) {
const texts = before.get(section.title);
if (texts) texts.push(section.text.trim());
else before.set(section.title, [section.text.trim()]);
}
const seen = new Map();
const changed = new Set();
for (const section of extractPlanSections(current)) {
const index = seen.get(section.title) ?? 0;
seen.set(section.title, index + 1);
const prior = before.get(section.title)?.[index];
if (prior === undefined || prior !== section.text.trim()) changed.add(section.title);
}
return changed;
}
/**
* Sections of a plan, split at heading lines outside code fences. The content
* before the first heading is a section titled `""`. Pi's `Markdown` renderer
* exposes no source-line map, so the review screen renders these chunks
* separately to know where each section begins.
*/
export function extractPlanSections(plan: string): PlanSection[] {
const sections: PlanSection[] = [];
let current: PlanSection | undefined;
let fence: string | undefined;
const flush = (): void => {
if (current && (current.title !== "" || current.text.trim() !== "")) sections.push(current);
current = undefined;
};
for (const line of plan.split("\n")) {
const fenceMatch = line.match(FENCE);
if (fenceMatch) {
if (fence === undefined) fence = fenceMatch[1][0];
else if (fence === fenceMatch[1][0]) fence = undefined;
} else if (fence === undefined) {
const heading = line.match(HEADING);
if (heading) {
flush();
current = { title: plainText(heading[2]), text: line };
continue;
}
}
if (current === undefined) current = { title: "", text: line };
else current.text += `\n${line}`;
}
flush();
return sections;
}