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}