Skip to content
Merged
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 @@ -152,6 +152,7 @@ export { resolvePlanOverallStatus, type PlanOverallStatus } from "./plan-overall
export { hasPlanReadySteps } from "./plan-ready.js";
export { isPlanTerminated } from "./plan-terminated.js";
export * from "./plan-templates.js";
export * from "./issue-plan-decomposition.js";
export {
PROMPT_PACKET_REDACTED_PATH,
PROMPT_PACKET_REDACTED_TERM,
Expand Down
73 changes: 73 additions & 0 deletions packages/gittensory-engine/src/issue-plan-decomposition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Issue-to-plan decomposition heuristic (pure) (#4292).
//
// The stateless plan-DAG surface (rawPlanStepSchema / gittensory_build_plan, src/mcp/server.ts; plan-store.js
// persistence) consumes a caller-supplied RawPlanStep[], but nothing in the repo turns a TARGET ISSUE into those
// steps — every caller has to hand it one already-built. plan-templates.ts's PLAN_TEMPLATE_BUILDERS describe the
// miner's OWN fixed lifecycle, not the issue's actual implementation work; planPlanTemplate even carries a
// `plan-dag-build` placeholder step with no logic behind it. This module is that missing piece: a deterministic,
// side-effect-free function that folds issue-level metadata (title / body / labels only — never source content)
// into a RawPlanStep[] execution DAG in the SAME raw-step shape plan-templates.ts emits, so build_plan and
// plan-store.js's validatePlanDag validate it identically.

import type { RawPlanStep } from "./plan-templates.js";

/** Issue-level metadata this heuristic decomposes into an execution plan. Every field is optional so a bare issue
* (title-only, or nothing at all) still yields a valid baseline DAG. A PromptPacket caller (#2321) can map its
* `taskBrief` onto `title` and `retrievalContext`/`constraints` onto `body`. */
export type IssuePlanInput = {
title?: string | undefined;
body?: string | undefined;
labels?: readonly string[] | undefined;
};

// Title ceiling of rawPlanStepSchema.title (max 300) and a subject cap kept well under it, mirroring
// plan-templates.ts, so a long issue title can never produce an out-of-range step title.
const MAX_TITLE_CHARS = 300;
const MAX_SUBJECT_CHARS = 200;

// Collapse any run of whitespace to a single space and trim/bound, so a subject yields a clean deterministic
// one-line title (mirrors plan-templates.ts's normalizeSubject).
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);
}

// Low-cardinality issue-kind signals derived deterministically from the combined title+body+labels text. Labels
// are folded into the SAME lowercased haystack as the free text, so an issue tagged `bug` and one whose title
// merely says "fix the crash" take the same path without a separate label-only branch.
const BUG_SIGNAL = /\b(bug|bugs|fix|fixes|regression|broken|crash|crashes|incorrect)\b/;
const DOCS_SIGNAL = /\b(doc|docs|documentation|readme|guide)\b/;

/**
* Decompose a target issue into a deterministic execution-plan DAG of {@link RawPlanStep}s. Same input always
* yields identical output (no clock, no randomness) — matching every other pure composer in this package. The spine
* is always `locate → implement → test → verify`; a bug signal inserts a `reproduce` step before `implement` (and
* asks `test` for a regression test), and a docs signal inserts a `docs` step that `verify` then also waits on.
* Every `dependsOn` references an EARLIER step, so the result is acyclic with a ready topological order and passes
* rawPlanStepSchema + plan-store.js's `validatePlanDag` unchanged (unique ids, in-plan deps, no cycles).
*/
export function decomposeIssueToPlan(issue: IssuePlanInput = {}): RawPlanStep[] {
const subject = normalizeSubject(issue.title);
const haystack = `${issue.title ?? ""} ${issue.body ?? ""} ${(issue.labels ?? []).join(" ")}`.toLowerCase();
const isBugFix = BUG_SIGNAL.test(haystack);
const wantsDocs = DOCS_SIGNAL.test(haystack);

const steps: RawPlanStep[] = [
{ id: "locate", title: titleFor("Locate the code to change", subject), actionClass: "analyze", dependsOn: [], maxAttempts: 2 },
];
if (isBugFix) {
steps.push({ id: "reproduce", title: titleFor("Reproduce the reported behavior", subject), actionClass: "analyze", dependsOn: ["locate"], maxAttempts: 1 });
}
steps.push({ id: "implement", title: titleFor("Implement the change", subject), actionClass: "codegen", dependsOn: [isBugFix ? "reproduce" : "locate"], maxAttempts: 1 });
steps.push({ id: "test", title: titleFor(isBugFix ? "Add a regression test and run the suite" : "Add tests and run the suite", subject), actionClass: "test", dependsOn: ["implement"], maxAttempts: 2 });
if (wantsDocs) {
steps.push({ id: "docs", title: titleFor("Update documentation", subject), actionClass: "compose", dependsOn: ["implement"], maxAttempts: 1 });
}
steps.push({ id: "verify", title: titleFor("Verify the full gate is green", subject), actionClass: "analyze", dependsOn: wantsDocs ? ["test", "docs"] : ["test"], maxAttempts: 2 });
return steps;
}
78 changes: 78 additions & 0 deletions test/unit/issue-plan-decomposition.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { decomposeIssueToPlan, type IssuePlanInput } from "../../packages/gittensory-engine/src/issue-plan-decomposition";
import type { RawPlanStep } from "../../packages/gittensory-engine/src/plan-templates";
import { rawPlanStepSchema } from "../../src/mcp/server";

