baa-conductor

git clone 

baa-conductor / packages / planner / src
im_wower  ·  2026-03-21

index.ts

  1export const STEP_STATUSES = ["pending", "running", "done", "failed", "timeout"] as const;
  2export type StepStatus = (typeof STEP_STATUSES)[number];
  3
  4export const STEP_KINDS = ["planner", "codex", "shell", "git", "review", "finalize"] as const;
  5export type StepKind = (typeof STEP_KINDS)[number];
  6
  7export const PLANNING_STRATEGIES = ["template_first", "planner_assisted", "manual"] as const;
  8export type PlanningStrategy = (typeof PLANNING_STRATEGIES)[number];
  9
 10export const PLANNER_PROVIDER_KINDS = ["template", "dispatch", "codex", "module"] as const;
 11export type PlannerProviderKind = (typeof PLANNER_PROVIDER_KINDS)[number];
 12
 13export const COMMON_PLAN_RISK_FLAGS = [
 14  "cross_scope_change",
 15  "external_dependency",
 16  "long_running_validation",
 17  "manual_verification",
 18  "missing_acceptance",
 19  "missing_repo_state",
 20  "unknown_step"
 21] as const;
 22export type CommonPlanRiskFlag = (typeof COMMON_PLAN_RISK_FLAGS)[number];
 23
 24export type PlanValue =
 25  | boolean
 26  | number
 27  | string
 28  | null
 29  | PlanValue[]
 30  | {
 31      [key: string]: PlanValue;
 32    };
 33
 34export type PlanInput = Record<string, PlanValue>;
 35export type PlanConstraintMap = PlanInput;
 36export type PlanMetadata = PlanInput;
 37
 38export interface RepoStateSnapshot {
 39  branchName?: string | null;
 40  baseRef?: string | null;
 41  headSha?: string | null;
 42  changedFiles?: string[];
 43  summary?: string;
 44}
 45
 46export interface PlannerTaskContext {
 47  taskId: string;
 48  repo: string;
 49  taskType: string;
 50  title: string;
 51  goal: string;
 52  constraints?: PlanConstraintMap;
 53  acceptance?: string[];
 54  metadata?: PlanMetadata;
 55  repoState?: RepoStateSnapshot;
 56}
 57
 58export interface ProposedStep {
 59  stepName: string;
 60  stepKind: StepKind;
 61  timeoutSec: number;
 62  retryLimit: number;
 63  input: PlanInput;
 64}
 65
 66export interface ProposedPlan {
 67  taskType: string;
 68  strategy: PlanningStrategy;
 69  reasoning: string;
 70  steps: ProposedStep[];
 71  riskFlags: string[];
 72}
 73
 74export interface PlannerCapabilities {
 75  supportedTaskTypes?: string[];
 76  supportedStrategies?: PlanningStrategy[];
 77  requiresRepoState?: boolean;
 78}
 79
 80export interface Planner {
 81  readonly providerKind: PlannerProviderKind;
 82  readonly capabilities?: PlannerCapabilities;
 83  plan(request: PlannerTaskContext): Promise<ProposedPlan>;
 84}
 85
 86export interface PlanValidationOptions {
 87  allowedStepKinds?: readonly StepKind[];
 88  knownStepNames?: readonly string[];
 89  maxTimeoutSec?: number;
 90}
 91
 92export interface PlanValidationIssue {
 93  path: string;
 94  message: string;
 95}
 96
 97export function isStepStatus(value: string): value is StepStatus {
 98  return (STEP_STATUSES as readonly string[]).includes(value);
 99}
