From f93b2da60d43303ec2eaf0eeab19bc3e972c554f Mon Sep 17 00:00:00 2001 From: davion-knight <298846663+davion-knight@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:16:56 -0500 Subject: [PATCH] feat(miner-plan): issue-to-plan decomposition heuristic (#4292) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add decomposeIssueToPlan(issue) in @jsonbored/gittensory-engine: a pure, deterministic 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 gittensory_build_plan normalizes it and plan-store.js's validatePlanDag accepts it unchanged. The spine is always locate -> implement -> test -> verify. A bug signal (keywords or a bug label) inserts a reproduce step before implement and asks test for a regression test; a docs signal inserts a docs step that verify also waits on. Every dependsOn references an earlier-declared step, so the output is unique-id'd, acyclic, and within rawPlanStepSchema's bounds by construction; the same issue always yields the same plan. This is the real logic behind planPlanTemplate's plan-dag-build placeholder, kept standalone (the template describes the miner's fixed lifecycle; this composes the target issue's work). Closes #4292 --- packages/gittensory-engine/src/index.ts | 1 + .../src/issue-plan-decomposition.ts | 73 +++++++++++++++++ test/unit/issue-plan-decomposition.test.ts | 78 +++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 packages/gittensory-engine/src/issue-plan-decomposition.ts create mode 100644 test/unit/issue-plan-decomposition.test.ts diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 3e46336637..171012c3c5 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -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, diff --git a/packages/gittensory-engine/src/issue-plan-decomposition.ts b/packages/gittensory-engine/src/issue-plan-decomposition.ts new file mode 100644 index 0000000000..2c49735381 --- /dev/null +++ b/packages/gittensory-engine/src/issue-plan-decomposition.ts @@ -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; +} diff --git a/test/unit/issue-plan-decomposition.test.ts b/test/unit/issue-plan-decomposition.test.ts new file mode 100644 index 0000000000..9e6a760f33 --- /dev/null +++ b/test/unit/issue-plan-decomposition.test.ts @@ -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(); + 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)); + }); +});