tools/delivery

planning.ts

tools/delivery/planning.ts
import { existsSync, readFileSync } from 'node:fs';
import { readFile, readdir } from 'node:fs/promises';
import { basename, dirname, join, resolve } from 'node:path';

import { parseRedPolicy, type RedPolicy } from './ticket-metadata';
import type { OrchestratorOptions, TicketDefinition } from './types';
import { DEFAULT_REVIEW_POLLING_PROFILE } from './review-polling-profile';

export function parsePlan(
  markdown: string,
  planPath: string,
  cwd?: string,
): TicketDefinition[] {
  const ticketOrderSection = markdown.match(
    /## Ticket Order\s+([\s\S]*?)\n## Ticket Files/,
  )?.[1];
  const ticketFilesSection = markdown.match(
    /## Ticket Files\s+([\s\S]*?)\n## Exit Condition/,
  )?.[1];

  if (!ticketOrderSection || !ticketFilesSection) {
    throw new Error(`Could not parse ticket order from ${planPath}.`);
  }

  const titles = [
    ...ticketOrderSection.matchAll(/`([A-Z0-9.]+)\s+([^`]+)`/g),
  ].map((match) => ({
    id: match[1] ?? '',
    title: match[2] ?? '',
  }));
  const files = [...ticketFilesSection.matchAll(/`([^`]+)`/g)].map(
    (match) => match[1] ?? '',
  );

  if (titles.length === 0 || titles.length !== files.length) {
    throw new Error(
      `Ticket order and ticket file sections are inconsistent in ${planPath}.`,
    );
  }

  const planDir = dirname(planPath);

  return titles.map((ticket, index) => {
    const ticketFile = join(planDir, files[index] ?? '');
    return {
      ...ticket,
      slug: slugify(ticket.title),
      type: parseTicketType(resolve(cwd ?? '', ticketFile)),
      scope: parseTicketScope(resolve(cwd ?? '', ticketFile)),
      redPolicy: parseTicketRedPolicy(resolve(cwd ?? '', ticketFile)),
      ticketFile,
    };
  });
}

export function derivePlanKey(planPath: string): string {
  const normalizedPlanPath = normalizeRepoPath(planPath);

  if (basename(normalizedPlanPath).toLowerCase() === 'implementation-plan.md') {
    return slugify(basename(dirname(normalizedPlanPath)));
  }

  return normalizedPlanPath
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .replace(/-md$/, '');
}

export function createOptions(input: {
  planPath?: string;
}): OrchestratorOptions {
  if (!input.planPath) {
    throw new Error(
      'Pass --plan <plan-path>. Phase aliases are no longer supported.',
    );
  }

  const planPath = normalizeRepoPath(input.planPath);
  const planKey = derivePlanKey(planPath);

  return {
    planPath,
    planKey,
    statePath: `.agents/delivery/${planKey}/state.json`,
    reviewsDirPath: `docs/product/delivery/${planKey}/reviews`,
    handoffsDirPath: `.agents/delivery/${planKey}/handoffs`,
    reviewPollIntervalMinutes: DEFAULT_REVIEW_POLLING_PROFILE.intervalMinutes,
    reviewPollMaxWaitMinutes: DEFAULT_REVIEW_POLLING_PROFILE.maxWaitMinutes,
  };
}

export async function inferPlanPathFromBranch(
  cwd: string,
  branch: string,
  planRoot: string,
  findExistingBranch: (
    branches: string[],
    definition: TicketDefinition,
  ) => { branch: string; source: 'ticket-id' | 'derived' } | undefined,
): Promise<string> {
  const planPaths = await listImplementationPlans(cwd, planRoot);
  const planIndex: Array<{ planPath: string; tickets: TicketDefinition[] }> =
    [];

  for (const planPath of planPaths) {
    const markdown = await readFile(resolve(cwd, planPath), 'utf8');
    planIndex.push({
      planPath,
      tickets: parsePlan(markdown, planPath, cwd),
    });
  }

  return resolvePlanPathForBranch(planIndex, branch, findExistingBranch);
}