// Assert the structural rules plan-store.js's validatePlanDag enforces (rawPlanStepSchema-valid steps, unique ids,
// in-plan non-self deps, every dep declared BEFORE use → acyclic with a ready topo order). Mirrors the equivalent
// checks in plan-templates.test.ts so the two composers are held to the same contract.
function assertValidDag(steps: RawPlanStep[]): void {
expect(steps.length).toBeGreaterThan(0);
for (const step of steps) expect(() => rawPlanStepSchema.parse(step)).not.toThrow();
const ids = steps.map((s) => s.id);
expect(new Set(ids).size).toBe(ids.length);
const seen = new Set<string>();
for (const step of steps) {
for (const dep of step.dependsOn ?? []) {
expect(dep).not.toBe(step.id);
expect(seen.has(dep)).toBe(true);
}
seen.add(step.id);
}
}

const idsOf = (steps: RawPlanStep[]): string[] => steps.map((s) => s.id);

describe("decomposeIssueToPlan (#4292 issue → plan DAG)", () => {
it("produces a schema-valid, acyclic 6-step DAG for a bug + docs issue", () => {
const steps = decomposeIssueToPlan({ title: "Fix the crash on empty input", body: "It crashes; update the docs too.", labels: ["bug", "docs"] });
assertValidDag(steps);
expect(idsOf(steps)).toEqual(["locate", "reproduce", "implement", "test", "docs", "verify"]);
expect(steps.find((s) => s.id === "implement")!.dependsOn).toEqual(["reproduce"]);
expect(steps.find((s) => s.id === "verify")!.dependsOn).toEqual(["test", "docs"]);
expect(steps.find((s) => s.id === "test")!.title).toContain("regression");
expect(steps[0]!.title).toContain("Fix the crash on empty input"); // subject woven into titles
});

it("produces the minimal baseline spine for a bare issue (no title/body/labels)", () => {
const steps = decomposeIssueToPlan();
assertValidDag(steps);
expect(idsOf(steps)).toEqual(["locate", "implement", "test", "verify"]);
expect(steps.find((s) => s.id === "implement")!.dependsOn).toEqual(["locate"]);
expect(steps.find((s) => s.id === "verify")!.dependsOn).toEqual(["test"]);
expect(steps.find((s) => s.id === "test")!.title).toBe("Add tests and run the suite"); // no subject, non-bug
});

it("inserts a reproduce step + regression test for a bug-signalled issue with no docs", () => {
const steps = decomposeIssueToPlan({ title: "regression in parser", labels: ["bug"] });
assertValidDag(steps);
expect(idsOf(steps)).toEqual(["locate", "reproduce", "implement", "test", "verify"]);
expect(steps.some((s) => s.id === "docs")).toBe(false);
});

it("inserts a docs step (verify waits on it) for a docs-signalled non-bug issue", () => {
const steps = decomposeIssueToPlan({ labels: ["documentation"] });
assertValidDag(steps);
expect(idsOf(steps)).toEqual(["locate", "implement", "test", "docs", "verify"]);
expect(steps.some((s) => s.id === "reproduce")).toBe(false);
expect(steps.find((s) => s.id === "verify")!.dependsOn).toEqual(["test", "docs"]);
});

it("detects the issue kind from free-text title/body even without labels", () => {
expect(decomposeIssueToPlan({ title: "Fix broken retry" }).some((s) => s.id === "reproduce")).toBe(true);
expect(decomposeIssueToPlan({ body: "please update the README guide" }).some((s) => s.id === "docs")).toBe(true);
});

it("caps an overlong title to the schema's 300-char ceiling", () => {
const steps = decomposeIssueToPlan({ title: "x".repeat(500) });
for (const step of steps) {
expect(step.title.length).toBeLessThanOrEqual(300);
expect(() => rawPlanStepSchema.parse(step)).not.toThrow();
}
});

it("is deterministic: same input yields identical output", () => {
const input: IssuePlanInput = { title: "Add a retry to the fetch helper", body: "b", labels: ["feature"] };
expect(decomposeIssueToPlan(input)).toEqual(decomposeIssueToPlan(input));
});
});