Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
78 changes: 78 additions & 0 deletions packages/gittensory-engine/src/plan-templates.ts
Original file line number Diff line number Diff line change
@@ -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<PlanTemplateStage, (context?: PlanTemplateContext) => 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);
}
2 changes: 1 addition & 1 deletion src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
80 changes: 80 additions & 0 deletions test/unit/plan-templates.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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"]);
});
});
Loading