export function resolvePlanPathForBranch(
  planIndex: Array<{ planPath: string; tickets: TicketDefinition[] }>,
  branch: string,
  findExistingBranch: (
    branches: string[],
    definition: TicketDefinition,
  ) => { branch: string; source: 'ticket-id' | 'derived' } | undefined,
): string {
  const matches: string[] = [];

  for (const plan of planIndex) {
    if (
      plan.tickets.some(
        (ticket) => findExistingBranch([branch], ticket)?.branch === branch,
      )
    ) {
      matches.push(plan.planPath);
    }
  }

  if (matches.length === 1) {
    return matches[0]!;
  }

  if (matches.length === 0) {
    throw new Error(
      `Could not infer a delivery plan for ${branch}. Pass --plan <plan-path>.`,
    );
  }

  throw new Error(
    `Multiple delivery plans match ${branch}: ${matches.join(', ')}. Pass --plan <plan-path>.`,
  );
}

async function listImplementationPlans(
  cwd: string,
  planRoot: string,
): Promise<string[]> {
  const deliveryRoot = resolve(cwd, planRoot, 'product/delivery');
  const entries = await readdir(deliveryRoot, { withFileTypes: true });

  return entries
    .filter((entry) => entry.isDirectory())
    .map((entry) =>
      relativeToRepo(
        cwd,
        resolve(deliveryRoot, entry.name, 'implementation-plan.md'),
      ),
    )
    .filter((planPath) => existsSync(resolve(cwd, planPath)))
    .sort();
}

function parseTicketType(ticketFilePath: string): string | undefined {
  try {
    const content = readFileSync(ticketFilePath, 'utf8');
    const match = content.match(/^Type:\s*(.+)$/im);
    return match ? match[1]!.trim().toLowerCase() : undefined;
  } catch {
    return undefined;
  }
}

function parseTicketScope(ticketFilePath: string): string | undefined {
  try {
    const content = readFileSync(ticketFilePath, 'utf8');
    const match = content.match(/^Scope:\s*(.+)$/im);
    return match ? match[1]!.trim().toLowerCase() : undefined;
  } catch {
    return undefined;
  }
}

function parseTicketRedPolicy(ticketFilePath: string): RedPolicy {
  let content: string;
  try {
    content = readFileSync(ticketFilePath, 'utf8');
  } catch {
    return 'required';
  }
  return parseRedPolicy(content);
}

function normalizeRepoPath(value: string): string {
  return value.replace(/^\.?\//, '');
}

export function relativeToRepo(cwd: string, absolutePath: string): string {
  return resolve(absolutePath).replace(`${resolve(cwd)}/`, '');
}

function slugify(value: string): string {
  return value
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '');
}

export type BranchMatch = {
  branch: string;
  source: 'ticket-id' | 'derived';
};

export function deriveBranchName(
  definition: Pick<TicketDefinition, 'id' | 'slug'>,
): string {
  return `agents/${definition.id.toLowerCase().replace('.', '-')}-${definition.slug}`;
}

export function deriveWorktreePath(cwd: string, ticketId: string): string {
  const parent = dirname(resolve(cwd));
  const repoBaseName = basename(resolve(cwd)).replace(
    /_(?:p\d+(?:_\d+)?|ee\d+_\d+)$/,
    '',
  );
  return join(
    parent,
    `${repoBaseName}_${ticketId.toLowerCase().replace('.', '_')}`,
  );
}

function preferDeliveryBranch(branches: string[]): string {
  return (
    branches.find((branch) => branch.startsWith('agents/')) ?? branches[0]!
  );
}

export function findExistingBranch(
  branches: string[],
  definition: Pick<TicketDefinition, 'id' | 'slug'>,
): BranchMatch | undefined {
  const ticketIdToken = definition.id.toLowerCase().replace('.', '-');
  const ticketIdMatches = branches.filter((branch) => {
    const normalized = branch.toLowerCase();
    return (
      normalized.includes(`/${ticketIdToken}`) ||
      normalized.includes(`-${ticketIdToken}`) ||
      normalized.endsWith(ticketIdToken)
    );
  });

  if (ticketIdMatches.length > 0) {
    return {
      branch: preferDeliveryBranch(ticketIdMatches),
      source: 'ticket-id',
    };
  }

  const derived = deriveBranchName(definition);

  if (branches.includes(derived)) {
    return {
      branch: derived,
      source: 'derived',
    };
  }

  return undefined;
}