Skip to content

Commit 5b6ca30

Browse files
committed
feat(miner-plan): add analyze and prepare plan-DAG templates to the shared engine
1 parent 86dde13 commit 5b6ca30

4 files changed

Lines changed: 180 additions & 1 deletion

File tree

packages/gittensory-engine/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export {
1111
} from "./opportunity-ranker.js";
1212
export * from "./governor/rate-limit.js";
1313
export * from "./plan-export.js";
14+
export * from "./plan-templates.js";
1415
export * from "./portfolio/queue.js";
1516
export {
1617
resolveAiPolicyVerdict,
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Plan-template library (pure).
2+
//
3+
// Reusable plan TEMPLATES for the fixed miner lifecycle (discover -> analyze -> plan -> prepare -> create ->
4+
// manage -> repeat), emitted in the exact stateless raw-step shape the MCP `gittensory_build_plan` tool accepts
5+
// (`rawPlanStepSchema` in src/mcp/server.ts), so `build_plan` can normalize them into a validated DAG. Each builder
6+
// is deterministic and side-effect-free: it only DESCRIBES steps and their `dependsOn` ordering — it never actuates
7+
// anything. The `RawPlanStep` type below mirrors the raw-step schema so the engine package stays standalone and
8+
// does not import the app's Zod schema (the tests validate the output against the real schema to guard drift).
9+
10+
// Mirror of `rawPlanStepSchema` (src/mcp/server.ts): the pre-normalization step shape `gittensory_build_plan` accepts.
11+
export type RawPlanStep = {
12+
id: string;
13+
title: string;
14+
actionClass?: string | undefined;
15+
dependsOn?: string[] | undefined;
16+
maxAttempts?: number | undefined;
17+
};
18+
19+
// The lifecycle-stage transitions this library provides a template for.
20+
export type PlanTemplateStage = "analyze" | "prepare";
21+
22+
// Context woven into a template's step titles so a plan reads against the opportunity it targets.
23+
export type PlanTemplateContext = {
24+
// A short human label for the issue/opportunity the plan is for (e.g. an issue title). Optional so a caller can
25+
// render a generic template; whitespace is collapsed and the value is length-bounded to keep every title valid.
26+
subject?: string | undefined;
27+
};
28+
29+
// Title length ceiling of `rawPlanStepSchema.title` (max 300). Titles are hard-capped to this so a long subject can
30+
// never produce an out-of-range step.
31+
const MAX_TITLE_CHARS = 300;
32+
// Keep the woven subject well under the title ceiling so the fixed prefix always survives the cap.
33+
const MAX_SUBJECT_CHARS = 200;
34+
35+
// Collapse any run of whitespace (including newlines) to a single space and trim, so a subject yields a clean,
36+
// deterministic one-line title.
37+
function normalizeSubject(subject: string | undefined): string {
38+
return (subject ?? "").replace(/\s+/g, " ").trim().slice(0, MAX_SUBJECT_CHARS);
39+
}
40+
41+
// Compose a step title from a fixed prefix and the optional subject, hard-capped to the schema's title ceiling.
42+
function titleFor(prefix: string, subject: string): string {
43+
const full = subject ? `${prefix}: ${subject}` : prefix;
44+
return full.slice(0, MAX_TITLE_CHARS);
45+
}
46+
47+
// analyze: feasibility check and repository RAG retrieval run independently, then the prompt-packet build consumes
48+
// both. Mirrors the ANALYZE-phase ordering described in the plan-template issue.
49+
export function analyzePlanTemplate(context: PlanTemplateContext = {}): RawPlanStep[] {
50+
const subject = normalizeSubject(context.subject);
51+
return [
52+
{ id: "feasibility-check", title: titleFor("Assess feasibility", subject), actionClass: "analyze", dependsOn: [], maxAttempts: 1 },
53+
{ id: "rag-retrieval", title: titleFor("Retrieve repository context", subject), actionClass: "retrieve", dependsOn: [], maxAttempts: 3 },
54+
{ id: "prompt-packet", title: titleFor("Build prompt packet", subject), actionClass: "compose", dependsOn: ["feasibility-check", "rag-retrieval"], maxAttempts: 2 },
55+
];
56+
}
57+
58+
// prepare: a strict chain — create the branch, invoke the coding agent (placeholder step; no actuation here), then
59+
// run the local tests. Mirrors the PREPARE-phase ordering described in the plan-template issue.
60+
export function preparePlanTemplate(context: PlanTemplateContext = {}): RawPlanStep[] {
61+
const subject = normalizeSubject(context.subject);
62+
return [
63+
{ id: "branch-create", title: titleFor("Create working branch", subject), actionClass: "vcs", dependsOn: [], maxAttempts: 3 },
64+
{ id: "coding-agent", title: titleFor("Invoke coding agent", subject), actionClass: "codegen", dependsOn: ["branch-create"], maxAttempts: 1 },
65+
{ id: "local-test", title: titleFor("Run local tests", subject), actionClass: "test", dependsOn: ["coding-agent"], maxAttempts: 2 },
66+
];
67+
}
68+
69+
// Registry of every stage transition to its template builder, so callers can enumerate or dispatch by stage.
70+
// Frozen so a consumer cannot mutate the shared registry and change dispatch behavior process-wide.
71+
export const PLAN_TEMPLATE_BUILDERS: Readonly<Record<PlanTemplateStage, (context?: PlanTemplateContext) => RawPlanStep[]>> =
72+
Object.freeze({
73+
analyze: analyzePlanTemplate,
74+
prepare: preparePlanTemplate,
75+
});
76+
77+
// Build the raw-step template for a stage. Pure — a thin dispatcher over `PLAN_TEMPLATE_BUILDERS` that rejects an
78+
// unknown stage with a clear error rather than a generic "not a function" TypeError (guards non-TypeScript callers).
79+
export function buildPlanTemplate(stage: PlanTemplateStage, context: PlanTemplateContext = {}): RawPlanStep[] {
80+
const builder = PLAN_TEMPLATE_BUILDERS[stage];
81+
if (!builder) throw new Error(`Unknown plan-template stage: ${String(stage)}`);
82+
return builder(context);
83+
}

src/mcp/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@ const localWriteActionOutputSchema = {
322322
// #783 plan DAG — STATELESS: the harness holds the plan and passes it back each call; these tools only advance
323323
// the state machine, so gittensory keeps no record of the miner's plan.
324324
const planStepStatusEnum = z.enum(["pending", "running", "completed", "failed", "skipped"]);
325-
const rawPlanStepSchema = z
325+
export const rawPlanStepSchema = z
326326
.object({
327327
id: z.string().min(1).max(100),
328328
title: z.string().min(1).max(300),

test/unit/plan-templates.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
analyzePlanTemplate,
4+
buildPlanTemplate,
5+
preparePlanTemplate,
6+
PLAN_TEMPLATE_BUILDERS,
7+
type PlanTemplateStage,
8+
type RawPlanStep,
9+
} from "../../packages/gittensory-engine/src/plan-templates";
10+
import { rawPlanStepSchema } from "../../src/mcp/server";
11+
12+
const STAGES = Object.keys(PLAN_TEMPLATE_BUILDERS) as PlanTemplateStage[];
13+
14+
function idsOf(steps: RawPlanStep[]): string[] {
15+
return steps.map((s) => s.id);
16+
}
17+
18+
describe("plan-templates", () => {
19+
it("exposes a builder for every declared stage", () => {
20+
expect(STAGES.sort()).toEqual(["analyze", "prepare"]);
21+
});
22+
23+
it.each(STAGES)("'%s' template round-trips through the real rawPlanStepSchema", (stage) => {
24+
const steps = buildPlanTemplate(stage, { subject: "fix flaky retry" });
25+
expect(steps.length).toBeGreaterThan(0);
26+
for (const step of steps) {
27+
expect(() => rawPlanStepSchema.parse(step)).not.toThrow();
28+
}
29+
});
30+
31+
it.each(STAGES)("'%s' template has unique ids and only in-plan, acyclic dependencies", (stage) => {
32+
const steps = buildPlanTemplate(stage);
33+
const ids = idsOf(steps);
34+
expect(new Set(ids).size).toBe(ids.length);
35+
const present = new Set(ids);
36+
for (const step of steps) {
37+
for (const dep of step.dependsOn ?? []) {
38+
expect(present.has(dep)).toBe(true);
39+
}
40+
}
41+
// A step may only depend on steps declared before it, which both proves acyclicity and gives a ready topo order.
42+
const seen = new Set<string>();
43+
for (const step of steps) {
44+
for (const dep of step.dependsOn ?? []) expect(seen.has(dep)).toBe(true);
45+
seen.add(step.id);
46+
}
47+
});
48+
49+
it("is deterministic: same context yields identical output", () => {
50+
expect(analyzePlanTemplate({ subject: "x" })).toEqual(analyzePlanTemplate({ subject: "x" }));
51+
expect(preparePlanTemplate()).toEqual(preparePlanTemplate());
52+
});
53+
54+
it("weaves the subject into every title as a single clean line", () => {
55+
const steps = analyzePlanTemplate({ subject: " add\ta\nlaptop mode " });
56+
for (const step of steps) {
57+
expect(step.title).toContain(": add a laptop mode");
58+
expect(step.title).not.toMatch(/[\r\n\t]/);
59+
}
60+
});
61+
62+
it("omits the subject suffix when no subject is given", () => {
63+
const first = preparePlanTemplate()[0];
64+
expect(first?.title).toBe("Create working branch");
65+
});
66+
67+
it("bounds an oversized subject so every title stays within the schema's 300-char limit", () => {
68+
const steps = analyzePlanTemplate({ subject: "z".repeat(5000) });
69+
for (const step of steps) {
70+
expect(() => rawPlanStepSchema.parse(step)).not.toThrow();
71+
expect(step.title.length).toBeLessThanOrEqual(300);
72+
}
73+
});
74+
75+
it("encodes the real analyze ordering: prompt-packet depends on both feasibility and retrieval", () => {
76+
const steps = analyzePlanTemplate();
77+
const packet = steps.find((s) => s.id === "prompt-packet");
78+
expect(packet?.dependsOn).toEqual(["feasibility-check", "rag-retrieval"]);
79+
});
80+
81+
it("encodes the real prepare ordering: a strict branch-create -> coding-agent -> local-test chain", () => {
82+
const steps = preparePlanTemplate();
83+
expect(steps.map((s) => s.id)).toEqual(["branch-create", "coding-agent", "local-test"]);
84+
expect(steps.find((s) => s.id === "coding-agent")?.dependsOn).toEqual(["branch-create"]);
85+
expect(steps.find((s) => s.id === "local-test")?.dependsOn).toEqual(["coding-agent"]);
86+
});
87+
88+
it("rejects an unknown stage with a clear error instead of a generic TypeError", () => {
89+
expect(() => buildPlanTemplate("bogus" as PlanTemplateStage)).toThrow(/Unknown plan-template stage/);
90+
});
91+
92+
it("exposes a frozen registry so the shared dispatch table cannot be mutated", () => {
93+
expect(Object.isFrozen(PLAN_TEMPLATE_BUILDERS)).toBe(true);
94+
});
95+
});

0 commit comments

Comments
 (0)