From 8bc9142975e45151edb0ad980637bd8c0a179c8c Mon Sep 17 00:00:00 2001 From: GildardoDev <267998055+GildardoDev@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:55:48 -0500 Subject: [PATCH] feat(miner-plan): add analyze and prepare plan-DAG templates to the shared engine --- packages/gittensory-engine/src/index.ts | 1 + .../gittensory-engine/src/plan-templates.ts | 78 ++++++++++++++++++ src/mcp/server.ts | 2 +- test/unit/plan-templates.test.ts | 80 +++++++++++++++++++ 4 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 packages/gittensory-engine/src/plan-templates.ts create mode 100644 test/unit/plan-templates.test.ts diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 031283c159..9c9717c01b 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -10,6 +10,7 @@ export { type OpportunityRankInput, } from "./opportunity-ranker.js"; export * from "./governor/rate-limit.js"; +export * from "./plan-templates.js"; export * from "./portfolio/queue.js"; export { resolveAiPolicyVerdict, diff --git a/packages/gittensory-engine/src/plan-templates.ts b/packages/gittensory-engine/src/plan-templates.ts new file mode 100644 index 0000000000..565504c41d --- /dev/null +++ b/packages/gittensory-engine/src/plan-templates.ts @@ -0,0 +1,78 @@ +// Plan-template library (pure). +// +// Reusable plan TEMPLATES for the fixed miner lifecycle (discover -> analyze -> plan -> prepare -> create -> +// manage -> repeat), emitted in the exact stateless raw-step shape the MCP `gittensory_build_plan` tool accepts +// (`rawPlanStepSchema` in src/mcp/server.ts), so `build_plan` can normalize them into a validated DAG. Each builder +// is deterministic and side-effect-free: it only DESCRIBES steps and their `dependsOn` ordering — it never actuates +// anything. The `RawPlanStep` type below mirrors the raw-step schema so the engine package stays standalone and +// does not import the app's Zod schema (the tests validate the output against the real schema to guard drift). + +// Mirror of `rawPlanStepSchema` (src/mcp/server.ts): the pre-normalization step shape `gittensory_build_plan` accepts. +export type RawPlanStep = { + id: string; + title: string; + actionClass?: string | undefined; + dependsOn?: string[] | undefined; + maxAttempts?: number | undefined; +}; + +// The lifecycle-stage transitions this library provides a template for. +export type PlanTemplateStage = "analyze" | "prepare"; + +// Context woven into a template's step titles so a plan reads against the opportunity it targets. +export type PlanTemplateContext = { + // A short human label for the issue/opportunity the plan is for (e.g. an issue title). Optional so a caller can + // render a generic template; whitespace is collapsed and the value is length-bounded to keep every title valid. + subject?: string | undefined; +}; + +// Title length ceiling of `rawPlanStepSchema.title` (max 300). Titles are hard-capped to this so a long subject can +// never produce an out-of-range step. +const MAX_TITLE_CHARS = 300; +// Keep the woven subject well under the title ceiling so the fixed prefix always survives the cap. +const MAX_SUBJECT_CHARS = 200; + +// Collapse any run of whitespace (including newlines) to a single space and trim, so a subject yields a clean, +// deterministic one-line title. +function normalizeSubject(subject: string | undefined): string { + return (subject ?? "").replace(/\s+/g, " ").trim().slice(0, MAX_SUBJECT_CHARS); +} + +// Compose a step title from a fixed prefix and the optional subject, hard-capped to the schema's title ceiling. +function titleFor(prefix: string, subject: string): string { + const full = subject ? `${prefix}: ${subject}` : prefix; + return full.slice(0, MAX_TITLE_CHARS); +} + +// analyze: feasibility check and repository RAG retrieval run independently, then the prompt-packet build consumes +// both. Mirrors the ANALYZE-phase ordering described in the plan-template issue. +export function analyzePlanTemplate(context: PlanTemplateContext = {}): RawPlanStep[] { + const subject = normalizeSubject(context.subject); + return [ + { id: "feasibility-check", title: titleFor("Assess feasibility", subject), actionClass: "analyze", dependsOn: [], maxAttempts: 1 }, + { id: "rag-retrieval", title: titleFor("Retrieve repository context", subject), actionClass: "retrieve", dependsOn: [], maxAttempts: 3 }, + { id: "prompt-packet", title: titleFor("Build prompt packet", subject), actionClass: "compose", dependsOn: ["feasibility-check", "rag-retrieval"], maxAttempts: 2 }, + ]; +} + +// prepare: a strict chain — create the branch, invoke the coding agent (placeholder step; no actuation here), then +// run the local tests. Mirrors the PREPARE-phase ordering described in the plan-template issue. +export function preparePlanTemplate(context: PlanTemplateContext = {}): RawPlanStep[] { + const subject = normalizeSubject(context.subject); + return [ + { id: "branch-create", title: titleFor("Create working branch", subject), actionClass: "vcs", dependsOn: [], maxAttempts: 3 }, + { id: "coding-agent", title: titleFor("Invoke coding agent", subject), actionClass: "codegen", dependsOn: ["branch-create"], maxAttempts: 1 }, + { id: "local-test", title: titleFor("Run local tests", subject), actionClass: "test", dependsOn: ["coding-agent"], maxAttempts: 2 }, + ]; +} + +// Registry of every stage transition to its template builder, so callers can enumerate or dispatch by stage. +export const PLAN_TEMPLATE_BUILDERS: Record RawPlanStep[]> = { + analyze: analyzePlanTemplate, + prepare: preparePlanTemplate, +}; + +// Build the raw-step template for a stage. Pure — a thin dispatcher over `PLAN_TEMPLATE_BUILDERS`. +export function buildPlanTemplate(stage: PlanTemplateStage, context: PlanTemplateContext = {}): RawPlanStep[] { + return PLAN_TEMPLATE_BUILDERS[stage](context); +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 6d79c80aae..a9a331c4ce 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -322,7 +322,7 @@ const localWriteActionOutputSchema = { // #783 plan DAG — STATELESS: the harness holds the plan and passes it back each call; these tools only advance // the state machine, so gittensory keeps no record of the miner's plan. const planStepStatusEnum = z.enum(["pending", "running", "completed", "failed", "skipped"]); -const rawPlanStepSchema = z +export const rawPlanStepSchema = z .object({ id: z.string().min(1).max(100), title: z.string().min(1).max(300), diff --git a/test/unit/plan-templates.test.ts b/test/unit/plan-templates.test.ts new file mode 100644 index 0000000000..841fcf87ca --- /dev/null +++ b/test/unit/plan-templates.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { + analyzePlanTemplate, + buildPlanTemplate, + preparePlanTemplate, + PLAN_TEMPLATE_BUILDERS, + type PlanTemplateStage, + type RawPlanStep, +} from "../../packages/gittensory-engine/src/plan-templates"; +import { rawPlanStepSchema } from "../../src/mcp/server"; + +const STAGES = Object.keys(PLAN_TEMPLATE_BUILDERS) as PlanTemplateStage[]; + +function idsOf(steps: RawPlanStep[]): string[] { + return steps.map((s) => s.id); +} + +describe("plan-templates", () => { + it("exposes a builder for every declared stage", () => { + expect(STAGES.sort()).toEqual(["analyze", "prepare"]); + }); + + it.each(STAGES)("'%s' template round-trips through the real rawPlanStepSchema", (stage) => { + const steps = buildPlanTemplate(stage, { subject: "fix flaky retry" }); + expect(steps.length).toBeGreaterThan(0); + for (const step of steps) { + expect(() => rawPlanStepSchema.parse(step)).not.toThrow(); + } + }); + + it.each(STAGES)("'%s' template has unique ids and only in-plan, acyclic dependencies", (stage) => { + const steps = buildPlanTemplate(stage); + const ids = idsOf(steps); + expect(new Set(ids).size).toBe(ids.length); + const present = new Set(ids); + for (const step of steps) { + for (const dep of step.dependsOn ?? []) { + expect(present.has(dep)).toBe(true); + } + } + // A step may only depend on steps declared before it, which both proves acyclicity and gives a ready topo order. + const seen = new Set(); + for (const step of steps) { + for (const dep of step.dependsOn ?? []) expect(seen.has(dep)).toBe(true); + seen.add(step.id); + } + }); + + it("is deterministic: same context yields identical output", () => { + expect(analyzePlanTemplate({ subject: "x" })).toEqual(analyzePlanTemplate({ subject: "x" })); + expect(preparePlanTemplate()).toEqual(preparePlanTemplate()); + }); + + it("weaves the subject into every title as a single clean line", () => { + const steps = analyzePlanTemplate({ subject: " add\ta\nlaptop mode " }); + for (const step of steps) { + expect(step.title).toContain(": add a laptop mode"); + expect(step.title).not.toMatch(/[\r\n\t]/); + } + }); + + it("omits the subject suffix when no subject is given", () => { + const first = preparePlanTemplate()[0]; + expect(first?.title).toBe("Create working branch"); + }); + + it("bounds an oversized subject so every title stays within the schema's 300-char limit", () => { + const steps = analyzePlanTemplate({ subject: "z".repeat(5000) }); + for (const step of steps) { + expect(() => rawPlanStepSchema.parse(step)).not.toThrow(); + expect(step.title.length).toBeLessThanOrEqual(300); + } + }); + + it("encodes the real analyze ordering: prompt-packet depends on both feasibility and retrieval", () => { + const steps = analyzePlanTemplate(); + const packet = steps.find((s) => s.id === "prompt-packet"); + expect(packet?.dependsOn).toEqual(["feasibility-check", "rag-retrieval"]); + }); +});