- commit
- ad2227e
- parent
- e4bbe2e
- author
- im_wower
- date
- 2026-03-21 19:40:35 +0800 CST
feat: add planner abstractions and templates
3 files changed,
+574,
-32
+13,
-6
1@@ -1,7 +1,7 @@
2 ---
3 task_id: T-007
4 title: Planner 抽象与模板
5-status: todo
6+status: review
7 branch: feat/T-007-planner
8 repo: /Users/george/code/baa-conductor
9 base_ref: main
10@@ -49,24 +49,31 @@ updated_at: 2026-03-21
11
12 ## files_changed
13
14-- 待填写
15+- `coordination/tasks/T-007-planner.md`
16+- `packages/planner/src/index.ts`
17+- `packages/step-templates/src/index.ts`
18
19 ## commands_run
20
21-- 待填写
22+- `./node_modules/.bin/tsc --noEmit -p packages/planner/tsconfig.json`
23+- `./node_modules/.bin/tsc --noEmit -p packages/step-templates/tsconfig.json`
24
25 ## result
26
27-- 待填写
28+- 补齐了 `planner` 抽象:策略、provider kind、step status、planner 输入上下文、`ProposedPlan` / `ProposedStep`、risk flags 建议值与 plan 校验 helper。
29+- 把常见任务模板整理成稳定数据结构,覆盖 `feature_impl`、`bugfix`、`review_only`、`ops_change`、`infra_bootstrap`。
30+- 提供了模板查询与 `buildTemplatePlan` helper,后续 conductor 可以直接据此产出结构化 plan。
31
32 ## risks
33
34-- 待填写
35+- `review_only`、`ops_change`、`infra_bootstrap` 的 step kind 与输入 payload 需要靠设计文本推断,后续若设计补充更细契约,可能要同步收窄。
36+- conductor 侧还未接入这些类型与模板;真正落计划验收时,需要决定不同 task 何时走 `template_first`、何时走 `planner_assisted`。
37
38 ## next_handoff
39
40-- 给 conductor 调用 planner 提供稳定接口
41+- conductor 可直接消费 `packages/planner` 的类型与 `validateProposedPlan`,并从 `packages/step-templates` 读取模板或调用 `buildTemplatePlan`。
42
43 ## notes
44
45 - `2026-03-21`: 创建任务卡
46+- `2026-03-21`: 完成 planner 抽象与模板实现,等待 review
+238,
-3
1@@ -1,10 +1,66 @@
2-export type PlanningStrategy = "template_first" | "planner_assisted" | "manual";
3+export const STEP_STATUSES = ["pending", "running", "done", "failed", "timeout"] as const;
4+export type StepStatus = (typeof STEP_STATUSES)[number];
5+
6+export const STEP_KINDS = ["planner", "codex", "shell", "git", "review", "finalize"] as const;
7+export type StepKind = (typeof STEP_KINDS)[number];
8+
9+export const PLANNING_STRATEGIES = ["template_first", "planner_assisted", "manual"] as const;
10+export type PlanningStrategy = (typeof PLANNING_STRATEGIES)[number];
11+
12+export const PLANNER_PROVIDER_KINDS = ["template", "dispatch", "codex", "module"] as const;
13+export type PlannerProviderKind = (typeof PLANNER_PROVIDER_KINDS)[number];
14+
15+export const COMMON_PLAN_RISK_FLAGS = [
16+ "cross_scope_change",
17+ "external_dependency",
18+ "long_running_validation",
19+ "manual_verification",
20+ "missing_acceptance",
21+ "missing_repo_state",
22+ "unknown_step"
23+] as const;
24+export type CommonPlanRiskFlag = (typeof COMMON_PLAN_RISK_FLAGS)[number];
25+
26+export type PlanValue =
27+ | boolean
28+ | number
29+ | string
30+ | null
31+ | PlanValue[]
32+ | {
33+ [key: string]: PlanValue;
34+ };
35+
36+export type PlanInput = Record<string, PlanValue>;
37+export type PlanConstraintMap = PlanInput;
38+export type PlanMetadata = PlanInput;
39+
40+export interface RepoStateSnapshot {
41+ branchName?: string | null;
42+ baseRef?: string | null;
43+ headSha?: string | null;
44+ changedFiles?: string[];
45+ summary?: string;
46+}
47+
48+export interface PlannerTaskContext {
49+ taskId: string;
50+ repo: string;
51+ taskType: string;
52+ title: string;
53+ goal: string;
54+ constraints?: PlanConstraintMap;
55+ acceptance?: string[];
56+ metadata?: PlanMetadata;
57+ repoState?: RepoStateSnapshot;
58+}
59
60 export interface ProposedStep {
61 stepName: string;
62- stepKind: "planner" | "codex" | "shell" | "git" | "review" | "finalize";
63+ stepKind: StepKind;
64 timeoutSec: number;
65 retryLimit: number;
66+ input: PlanInput;
67 }
68
69 export interface ProposedPlan {
70@@ -12,9 +68,188 @@ export interface ProposedPlan {
71 strategy: PlanningStrategy;
72 reasoning: string;
73 steps: ProposedStep[];
74+ riskFlags: string[];
75+}
76+
77+export interface PlannerCapabilities {
78+ supportedTaskTypes?: string[];
79+ supportedStrategies?: PlanningStrategy[];
80+ requiresRepoState?: boolean;
81 }
82
83 export interface Planner {
84- plan(taskId: string, goal: string): Promise<ProposedPlan>;
85+ readonly providerKind: PlannerProviderKind;
86+ readonly capabilities?: PlannerCapabilities;
87+ plan(request: PlannerTaskContext): Promise<ProposedPlan>;
88 }
89
90+export interface PlanValidationOptions {
91+ allowedStepKinds?: readonly StepKind[];
92+ knownStepNames?: readonly string[];
93+ maxTimeoutSec?: number;
94+}
95+
96+export interface PlanValidationIssue {
97+ path: string;
98+ message: string;
99+}
100+
101+export function isStepStatus(value: string): value is StepStatus {
102+ return (STEP_STATUSES as readonly string[]).includes(value);
103+}
104+
105+export function isStepKind(value: string): value is StepKind {
106+ return (STEP_KINDS as readonly string[]).includes(value);
107+}
108+
109+export function isPlanningStrategy(value: string): value is PlanningStrategy {
110+ return (PLANNING_STRATEGIES as readonly string[]).includes(value);
111+}
112+
113+export function isPlannerProviderKind(value: string): value is PlannerProviderKind {
114+ return (PLANNER_PROVIDER_KINDS as readonly string[]).includes(value);
115+}
116+
117+export function isCommonPlanRiskFlag(value: string): value is CommonPlanRiskFlag {
118+ return (COMMON_PLAN_RISK_FLAGS as readonly string[]).includes(value);
119+}
120+
121+export function isPlanInput(value: unknown): value is PlanInput {
122+ return isPlainObject(value) && Object.values(value).every((entry) => isPlanValue(entry));
123+}
124+
125+export function validateProposedPlan(
126+ plan: ProposedPlan,
127+ options: PlanValidationOptions = {}
128+): PlanValidationIssue[] {
129+ const issues: PlanValidationIssue[] = [];
130+ const knownStepNames = options.knownStepNames ? new Set(options.knownStepNames) : null;
131+ const allowedStepKinds = options.allowedStepKinds ? new Set(options.allowedStepKinds) : null;
132+
133+ if (plan.taskType.trim().length === 0) {
134+ issues.push({
135+ path: "taskType",
136+ message: "taskType must be a non-empty string."
137+ });
138+ }
139+
140+ if (!isPlanningStrategy(plan.strategy)) {
141+ issues.push({
142+ path: "strategy",
143+ message: "strategy must be one of the documented planning strategies."
144+ });
145+ }
146+
147+ if (plan.reasoning.trim().length === 0) {
148+ issues.push({
149+ path: "reasoning",
150+ message: "reasoning must be a non-empty string."
151+ });
152+ }
153+
154+ if (
155+ !Array.isArray(plan.riskFlags) ||
156+ plan.riskFlags.some((flag) => typeof flag !== "string" || flag.trim().length === 0)
157+ ) {
158+ issues.push({
159+ path: "riskFlags",
160+ message: "riskFlags must contain only non-empty strings."
161+ });
162+ }
163+
164+ if (!Array.isArray(plan.steps) || plan.steps.length === 0) {
165+ issues.push({
166+ path: "steps",
167+ message: "steps must contain at least one proposed step."
168+ });
169+ return issues;
170+ }
171+
172+ for (const [index, step] of plan.steps.entries()) {
173+ const path = `steps[${index}]`;
174+
175+ if (step.stepName.trim().length === 0) {
176+ issues.push({
177+ path: `${path}.stepName`,
178+ message: "stepName must be a non-empty string."
179+ });
180+ } else if (knownStepNames && !knownStepNames.has(step.stepName)) {
181+ issues.push({
182+ path: `${path}.stepName`,
183+ message: `stepName "${step.stepName}" is not part of the accepted template set.`
184+ });
185+ }
186+
187+ if (!isStepKind(step.stepKind)) {
188+ issues.push({
189+ path: `${path}.stepKind`,
190+ message: "stepKind must be one of the documented step kinds."
191+ });
192+ } else if (allowedStepKinds && !allowedStepKinds.has(step.stepKind)) {
193+ issues.push({
194+ path: `${path}.stepKind`,
195+ message: `stepKind "${step.stepKind}" is not permitted for this plan.`
196+ });
197+ }
198+
199+ if (!Number.isInteger(step.timeoutSec) || step.timeoutSec <= 0) {
200+ issues.push({
201+ path: `${path}.timeoutSec`,
202+ message: "timeoutSec must be a positive integer."
203+ });
204+ } else if (
205+ typeof options.maxTimeoutSec === "number" &&
206+ Number.isInteger(options.maxTimeoutSec) &&
207+ step.timeoutSec > options.maxTimeoutSec
208+ ) {
209+ issues.push({
210+ path: `${path}.timeoutSec`,
211+ message: `timeoutSec must not exceed ${options.maxTimeoutSec}.`
212+ });
213+ }
214+
215+ if (!Number.isInteger(step.retryLimit) || step.retryLimit < 0) {
216+ issues.push({
217+ path: `${path}.retryLimit`,
218+ message: "retryLimit must be a non-negative integer."
219+ });
220+ }
221+
222+ if (!isPlanInput(step.input)) {
223+ issues.push({
224+ path: `${path}.input`,
225+ message: "input must be a plain object whose values are JSON-like plan values."
226+ });
227+ }
228+ }
229+
230+ return issues;
231+}
232+
233+function isPlainObject(value: unknown): value is Record<string, unknown> {
234+ return typeof value === "object" && value !== null && !Array.isArray(value);
235+}
236+
237+function isPlanValue(value: unknown): value is PlanValue {
238+ if (value === null) {
239+ return true;
240+ }
241+
242+ if (typeof value === "boolean" || typeof value === "string") {
243+ return true;
244+ }
245+
246+ if (typeof value === "number") {
247+ return Number.isFinite(value);
248+ }
249+
250+ if (Array.isArray(value)) {
251+ return value.every((entry) => isPlanValue(entry));
252+ }
253+
254+ if (isPlainObject(value)) {
255+ return Object.values(value).every((entry) => isPlanValue(entry));
256+ }
257+
258+ return false;
259+}
+323,
-23
1@@ -1,25 +1,325 @@
2+export const TEMPLATE_TASK_TYPES = [
3+ "feature_impl",
4+ "bugfix",
5+ "review_only",
6+ "ops_change",
7+ "infra_bootstrap"
8+] as const;
9+export type TaskTemplateType = (typeof TEMPLATE_TASK_TYPES)[number];
10+
11+export const TEMPLATE_STEP_KINDS = ["planner", "codex", "shell", "git", "review", "finalize"] as const;
12+export type TemplateStepKind = (typeof TEMPLATE_STEP_KINDS)[number];
13+
14+export const TEMPLATE_PLANNING_STRATEGIES = [
15+ "template_first",
16+ "planner_assisted",
17+ "manual"
18+] as const;
19+export type TemplatePlanningStrategy = (typeof TEMPLATE_PLANNING_STRATEGIES)[number];
20+
21+export const TEMPLATE_STEP_NAMES = [
22+ "prepare_branch",
23+ "inspect_context",
24+ "implement",
25+ "run_tests",
26+ "review_fix",
27+ "commit_push",
28+ "finalize",
29+ "reproduce",
30+ "implement_fix",
31+ "run_targeted_tests",
32+ "prepare_context",
33+ "analyze_diff",
34+ "write_review",
35+ "inspect_ops_context",
36+ "edit_ops_files",
37+ "validate_config",
38+ "inspect_current_ops_state",
39+ "edit_dns_or_nginx_docs",
40+ "validate_nginx_config"
41+] as const;
42+export type TemplateStepName = (typeof TEMPLATE_STEP_NAMES)[number];
43+
44+export type TemplateValue =
45+ | boolean
46+ | number
47+ | string
48+ | null
49+ | TemplateValue[]
50+ | {
51+ [key: string]: TemplateValue;
52+ };
53+
54+export type TemplateInput = Record<string, TemplateValue>;
55+
56 export interface TemplateStep {
57- stepName: string;
58- stepKind: "planner" | "codex" | "shell" | "git" | "review" | "finalize";
59-}
60-
61-export const STEP_TEMPLATES = {
62- feature_impl: [
63- { stepName: "prepare_branch", stepKind: "git" },
64- { stepName: "inspect_context", stepKind: "codex" },
65- { stepName: "implement", stepKind: "codex" },
66- { stepName: "run_tests", stepKind: "shell" },
67- { stepName: "review_fix", stepKind: "review" },
68- { stepName: "commit_push", stepKind: "git" },
69- { stepName: "finalize", stepKind: "finalize" }
70- ],
71- bugfix: [
72- { stepName: "prepare_branch", stepKind: "git" },
73- { stepName: "reproduce", stepKind: "shell" },
74- { stepName: "implement_fix", stepKind: "codex" },
75- { stepName: "run_targeted_tests", stepKind: "shell" },
76- { stepName: "commit_push", stepKind: "git" },
77- { stepName: "finalize", stepKind: "finalize" }
78- ]
79-} as const satisfies Record<string, TemplateStep[]>;
80+ stepName: TemplateStepName;
81+ stepKind: TemplateStepKind;
82+ timeoutSec: number;
83+ retryLimit: number;
84+ input: TemplateInput;
85+}
86+
87+export interface TaskTemplateDefinition {
88+ taskType: TaskTemplateType;
89+ summary: string;
90+ defaultReasoning: string;
91+ recommendedStrategies: readonly [
92+ TemplatePlanningStrategy,
93+ ...TemplatePlanningStrategy[]
94+ ];
95+ steps: readonly TemplateStep[];
96+}
97+
98+export interface BuildTemplatePlanOptions {
99+ strategy?: TemplatePlanningStrategy;
100+ reasoning?: string;
101+ riskFlags?: string[];
102+}
103+
104+export interface TemplatePlan {
105+ taskType: TaskTemplateType;
106+ strategy: TemplatePlanningStrategy;
107+ reasoning: string;
108+ steps: TemplateStep[];
109+ riskFlags: string[];
110+}
111+
112+const STEP_TEMPLATES = {
113+ feature_impl: {
114+ taskType: "feature_impl",
115+ summary: "Default feature-delivery flow with explicit context gathering, implementation, validation, review, and handoff.",
116+ defaultReasoning:
117+ "Use the feature implementation template so discovery, coding, validation, and review remain explicit recovery boundaries.",
118+ recommendedStrategies: ["template_first", "planner_assisted"],
119+ steps: [
120+ createStep("prepare_branch", "git", 120, 0, {
121+ action: "checkout_task_branch",
122+ branch_name: "{task.branch_name}",
123+ base_ref: "{task.base_ref}"
124+ }),
125+ createStep("inspect_context", "codex", 600, 1, {
126+ goal: "Read the repository and summarize the files, modules, and tests relevant to the requested feature."
127+ }),
128+ createStep("implement", "codex", 1800, 1, {
129+ goal: "Implement the requested feature within the approved write scope and update nearby tests or fixtures when needed."
130+ }),
131+ createStep("run_tests", "shell", 1200, 1, {
132+ command: "pnpm test",
133+ allow_override: true
134+ }),
135+ createStep("review_fix", "review", 900, 1, {
136+ goal: "Review the diff for regressions and apply any follow-up fixes before final handoff."
137+ }),
138+ createStep("commit_push", "git", 300, 0, {
139+ action: "commit_and_push_task_branch",
140+ branch_name: "{task.branch_name}"
141+ }),
142+ createStep("finalize", "finalize", 120, 0, {
143+ emit_task_summary: true
144+ })
145+ ]
146+ },
147+ bugfix: {
148+ taskType: "bugfix",
149+ summary: "Bugfix flow with reproduction before the fix and targeted validation after the fix.",
150+ defaultReasoning:
151+ "Use the standard bugfix template first so reproduction, implementation, and targeted validation stay deterministic.",
152+ recommendedStrategies: ["template_first", "planner_assisted"],
153+ steps: [
154+ createStep("prepare_branch", "git", 120, 0, {
155+ action: "checkout_task_branch",
156+ branch_name: "{task.branch_name}",
157+ base_ref: "{task.base_ref}"
158+ }),
159+ createStep("reproduce", "shell", 600, 1, {
160+ command: "{task.repro_command}",
161+ allow_override: true
162+ }),
163+ createStep("implement_fix", "codex", 1200, 1, {
164+ goal: "Implement the requested bug fix without changing unrelated behavior."
165+ }),
166+ createStep("run_targeted_tests", "shell", 900, 1, {
167+ command: "{task.targeted_test_command}",
168+ allow_override: true
169+ }),
170+ createStep("commit_push", "git", 300, 0, {
171+ action: "commit_and_push_task_branch",
172+ branch_name: "{task.branch_name}"
173+ }),
174+ createStep("finalize", "finalize", 120, 0, {
175+ emit_task_summary: true
176+ })
177+ ]
178+ },
179+ review_only: {
180+ taskType: "review_only",
181+ summary: "Diff review flow for findings-only work with no implementation step.",
182+ defaultReasoning:
183+ "Use the review-only template because the task needs review output, not repository modifications.",
184+ recommendedStrategies: ["template_first"],
185+ steps: [
186+ createStep("prepare_context", "git", 120, 0, {
187+ action: "prepare_review_context",
188+ base_ref: "{task.base_ref}"
189+ }),
190+ createStep("analyze_diff", "review", 900, 1, {
191+ goal: "Inspect the diff and enumerate findings, risks, and missing tests."
192+ }),
193+ createStep("write_review", "review", 600, 0, {
194+ goal: "Write the final structured review with file references and severity."
195+ }),
196+ createStep("finalize", "finalize", 120, 0, {
197+ emit_task_summary: true
198+ })
199+ ]
200+ },
201+ ops_change: {
202+ taskType: "ops_change",
203+ summary: "Ops change flow with manual-first strategy and explicit config validation.",
204+ defaultReasoning:
205+ "Prefer a manual-first ops template because environment-specific configuration changes often need operator confirmation.",
206+ recommendedStrategies: ["manual", "template_first"],
207+ steps: [
208+ createStep("prepare_branch", "git", 120, 0, {
209+ action: "checkout_task_branch",
210+ branch_name: "{task.branch_name}",
211+ base_ref: "{task.base_ref}"
212+ }),
213+ createStep("inspect_ops_context", "codex", 600, 1, {
214+ goal: "Read the current ops configuration and summarize the change surface before editing."
215+ }),
216+ createStep("edit_ops_files", "codex", 1200, 1, {
217+ goal: "Apply the requested ops change to the approved config or docs files."
218+ }),
219+ createStep("validate_config", "shell", 900, 1, {
220+ command: "{task.validation_command}",
221+ allow_override: true
222+ }),
223+ createStep("commit_push", "git", 300, 0, {
224+ action: "commit_and_push_task_branch",
225+ branch_name: "{task.branch_name}"
226+ }),
227+ createStep("finalize", "finalize", 120, 0, {
228+ emit_task_summary: true
229+ })
230+ ]
231+ },
232+ infra_bootstrap: {
233+ taskType: "infra_bootstrap",
234+ summary: "Infrastructure bootstrap flow for DNS, nginx, and related docs or host setup tasks.",
235+ defaultReasoning:
236+ "Use the bootstrap template so current state inspection, config edits, and nginx validation stay separated into recoverable steps.",
237+ recommendedStrategies: ["template_first", "planner_assisted"],
238+ steps: [
239+ createStep("prepare_branch", "git", 120, 0, {
240+ action: "checkout_task_branch",
241+ branch_name: "{task.branch_name}",
242+ base_ref: "{task.base_ref}"
243+ }),
244+ createStep("inspect_current_ops_state", "codex", 600, 1, {
245+ goal: "Inspect the current DNS, nginx, or host bootstrap state and summarize what must change."
246+ }),
247+ createStep("edit_dns_or_nginx_docs", "codex", 1200, 1, {
248+ goal: "Apply the bootstrap-related DNS, nginx, or runbook edits required by the task."
249+ }),
250+ createStep("validate_nginx_config", "shell", 900, 1, {
251+ command: "nginx -t",
252+ allow_override: true
253+ }),
254+ createStep("commit_push", "git", 300, 0, {
255+ action: "commit_and_push_task_branch",
256+ branch_name: "{task.branch_name}"
257+ }),
258+ createStep("finalize", "finalize", 120, 0, {
259+ emit_task_summary: true
260+ })
261+ ]
262+ }
263+} as const satisfies Record<TaskTemplateType, TaskTemplateDefinition>;
264+
265+export const TASK_TEMPLATE_ORDER = [...TEMPLATE_TASK_TYPES];
266+export const TASK_TEMPLATES = STEP_TEMPLATES;
267+
268+export function isTaskTemplateType(value: string): value is TaskTemplateType {
269+ return (TEMPLATE_TASK_TYPES as readonly string[]).includes(value);
270+}
271+
272+export function getTaskTemplate(taskType: string): TaskTemplateDefinition | undefined {
273+ if (!isTaskTemplateType(taskType)) {
274+ return undefined;
275+ }
276+
277+ return STEP_TEMPLATES[taskType];
278+}
279+
280+export function listTaskTemplates(): TaskTemplateDefinition[] {
281+ return TASK_TEMPLATE_ORDER.map((taskType) => STEP_TEMPLATES[taskType]);
282+}
283+
284+export function buildTemplatePlan(
285+ taskType: TaskTemplateType,
286+ options: BuildTemplatePlanOptions = {}
287+): TemplatePlan {
288+ const template = STEP_TEMPLATES[taskType];
289+
290+ return {
291+ taskType,
292+ strategy: options.strategy ?? template.recommendedStrategies[0],
293+ reasoning: options.reasoning ?? template.defaultReasoning,
294+ steps: template.steps.map((step) => cloneTemplateStep(step)),
295+ riskFlags: options.riskFlags ? [...options.riskFlags] : []
296+ };
297+}
298+
299+function createStep(
300+ stepName: TemplateStepName,
301+ stepKind: TemplateStepKind,
302+ timeoutSec: number,
303+ retryLimit: number,
304+ input: TemplateInput
305+): TemplateStep {
306+ return {
307+ stepName,
308+ stepKind,
309+ timeoutSec,
310+ retryLimit,
311+ input
312+ };
313+}
314+
315+function cloneTemplateStep(step: TemplateStep): TemplateStep {
316+ return {
317+ stepName: step.stepName,
318+ stepKind: step.stepKind,
319+ timeoutSec: step.timeoutSec,
320+ retryLimit: step.retryLimit,
321+ input: cloneTemplateInput(step.input)
322+ };
323+}
324
325+function cloneTemplateInput(input: TemplateInput): TemplateInput {
326+ return cloneTemplateValue(input) as TemplateInput;
327+}
328+
329+function cloneTemplateValue(value: TemplateValue): TemplateValue {
330+ if (Array.isArray(value)) {
331+ return value.map((entry) => cloneTemplateValue(entry));
332+ }
333+
334+ if (isPlainObject(value)) {
335+ const next: TemplateInput = {};
336+
337+ for (const [key, entry] of Object.entries(value)) {
338+ next[key] = cloneTemplateValue(entry);
339+ }
340+
341+ return next;
342+ }
343+
344+ return value;
345+}
346+
347+function isPlainObject(value: TemplateValue): value is TemplateInput {
348+ return typeof value === "object" && value !== null && !Array.isArray(value);
349+}