100
101export function isStepKind(value: string): value is StepKind {
102  return (STEP_KINDS as readonly string[]).includes(value);
103}
104
105export function isPlanningStrategy(value: string): value is PlanningStrategy {
106  return (PLANNING_STRATEGIES as readonly string[]).includes(value);
107}
108
109export function isPlannerProviderKind(value: string): value is PlannerProviderKind {
110  return (PLANNER_PROVIDER_KINDS as readonly string[]).includes(value);
111}
112
113export function isCommonPlanRiskFlag(value: string): value is CommonPlanRiskFlag {
114  return (COMMON_PLAN_RISK_FLAGS as readonly string[]).includes(value);
115}
116
117export function isPlanInput(value: unknown): value is PlanInput {
118  return isPlainObject(value) && Object.values(value).every((entry) => isPlanValue(entry));
119}
120
121export function validateProposedPlan(
122  plan: ProposedPlan,
123  options: PlanValidationOptions = {}
124): PlanValidationIssue[] {
125  const issues: PlanValidationIssue[] = [];
126  const knownStepNames = options.knownStepNames ? new Set(options.knownStepNames) : null;
127  const allowedStepKinds = options.allowedStepKinds ? new Set(options.allowedStepKinds) : null;
128
129  if (plan.taskType.trim().length === 0) {
130    issues.push({
131      path: "taskType",
132      message: "taskType must be a non-empty string."
133    });
134  }
135
136  if (!isPlanningStrategy(plan.strategy)) {
137    issues.push({
138      path: "strategy",
139      message: "strategy must be one of the documented planning strategies."
140    });
141  }
142
143  if (plan.reasoning.trim().length === 0) {
144    issues.push({
145      path: "reasoning",
146      message: "reasoning must be a non-empty string."
147    });
148  }
149
150  if (
151    !Array.isArray(plan.riskFlags) ||
152    plan.riskFlags.some((flag) => typeof flag !== "string" || flag.trim().length === 0)
153  ) {
154    issues.push({
155      path: "riskFlags",
156      message: "riskFlags must contain only non-empty strings."
157    });
158  }
159
160  if (!Array.isArray(plan.steps) || plan.steps.length === 0) {
161    issues.push({
162      path: "steps",
163      message: "steps must contain at least one proposed step."
164    });
165    return issues;
166  }
167
168  for (const [index, step] of plan.steps.entries()) {
169    const path = `steps[${index}]`;
170
171    if (step.stepName.trim().length === 0) {
172      issues.push({
173        path: `${path}.stepName`,
174        message: "stepName must be a non-empty string."
175      });
176    } else if (knownStepNames && !knownStepNames.has(step.stepName)) {
177      issues.push({
178        path: `${path}.stepName`,
179        message: `stepName "${step.stepName}" is not part of the accepted template set.`
180      });
181    }
182
183    if (!isStepKind(step.stepKind)) {
184      issues.push({
185        path: `${path}.stepKind`,
186        message: "stepKind must be one of the documented step kinds."
187      });
188    } else if (allowedStepKinds && !allowedStepKinds.has(step.stepKind)) {
189      issues.push({
190        path: `${path}.stepKind`,
191        message: `stepKind "${step.stepKind}" is not permitted for this plan.`
192      });
193    }
194
195    if (!Number.isInteger(step.timeoutSec) || step.timeoutSec <= 0) {
196      issues.push({
197        path: `${path}.timeoutSec`,
198        message: "timeoutSec must be a positive integer."
199      });
200    } else if (
201      typeof options.maxTimeoutSec === "number" &&
202      Number.isInteger(options.maxTimeoutSec) &&
203      step.timeoutSec > options.maxTimeoutSec
204    ) {
205      issues.push({
206        path: `${path}.timeoutSec`,
207        message: `timeoutSec must not exceed ${options.maxTimeoutSec}.`
208      });
209    }
210
211    if (!Number.isInteger(step.retryLimit) || step.retryLimit < 0) {
212      issues.push({
213        path: `${path}.retryLimit`,
214        message: "retryLimit must be a non-negative integer."
215      });
216    }
217
218    if (!isPlanInput(step.input)) {
219      issues.push({
220        path: `${path}.input`,
221        message: "input must be a plain object whose values are JSON-like plan values."
222      });
223    }
224  }
225
226  return issues;
227}
228
229function isPlainObject(value: unknown): value is Record<string, unknown> {
230  return typeof value === "object" && value !== null && !Array.isArray(value);
231}
232
233function isPlanValue(value: unknown): value is PlanValue {
234  if (value === null) {
235    return true;
236  }
237
238  if (typeof value === "boolean" || typeof value === "string") {
239    return true;
240  }
241
242  if (typeof value === "number") {
243    return Number.isFinite(value);
244  }
245
246  if (Array.isArray(value)) {
247    return value.every((entry) => isPlanValue(entry));
248  }
249
250  if (isPlainObject(value)) {
251    return Object.values(value).every((entry) => isPlanValue(entry));
252  }
253
254  return false;
255}