diff --git a/src/__tests__/planLifecycle.test.ts b/src/__tests__/planLifecycle.test.ts new file mode 100644 index 0000000..4113dec --- /dev/null +++ b/src/__tests__/planLifecycle.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect } from "vitest"; +import { + deriveLifecycleState, + LIFECYCLE_LABEL, + buildRefactorFleet, + buildRefactorIssues, + type LifecycleSignals, +} from "../lib/planLifecycle"; + +// ── deriveLifecycleState ────────────────────────────────────────────────────── + +describe("deriveLifecycleState (#458 lifecycle state)", () => { + const active = (overrides?: Partial): LifecycleSignals => + ({ isExisting: true, totalIssues: 10, closedIssues: 0, planGradeScore: 0.8, ...overrides }); + + it("returns 'new' when the project is not yet published", () => { + expect(deriveLifecycleState({ isExisting: false, totalIssues: 20, closedIssues: 15 })).toBe("new"); + }); + + it("returns 'active' for an existing project with < 50% closure", () => { + expect(deriveLifecycleState(active({ closedIssues: 4 }))).toBe("active"); // 40% + }); + + it("returns 'near-complete' when ≥75% of issues are closed", () => { + expect(deriveLifecycleState(active({ closedIssues: 8 }))).toBe("near-complete"); // 80% + expect(deriveLifecycleState(active({ closedIssues: 7, totalIssues: 9 }))).toBe("near-complete"); // ~78% + expect(deriveLifecycleState(active({ closedIssues: 75, totalIssues: 100 }))).toBe("near-complete"); // 75% + }); + + it("returns 'near-complete' at 50%+ closure when the plan grade is B or better (≥0.75)", () => { + expect(deriveLifecycleState(active({ closedIssues: 6, planGradeScore: 0.80 }))).toBe("near-complete"); // 60%+B + expect(deriveLifecycleState(active({ closedIssues: 5, planGradeScore: 0.75 }))).toBe("near-complete"); // 50%+B + }); + + it("remains 'active' at 50%+ closure when the grade is below B", () => { + expect(deriveLifecycleState(active({ closedIssues: 6, planGradeScore: 0.60 }))).toBe("active"); // 60%+C + expect(deriveLifecycleState(active({ closedIssues: 5, planGradeScore: 0.74 }))).toBe("active"); // 50%+C + }); + + it("returns 'active' for a plan with no issues (ratio = 0)", () => { + expect(deriveLifecycleState(active({ totalIssues: 0, closedIssues: 0 }))).toBe("active"); + }); + + it("LIFECYCLE_LABEL maps all states", () => { + expect(LIFECYCLE_LABEL["new"]).toBe("drafting"); + expect(LIFECYCLE_LABEL["active"]).toBe("expanding"); + expect(LIFECYCLE_LABEL["near-complete"]).toBe("near-complete"); + }); +}); + +// ── buildRefactorFleet ──────────────────────────────────────────────────────── + +describe("buildRefactorFleet (#458 refactor fleet shape)", () => { + it("produces two non-overlapping streams per repo (frontend + backend)", () => { + const fleet = buildRefactorFleet(["acme/web"]); + expect(fleet.streams).toHaveLength(2); + const [fe, be] = fleet.streams; + // fe owns src/**, be owns src-tauri/** — guaranteed non-overlapping + expect(fe.owns).toContain("src/**"); + expect(be.owns).toContain("src-tauri/**"); + // No overlap between the two own globs + expect(fe.owns.some(o => be.owns.includes(o))).toBe(false); + }); + + it("ids are lowercase-hyphen slugs (valid git branch names)", () => { + const fleet = buildRefactorFleet(["My-Org/My Repo"]); + for (const s of fleet.streams) { + expect(s.id).toMatch(/^[a-z0-9-]+$/); + } + }); + + it("produces non-overlapping streams across multiple repos", () => { + const fleet = buildRefactorFleet(["acme/web", "acme/api"]); + expect(fleet.streams).toHaveLength(4); + // Each stream owns either src/** or src-tauri/** + // Deduplicated: only two unique globs + const unique = [...new Set(fleet.streams.flatMap(s => s.owns))]; + expect(unique).toHaveLength(2); // src/** and src-tauri/** + // No two streams with the SAME repo own the same glob + for (let i = 0; i < fleet.streams.length; i++) { + for (let j = i + 1; j < fleet.streams.length; j++) { + const a = fleet.streams[i], b = fleet.streams[j]; + if (a.repo === b.repo) { + expect(a.owns.some(o => b.owns.includes(o))).toBe(false); + } + } + } + }); + + it("enables the director when there are multiple repos", () => { + expect(buildRefactorFleet(["a/b", "c/d"]).director.enabled).toBe(true); + }); + + it("enables the director even for a single repo (refactor PRs need a reviewer)", () => { + expect(buildRefactorFleet(["a/b"]).director.enabled).toBe(true); + }); + + it("all streams have confirm-gate push (human reviews before landing)", () => { + const fleet = buildRefactorFleet(["acme/web"]); + for (const s of fleet.streams) { + expect(s.flow?.push).toBe("push-confirm"); + expect(s.flow?.gate).toBe("hard"); + } + }); + + it("returns an empty fleet for an empty repos list", () => { + expect(buildRefactorFleet([]).streams).toHaveLength(0); + }); +}); + +// ── buildRefactorIssues ─────────────────────────────────────────────────────── + +describe("buildRefactorIssues (#458 agent-ready refactor issues)", () => { + it("produces agent-ready issues with acceptance criteria and owned files", () => { + const issues = buildRefactorIssues(["acme/web"]); + expect(issues.length).toBeGreaterThan(0); + for (const i of issues) { + expect(i.acceptance.length).toBeGreaterThan(0); + expect(i.owns.length).toBeGreaterThan(0); + expect(i.stream).toBeTruthy(); + expect(i.phase).toBe("Refactor"); + } + }); + + it("assigns frontend issues to the -fe stream and backend to -be stream", () => { + const issues = buildRefactorIssues(["acme/web"]); + const feIssues = issues.filter(i => i.stream?.endsWith("-refactor-fe")); + const beIssues = issues.filter(i => i.stream?.endsWith("-refactor-be")); + expect(feIssues.length).toBeGreaterThan(0); + expect(beIssues.length).toBeGreaterThan(0); + for (const i of feIssues) expect(i.owns).toContain("src/**"); + for (const i of beIssues) expect(i.owns).toContain("src-tauri/**"); + }); + + it("scales to multiple repos — separate issues per repo", () => { + const single = buildRefactorIssues(["acme/web"]); + const multi = buildRefactorIssues(["acme/web", "acme/api"]); + expect(multi.length).toBe(single.length * 2); + }); + + it("issue refs are stable and unique", () => { + const issues = buildRefactorIssues(["acme/web", "acme/api"]); + const refs = issues.map(i => i.ref); + expect(new Set(refs).size).toBe(refs.length); + }); +}); diff --git a/src/__tests__/planSeamGraph.test.ts b/src/__tests__/planSeamGraph.test.ts new file mode 100644 index 0000000..ca63980 --- /dev/null +++ b/src/__tests__/planSeamGraph.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect } from "vitest"; +import { buildSeamGraph, type SeamGraph } from "../lib/planSeamGraph"; +import type { PlanIssue } from "../screens/projects/planIssues"; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +function issue(ref: string, overrides: Partial = {}): PlanIssue { + return { + ref, + title: `Issue ${ref}`, + acceptance: [], + owns: [], + dependsOn: [], + labels: [], + ...overrides, + }; +} + +function layersOf(g: SeamGraph): Map { + return new Map(g.nodes.map(n => [n.id, n.layer])); +} + +// ── buildSeamGraph ──────────────────────────────────────────────────────────── + +describe("buildSeamGraph (#294 seam graph builder)", () => { + + it("returns an empty graph for an empty issue list", () => { + const g = buildSeamGraph([]); + expect(g.nodes).toHaveLength(0); + expect(g.edges).toHaveLength(0); + expect(g.layerCount).toBe(0); + expect(g.danglingCount).toBe(0); + }); + + it("places a single node with no deps in layer 0", () => { + const g = buildSeamGraph([issue("A")]); + expect(g.nodes).toHaveLength(1); + expect(g.nodes[0].layer).toBe(0); + expect(g.nodes[0].order).toBe(0); + expect(g.edges).toHaveLength(0); + }); + + it("builds a linear chain with increasing layers", () => { + // A → B → C: A is layer 0, B is layer 1, C is layer 2. + const issues = [ + issue("A"), + issue("B", { dependsOn: ["A"] }), + issue("C", { dependsOn: ["B"] }), + ]; + const g = buildSeamGraph(issues); + const layers = layersOf(g); + expect(layers.get("A")).toBe(0); + expect(layers.get("B")).toBe(1); + expect(layers.get("C")).toBe(2); + expect(g.layerCount).toBe(3); + expect(g.danglingCount).toBe(0); + }); + + it("uses the longest path (critical path) for diamond DAGs", () => { + // A → B, A → C, B → D, C → D. + // Shortest path to D via A→B→D is 2; via A→C→D is also 2. Layer of D = 2. + const issues = [ + issue("A"), + issue("B", { dependsOn: ["A"] }), + issue("C", { dependsOn: ["A"] }), + issue("D", { dependsOn: ["B", "C"] }), + ]; + const g = buildSeamGraph(issues); + const layers = layersOf(g); + expect(layers.get("A")).toBe(0); + expect(layers.get("B")).toBe(1); + expect(layers.get("C")).toBe(1); + expect(layers.get("D")).toBe(2); + expect(g.layerCount).toBe(3); + }); + + it("uses the longest path when branches have unequal depth", () => { + // A → B → C → E, A → D → E. + // Depth of E via A→B→C: 3; via A→D: 2. Longest path places E at layer 3. + const issues = [ + issue("A"), + issue("B", { dependsOn: ["A"] }), + issue("C", { dependsOn: ["B"] }), + issue("D", { dependsOn: ["A"] }), + issue("E", { dependsOn: ["C", "D"] }), + ]; + const g = buildSeamGraph(issues); + const layers = layersOf(g); + expect(layers.get("E")).toBe(3); + expect(layers.get("D")).toBe(1); + }); + + it("marks dangling deps and counts them", () => { + // B depends on X which is not in the issue list. + const g = buildSeamGraph([issue("A"), issue("B", { dependsOn: ["X"] })]); + expect(g.danglingCount).toBe(1); + const dangling = g.edges.filter(e => e.dangling); + expect(dangling).toHaveLength(1); + expect(dangling[0].from).toBe("X"); + expect(dangling[0].to).toBe("B"); + }); + + it("dangling deps do not affect layer assignment (B still lands at layer 0)", () => { + // B has a dep on absent X; without incoming non-dangling edges B is a source. + const g = buildSeamGraph([issue("A"), issue("B", { dependsOn: ["X"] })]); + const layers = layersOf(g); + expect(layers.get("B")).toBe(0); + }); + + it("multiple dangling deps accumulate in danglingCount", () => { + const g = buildSeamGraph([issue("A", { dependsOn: ["X", "Y", "Z"] })]); + expect(g.danglingCount).toBe(3); + }); + + it("filterRepo restricts nodes to the specified repo", () => { + const issues = [ + issue("A", { repo: "org/web" }), + issue("B", { repo: "org/api" }), + issue("C", { repo: "org/web" }), + ]; + const g = buildSeamGraph(issues, "org/web"); + expect(g.nodes.map(n => n.id).sort()).toEqual(["A", "C"]); + }); + + it("filterRepo marks cross-repo deps as dangling", () => { + // C (web) depends on B (api); when filtered to web, dep on B is dangling. + const issues = [ + issue("A", { repo: "org/web" }), + issue("B", { repo: "org/api" }), + issue("C", { repo: "org/web", dependsOn: ["B"] }), + ]; + const g = buildSeamGraph(issues, "org/web"); + expect(g.danglingCount).toBe(1); + expect(g.edges[0].dangling).toBe(true); + }); + + it("with no filterRepo, all issues are included and cross-repo deps resolve", () => { + const issues = [ + issue("A", { repo: "org/web" }), + issue("B", { repo: "org/api", dependsOn: ["A"] }), + ]; + const g = buildSeamGraph(issues); + expect(g.nodes).toHaveLength(2); + expect(g.danglingCount).toBe(0); + const layers = layersOf(g); + expect(layers.get("A")).toBe(0); + expect(layers.get("B")).toBe(1); + }); + + it("carries owns and acceptance onto each SeamNode for drill-down", () => { + const accepts = ["criterion one", "criterion two"]; + const owns = ["src/foo/**"]; + const g = buildSeamGraph([issue("A", { acceptance: accepts, owns })]); + const n = g.nodes.find(n => n.id === "A")!; + expect(n.owns).toEqual(owns); + expect(n.acceptance).toEqual(accepts); + }); + + it("derives maturity: done when labels contain 'done'", () => { + const g = buildSeamGraph([issue("A", { labels: ["done"] })]); + expect(g.nodes[0].maturity).toBe("done"); + }); + + it("derives maturity: done for 'closed' label", () => { + const g = buildSeamGraph([issue("A", { labels: ["closed"] })]); + expect(g.nodes[0].maturity).toBe("done"); + }); + + it("derives maturity: active when ≥2 acceptance criteria AND has owns", () => { + const g = buildSeamGraph([issue("A", { acceptance: ["a", "b"], owns: ["src/**"] })]); + expect(g.nodes[0].maturity).toBe("active"); + }); + + it("derives maturity: backlog when only acceptance criteria exist (no owns)", () => { + const g = buildSeamGraph([issue("A", { acceptance: ["a"] })]); + expect(g.nodes[0].maturity).toBe("backlog"); + }); + + it("derives maturity: backlog when only owns exist (no acceptance)", () => { + const g = buildSeamGraph([issue("A", { owns: ["src/**"] })]); + expect(g.nodes[0].maturity).toBe("backlog"); + }); + + it("derives maturity: stub when neither acceptance nor owns", () => { + const g = buildSeamGraph([issue("A")]); + expect(g.nodes[0].maturity).toBe("stub"); + }); + + it("nodes within the same layer are ordered stably (by stream, then ref)", () => { + const issues = [ + issue("C", { stream: "alpha" }), + issue("A", { stream: "beta" }), + issue("B", { stream: "alpha" }), + ]; + const g = buildSeamGraph(issues); + // All in layer 0. Sorted: alpha/B, alpha/C, beta/A → orders 0,1,2 + const orderByRef = new Map(g.nodes.map(n => [n.id, n.order])); + expect(orderByRef.get("B")).toBeLessThan(orderByRef.get("C")!); + expect(orderByRef.get("C")!).toBeLessThan(orderByRef.get("A")!); + }); + + it("carries stream and phase through to the node", () => { + const g = buildSeamGraph([issue("A", { stream: "my-stream", phase: 2 })]); + const n = g.nodes[0]; + expect(n.stream).toBe("my-stream"); + expect(n.phase).toBe(2); + }); + + it("disconnected subgraphs both land in their own layer assignments", () => { + // Two independent chains: A→B and C→D. + const issues = [ + issue("A"), + issue("B", { dependsOn: ["A"] }), + issue("C"), + issue("D", { dependsOn: ["C"] }), + ]; + const g = buildSeamGraph(issues); + const layers = layersOf(g); + expect(layers.get("A")).toBe(0); + expect(layers.get("B")).toBe(1); + expect(layers.get("C")).toBe(0); + expect(layers.get("D")).toBe(1); + expect(g.layerCount).toBe(2); + }); + + it("nodes in a cycle fall back to layer 0 (no infinite loop)", () => { + // A → B → A (cycle). Kahn cannot process them, so both fall to layer 0. + const issues = [ + issue("A", { dependsOn: ["B"] }), + issue("B", { dependsOn: ["A"] }), + ]; + // Must terminate without hanging. + const g = buildSeamGraph(issues); + expect(g.nodes).toHaveLength(2); + const layers = layersOf(g); + expect(layers.get("A")).toBe(0); + expect(layers.get("B")).toBe(0); + }); +}); diff --git a/src/lib/planLifecycle.ts b/src/lib/planLifecycle.ts new file mode 100644 index 0000000..41efee1 --- /dev/null +++ b/src/lib/planLifecycle.ts @@ -0,0 +1,189 @@ +// Lifecycle-aware planning (#458): project lifecycle state derivation and +// refactor/optimization fleet generation. Pure (no React / Tauri) so everything +// is unit-testable without a live app or GitHub. + +import type { FleetPlan, AgentStream } from "../screens/projects/planSections"; +import type { PlanIssue } from "../screens/projects/planIssues"; + +// ── Lifecycle state ─────────────────────────────────────────────────────────── + +export type LifecycleState = "new" | "active" | "near-complete"; + +/** Signals used to derive the lifecycle state. */ +export interface LifecycleSignals { + /** Whether the project is published (activeProjectId is set). */ + isExisting: boolean; + /** Total plan issues (from phaseStructure rollup). */ + totalIssues: number; + /** Issues marked closed on GitHub (from ghProgress / phaseStructure). */ + closedIssues: number; + /** 0–1 plan grade score from planGrade; undefined when no issues exist. */ + planGradeScore?: number; +} + +// A project is "near-complete" when ≥75% of its issues are closed, OR when +// ≥50% are closed AND the plan grade is B or better (≥0.75). Both thresholds +// are conservative to avoid false positives on small plans. +const NEAR_COMPLETE_RATIO = 0.75; +const NEAR_COMPLETE_RATIO_GRADED = 0.50; +const NEAR_COMPLETE_GRADE_B = 0.75; + +/** + * Derive a project's lifecycle state from its current signals. + * "new" → not yet published; "active" → in flight; "near-complete" → most + * work closed, a refactor pass is appropriate. + */ +export function deriveLifecycleState(s: LifecycleSignals): LifecycleState { + if (!s.isExisting) return "new"; + const ratio = s.totalIssues > 0 ? s.closedIssues / s.totalIssues : 0; + const nearComplete = + ratio >= NEAR_COMPLETE_RATIO || + (ratio >= NEAR_COMPLETE_RATIO_GRADED && (s.planGradeScore ?? 0) >= NEAR_COMPLETE_GRADE_B); + return nearComplete ? "near-complete" : "active"; +} + +export const LIFECYCLE_LABEL: Record = { + "new": "drafting", + "active": "expanding", + "near-complete": "near-complete", +}; + +// ── Refactor fleet ──────────────────────────────────────────────────────────── + +// Fixed split: frontend (src/**) and Rust backend (src-tauri/**). These two +// globs are guaranteed non-overlapping regardless of project structure — an +// agent whose repo has no src-tauri layer will simply find nothing to refactor +// in that stream and self-report done quickly. Using two streams (not one) lets +// two agents work in parallel and keeps concerns separate. +const FE_OWNS = "src/**"; +const BE_OWNS = "src-tauri/**"; + +/** + * Build a refactor/optimization fleet plan for the given set of repos. Produces + * two non-overlapping streams per repo — one for the JS/TS frontend layer + * (`src/**`) and one for the Rust backend layer (`src-tauri/**`) — plus a + * coordinating director when more than one stream is generated. The fleet reuses + * the existing `fleetStartProject` machinery (worktrees, profiles, flows). + * + * Stream ids are lowercase-hyphen slugs (they become git branch names). + */ +export function buildRefactorFleet(repos: string[]): FleetPlan { + const streams: AgentStream[] = repos.flatMap((repo) => { + const short = (repo.split("/")[1] ?? repo).toLowerCase().replace(/[^a-z0-9]+/g, "-"); + const fe: AgentStream = { + id: `${short}-refactor-fe`, + name: `${short} — frontend refactor`, + repo, + owns: [FE_OWNS], + issues: [], + dependsOn: [], + flow: { autonomy: "confirm", push: "push-confirm", trigger: "per-stage", gate: "hard" }, + }; + const be: AgentStream = { + id: `${short}-refactor-be`, + name: `${short} — backend refactor`, + repo, + owns: [BE_OWNS], + issues: [], + dependsOn: [], + flow: { autonomy: "confirm", push: "push-confirm", trigger: "per-stage", gate: "hard" }, + }; + return [fe, be]; + }); + + return { + recommended: streams.length, + reasoning: `Refactor pass: ${repos.length} repo${repos.length !== 1 ? "s" : ""} split into non-overlapping frontend + backend streams with confirm-gate push policy so every change is reviewed before landing.`, + streams, + director: { + enabled: true, + role: "async integrator for refactor pass: review and merge refactor PRs, coordinate between streams, keep milestones current", + }, + strategy: "pr-ci", + }; +} + +// ── Refactor issues ─────────────────────────────────────────────────────────── + +// Standard refactor work items generated per repo. Each is a `PlanIssue` ready +// to be published as a GitHub issue and assigned to the appropriate stream. +interface RefactorTemplate { + title: string; + area: "fe" | "be"; + acceptance: string[]; + labels: string[]; +} + +const REFACTOR_TEMPLATES: RefactorTemplate[] = [ + { + title: "Dead-code sweep — remove unused exports, dead files, and unreferenced deps", + area: "fe", + acceptance: [ + "TypeScript build passes with --noUnusedLocals and --noUnusedParameters", + "No unreachable top-level exports remain in src/**", + "package.json has no unused direct dependencies", + "Removal PR passes CI", + ], + labels: ["type:refactor", "area:dead-code"], + }, + { + title: "Simplification pass — reduce complexity, consolidate duplication, tighten APIs", + area: "fe", + acceptance: [ + "No function exceeds 60 lines without justification", + "Duplicated logic consolidated into shared helpers", + "Component/module surface area (exports) trimmed to what callers actually use", + "Tests still pass with no mocks added", + ], + labels: ["type:refactor", "area:simplification"], + }, + { + title: "Rust backend refactor — dead code, unused deps, clippy clean", + area: "be", + acceptance: [ + "cargo clippy -- -D warnings passes", + "No dead_code warnings in src-tauri/**", + "Cargo.toml has no unused crate dependencies", + "cargo test passes", + ], + labels: ["type:refactor", "area:backend"], + }, + { + title: "Performance pass — optimize hot paths, eliminate unnecessary re-renders", + area: "fe", + acceptance: [ + "React DevTools profiler shows no avoidable re-renders on the main planning page", + "No synchronous expensive computation in render paths (moved to useMemo / Web Worker)", + "Network waterfall: no sequential fetches that could be parallelised", + ], + labels: ["type:refactor", "area:performance"], + }, +]; + +/** + * Generate standard agent-ready refactor {@link PlanIssue}s for `repos`, with + * each issue assigned to its owning stream from {@link buildRefactorFleet}. + * Issues are given a `phase` of `"Refactor"` so they can be published into a + * dedicated milestone without touching the existing feature milestones. + */ +export function buildRefactorIssues(repos: string[]): PlanIssue[] { + const out: PlanIssue[] = []; + for (const repo of repos) { + const short = (repo.split("/")[1] ?? repo).toLowerCase().replace(/[^a-z0-9]+/g, "-"); + for (const tpl of REFACTOR_TEMPLATES) { + const streamId = `${short}-refactor-${tpl.area}`; + out.push({ + ref: `refactor:${short}:${tpl.area}:${tpl.labels[1]?.replace("area:", "") ?? tpl.area}`, + title: tpl.title, + phase: "Refactor", + acceptance: tpl.acceptance, + owns: tpl.area === "fe" ? [FE_OWNS] : [BE_OWNS], + dependsOn: [], + labels: [...tpl.labels, `stream:${streamId}`], + repo, + stream: streamId, + }); + } + } + return out; +} diff --git a/src/lib/planSeamGraph.ts b/src/lib/planSeamGraph.ts new file mode 100644 index 0000000..e99992c --- /dev/null +++ b/src/lib/planSeamGraph.ts @@ -0,0 +1,168 @@ +// Per-repo seam/contract DAG visualization (#294). Pure (no React / Tauri) so +// the graph builder and layout are unit-testable. The visualization layer lives +// in SeamGraphView.tsx. +// +// When full FeatureContract seam data is available the graph shows produce→consume +// edges; without it (the common case for projects that haven't authored explicit +// contracts) the graph falls back to `PlanIssue.dependsOn` dependency edges — +// still a useful topological picture of the build order. + +import type { PlanIssue } from "../screens/projects/planIssues"; + +// ── Node maturity ───────────────────────────────────────────────────────────── + +/** Visual maturity derived from the issue's current state. */ +export type NodeMaturity = "stub" | "backlog" | "active" | "done"; + +function maturityOf(issue: PlanIssue): NodeMaturity { + if (issue.labels.some(l => /^(done|closed)$/i.test(l))) return "done"; + if (issue.acceptance.length >= 2 && issue.owns.length > 0) return "active"; + if (issue.acceptance.length > 0 || issue.owns.length > 0) return "backlog"; + return "stub"; +} + +// ── Graph types ─────────────────────────────────────────────────────────────── + +/** One feature/issue node in the seam graph. */ +export interface SeamNode { + /** Issue ref (the stable planner-local id). */ + id: string; + title: string; + stream?: string; + phase?: number | string; + maturity: NodeMaturity; + /** Files/dirs this issue owns (from PlanIssue.owns); used for the drill-down panel. */ + owns: string[]; + /** Acceptance criteria (from PlanIssue.acceptance); used for the drill-down panel. */ + acceptance: string[]; + /** Topological layer: 0 = sources (no upstream deps), higher = later. */ + layer: number; + /** Position within the layer (0-based). */ + order: number; +} + +/** A directed dependency edge: `from` is upstream of `to`. */ +export interface SeamEdge { + from: string; // upstream issue ref + to: string; // downstream issue ref + /** True when `from` doesn't resolve to any node in this graph (dangling dep). */ + dangling: boolean; +} + +export interface SeamGraph { + nodes: SeamNode[]; + edges: SeamEdge[]; + /** Total number of topological layers. */ + layerCount: number; + /** Edges where the upstream node is absent from the set. */ + danglingCount: number; +} + +// ── Graph builder ───────────────────────────────────────────────────────────── + +/** + * Build a seam graph from a set of `PlanIssue`s: + * - **Nodes** — one per issue, carrying title/stream/phase/maturity/owns/acceptance. + * - **Edges** — from `issue.dependsOn` links. Missing deps become dangling edges. + * - **Layout** — topological layers via Kahn's algorithm (longest path to a source), + * so dependency order reads left-to-right. + * + * `filterRepo` restricts the graph to one repo's issues; cross-repo edges that leave + * the visible set are marked dangling so they're highlighted rather than silently + * dropped. + */ +export function buildSeamGraph(issues: PlanIssue[], filterRepo?: string): SeamGraph { + const visible = filterRepo + ? issues.filter(i => i.repo === filterRepo) + : issues; + + const nodeSet = new Set(visible.map(i => i.ref)); + + // ── edges ───────────────────────────────────────────────────────────────── + const edges: SeamEdge[] = []; + for (const issue of visible) { + for (const dep of issue.dependsOn) { + edges.push({ from: dep, to: issue.ref, dangling: !nodeSet.has(dep) }); + } + } + + // ── topological layer assignment (Kahn's algorithm, longest-path variant) ── + // layer(n) = max(layer(upstream)) + 1. Sources land in layer 0. + const incomingEdges = new Map(); // node → upstream refs + for (const n of visible) incomingEdges.set(n.ref, []); + for (const e of edges) { + if (!e.dangling) { + const list = incomingEdges.get(e.to) ?? []; + list.push(e.from); + incomingEdges.set(e.to, list); + } + } + + const layerOf = new Map(); + const queue: string[] = []; + + for (const n of visible) { + if ((incomingEdges.get(n.ref) ?? []).length === 0) { + layerOf.set(n.ref, 0); + queue.push(n.ref); + } + } + + let head = 0; + while (head < queue.length) { + const cur = queue[head++]; + const curLayer = layerOf.get(cur) ?? 0; + for (const e of edges) { + if (e.from === cur && !e.dangling) { + const existing = layerOf.get(e.to) ?? -1; + const proposed = curLayer + 1; + if (proposed > existing) { + layerOf.set(e.to, proposed); + queue.push(e.to); + } + } + } + } + + // Nodes not reached by the BFS (cycles or disconnected) fall to layer 0. + for (const n of visible) { + if (!layerOf.has(n.ref)) layerOf.set(n.ref, 0); + } + + // ── order within each layer ─────────────────────────────────────────────── + const byLayer = new Map(); + for (const [ref, layer] of layerOf) { + const list = byLayer.get(layer) ?? []; + list.push(ref); + byLayer.set(layer, list); + } + const issueByRef = new Map(visible.map(i => [i.ref, i])); + for (const list of byLayer.values()) { + list.sort((a, b) => { + const ia = issueByRef.get(a), ib = issueByRef.get(b); + const sa = ia?.stream ?? "", sb = ib?.stream ?? ""; + return sa !== sb ? sa.localeCompare(sb) : a.localeCompare(b); + }); + } + const orderOf = new Map(); + for (const list of byLayer.values()) { + list.forEach((ref, i) => orderOf.set(ref, i)); + } + + // ── assemble nodes ──────────────────────────────────────────────────────── + const nodes: SeamNode[] = visible.map(issue => ({ + id: issue.ref, + title: issue.title, + stream: issue.stream, + phase: issue.phase, + maturity: maturityOf(issue), + owns: issue.owns, + acceptance: issue.acceptance, + layer: layerOf.get(issue.ref) ?? 0, + order: orderOf.get(issue.ref) ?? 0, + })); + + const layerCount = byLayer.size; + const danglingCount = edges.filter(e => e.dangling).length; + return { nodes, edges, layerCount, danglingCount }; +} diff --git a/src/screens/projects/Planning.tsx b/src/screens/projects/Planning.tsx index 88b9464..ef9f7e3 100644 --- a/src/screens/projects/Planning.tsx +++ b/src/screens/projects/Planning.tsx @@ -40,6 +40,7 @@ import { Dialog } from "../../components/Dialog"; import { parseUiPreviewTags, stripUiPreviewTags } from "./uiPreviewTag"; import { dispatchRenderPreview } from "./renderPreview"; import { buildProjectPaneData } from "./projectPaneData"; +import { deriveLifecycleState, LIFECYCLE_LABEL, buildRefactorFleet, buildRefactorIssues } from "../../lib/planLifecycle"; const TERM_THEME: import("@xterm/xterm").ITheme = { background: "#181a1f", @@ -723,6 +724,19 @@ export function Planning({ visible }: { visible: boolean }) { [stageConfig, stageState], ); + // Lifecycle state (#458): new → active → near-complete, derived from the live + // issue closure ratio (paneData.phaseStructure) and the plan grade score. + const lifecycleState = useMemo(() => { + const totalIssues = paneData.phaseStructure.reduce((sum, p) => sum + p.total, 0); + const closedIssues = paneData.phaseStructure.reduce((sum, p) => sum + p.closed, 0); + return deriveLifecycleState({ + isExisting: isExisting, + totalIssues, + closedIssues, + planGradeScore: paneData.grade?.score, + }); + }, [isExisting, paneData.phaseStructure, paneData.grade?.score]); + // Signature of the current inputs — changes when repos/kb/stages change (#175). // Compared against lastSetupSig to decide whether to show the "context updated" badge. const currentSig = useMemo(() => { @@ -736,6 +750,8 @@ export function Planning({ visible }: { visible: boolean }) { const [triaging, setTriaging] = useState(false); // Worktree creation error shown inline by the Triage button (#551). const [triageError, setTriageError] = useState(null); + // Refactor pass mode (#458): activated when the project is near-complete. + const [refactorPassActive, setRefactorPassActive] = useState(false); type PublishPhase = "idle" | "running" | "done" | "error"; const [publishPhase, setPublishPhase] = useState("idle"); const [publishEarlyError, setPublishEarlyError] = useState(null); @@ -1431,6 +1447,24 @@ export function Planning({ visible }: { visible: boolean }) { } } + // Refactor pass (#458): configure the fleet + inject pre-built refactor issues + // into the plan so the Triage / Fleet launch can immediately execute them. + function activateRefactorPass() { + if (publishRepos.length === 0) return; + const fleet = buildRefactorFleet(publishRepos); + const issues = buildRefactorIssues(publishRepos); + const store = useAppStore.getState(); + store.setPlanFleet(effectiveProjectId, fleet); + // Append refactor issues to issues.json. + const existing = sections.find(s => s.k === "issues")?.content ?? "[]"; + let parsedArr: object[] = []; + try { parsedArr = JSON.parse(existing.trim() || "[]"); } catch { parsedArr = []; } + const merged = [...parsedArr, ...issues]; + store.setPlanSection(effectiveProjectId, "issues", JSON.stringify(merged, null, 2)); + store.confirmPlanSection(effectiveProjectId, "issues"); + setRefactorPassActive(true); + } + // Publish the plan to GitHub: repositories → project board → milestones → // issues. Every step is idempotent (check-then-create) so re-running acts as a // sync. Status is reported through ghStatus, keyed by the buildGhStructure ids, @@ -1829,7 +1863,9 @@ _Auto-generated by base-studio-code planner._`, {confirmedCount}/{sections.length} sections confirmed - ● {isExisting ? "expanding" : "drafting"} + + ● {LIFECYCLE_LABEL[lifecycleState]} + @@ -1902,6 +1938,26 @@ _Auto-generated by base-studio-code planner._`, ); })()} + {lifecycleState === "near-complete" && !refactorPassActive && ( + + )} + {refactorPassActive && ( + + ✓ refactor fleet loaded + + )} {triageError && (
("phase"); + const grade = hasData ? data!.grade : undefined; + const seamGraph = hasData ? data!.seamGraph : undefined; + // Phase-first is the primary lens (#497); repo-first and graph are the secondary ones. + const [structView, setStructView] = useState<"phase" | "repo" | "graph">("phase"); // Build a flat ref → IssueGrade lookup for per-row chips (#445). const issueGradeMap = new Map(); @@ -1033,7 +1035,9 @@ export function ProjectPane({ data, projectName, projectId, onPerm, onPreset, on {grade && } {structView === "phase" ? `${phaseStructure.length} phase${phaseStructure.length !== 1 ? "s" : ""}` - : `${repos.length} repos · ${structure.length} milestones`} + : structView === "repo" + ? `${repos.length} repos · ${structure.length} milestones` + : `${seamGraph?.nodes.length ?? 0} nodes · ${seamGraph?.edges.length ?? 0} edges`} } open={true} @@ -1042,7 +1046,7 @@ export function ProjectPane({ data, projectName, projectId, onPerm, onPreset, on // collapse the section. right={ e.stopPropagation()}> - setStructView(v as "phase" | "repo")} tiny /> + setStructView(v as "phase" | "repo" | "graph")} tiny /> } > @@ -1059,7 +1063,11 @@ export function ProjectPane({ data, projectName, projectId, onPerm, onPreset, on )} {structView === "phase" ? - : } + : structView === "repo" + ? + : seamGraph + ? + :
No plan data yet.
} }> diff --git a/src/screens/projects/SeamGraphView.tsx b/src/screens/projects/SeamGraphView.tsx new file mode 100644 index 0000000..5502cfd --- /dev/null +++ b/src/screens/projects/SeamGraphView.tsx @@ -0,0 +1,285 @@ +// SeamGraphView — hand-rolled SVG renderer for the per-project dependency DAG +// (#294). No heavy graphing dep; the layout comes from buildSeamGraph (Kahn's +// longest-path topological sort). Nodes are colored by maturity; clicking one +// opens a drill-down panel showing owns, acceptance criteria, and stream. + +import { useState } from "react"; +import type { SeamGraph, SeamNode, NodeMaturity } from "../../lib/planSeamGraph"; + +// ── Layout constants ───────────────────────────────────────────────────────── + +const NODE_W = 148; // node rect width +const NODE_H = 36; // node rect height +const COL_W = 196; // horizontal step between layers (includes gap) +const ROW_H = 54; // vertical step within a layer (includes gap) +const PAD = 18; // canvas padding on all sides + +// ── Maturity palette ───────────────────────────────────────────────────────── + +const MATURITY_COLOR: Record = { + done: "var(--success, #3fbb6f)", + active: "var(--accent, #5b8ef7)", + backlog: "oklch(0.68 0.09 230)", + stub: "var(--fg-dim, #555)", +}; + +// ── Coordinate helpers ──────────────────────────────────────────────────────── + +function nx(layer: number) { return PAD + layer * COL_W; } +function ny(order: number) { return PAD + order * ROW_H; } +function ncx(layer: number) { return nx(layer) + NODE_W; } // right edge x +function ncy(order: number) { return ny(order) + NODE_H / 2; } // vertical centre + +// ── Edge renderer ───────────────────────────────────────────────────────────── + +function EdgePath({ fromNode, toNode, dangling }: { fromNode: SeamNode; toNode: SeamNode; dangling: boolean }) { + const x1 = ncx(fromNode.layer), y1 = ncy(fromNode.order); + const x2 = nx(toNode.layer), y2 = ncy(toNode.order); + const dx = Math.max(20, (x2 - x1) * 0.4); + const d = `M${x1},${y1} C${x1 + dx},${y1} ${x2 - dx},${y2} ${x2},${y2}`; + const stroke = dangling ? "oklch(0.72 0.15 55)" : "var(--fg-dim, #444)"; + const markerId = dangling ? "seam-arrow-dangle" : "seam-arrow"; + return ( + + ); +} + +// ── Node renderer ───────────────────────────────────────────────────────────── + +function NodeRect({ node, selected, onClick }: { node: SeamNode; selected: boolean; onClick: () => void }) { + const color = MATURITY_COLOR[node.maturity]; + const label = node.title.length > 21 ? node.title.slice(0, 20) + "…" : node.title; + const x = nx(node.layer), y = ny(node.order); + return ( + + + + + {label} + + + ); +} + +// ── Empty state ─────────────────────────────────────────────────────────────── + +function EmptyGraph() { + return ( +
+ No issues yet. Add issues with dependsOn links to see the dependency graph. +
+ ); +} + +// ── Drill-down panel ────────────────────────────────────────────────────────── + +function DrillDown({ node, onClose }: { node: SeamNode; onClose: () => void }) { + const color = MATURITY_COLOR[node.maturity]; + return ( +
+
+ + + {node.title} + + +
+
+
+ maturity + {node.maturity} +
+ {node.stream && ( +
+ stream + {node.stream} +
+ )} + {node.phase !== undefined && ( +
+ phase + {String(node.phase)} +
+ )} +
+ {node.owns.length > 0 && ( +
+ owns + {node.owns.join(", ")} +
+ )} + {node.acceptance.length > 0 && ( +
+
+ acceptance ({node.acceptance.length}) +
+ {node.acceptance.map((a, i) => ( +
+ · {a.length > 90 ? a.slice(0, 89) + "…" : a} +
+ ))} +
+ )} +
+ ); +} + +// ── Legend ──────────────────────────────────────────────────────────────────── + +function Legend({ nodes, layerCount, edgeCount }: { nodes: SeamNode[]; layerCount: number; edgeCount: number }) { + return ( +
+ {nodes.length} node{nodes.length !== 1 ? "s" : ""} + {edgeCount} edge{edgeCount !== 1 ? "s" : ""} + {layerCount} layer{layerCount !== 1 ? "s" : ""} + + {(["done", "active", "backlog", "stub"] as NodeMaturity[]).map(m => ( + + + {m} + + ))} + +
+ ); +} + +// ── Main component ───────────────────────────────────────────────────────────── + +/** + * Hand-rolled SVG renderer for the project's dependency DAG (#294). + * Nodes are issues; edges are `dependsOn` links; layout is topological + * (left-to-right, sources on the left). Click a node for drill-down. + */ +export function SeamGraphView({ graph }: { graph: SeamGraph }) { + const [selectedId, setSelectedId] = useState(null); + + if (graph.nodes.length === 0) return ; + + const maxLayer = Math.max(0, ...graph.nodes.map(n => n.layer)); + const maxOrder = Math.max(0, ...graph.nodes.map(n => n.order)); + const svgW = PAD + maxLayer * COL_W + NODE_W + PAD; + const svgH = PAD + maxOrder * ROW_H + NODE_H + PAD; + + const nodeById = new Map(graph.nodes.map(n => [n.id, n])); + const selectedNode = selectedId ? nodeById.get(selectedId) ?? null : null; + + function toggleNode(id: string) { + setSelectedId(prev => prev === id ? null : id); + } + + return ( +
+ + {/* Dangling-dep warning */} + {graph.danglingCount > 0 && ( +
+ {graph.danglingCount} dangling dep{graph.danglingCount !== 1 ? "s" : ""} — dashed edges point to issues not in this plan +
+ )} + + {/* SVG canvas */} +
+ + + + + + + + + + + {/* Edges rendered before nodes so nodes appear on top */} + {graph.edges.map((e, i) => { + const fromN = nodeById.get(e.from); + const toN = nodeById.get(e.to); + if (!toN) return null; + // Dangling edges: draw from a phantom column to the left of layer 0. + if (e.dangling || !fromN) { + const x2 = nx(toN.layer), y2 = ncy(toN.order); + return ( + + ); + } + return ; + })} + + {/* Nodes */} + {graph.nodes.map(n => ( + toggleNode(n.id)} + /> + ))} + +
+ + {/* Drill-down panel (shown when a node is selected) */} + {selectedNode && ( + setSelectedId(null)} /> + )} + + {/* Footer legend */} + +
+ ); +} diff --git a/src/screens/projects/projectPane.types.ts b/src/screens/projects/projectPane.types.ts index f7c3ab9..61a1472 100644 --- a/src/screens/projects/projectPane.types.ts +++ b/src/screens/projects/projectPane.types.ts @@ -9,8 +9,10 @@ import type { DirectorDrive } from "./directorDrive"; import type { IntegrationStrategy } from "./integrationStrategy"; import type { PlanGrade } from "../../lib/planGrade"; +import type { SeamGraph } from "../../lib/planSeamGraph"; export type { PlanGrade }; +export type { SeamGraph }; export type Posture = "allow" | "ask" | "deny"; export type Perm = Record; @@ -96,4 +98,6 @@ export interface ProjectPaneData { fleetStrategy?: IntegrationStrategy; /** Deterministic agent-readiness grade (#445): overall rollup + per-repo/milestone/issue breakdowns. */ grade?: PlanGrade; + /** Dependency DAG for the seam/contract graph view (#294): nodes (features) and edges (dependsOn). */ + seamGraph?: SeamGraph; } diff --git a/src/screens/projects/projectPaneData.ts b/src/screens/projects/projectPaneData.ts index 081b1e0..bec0445 100644 --- a/src/screens/projects/projectPaneData.ts +++ b/src/screens/projects/projectPaneData.ts @@ -14,6 +14,7 @@ import { resolvePhaseIndex } from "./planIssues"; import { resolveFlow } from "./agentFlow"; import { resolveDirectorDrive } from "./directorDrive"; import { gradePlan } from "../../lib/planGrade"; +import { buildSeamGraph } from "../../lib/planSeamGraph"; export type { PlanGrade, IssueGrade, MilestoneGrade, RepoGrade, Letter } from "../../lib/planGrade"; // The render-shape contract lives in projectPane.types (#356, the shared pane @@ -302,6 +303,7 @@ export function buildProjectPaneData(input: BuildProjectPaneInput): ProjectPaneD drive: resolveDirectorDrive(input.fleet?.director.drive), }, fleetStrategy: input.fleet?.strategy, - grade: gradePlan(input.issues, input.phases, input.repos), + grade: gradePlan(input.issues, input.phases, input.repos), + seamGraph: buildSeamGraph(input.issues), }; }