Chapter 6

Build a Plan You Can Reject Before the Patch

A plan is useful if it names files, symbols, risks, commands, and expected evidence. A list of "write code, add tests" doesn't help check the scope and doesn't surface hidden actions. In the companion project a plan step is a typed structure, not free text.

TypeScript
export interface PlanStep {
  readonly id: string;
  readonly title: string;
  readonly reads: readonly string[];
  readonly writes: readonly string[];
  readonly commands: readonly CommandSpec[];
  readonly evidence: string;
}

export interface ChangePlan {
  readonly contract: TaskContract;
  readonly steps: readonly PlanStep[];
}

And because the plan is data, it can be checked by code before execution: that every write falls inside the allowed scope and every command is in the allowlist.

TypeScript
function validateStep(root: string, contract: TaskContract, step: PlanStep, policy: CommandPolicy): void {
  if (step.id.trim() === "" || step.title.trim() === "" || step.evidence.trim() === "") {
    throw new Error("Every plan step needs an id, title, and evidence target");
  }
  for (const path of [...step.reads, ...step.writes]) {
    resolveContained(root, path);
  }
  for (const path of step.writes) {
    const normalized = relative(root, resolveContained(root, path)).split("\\").join("/");
    if (!pathAllowed(normalized, contract.allowedWritePaths)) {
      throw new Error(`Plan writes outside allowed scope: ${path}`);
    }
  }
  for (const command of step.commands) {
    if (!policy.allowedCommandKeys.has(commandKey(command))) {
      throw new Error(`Plan contains a command outside the allowlist: ${command.label}`);
    }
  }
}

export function validatePlan(root: string, plan: ChangePlan, policy: CommandPolicy): ChangePlan {
  if (plan.steps.length === 0) {
    throw new Error("Plan must have at least one step");
  }
  const ids = new Set<string>();
  for (const step of plan.steps) {
    if (ids.has(step.id)) {
      throw new Error(`Duplicate plan step id: ${step.id}`);
    }
    ids.add(step.id);
    validateStep(root, plan.contract, step, policy);
  }
  return plan;

The validator's meaning shows through the review questions to each step field.

Step field · Review question

  • reads - Are there enough facts to change this layer?
  • writes - Do all paths fall inside the allowed scope?
  • commands - Is the command exact, safe, and available in the environment?
  • evidence - What observable result will prove the step?
  • risk - What could break beyond the happy path?
  • stop - What discovery requires a new owner decision?

To run a plan through a completeness check and see where it's silent about risks or evidence, use the lab.

Interactive lab 2

Check the plan's completeness

0 of 5 filled. Don't allow edits while open items change the scope or the check.

The main thing is to forbid silent expansion. If during implementation it turns out you need to change a schema, a dependency, or the public API, the old plan no longer holds.

A plan is a rejection point, not a formality. The agent should come back with a new fact and a proposal, not silently append a patch beyond what was agreed. Rejecting a plan is cheap; rolling back an unplanned change in production is expensive.

The plan is agreed. But before writing code you must decide where the rules live - and here it's important not to mix shared instructions with product-specific ones.

Links