baa-conductor

git clone 

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
M coordination/tasks/T-007-planner.md
+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
M packages/planner/src/index.ts
+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+}
M packages/step-templates/src/index.ts
+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+}