diff --git a/.release-please-manifest.json b/.release-please-manifest.json index a828fda..e108018 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -3,5 +3,6 @@ "packages/pi-red-green": "0.2.1", "packages/pi-compass": "0.2.0", "packages/pi-simplify": "0.2.0", - "packages/pi-code-review": "0.2.0" + "packages/pi-code-review": "0.2.0", + "packages/pi-blueprint": "0.1.0" } diff --git a/AGENTS.md b/AGENTS.md index 6b4a8ba..78329b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,9 @@ packages/ pi-code-review/ # Pi extension: automated language-aware code review src/ # TypeScript source + tests (*.test.ts alongside source) CHANGELOG.md # Release history (managed by release-please) + pi-blueprint/ # Pi extension: multi-session planning with dependency tracking + src/ # TypeScript source + tests (*.test.ts alongside source) + CHANGELOG.md # Release history (managed by release-please) ``` ## Commands (run from repo root) diff --git a/README.md b/README.md index 6835fd5..18a5229 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ A monorepo of [Pi](https://github.com/nicholasgasior/pi-coding-agent) extensions | [pi-compass](packages/pi-compass) | Codebase navigation: generates structured codemaps and interactive code tours for faster agent onboarding | [![npm](https://img.shields.io/npm/v/pi-compass)](https://www.npmjs.com/package/pi-compass) | | [pi-simplify](packages/pi-simplify) | Code simplification: reviews recently changed files for clarity, consistency, and maintainability | [![npm](https://img.shields.io/npm/v/pi-simplify)](https://www.npmjs.com/package/pi-simplify) | | [pi-code-review](packages/pi-code-review) | Automated code review: language-aware review after edits with structured findings | [![npm](https://img.shields.io/npm/v/pi-code-review)](https://www.npmjs.com/package/pi-code-review) | +| [pi-blueprint](packages/pi-blueprint) | Multi-session planning: turns objectives into phased construction plans with dependency tracking and verification gates | [![npm](https://img.shields.io/npm/v/pi-blueprint)](https://www.npmjs.com/package/pi-blueprint) | ## Development diff --git a/package-lock.json b/package-lock.json index f4a8343..e7118f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4023,6 +4023,10 @@ "license": "MIT", "peer": true }, + "node_modules/pi-blueprint": { + "resolved": "packages/pi-blueprint", + "link": true + }, "node_modules/pi-code-review": { "resolved": "packages/pi-code-review", "link": true @@ -5006,6 +5010,27 @@ "zod": "^3.25 || ^4" } }, + "packages/pi-blueprint": { + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^24.0.0", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "eslint": "^9.0.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@mariozechner/pi-ai": "^0.62.0", + "@mariozechner/pi-coding-agent": "^0.62.0", + "@mariozechner/pi-tui": "^0.62.0", + "@sinclair/typebox": "^0.34.0" + } + }, "packages/pi-code-review": { "version": "0.2.0", "license": "MIT", diff --git a/packages/pi-blueprint/README.md b/packages/pi-blueprint/README.md new file mode 100644 index 0000000..529de0e --- /dev/null +++ b/packages/pi-blueprint/README.md @@ -0,0 +1,57 @@ +# pi-blueprint + +A Pi extension that turns high-level objectives into phased, multi-session construction plans with dependency tracking and verification gates. + +## Installation + +```bash +pi install npm:pi-blueprint +``` + +## Commands + +| Command | Description | +|---|---| +| `/blueprint ` | Generate a phased plan from an objective | +| `/blueprint abandon` | Abandon the active blueprint | +| `/plan-status` | Show detailed progress with completion percentage | +| `/plan-verify` | Run verification gates for the current phase | +| `/plan-next` | Get and start the next actionable task | + +## LLM Tools + +| Tool | Description | +|---|---| +| `blueprint_create` | Create a new blueprint from structured phases | +| `blueprint_status` | Get current plan progress | +| `blueprint_update` | Mark tasks as completed, in_progress, or skipped | +| `blueprint_next` | Get the next actionable task | + +## How It Works + +1. Run `/blueprint "Add OAuth2 authentication"` to start +2. The LLM generates a phased plan with tasks, dependencies, and verification gates +3. On each session start, the active blueprint context is injected into the system prompt +4. Use `/plan-next` to work through tasks sequentially +5. Use `/plan-verify` to run phase verification gates (tests, typecheck) before advancing +6. Progress persists across sessions in `~/.pi/blueprints/` + +## Storage + +``` +~/.pi/blueprints/ + index.json # Active blueprint pointer + / + plan.md # Human-readable plan (auto-generated) + state.json # Machine-readable state (source of truth) + history.jsonl # Audit log of state transitions + sessions.json # Session-to-task mapping +``` + +## Features + +- **Phased execution**: Work is decomposed into ordered phases with verification gates +- **Dependency tracking**: Tasks declare dependencies; blocked tasks are surfaced automatically +- **Verification gates**: Tests, type-check, user approval, or custom commands gate phase advancement +- **Multi-session persistence**: Plan state survives session restarts with context injection +- **Cycle detection**: Dependency cycles are rejected at blueprint creation time diff --git a/packages/pi-blueprint/package.json b/packages/pi-blueprint/package.json new file mode 100644 index 0000000..35bb166 --- /dev/null +++ b/packages/pi-blueprint/package.json @@ -0,0 +1,72 @@ +{ + "name": "pi-blueprint", + "version": "0.1.0", + "description": "A Pi extension that turns high-level objectives into phased, multi-session construction plans with dependency tracking and verification gates.", + "type": "module", + "license": "MIT", + "author": "Matt Devy", + "repository": { + "type": "git", + "url": "https://github.com/MattDevy/pi-extensions.git", + "directory": "packages/pi-blueprint" + }, + "homepage": "https://github.com/MattDevy/pi-extensions/tree/main/packages/pi-blueprint#readme", + "bugs": { + "url": "https://github.com/MattDevy/pi-extensions/issues" + }, + "keywords": [ + "pi-package", + "pi-extension", + "pi-coding-agent", + "blueprint", + "planning", + "multi-session", + "dependency-tracking", + "ai", + "llm", + "ai-agent", + "coding-assistant", + "developer-tools" + ], + "engines": { + "node": ">=18" + }, + "files": [ + "dist", + "src", + "!src/**/*.test.ts", + "README.md", + "LICENSE" + ], + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "pi": { + "extensions": [ + "dist/index.js" + ] + }, + "scripts": { + "clean": "rm -rf dist", + "build": "npm run clean && tsc -p tsconfig.build.json", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "lint": "eslint src/", + "check": "vitest run && eslint src/ && tsc --noEmit", + "prepublishOnly": "npm run build && npm run check", + "prepack": "test -d dist || { echo 'Error: dist/ missing. Run npm run build first.' && exit 1; }" + }, + "peerDependencies": { + "@mariozechner/pi-coding-agent": "^0.62.0", + "@mariozechner/pi-ai": "^0.62.0", + "@mariozechner/pi-tui": "^0.62.0", + "@sinclair/typebox": "^0.34.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "eslint": "^9.0.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/packages/pi-blueprint/src/blueprint-command.test.ts b/packages/pi-blueprint/src/blueprint-command.test.ts new file mode 100644 index 0000000..d2399a6 --- /dev/null +++ b/packages/pi-blueprint/src/blueprint-command.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; +import { handleBlueprintCommand } from "./blueprint-command.js"; +import type { StateRef, BlueprintExtensionState, Blueprint, Phase, Task } from "./types.js"; + +vi.mock("./storage.js", () => ({ + saveBlueprint: vi.fn(), + appendHistory: vi.fn(), + saveIndex: vi.fn(), + loadIndex: vi.fn().mockReturnValue(null), +})); + +function makeTask(overrides: Partial & { id: string }): Task { + return { + title: overrides.id, description: "", status: "pending", + acceptance_criteria: [], file_targets: [], dependencies: [], + started_at: null, completed_at: null, session_id: null, notes: null, + ...overrides, + }; +} + +function makePhase(overrides: Partial & { id: string }): Phase { + return { + title: `Phase ${overrides.id}`, description: "", status: "pending", + tasks: [], verification_gates: [], started_at: null, completed_at: null, + ...overrides, + }; +} + +function makeBlueprint(): Blueprint { + return { + id: "bp-1", objective: "Test", project_id: "proj-1", status: "active", + created_at: "2026-04-11T00:00:00.000Z", updated_at: "2026-04-11T00:00:00.000Z", + phases: [makePhase({ id: "1", tasks: [makeTask({ id: "1.1" })] })], + active_phase_id: "1", active_task_id: "1.1", + }; +} + +function createMocks() { + const notifications: { text: string; level: string }[] = []; + const sentMessages: string[] = []; + const ctx = { + ui: { + notify: vi.fn((text: string, level: string) => notifications.push({ text, level })), + }, + }; + const pi = { + sendUserMessage: vi.fn((text: string) => sentMessages.push(text)), + }; + return { ctx, pi, notifications, sentMessages }; +} + +describe("handleBlueprintCommand", () => { + let state: BlueprintExtensionState; + let stateRef: StateRef; + + beforeEach(() => { + state = { project: { id: "p", name: "test", root: "/tmp" }, blueprint: null, sessionId: "s" }; + stateRef = { get: () => state, set: (s) => { state = s; } }; + }); + + it("shows status when no args and no blueprint", async () => { + const { ctx, pi } = createMocks(); + await handleBlueprintCommand("", ctx as unknown as ExtensionCommandContext, stateRef, pi as unknown as ExtensionAPI); + expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("No active blueprint"), "info"); + }); + + it("shows plan when no args and blueprint exists", async () => { + state = { ...state, blueprint: makeBlueprint() }; + const { ctx, pi } = createMocks(); + await handleBlueprintCommand("", ctx as unknown as ExtensionCommandContext, stateRef, pi as unknown as ExtensionAPI); + expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Blueprint: Test"), "info"); + }); + + it("sends generation prompt for new objective", async () => { + const { ctx, pi } = createMocks(); + await handleBlueprintCommand("Add OAuth2 auth", ctx as unknown as ExtensionCommandContext, stateRef, pi as unknown as ExtensionAPI); + expect(pi.sendUserMessage).toHaveBeenCalledWith( + expect.stringContaining("Add OAuth2 auth"), + expect.objectContaining({ deliverAs: "followUp" }), + ); + }); + + it("warns when active blueprint exists", async () => { + state = { ...state, blueprint: makeBlueprint() }; + const { ctx, pi } = createMocks(); + await handleBlueprintCommand("New thing", ctx as unknown as ExtensionCommandContext, stateRef, pi as unknown as ExtensionAPI); + expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("already exists"), "warning"); + expect(pi.sendUserMessage).not.toHaveBeenCalled(); + }); + + it("abandons active blueprint", async () => { + state = { ...state, blueprint: makeBlueprint() }; + const { ctx, pi } = createMocks(); + await handleBlueprintCommand("abandon", ctx as unknown as ExtensionCommandContext, stateRef, pi as unknown as ExtensionAPI); + expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("abandoned"), "info"); + expect(state.blueprint).toBeNull(); + }); +}); diff --git a/packages/pi-blueprint/src/blueprint-command.ts b/packages/pi-blueprint/src/blueprint-command.ts new file mode 100644 index 0000000..499099e --- /dev/null +++ b/packages/pi-blueprint/src/blueprint-command.ts @@ -0,0 +1,84 @@ +import type { + ExtensionAPI, + ExtensionCommandContext, +} from "@mariozechner/pi-coding-agent"; +import type { StateRef } from "./types.js"; +import { renderPlanMarkdown } from "./plan-renderer.js"; +import { getBlueprintGeneratePrompt } from "./prompts/blueprint-generate.js"; +import { abandonBlueprint } from "./state-machine.js"; +import { saveBlueprint, appendHistory, saveIndex, loadIndex } from "./storage.js"; + +export const COMMAND_NAME = "blueprint"; + +export async function handleBlueprintCommand( + args: string, + ctx: ExtensionCommandContext, + stateRef: StateRef, + pi: ExtensionAPI, +): Promise { + const trimmed = args.trim(); + + if (trimmed === "") { + return showBriefStatus(ctx, stateRef); + } + + if (trimmed.toLowerCase() === "abandon") { + return handleAbandon(ctx, stateRef); + } + + const state = stateRef.get(); + if (state.blueprint && state.blueprint.status === "active") { + ctx.ui.notify( + `An active blueprint already exists: "${state.blueprint.objective}"\nUse /blueprint abandon to discard it, or /plan-status for details.`, + "warning", + ); + return; + } + + const prompt = getBlueprintGeneratePrompt(trimmed); + pi.sendUserMessage(prompt, { deliverAs: "followUp" }); +} + +function showBriefStatus(ctx: ExtensionCommandContext, stateRef: StateRef): void { + const state = stateRef.get(); + if (!state.blueprint) { + ctx.ui.notify( + "No active blueprint. Use /blueprint to create one.", + "info", + ); + return; + } + ctx.ui.notify(renderPlanMarkdown(state.blueprint), "info"); +} + +function handleAbandon(ctx: ExtensionCommandContext, stateRef: StateRef): void { + const state = stateRef.get(); + if (!state.blueprint || state.blueprint.status !== "active") { + ctx.ui.notify("No active blueprint to abandon.", "info"); + return; + } + + const bp = abandonBlueprint(state.blueprint); + saveBlueprint(bp); + appendHistory(bp.id, { + timestamp: new Date().toISOString(), + event: "blueprint_abandoned", + phase_id: null, + task_id: null, + session_id: state.sessionId, + details: "User abandoned blueprint", + }); + + const index = loadIndex(); + if (index) { + saveIndex({ + active_blueprint_id: null, + blueprints: index.blueprints.map((e) => + e.id === bp.id ? { ...e, status: "abandoned" as const } : e, + ), + }); + } + + stateRef.set({ ...state, blueprint: null }); + ctx.ui.notify(`Blueprint "${bp.objective}" abandoned.`, "info"); +} diff --git a/packages/pi-blueprint/src/blueprint-injector.test.ts b/packages/pi-blueprint/src/blueprint-injector.test.ts new file mode 100644 index 0000000..817895c --- /dev/null +++ b/packages/pi-blueprint/src/blueprint-injector.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from "vitest"; +import { buildInjectionBlock } from "./blueprint-injector.js"; +import type { Blueprint, Phase, Task } from "./types.js"; + +function makeTask(overrides: Partial & { id: string }): Task { + return { + title: overrides.id, + description: "", + status: "pending", + acceptance_criteria: [], + file_targets: [], + dependencies: [], + started_at: null, + completed_at: null, + session_id: null, + notes: null, + ...overrides, + }; +} + +function makePhase(overrides: Partial & { id: string }): Phase { + return { + title: `Phase ${overrides.id}`, + description: "", + status: "pending", + tasks: [], + verification_gates: [], + started_at: null, + completed_at: null, + ...overrides, + }; +} + +function makeBlueprint(phases: Phase[]): Blueprint { + return { + id: "bp-1", + objective: "Add OAuth2", + project_id: "proj-1", + status: "active", + created_at: "2026-04-11T00:00:00.000Z", + updated_at: "2026-04-11T00:00:00.000Z", + phases, + active_phase_id: phases[0]?.id ?? null, + active_task_id: null, + }; +} + +describe("buildInjectionBlock", () => { + it("returns null for no blueprint", () => { + expect(buildInjectionBlock(null)).toBeNull(); + }); + + it("returns null for completed blueprint", () => { + const bp = { ...makeBlueprint([]), status: "completed" as const }; + expect(buildInjectionBlock(bp)).toBeNull(); + }); + + it("returns null when no active phase", () => { + const bp = { ...makeBlueprint([]), active_phase_id: null }; + expect(buildInjectionBlock(bp)).toBeNull(); + }); + + it("includes objective and phase info", () => { + const bp = makeBlueprint([ + makePhase({ + id: "1", + status: "active", + tasks: [makeTask({ id: "1.1", title: "Do thing", status: "in_progress" })], + }), + ]); + const withTask = { ...bp, active_task_id: "1.1" }; + const block = buildInjectionBlock(withTask); + expect(block).toContain("Add OAuth2"); + expect(block).toContain("Phase 1"); + expect(block).toContain("1.1 - Do thing"); + }); + + it("includes blocked tasks", () => { + const bp = makeBlueprint([ + makePhase({ + id: "1", + status: "active", + tasks: [ + makeTask({ id: "1.1" }), + makeTask({ id: "1.2", title: "Blocked one", status: "blocked", dependencies: ["1.1"] }), + ], + }), + ]); + const block = buildInjectionBlock(bp); + expect(block).toContain("Blocked Tasks"); + expect(block).toContain("Blocked one"); + }); + + it("includes acceptance criteria for active task", () => { + const bp = makeBlueprint([ + makePhase({ + id: "1", + status: "active", + tasks: [ + makeTask({ + id: "1.1", + status: "in_progress", + acceptance_criteria: ["Tests pass", "No regressions"], + }), + ], + }), + ]); + const withTask = { ...bp, active_task_id: "1.1" }; + const block = buildInjectionBlock(withTask); + expect(block).toContain("Tests pass"); + expect(block).toContain("No regressions"); + }); +}); diff --git a/packages/pi-blueprint/src/blueprint-injector.ts b/packages/pi-blueprint/src/blueprint-injector.ts new file mode 100644 index 0000000..d610f0e --- /dev/null +++ b/packages/pi-blueprint/src/blueprint-injector.ts @@ -0,0 +1,10 @@ +import type { Blueprint } from "./types.js"; +import { buildPhaseContext } from "./prompts/phase-context.js"; + +export function buildInjectionBlock(blueprint: Blueprint | null): string | null { + if (!blueprint) return null; + if (blueprint.status !== "active") return null; + if (!blueprint.active_phase_id) return null; + + return "\n\n" + buildPhaseContext(blueprint); +} diff --git a/packages/pi-blueprint/src/blueprint-tools.test.ts b/packages/pi-blueprint/src/blueprint-tools.test.ts new file mode 100644 index 0000000..a6fd4cf --- /dev/null +++ b/packages/pi-blueprint/src/blueprint-tools.test.ts @@ -0,0 +1,234 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { registerBlueprintTools } from "./blueprint-tools.js"; +import type { StateRef, BlueprintExtensionState, Blueprint, Phase, Task } from "./types.js"; + +vi.mock("./storage.js", () => ({ + saveBlueprint: vi.fn(), + saveIndex: vi.fn(), + appendHistory: vi.fn(), + loadIndex: vi.fn().mockReturnValue(null), +})); + +function makeTask(overrides: Partial & { id: string }): Task { + return { + title: overrides.id, + description: "", + status: "pending", + acceptance_criteria: [], + file_targets: [], + dependencies: [], + started_at: null, + completed_at: null, + session_id: null, + notes: null, + ...overrides, + }; +} + +function makePhase(overrides: Partial & { id: string }): Phase { + return { + title: `Phase ${overrides.id}`, + description: "", + status: "pending", + tasks: [], + verification_gates: [], + started_at: null, + completed_at: null, + ...overrides, + }; +} + +function makeBlueprint(phases: Phase[]): Blueprint { + return { + id: "bp-1", + objective: "Test", + project_id: "proj-1", + status: "active", + created_at: "2026-04-11T00:00:00.000Z", + updated_at: "2026-04-11T00:00:00.000Z", + phases, + active_phase_id: phases[0]?.id ?? null, + active_task_id: phases[0]?.tasks[0]?.id ?? null, + }; +} + +function createMockPi() { + const tools = new Map Promise }>(); + return { + registerTool: vi.fn((tool: { name: string; execute: (...args: unknown[]) => Promise }) => { + tools.set(tool.name, tool); + }), + tools, + }; +} + +describe("registerBlueprintTools", () => { + let pi: ReturnType; + let state: BlueprintExtensionState; + let stateRef: StateRef; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-11T00:00:00.000Z")); + pi = createMockPi(); + state = { project: { id: "proj-1", name: "test", root: "/tmp" }, blueprint: null, sessionId: "s-1" }; + stateRef = { + get: () => state, + set: (s) => { state = s; }, + }; + registerBlueprintTools(pi as unknown as import("@mariozechner/pi-coding-agent").ExtensionAPI, stateRef); + }); + + it("registers 4 tools", () => { + expect(pi.registerTool).toHaveBeenCalledTimes(4); + expect(pi.tools.has("blueprint_create")).toBe(true); + expect(pi.tools.has("blueprint_status")).toBe(true); + expect(pi.tools.has("blueprint_update")).toBe(true); + expect(pi.tools.has("blueprint_next")).toBe(true); + }); + + describe("blueprint_create", () => { + it("creates a blueprint from phases", async () => { + const tool = pi.tools.get("blueprint_create")!; + const result = await tool.execute("tc-1", { + objective: "Add auth", + phases: [{ + id: "1", + title: "Foundation", + description: "Set up basics", + tasks: [{ + id: "1.1", + title: "Create types", + description: "Define types", + acceptance_criteria: ["Types compile"], + file_targets: ["src/types.ts"], + dependencies: [], + }], + verification_gates: [{ type: "tests_pass", description: "Tests pass" }], + }], + }, undefined, undefined, undefined) as { content: { text: string }[]; details: Record }; + expect(result.content[0]!.text).toContain("Blueprint created"); + expect(result.details["tasks"]).toBe(1); + expect(state.blueprint).not.toBeNull(); + }); + + it("rejects when active blueprint exists", async () => { + state = { + ...state, + blueprint: makeBlueprint([makePhase({ id: "1" })]), + }; + const tool = pi.tools.get("blueprint_create")!; + const result = await tool.execute("tc-1", { + objective: "New", + phases: [], + }, undefined, undefined, undefined) as { content: { text: string }[] }; + expect(result.content[0]!.text).toContain("active blueprint already exists"); + }); + + it("rejects dependency cycles", async () => { + const tool = pi.tools.get("blueprint_create")!; + const result = await tool.execute("tc-1", { + objective: "Cyclic", + phases: [{ + id: "1", + title: "P1", + description: "", + tasks: [ + { id: "1.1", title: "A", description: "", acceptance_criteria: [], file_targets: [], dependencies: ["1.2"] }, + { id: "1.2", title: "B", description: "", acceptance_criteria: [], file_targets: [], dependencies: ["1.1"] }, + ], + verification_gates: [], + }], + }, undefined, undefined, undefined) as { content: { text: string }[] }; + expect(result.content[0]!.text).toContain("cycles"); + }); + }); + + describe("blueprint_status", () => { + it("returns no blueprint message when none active", async () => { + const tool = pi.tools.get("blueprint_status")!; + const result = await tool.execute("tc-1", {}, undefined, undefined, undefined) as { content: { text: string }[] }; + expect(result.content[0]!.text).toContain("No active blueprint"); + }); + + it("returns plan markdown when blueprint exists", async () => { + state = { + ...state, + blueprint: makeBlueprint([makePhase({ id: "1", tasks: [makeTask({ id: "1.1" })] })]), + }; + const tool = pi.tools.get("blueprint_status")!; + const result = await tool.execute("tc-1", {}, undefined, undefined, undefined) as { content: { text: string }[] }; + expect(result.content[0]!.text).toContain("Blueprint: Test"); + }); + }); + + describe("blueprint_update", () => { + it("marks task completed", async () => { + state = { + ...state, + blueprint: makeBlueprint([ + makePhase({ id: "1", status: "active", tasks: [makeTask({ id: "1.1", status: "in_progress" })] }), + ]), + }; + const tool = pi.tools.get("blueprint_update")!; + const result = await tool.execute("tc-1", { + task_id: "1.1", + status: "completed", + }, undefined, undefined, undefined) as { content: { text: string }[] }; + expect(result.content[0]!.text).toContain("updated to completed"); + expect(state.blueprint!.phases[0]!.tasks[0]!.status).toBe("completed"); + }); + + it("returns error for nonexistent task", async () => { + state = { + ...state, + blueprint: makeBlueprint([makePhase({ id: "1" })]), + }; + const tool = pi.tools.get("blueprint_update")!; + const result = await tool.execute("tc-1", { + task_id: "99.99", + status: "completed", + }, undefined, undefined, undefined) as { content: { text: string }[] }; + expect(result.content[0]!.text).toContain("not found"); + }); + }); + + describe("blueprint_next", () => { + it("returns next actionable task", async () => { + state = { + ...state, + blueprint: makeBlueprint([ + makePhase({ + id: "1", + status: "active", + tasks: [ + makeTask({ id: "1.1", status: "completed" }), + makeTask({ id: "1.2", title: "Next thing", acceptance_criteria: ["Works"] }), + ], + }), + ]), + }; + const tool = pi.tools.get("blueprint_next")!; + const result = await tool.execute("tc-1", {}, undefined, undefined, undefined) as { content: { text: string }[] }; + expect(result.content[0]!.text).toContain("Next thing"); + expect(result.content[0]!.text).toContain("Works"); + }); + + it("reports no tasks when all done", async () => { + state = { + ...state, + blueprint: { + ...makeBlueprint([ + makePhase({ id: "1", status: "verified", tasks: [makeTask({ id: "1.1", status: "completed" })] }), + ]), + status: "completed", + active_phase_id: null, + active_task_id: null, + }, + }; + const tool = pi.tools.get("blueprint_next")!; + const result = await tool.execute("tc-1", {}, undefined, undefined, undefined) as { content: { text: string }[] }; + expect(result.content[0]!.text).toContain("complete"); + }); + }); +}); diff --git a/packages/pi-blueprint/src/blueprint-tools.ts b/packages/pi-blueprint/src/blueprint-tools.ts new file mode 100644 index 0000000..355e47a --- /dev/null +++ b/packages/pi-blueprint/src/blueprint-tools.ts @@ -0,0 +1,380 @@ +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +import type { StateRef, Phase, Task, VerificationGate } from "./types.js"; +import { + createBlueprint, + startTask, + completeTask, + skipTask, + getNextTask, +} from "./state-machine.js"; +import { detectCycles, getAllTasks } from "./dependency-graph.js"; +import { saveBlueprint, saveIndex, appendHistory, loadIndex } from "./storage.js"; +import { renderPlanMarkdown } from "./plan-renderer.js"; + +const PhaseSchema = Type.Object({ + id: Type.String({ description: "Phase ID, e.g. '1', '2'" }), + title: Type.String({ description: "Phase title" }), + description: Type.String({ description: "Phase description" }), + tasks: Type.Array( + Type.Object({ + id: Type.String({ description: "Task ID, e.g. '1.1', '2.3'" }), + title: Type.String({ description: "Imperative task title" }), + description: Type.String({ description: "What to implement" }), + acceptance_criteria: Type.Array(Type.String(), { description: "Testable acceptance criteria" }), + file_targets: Type.Array(Type.String(), { description: "Files to create or modify" }), + dependencies: Type.Array(Type.String(), { description: "Task IDs this depends on" }), + }), + ), + verification_gates: Type.Array( + Type.Object({ + type: Type.Union([ + Type.Literal("tests_pass"), + Type.Literal("typecheck_clean"), + Type.Literal("user_approval"), + Type.Literal("custom_command"), + ]), + description: Type.String(), + command: Type.Optional(Type.String({ description: "Shell command for custom_command type" })), + }), + ), +}); + +const CreateParams = Type.Object({ + objective: Type.String({ description: "High-level objective for the blueprint" }), + phases: Type.Array(PhaseSchema, { description: "Ordered phases of work" }), +}); + +const StatusParams = Type.Object({}); + +const UpdateParams = Type.Object({ + task_id: Type.String({ description: "Task ID to update (e.g. '1.1')" }), + status: Type.Union([ + Type.Literal("in_progress"), + Type.Literal("completed"), + Type.Literal("skipped"), + ], { description: "New status" }), + notes: Type.Optional(Type.String({ description: "Optional notes about the change" })), +}); + +const NextParams = Type.Object({}); + +function generateId(): string { + return `bp-${Date.now().toString(36)}`; +} + +export function registerBlueprintTools(pi: ExtensionAPI, stateRef: StateRef): void { + const guidelines = [ + "Use blueprint_create to generate a new multi-session plan from an objective.", + "Use blueprint_status to check current plan progress.", + "Use blueprint_update to mark tasks as completed, in_progress, or skipped.", + "Use blueprint_next to get the next actionable task.", + ]; + + pi.registerTool({ + name: "blueprint_create" as const, + label: "Create Blueprint", + description: "Create a new phased construction plan from an objective and structured phases", + promptSnippet: "Create a multi-session blueprint plan", + parameters: CreateParams, + promptGuidelines: guidelines, + async execute( + _toolCallId: string, + params: { objective: string; phases: readonly RawPhase[] }, + _signal: AbortSignal | undefined, + _onUpdate: unknown, + _ctx: unknown, + ) { + const state = stateRef.get(); + if (state.blueprint && state.blueprint.status === "active") { + return { + content: [{ + type: "text" as const, + text: `Error: An active blueprint already exists ("${state.blueprint.objective}"). Complete or abandon it first.`, + }], + details: { error: "active_blueprint_exists" } as Record, + }; + } + + const phases = buildPhases(params.phases); + const allTasks = getAllTasks(phases); + const cycles = detectCycles(allTasks); + if (cycles.length > 0) { + return { + content: [{ + type: "text" as const, + text: `Error: Dependency cycles detected: ${JSON.stringify(cycles)}`, + }], + details: { error: "dependency_cycles", cycles } as Record, + }; + } + + const projectId = state.project?.id ?? "unknown"; + const id = generateId(); + const blueprint = createBlueprint(id, params.objective, projectId, phases); + + saveBlueprint(blueprint); + const index = loadIndex() ?? { active_blueprint_id: null, blueprints: [] }; + saveIndex({ + active_blueprint_id: id, + blueprints: [ + ...index.blueprints, + { + id, + objective: params.objective, + status: "active", + created_at: blueprint.created_at, + project_id: projectId, + }, + ], + }); + appendHistory(id, { + timestamp: new Date().toISOString(), + event: "blueprint_created", + phase_id: null, + task_id: null, + session_id: state.sessionId, + details: params.objective, + }); + + stateRef.set({ ...state, blueprint }); + + const summary = renderPlanMarkdown(blueprint); + return { + content: [{ type: "text" as const, text: `Blueprint created: ${id}\n\n${summary}` }], + details: { blueprint_id: id, phases: phases.length, tasks: allTasks.length } as Record, + }; + }, + }); + + pi.registerTool({ + name: "blueprint_status" as const, + label: "Blueprint Status", + description: "Get the current blueprint progress, active phase, and task status", + promptSnippet: "Check current blueprint plan progress", + parameters: StatusParams, + promptGuidelines: guidelines, + async execute() { + const state = stateRef.get(); + if (!state.blueprint) { + return { + content: [{ type: "text" as const, text: "No active blueprint." }], + details: { has_blueprint: false } as Record, + }; + } + + const summary = renderPlanMarkdown(state.blueprint); + return { + content: [{ type: "text" as const, text: summary }], + details: { + blueprint_id: state.blueprint.id, + status: state.blueprint.status, + active_phase: state.blueprint.active_phase_id, + active_task: state.blueprint.active_task_id, + } as Record, + }; + }, + }); + + pi.registerTool({ + name: "blueprint_update" as const, + label: "Update Blueprint", + description: "Update a blueprint task status (mark as completed, in_progress, or skipped)", + promptSnippet: "Mark a blueprint task as completed or update its status", + parameters: UpdateParams, + promptGuidelines: guidelines, + async execute( + _toolCallId: string, + params: { task_id: string; status: "in_progress" | "completed" | "skipped"; notes?: string }, + _signal: AbortSignal | undefined, + _onUpdate: unknown, + _ctx: unknown, + ) { + const state = stateRef.get(); + if (!state.blueprint) { + return { + content: [{ type: "text" as const, text: "No active blueprint." }], + details: { error: "no_blueprint" } as Record, + }; + } + + let bp = state.blueprint; + const allTasks = getAllTasks(bp.phases); + const task = allTasks.find((t) => t.id === params.task_id); + if (!task) { + return { + content: [{ type: "text" as const, text: `Task ${params.task_id} not found.` }], + details: { error: "task_not_found" } as Record, + }; + } + + switch (params.status) { + case "in_progress": + bp = startTask(bp, params.task_id, state.sessionId); + break; + case "completed": + bp = completeTask(bp, params.task_id); + break; + case "skipped": + bp = skipTask(bp, params.task_id); + break; + } + + if (params.notes) { + bp = { + ...bp, + phases: bp.phases.map((p) => ({ + ...p, + tasks: p.tasks.map((t) => + t.id === params.task_id ? { ...t, notes: params.notes ?? null } : t, + ), + })), + }; + } + + saveBlueprint(bp); + appendHistory(bp.id, { + timestamp: new Date().toISOString(), + event: params.status === "completed" ? "task_completed" + : params.status === "skipped" ? "task_skipped" + : "task_started", + phase_id: bp.active_phase_id, + task_id: params.task_id, + session_id: state.sessionId, + details: params.notes ?? `Task ${params.task_id} -> ${params.status}`, + }); + + stateRef.set({ ...state, blueprint: bp }); + + const next = getNextTask(bp); + const nextInfo = next ? `\nNext task: ${next.id} - ${next.title}` : "\nNo more tasks."; + return { + content: [{ + type: "text" as const, + text: `Task ${params.task_id} updated to ${params.status}.${nextInfo}`, + }], + details: { + task_id: params.task_id, + new_status: params.status, + next_task: next?.id ?? null, + blueprint_status: bp.status, + } as Record, + }; + }, + }); + + pi.registerTool({ + name: "blueprint_next" as const, + label: "Next Blueprint Task", + description: "Get the next actionable task from the active blueprint", + promptSnippet: "Get the next task to work on", + parameters: NextParams, + promptGuidelines: guidelines, + async execute() { + const state = stateRef.get(); + if (!state.blueprint) { + return { + content: [{ type: "text" as const, text: "No active blueprint." }], + details: { has_blueprint: false } as Record, + }; + } + + const next = getNextTask(state.blueprint); + if (!next) { + return { + content: [{ + type: "text" as const, + text: state.blueprint.status === "completed" + ? "Blueprint is complete. All tasks and verifications are done." + : "No actionable tasks. Some may be blocked or awaiting verification.", + }], + details: { blueprint_status: state.blueprint.status } as Record, + }; + } + + const lines = [ + `## Next Task: ${next.id} - ${next.title}`, + "", + next.description, + ]; + + if (next.acceptance_criteria.length > 0) { + lines.push("", "**Acceptance criteria:**"); + for (const c of next.acceptance_criteria) { + lines.push(`- ${c}`); + } + } + + if (next.file_targets.length > 0) { + lines.push("", "**File targets:**"); + for (const f of next.file_targets) { + lines.push(`- ${f}`); + } + } + + if (next.dependencies.length > 0) { + lines.push("", `**Dependencies:** ${next.dependencies.join(", ")} (all completed)`); + } + + return { + content: [{ type: "text" as const, text: lines.join("\n") }], + details: { task_id: next.id, phase_id: state.blueprint.active_phase_id } as Record, + }; + }, + }); +} + +interface RawPhase { + readonly id: string; + readonly title: string; + readonly description: string; + readonly tasks: readonly RawTask[]; + readonly verification_gates: readonly RawGate[]; +} + +interface RawTask { + readonly id: string; + readonly title: string; + readonly description: string; + readonly acceptance_criteria: readonly string[]; + readonly file_targets: readonly string[]; + readonly dependencies: readonly string[]; +} + +interface RawGate { + readonly type: "tests_pass" | "typecheck_clean" | "user_approval" | "custom_command"; + readonly description: string; + readonly command?: string; +} + +function buildPhases(raw: readonly RawPhase[]): Phase[] { + return raw.map((rp): Phase => ({ + id: rp.id, + title: rp.title, + description: rp.description, + status: "pending", + tasks: rp.tasks.map((rt): Task => ({ + id: rt.id, + title: rt.title, + description: rt.description, + status: "pending", + acceptance_criteria: [...rt.acceptance_criteria], + file_targets: [...rt.file_targets], + dependencies: [...rt.dependencies], + started_at: null, + completed_at: null, + session_id: null, + notes: null, + })), + verification_gates: rp.verification_gates.map((rg): VerificationGate => ({ + type: rg.type, + command: rg.command ?? null, + description: rg.description, + passed: false, + last_checked_at: null, + error_message: null, + })), + started_at: null, + completed_at: null, + })); +} diff --git a/packages/pi-blueprint/src/dependency-graph.test.ts b/packages/pi-blueprint/src/dependency-graph.test.ts new file mode 100644 index 0000000..ab22379 --- /dev/null +++ b/packages/pi-blueprint/src/dependency-graph.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect } from "vitest"; +import { + findBlockedTasks, + isTaskReady, + getBlockingTasks, + detectCycles, + topologicalSort, +} from "./dependency-graph.js"; +import type { Task } from "./types.js"; + +function makeTask(overrides: Partial & { id: string }): Task { + return { + title: overrides.id, + description: "", + status: "pending", + acceptance_criteria: [], + file_targets: [], + dependencies: [], + started_at: null, + completed_at: null, + session_id: null, + notes: null, + ...overrides, + }; +} + +describe("findBlockedTasks", () => { + it("returns empty for tasks with no dependencies", () => { + const tasks = [makeTask({ id: "1.1" }), makeTask({ id: "1.2" })]; + expect(findBlockedTasks(tasks)).toEqual([]); + }); + + it("returns task IDs with incomplete dependencies", () => { + const tasks = [ + makeTask({ id: "1.1", status: "pending" }), + makeTask({ id: "1.2", dependencies: ["1.1"] }), + ]; + expect(findBlockedTasks(tasks)).toEqual(["1.2"]); + }); + + it("excludes tasks whose dependencies are completed", () => { + const tasks = [ + makeTask({ id: "1.1", status: "completed" }), + makeTask({ id: "1.2", dependencies: ["1.1"] }), + ]; + expect(findBlockedTasks(tasks)).toEqual([]); + }); + + it("treats skipped tasks as resolved dependencies", () => { + const tasks = [ + makeTask({ id: "1.1", status: "skipped" }), + makeTask({ id: "1.2", dependencies: ["1.1"] }), + ]; + expect(findBlockedTasks(tasks)).toEqual([]); + }); + + it("handles diamond dependencies", () => { + const tasks = [ + makeTask({ id: "1.1", status: "completed" }), + makeTask({ id: "1.2", dependencies: ["1.1"], status: "completed" }), + makeTask({ id: "1.3", dependencies: ["1.1"], status: "pending" }), + makeTask({ id: "1.4", dependencies: ["1.2", "1.3"] }), + ]; + expect(findBlockedTasks(tasks)).toEqual(["1.4"]); + }); + + it("does not include already completed tasks", () => { + const tasks = [ + makeTask({ id: "1.1", status: "pending" }), + makeTask({ id: "1.2", dependencies: ["1.1"], status: "completed" }), + ]; + expect(findBlockedTasks(tasks)).toEqual([]); + }); +}); + +describe("isTaskReady", () => { + it("returns true for task with no dependencies", () => { + const tasks = [makeTask({ id: "1.1" })]; + expect(isTaskReady(tasks, "1.1")).toBe(true); + }); + + it("returns false for task with incomplete dependency", () => { + const tasks = [ + makeTask({ id: "1.1" }), + makeTask({ id: "1.2", dependencies: ["1.1"] }), + ]; + expect(isTaskReady(tasks, "1.2")).toBe(false); + }); + + it("returns true when all dependencies are completed", () => { + const tasks = [ + makeTask({ id: "1.1", status: "completed" }), + makeTask({ id: "1.2", dependencies: ["1.1"] }), + ]; + expect(isTaskReady(tasks, "1.2")).toBe(true); + }); + + it("returns false for completed task", () => { + const tasks = [makeTask({ id: "1.1", status: "completed" })]; + expect(isTaskReady(tasks, "1.1")).toBe(false); + }); + + it("returns false for nonexistent task", () => { + expect(isTaskReady([], "nope")).toBe(false); + }); +}); + +describe("getBlockingTasks", () => { + it("returns empty for task with no dependencies", () => { + const tasks = [makeTask({ id: "1.1" })]; + expect(getBlockingTasks(tasks, "1.1")).toEqual([]); + }); + + it("returns incomplete dependency IDs", () => { + const tasks = [ + makeTask({ id: "1.1", status: "pending" }), + makeTask({ id: "1.2", status: "completed" }), + makeTask({ id: "1.3", dependencies: ["1.1", "1.2"] }), + ]; + expect(getBlockingTasks(tasks, "1.3")).toEqual(["1.1"]); + }); + + it("returns empty for nonexistent task", () => { + expect(getBlockingTasks([], "nope")).toEqual([]); + }); +}); + +describe("detectCycles", () => { + it("returns empty for acyclic graph", () => { + const tasks = [ + makeTask({ id: "1.1" }), + makeTask({ id: "1.2", dependencies: ["1.1"] }), + makeTask({ id: "1.3", dependencies: ["1.2"] }), + ]; + expect(detectCycles(tasks)).toEqual([]); + }); + + it("detects simple cycle", () => { + const tasks = [ + makeTask({ id: "1.1", dependencies: ["1.2"] }), + makeTask({ id: "1.2", dependencies: ["1.1"] }), + ]; + const cycles = detectCycles(tasks); + expect(cycles.length).toBeGreaterThan(0); + }); + + it("detects self-loop", () => { + const tasks = [makeTask({ id: "1.1", dependencies: ["1.1"] })]; + const cycles = detectCycles(tasks); + expect(cycles.length).toBeGreaterThan(0); + }); + + it("returns empty for isolated nodes", () => { + const tasks = [makeTask({ id: "1.1" }), makeTask({ id: "1.2" })]; + expect(detectCycles(tasks)).toEqual([]); + }); + + it("ignores dependencies referencing nonexistent tasks", () => { + const tasks = [makeTask({ id: "1.1", dependencies: ["missing"] })]; + expect(detectCycles(tasks)).toEqual([]); + }); +}); + +describe("topologicalSort", () => { + it("returns IDs in dependency order", () => { + const tasks = [ + makeTask({ id: "1.3", dependencies: ["1.2"] }), + makeTask({ id: "1.1" }), + makeTask({ id: "1.2", dependencies: ["1.1"] }), + ]; + const sorted = topologicalSort(tasks); + expect(sorted.indexOf("1.1")).toBeLessThan(sorted.indexOf("1.2")); + expect(sorted.indexOf("1.2")).toBeLessThan(sorted.indexOf("1.3")); + }); + + it("returns all task IDs for independent tasks", () => { + const tasks = [makeTask({ id: "a" }), makeTask({ id: "b" }), makeTask({ id: "c" })]; + const sorted = topologicalSort(tasks); + expect(sorted).toHaveLength(3); + expect(new Set(sorted)).toEqual(new Set(["a", "b", "c"])); + }); + + it("handles empty input", () => { + expect(topologicalSort([])).toEqual([]); + }); + + it("omits nodes involved in cycles from result", () => { + const tasks = [ + makeTask({ id: "1.1", dependencies: ["1.2"] }), + makeTask({ id: "1.2", dependencies: ["1.1"] }), + makeTask({ id: "1.3" }), + ]; + const sorted = topologicalSort(tasks); + expect(sorted).toContain("1.3"); + expect(sorted).toHaveLength(1); + }); +}); diff --git a/packages/pi-blueprint/src/dependency-graph.ts b/packages/pi-blueprint/src/dependency-graph.ts new file mode 100644 index 0000000..acbe49f --- /dev/null +++ b/packages/pi-blueprint/src/dependency-graph.ts @@ -0,0 +1,113 @@ +import type { Task } from "./types.js"; +import { isTaskDone, getCompletedTaskIds } from "./types.js"; + +export function findBlockedTasks(tasks: readonly Task[]): readonly string[] { + const completedIds = getCompletedTaskIds(tasks); + return tasks + .filter( + (t) => + !isTaskDone(t) && + t.dependencies.length > 0 && + t.dependencies.some((dep) => !completedIds.has(dep)), + ) + .map((t) => t.id); +} + +export function isTaskReady(tasks: readonly Task[], taskId: string): boolean { + const task = tasks.find((t) => t.id === taskId); + if (!task) return false; + if (isTaskDone(task)) return false; + if (task.dependencies.length === 0) return true; + const completedIds = getCompletedTaskIds(tasks); + return task.dependencies.every((dep) => completedIds.has(dep)); +} + +export function getBlockingTasks(tasks: readonly Task[], taskId: string): readonly string[] { + const task = tasks.find((t) => t.id === taskId); + if (!task) return []; + const completedIds = getCompletedTaskIds(tasks); + return task.dependencies.filter((dep) => !completedIds.has(dep)); +} + +export function detectCycles(tasks: readonly Task[]): readonly (readonly string[])[] { + const ids = new Set(tasks.map((t) => t.id)); + const adj = new Map(); + for (const t of tasks) { + adj.set(t.id, t.dependencies.filter((d) => ids.has(d))); + } + + const WHITE = 0; + const GRAY = 1; + const BLACK = 2; + const color = new Map(); + for (const id of ids) color.set(id, WHITE); + + const cycles: string[][] = []; + const stack: string[] = []; + + function dfs(node: string): void { + color.set(node, GRAY); + stack.push(node); + + for (const neighbor of adj.get(node) ?? []) { + const c = color.get(neighbor); + if (c === GRAY) { + const cycleStart = stack.indexOf(neighbor); + cycles.push(stack.slice(cycleStart)); + } else if (c === WHITE) { + dfs(neighbor); + } + } + + stack.pop(); + color.set(node, BLACK); + } + + for (const id of ids) { + if (color.get(id) === WHITE) dfs(id); + } + + return cycles; +} + +export function topologicalSort(tasks: readonly Task[]): readonly string[] { + const ids = new Set(tasks.map((t) => t.id)); + const adj = new Map(); + const inDegree = new Map(); + + for (const id of ids) { + adj.set(id, []); + inDegree.set(id, 0); + } + + for (const t of tasks) { + for (const dep of t.dependencies) { + if (ids.has(dep)) { + adj.get(dep)!.push(t.id); + inDegree.set(t.id, (inDegree.get(t.id) ?? 0) + 1); + } + } + } + + const queue: string[] = []; + for (const [id, deg] of inDegree) { + if (deg === 0) queue.push(id); + } + + const result: string[] = []; + while (queue.length > 0) { + const node = queue.shift()!; + result.push(node); + for (const neighbor of adj.get(node) ?? []) { + const newDeg = (inDegree.get(neighbor) ?? 1) - 1; + inDegree.set(neighbor, newDeg); + if (newDeg === 0) queue.push(neighbor); + } + } + + return result; +} + +export function getAllTasks(phases: readonly { readonly tasks: readonly Task[] }[]): readonly Task[] { + return phases.flatMap((p) => p.tasks); +} diff --git a/packages/pi-blueprint/src/index.test.ts b/packages/pi-blueprint/src/index.test.ts new file mode 100644 index 0000000..2ec4305 --- /dev/null +++ b/packages/pi-blueprint/src/index.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi } from "vitest"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import registerExtension from "./index.js"; +import { registerBlueprintTools } from "./blueprint-tools.js"; + +vi.mock("./storage.js", () => ({ + ensureBaseDir: vi.fn(), + loadIndex: vi.fn().mockReturnValue(null), + loadBlueprint: vi.fn().mockReturnValue(null), + saveBlueprint: vi.fn(), +})); + +vi.mock("./blueprint-tools.js", () => ({ + registerBlueprintTools: vi.fn(), +})); + +function createMockPi() { + const hooks = new Map unknown)[]>(); + const commands = new Map(); + return { + on: vi.fn((event: string, handler: (...args: unknown[]) => unknown) => { + if (!hooks.has(event)) hooks.set(event, []); + hooks.get(event)!.push(handler); + }), + registerCommand: vi.fn((name: string, def: unknown) => { + commands.set(name, def); + }), + registerTool: vi.fn(), + sendUserMessage: vi.fn(), + hooks, + commands, + }; +} + +describe("index", () => { + it("registers 4 hooks", () => { + const pi = createMockPi(); + registerExtension(pi as unknown as ExtensionAPI); + expect(pi.on).toHaveBeenCalledWith("session_start", expect.any(Function)); + expect(pi.on).toHaveBeenCalledWith("session_shutdown", expect.any(Function)); + expect(pi.on).toHaveBeenCalledWith("before_agent_start", expect.any(Function)); + expect(pi.on).toHaveBeenCalledWith("turn_end", expect.any(Function)); + }); + + it("registers 4 commands", () => { + const pi = createMockPi(); + registerExtension(pi as unknown as ExtensionAPI); + expect(pi.registerCommand).toHaveBeenCalledTimes(4); + expect(pi.commands.has("blueprint")).toBe(true); + expect(pi.commands.has("plan-status")).toBe(true); + expect(pi.commands.has("plan-verify")).toBe(true); + expect(pi.commands.has("plan-next")).toBe(true); + }); + + it("session_start loads blueprint and registers tools", () => { + const pi = createMockPi(); + registerExtension(pi as unknown as ExtensionAPI); + + const sessionStartHandler = pi.hooks.get("session_start")![0]!; + sessionStartHandler({}, { ui: { notify: vi.fn() } }); + + expect(registerBlueprintTools).toHaveBeenCalled(); + }); + + it("before_agent_start returns undefined when no blueprint", () => { + const pi = createMockPi(); + registerExtension(pi as unknown as ExtensionAPI); + + const handler = pi.hooks.get("before_agent_start")![0]!; + const result = handler({ systemPrompt: "base" }, {}); + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/pi-blueprint/src/index.ts b/packages/pi-blueprint/src/index.ts new file mode 100644 index 0000000..148e66c --- /dev/null +++ b/packages/pi-blueprint/src/index.ts @@ -0,0 +1,118 @@ +import type { + ExtensionAPI, + ExtensionCommandContext, +} from "@mariozechner/pi-coding-agent"; +import type { BlueprintExtensionState } from "./types.js"; +import { + ensureBaseDir, + loadIndex, + loadBlueprint, + saveBlueprint, +} from "./storage.js"; +import { buildInjectionBlock } from "./blueprint-injector.js"; +import { registerBlueprintTools } from "./blueprint-tools.js"; +import { + handleBlueprintCommand, + COMMAND_NAME as BLUEPRINT_CMD, +} from "./blueprint-command.js"; +import { + handlePlanStatusCommand, + COMMAND_NAME as STATUS_CMD, +} from "./plan-status-command.js"; +import { + handlePlanVerifyCommand, + COMMAND_NAME as VERIFY_CMD, +} from "./plan-verify-command.js"; +import { + handlePlanNextCommand, + COMMAND_NAME as NEXT_CMD, +} from "./plan-next-command.js"; + +export default function (pi: ExtensionAPI): void { + let state: BlueprintExtensionState = { + project: null, + blueprint: null, + sessionId: "", + }; + let dirty = false; + + const stateRef = { + get: () => state, + set: (s: BlueprintExtensionState) => { + state = s; + dirty = true; + }, + }; + + pi.on("session_start", (_event, _ctx) => { + try { + ensureBaseDir(); + const index = loadIndex(); + if (index?.active_blueprint_id) { + const blueprint = loadBlueprint(index.active_blueprint_id); + if (blueprint && blueprint.status === "active") { + state = { ...state, blueprint }; + } + } + registerBlueprintTools(pi, stateRef); + } catch (err) { + console.error("[pi-blueprint] session_start error:", err); + } + }); + + pi.on("session_shutdown", (_event, _ctx) => { + try { + if (state.blueprint) { + saveBlueprint(state.blueprint); + } + } catch (err) { + console.error("[pi-blueprint] session_shutdown error:", err); + } + }); + + pi.on("before_agent_start", (event, _ctx) => { + try { + const block = buildInjectionBlock(state.blueprint); + if (!block) return; + const e = event as { systemPrompt?: string }; + return { systemPrompt: (e.systemPrompt ?? "") + block }; + } catch (err) { + console.error("[pi-blueprint] before_agent_start error:", err); + } + }); + + pi.on("turn_end", (_event, _ctx) => { + try { + if (state.blueprint && dirty) { + saveBlueprint(state.blueprint); + dirty = false; + } + } catch (err) { + console.error("[pi-blueprint] turn_end error:", err); + } + }); + + pi.registerCommand(BLUEPRINT_CMD, { + description: "Create or manage a multi-session blueprint plan", + handler: (args: string, ctx: ExtensionCommandContext) => + handleBlueprintCommand(args, ctx, stateRef, pi), + }); + + pi.registerCommand(STATUS_CMD, { + description: "Show detailed blueprint progress", + handler: (args: string, ctx: ExtensionCommandContext) => + handlePlanStatusCommand(args, ctx, stateRef), + }); + + pi.registerCommand(VERIFY_CMD, { + description: "Run verification gates for the current phase", + handler: (args: string, ctx: ExtensionCommandContext) => + handlePlanVerifyCommand(args, ctx, stateRef), + }); + + pi.registerCommand(NEXT_CMD, { + description: "Get and start the next blueprint task", + handler: (args: string, ctx: ExtensionCommandContext) => + handlePlanNextCommand(args, ctx, stateRef, pi), + }); +} diff --git a/packages/pi-blueprint/src/plan-next-command.test.ts b/packages/pi-blueprint/src/plan-next-command.test.ts new file mode 100644 index 0000000..e20d851 --- /dev/null +++ b/packages/pi-blueprint/src/plan-next-command.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi } from "vitest"; +import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; +import { handlePlanNextCommand } from "./plan-next-command.js"; +import type { StateRef, BlueprintExtensionState, Phase, Task } from "./types.js"; + +function makeTask(overrides: Partial & { id: string }): Task { + return { + title: overrides.id, description: "", status: "pending", + acceptance_criteria: [], file_targets: [], dependencies: [], + started_at: null, completed_at: null, session_id: null, notes: null, + ...overrides, + }; +} + +function makePhase(overrides: Partial & { id: string }): Phase { + return { + title: `Phase ${overrides.id}`, description: "", status: "pending", + tasks: [], verification_gates: [], started_at: null, completed_at: null, + ...overrides, + }; +} + +describe("handlePlanNextCommand", () => { + it("reports no blueprint", () => { + const state: BlueprintExtensionState = { project: null, blueprint: null, sessionId: "" }; + const stateRef: StateRef = { get: () => state, set: () => {} }; + const ctx = { ui: { notify: vi.fn() } }; + const pi = { sendUserMessage: vi.fn() }; + handlePlanNextCommand("", ctx as unknown as ExtensionCommandContext, stateRef, pi as unknown as ExtensionAPI); + expect(ctx.ui.notify).toHaveBeenCalledWith("No active blueprint.", "info"); + }); + + it("sends next task as follow-up message", () => { + const state: BlueprintExtensionState = { + project: null, sessionId: "", + blueprint: { + id: "bp-1", objective: "Test", project_id: "p", status: "active", + created_at: "2026-04-11T00:00:00.000Z", updated_at: "2026-04-11T00:00:00.000Z", + phases: [makePhase({ + id: "1", status: "active", + tasks: [makeTask({ id: "1.1", title: "Do next thing" })], + })], + active_phase_id: "1", active_task_id: "1.1", + }, + }; + const stateRef: StateRef = { get: () => state, set: () => {} }; + const ctx = { ui: { notify: vi.fn() } }; + const pi = { sendUserMessage: vi.fn() }; + handlePlanNextCommand("", ctx as unknown as ExtensionCommandContext, stateRef, pi as unknown as ExtensionAPI); + expect(pi.sendUserMessage).toHaveBeenCalledWith( + expect.stringContaining("Do next thing"), + expect.objectContaining({ deliverAs: "followUp" }), + ); + }); + + it("reports completion when all done", () => { + const state: BlueprintExtensionState = { + project: null, sessionId: "", + blueprint: { + id: "bp-1", objective: "Test", project_id: "p", status: "completed", + created_at: "2026-04-11T00:00:00.000Z", updated_at: "2026-04-11T00:00:00.000Z", + phases: [makePhase({ + id: "1", status: "verified", + tasks: [makeTask({ id: "1.1", status: "completed" })], + })], + active_phase_id: null, active_task_id: null, + }, + }; + const stateRef: StateRef = { get: () => state, set: () => {} }; + const ctx = { ui: { notify: vi.fn() } }; + const pi = { sendUserMessage: vi.fn() }; + handlePlanNextCommand("", ctx as unknown as ExtensionCommandContext, stateRef, pi as unknown as ExtensionAPI); + expect(ctx.ui.notify).toHaveBeenCalledWith("Blueprint is complete.", "info"); + }); +}); diff --git a/packages/pi-blueprint/src/plan-next-command.ts b/packages/pi-blueprint/src/plan-next-command.ts new file mode 100644 index 0000000..5f7b7de --- /dev/null +++ b/packages/pi-blueprint/src/plan-next-command.ts @@ -0,0 +1,56 @@ +import type { + ExtensionAPI, + ExtensionCommandContext, +} from "@mariozechner/pi-coding-agent"; +import type { StateRef } from "./types.js"; +import { getNextTask } from "./state-machine.js"; + +export const COMMAND_NAME = "plan-next"; + +export async function handlePlanNextCommand( + _args: string, + ctx: ExtensionCommandContext, + stateRef: StateRef, + pi: ExtensionAPI, +): Promise { + const state = stateRef.get(); + if (!state.blueprint) { + ctx.ui.notify("No active blueprint.", "info"); + return; + } + + const next = getNextTask(state.blueprint); + if (!next) { + ctx.ui.notify( + state.blueprint.status === "completed" + ? "Blueprint is complete." + : "No actionable tasks. Some may be blocked or awaiting verification.", + "info", + ); + return; + } + + const lines = [ + `Work on blueprint task ${next.id}: ${next.title}`, + "", + next.description, + ]; + + if (next.acceptance_criteria.length > 0) { + lines.push("", "Acceptance criteria:"); + for (const c of next.acceptance_criteria) { + lines.push(`- ${c}`); + } + } + + if (next.file_targets.length > 0) { + lines.push("", "File targets:"); + for (const f of next.file_targets) { + lines.push(`- ${f}`); + } + } + + lines.push("", "When done, call the blueprint_update tool to mark this task as completed."); + + pi.sendUserMessage(lines.join("\n"), { deliverAs: "followUp" }); +} diff --git a/packages/pi-blueprint/src/plan-renderer.test.ts b/packages/pi-blueprint/src/plan-renderer.test.ts new file mode 100644 index 0000000..c2e69dd --- /dev/null +++ b/packages/pi-blueprint/src/plan-renderer.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from "vitest"; +import { renderPlanMarkdown } from "./plan-renderer.js"; +import type { Blueprint, Phase, Task, VerificationGate } from "./types.js"; + +function makeTask(overrides: Partial & { id: string }): Task { + return { + title: overrides.id, + description: "", + status: "pending", + acceptance_criteria: [], + file_targets: [], + dependencies: [], + started_at: null, + completed_at: null, + session_id: null, + notes: null, + ...overrides, + }; +} + +function makePhase(overrides: Partial & { id: string }): Phase { + return { + title: `Phase ${overrides.id}`, + description: "", + status: "pending", + tasks: [], + verification_gates: [], + started_at: null, + completed_at: null, + ...overrides, + }; +} + +function makeBlueprint(phases: Phase[]): Blueprint { + return { + id: "bp-1", + objective: "Test objective", + project_id: "proj-1", + status: "active", + created_at: "2026-04-11T00:00:00.000Z", + updated_at: "2026-04-11T00:00:00.000Z", + phases, + active_phase_id: phases[0]?.id ?? null, + active_task_id: null, + }; +} + +describe("renderPlanMarkdown", () => { + it("renders header with objective and status", () => { + const bp = makeBlueprint([]); + const md = renderPlanMarkdown(bp); + expect(md).toContain("# Blueprint: Test objective"); + expect(md).toContain("**Status:** active"); + }); + + it("renders phases with task counts", () => { + const bp = makeBlueprint([ + makePhase({ + id: "1", + tasks: [ + makeTask({ id: "1.1", status: "completed" }), + makeTask({ id: "1.2" }), + ], + }), + ]); + const md = renderPlanMarkdown(bp); + expect(md).toContain("## Phase 1: Phase 1 (active)"); + expect(md).toContain("1/2 tasks completed"); + }); + + it("renders task checkboxes", () => { + const bp = makeBlueprint([ + makePhase({ + id: "1", + tasks: [ + makeTask({ id: "1.1", title: "Do thing", status: "completed" }), + makeTask({ id: "1.2", title: "Next thing", status: "in_progress" }), + makeTask({ id: "1.3", title: "Blocked thing", status: "blocked" }), + ], + }), + ]); + const md = renderPlanMarkdown(bp); + expect(md).toContain("- [x] 1.1 Do thing"); + expect(md).toContain("- [ ] 1.2 Next thing *(in progress)*"); + expect(md).toContain("- [ ] 1.3 Blocked thing *(blocked)*"); + }); + + it("renders verification gates", () => { + const gate: VerificationGate = { + type: "tests_pass", + command: null, + description: "All tests pass", + passed: true, + last_checked_at: null, + error_message: null, + }; + const bp = makeBlueprint([ + makePhase({ id: "1", verification_gates: [gate] }), + ]); + const md = renderPlanMarkdown(bp); + expect(md).toContain("- [x] All tests pass"); + }); + + it("marks active phase", () => { + const bp = makeBlueprint([ + makePhase({ id: "1" }), + makePhase({ id: "2" }), + ]); + const md = renderPlanMarkdown(bp); + expect(md).toContain("Phase 1 (active)"); + expect(md).not.toContain("Phase 2 (active)"); + }); +}); diff --git a/packages/pi-blueprint/src/plan-renderer.ts b/packages/pi-blueprint/src/plan-renderer.ts new file mode 100644 index 0000000..d1ae275 --- /dev/null +++ b/packages/pi-blueprint/src/plan-renderer.ts @@ -0,0 +1,70 @@ +import type { Blueprint, Phase, Task } from "./types.js"; +import { isTaskDone } from "./types.js"; + +export function renderPlanMarkdown(blueprint: Blueprint): string { + const lines: string[] = []; + lines.push(`# Blueprint: ${blueprint.objective}`); + lines.push(""); + lines.push(`**Status:** ${blueprint.status} | Created: ${formatDate(blueprint.created_at)} | Updated: ${formatDate(blueprint.updated_at)}`); + + for (const phase of blueprint.phases) { + lines.push(""); + lines.push("---"); + lines.push(""); + lines.push(renderPhase(phase, blueprint.active_phase_id)); + } + + return lines.join("\n") + "\n"; +} + +function renderPhase(phase: Phase, activePhaseId: string | null): string { + const lines: string[] = []; + const isActive = phase.id === activePhaseId; + const activeTag = isActive ? " (active)" : ""; + + const completed = phase.tasks.filter(isTaskDone).length; + const total = phase.tasks.length; + + lines.push(`## Phase ${phase.id}: ${phase.title}${activeTag}`); + lines.push(""); + lines.push(`**Status:** ${phase.status} | ${completed}/${total} tasks completed`); + + if (phase.description) { + lines.push(""); + lines.push(phase.description); + } + + if (phase.tasks.length > 0) { + lines.push(""); + lines.push("### Tasks"); + lines.push(""); + for (const task of phase.tasks) { + lines.push(renderTask(task)); + } + } + + if (phase.verification_gates.length > 0) { + lines.push(""); + lines.push("### Verification Gates"); + lines.push(""); + for (const gate of phase.verification_gates) { + const check = gate.passed ? "x" : " "; + lines.push(`- [${check}] ${gate.description}`); + } + } + + return lines.join("\n"); +} + +function renderTask(task: Task): string { + const check = isTaskDone(task) ? "x" : " "; + let annotation = ""; + if (task.status === "in_progress") annotation = " *(in progress)*"; + else if (task.status === "blocked") annotation = " *(blocked)*"; + else if (task.status === "skipped") annotation = " *(skipped)*"; + return `- [${check}] ${task.id} ${task.title}${annotation}`; +} + +function formatDate(iso: string): string { + return iso.slice(0, 10); +} diff --git a/packages/pi-blueprint/src/plan-status-command.test.ts b/packages/pi-blueprint/src/plan-status-command.test.ts new file mode 100644 index 0000000..14f5cbc --- /dev/null +++ b/packages/pi-blueprint/src/plan-status-command.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, vi } from "vitest"; +import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; +import { handlePlanStatusCommand } from "./plan-status-command.js"; +import type { StateRef, BlueprintExtensionState, Phase, Task } from "./types.js"; + +function makeTask(overrides: Partial & { id: string }): Task { + return { + title: overrides.id, description: "", status: "pending", + acceptance_criteria: [], file_targets: [], dependencies: [], + started_at: null, completed_at: null, session_id: null, notes: null, + ...overrides, + }; +} + +function makePhase(overrides: Partial & { id: string }): Phase { + return { + title: `Phase ${overrides.id}`, description: "", status: "pending", + tasks: [], verification_gates: [], started_at: null, completed_at: null, + ...overrides, + }; +} + +describe("handlePlanStatusCommand", () => { + it("reports no blueprint", () => { + const state: BlueprintExtensionState = { project: null, blueprint: null, sessionId: "" }; + const stateRef: StateRef = { get: () => state, set: () => {} }; + const ctx = { ui: { notify: vi.fn() } }; + handlePlanStatusCommand("", ctx as unknown as ExtensionCommandContext, stateRef); + expect(ctx.ui.notify).toHaveBeenCalledWith("No active blueprint.", "info"); + }); + + it("shows progress percentage", () => { + const state: BlueprintExtensionState = { + project: null, sessionId: "", + blueprint: { + id: "bp-1", objective: "Test", project_id: "p", status: "active", + created_at: "2026-04-11T00:00:00.000Z", updated_at: "2026-04-11T00:00:00.000Z", + phases: [makePhase({ + id: "1", + tasks: [ + makeTask({ id: "1.1", status: "completed" }), + makeTask({ id: "1.2" }), + ], + })], + active_phase_id: "1", active_task_id: "1.2", + }, + }; + const stateRef: StateRef = { get: () => state, set: () => {} }; + const ctx = { ui: { notify: vi.fn() } }; + handlePlanStatusCommand("", ctx as unknown as ExtensionCommandContext, stateRef); + expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("1/2 tasks (50%)"), "info"); + }); +}); diff --git a/packages/pi-blueprint/src/plan-status-command.ts b/packages/pi-blueprint/src/plan-status-command.ts new file mode 100644 index 0000000..c342527 --- /dev/null +++ b/packages/pi-blueprint/src/plan-status-command.ts @@ -0,0 +1,30 @@ +import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; +import type { StateRef } from "./types.js"; +import { isTaskDone } from "./types.js"; +import { renderPlanMarkdown } from "./plan-renderer.js"; +import { getAllTasks } from "./dependency-graph.js"; + +export const COMMAND_NAME = "plan-status"; + +export async function handlePlanStatusCommand( + _args: string, + ctx: ExtensionCommandContext, + stateRef: StateRef, +): Promise { + const state = stateRef.get(); + if (!state.blueprint) { + ctx.ui.notify("No active blueprint.", "info"); + return; + } + + const bp = state.blueprint; + const allTasks = getAllTasks(bp.phases); + const completed = allTasks.filter(isTaskDone).length; + const total = allTasks.length; + const pct = total > 0 ? Math.round((completed / total) * 100) : 0; + + const header = `Progress: ${completed}/${total} tasks (${pct}%)`; + const plan = renderPlanMarkdown(bp); + + ctx.ui.notify(`${header}\n\n${plan}`, "info"); +} diff --git a/packages/pi-blueprint/src/plan-verify-command.test.ts b/packages/pi-blueprint/src/plan-verify-command.test.ts new file mode 100644 index 0000000..c4cf8ea --- /dev/null +++ b/packages/pi-blueprint/src/plan-verify-command.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; +import { handlePlanVerifyCommand } from "./plan-verify-command.js"; +import type { StateRef, BlueprintExtensionState, Phase, Task, VerificationGate } from "./types.js"; + +vi.mock("./storage.js", () => ({ + saveBlueprint: vi.fn(), + appendHistory: vi.fn(), +})); + +vi.mock("./verification.js", () => ({ + runGate: vi.fn().mockReturnValue({ passed: true, output: "ok", duration_ms: 100 }), +})); + +function makeTask(overrides: Partial & { id: string }): Task { + return { + title: overrides.id, description: "", status: "completed", + acceptance_criteria: [], file_targets: [], dependencies: [], + started_at: null, completed_at: null, session_id: null, notes: null, + ...overrides, + }; +} + +function makeGate(overrides?: Partial): VerificationGate { + return { + type: "tests_pass", command: null, description: "Tests pass", + passed: false, last_checked_at: null, error_message: null, + ...overrides, + }; +} + +describe("handlePlanVerifyCommand", () => { + let state: BlueprintExtensionState; + let stateRef: StateRef; + let ctx: { ui: { notify: ReturnType } }; + + beforeEach(() => { + ctx = { ui: { notify: vi.fn() } }; + state = { + project: { id: "p", name: "test", root: "/tmp" }, + sessionId: "s", + blueprint: { + id: "bp-1", objective: "Test", project_id: "p", status: "active", + created_at: "2026-04-11T00:00:00.000Z", updated_at: "2026-04-11T00:00:00.000Z", + phases: [{ + id: "1", title: "P1", description: "", status: "completed", + tasks: [makeTask({ id: "1.1" })], + verification_gates: [makeGate()], + started_at: null, completed_at: null, + } satisfies Phase], + active_phase_id: "1", active_task_id: null, + }, + }; + stateRef = { get: () => state, set: (s) => { state = s; } }; + }); + + it("reports no blueprint", () => { + state = { ...state, blueprint: null }; + handlePlanVerifyCommand("", ctx as unknown as ExtensionCommandContext, stateRef); + expect(ctx.ui.notify).toHaveBeenCalledWith("No active blueprint.", "info"); + }); + + it("runs verification gates and reports results", () => { + handlePlanVerifyCommand("", ctx as unknown as ExtensionCommandContext, stateRef); + expect(ctx.ui.notify).toHaveBeenCalledWith( + expect.stringContaining("PASSED"), + "info", + ); + }); +}); diff --git a/packages/pi-blueprint/src/plan-verify-command.ts b/packages/pi-blueprint/src/plan-verify-command.ts new file mode 100644 index 0000000..f8dcd0a --- /dev/null +++ b/packages/pi-blueprint/src/plan-verify-command.ts @@ -0,0 +1,82 @@ +import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; +import type { StateRef } from "./types.js"; +import { verifyGate, advancePhase } from "./state-machine.js"; +import { runGate } from "./verification.js"; +import { saveBlueprint, appendHistory } from "./storage.js"; + +export const COMMAND_NAME = "plan-verify"; + +export async function handlePlanVerifyCommand( + _args: string, + ctx: ExtensionCommandContext, + stateRef: StateRef, +): Promise { + const state = stateRef.get(); + if (!state.blueprint) { + ctx.ui.notify("No active blueprint.", "info"); + return; + } + + const bp = state.blueprint; + const phase = bp.phases.find((p) => p.id === bp.active_phase_id); + if (!phase) { + ctx.ui.notify("No active phase to verify.", "info"); + return; + } + + if (phase.verification_gates.length === 0) { + ctx.ui.notify(`Phase ${phase.id} has no verification gates. Advancing.`, "info"); + const advanced = advancePhase(bp); + saveBlueprint(advanced); + stateRef.set({ ...state, blueprint: advanced }); + return; + } + + const cwd = state.project?.root ?? process.cwd(); + let updated = bp; + const results: string[] = []; + + for (let i = 0; i < phase.verification_gates.length; i++) { + const gate = phase.verification_gates[i]!; + + if (gate.type === "user_approval") { + results.push(`- ${gate.description}: requires manual approval (skipped in automated run)`); + continue; + } + + ctx.ui.notify(`Running: ${gate.description}...`, "info"); + const result = runGate(gate, cwd); + updated = verifyGate(updated, phase.id, i, result.passed, result.passed ? undefined : result.output); + + const status = result.passed ? "PASSED" : "FAILED"; + results.push(`- ${gate.description}: ${status} (${result.duration_ms}ms)`); + + appendHistory(updated.id, { + timestamp: new Date().toISOString(), + event: result.passed ? "verification_passed" : "verification_failed", + phase_id: phase.id, + task_id: null, + session_id: state.sessionId, + details: `${gate.description}: ${status}`, + }); + } + + const currentPhase = updated.phases.find((p) => p.id === phase.id)!; + const allPassed = currentPhase.verification_gates + .filter((g) => g.type !== "user_approval") + .every((g) => g.passed); + + if (allPassed) { + const hasUserApproval = currentPhase.verification_gates.some((g) => g.type === "user_approval" && !g.passed); + if (!hasUserApproval) { + updated = advancePhase(updated); + results.push("\nAll gates passed. Phase advanced."); + } else { + results.push("\nAutomated gates passed. User approval still required."); + } + } + + saveBlueprint(updated); + stateRef.set({ ...state, blueprint: updated }); + ctx.ui.notify(`Verification results for Phase ${phase.id}:\n${results.join("\n")}`, "info"); +} diff --git a/packages/pi-blueprint/src/prompts/blueprint-generate.ts b/packages/pi-blueprint/src/prompts/blueprint-generate.ts new file mode 100644 index 0000000..b6a4b9f --- /dev/null +++ b/packages/pi-blueprint/src/prompts/blueprint-generate.ts @@ -0,0 +1,34 @@ +export function getBlueprintGeneratePrompt(objective: string): string { + return `You are a senior software architect planning a complex implementation. + +## Objective +${objective} + +## Instructions + +Analyze the codebase and the objective above, then create a phased construction plan by calling the \`blueprint_create\` tool. + +### Plan structure requirements: +1. Break the work into 2-6 phases, ordered by dependency (foundations first) +2. Each phase should have 2-8 concrete, agent-sized tasks +3. Each task must have: + - A clear, imperative title (e.g., "Add OAuth2 callback endpoint") + - A brief description of what to implement + - Acceptance criteria (testable conditions) + - File targets (files to create or modify) + - Dependencies on other tasks (by task ID, e.g., "1.1", "2.3") +4. Each phase should have verification gates: + - Phase 1 typically: tests_pass + - Later phases: tests_pass + typecheck_clean + - Final phase: tests_pass + typecheck_clean + user_approval (optional) +5. Tasks within a phase can depend on other tasks (within or across phases) +6. Task IDs follow the format "phase.task" (e.g., "1.1", "1.2", "2.1") + +### Quality criteria: +- Each task should be completable in a single agent session +- Dependencies should be minimal and acyclic +- Acceptance criteria should be specific and testable +- File targets should reference real paths in the codebase when possible + +Call the \`blueprint_create\` tool with the structured plan.`; +} diff --git a/packages/pi-blueprint/src/prompts/phase-context.ts b/packages/pi-blueprint/src/prompts/phase-context.ts new file mode 100644 index 0000000..f678228 --- /dev/null +++ b/packages/pi-blueprint/src/prompts/phase-context.ts @@ -0,0 +1,76 @@ +import type { Blueprint, Task } from "../types.js"; +import { isTaskDone } from "../types.js"; +import { getBlockingTasks, getAllTasks } from "../dependency-graph.js"; + +export function buildPhaseContext(blueprint: Blueprint): string { + const lines: string[] = []; + + lines.push(`## Active Blueprint: "${blueprint.objective}"`); + lines.push(""); + + const activePhase = blueprint.phases.find((p) => p.id === blueprint.active_phase_id); + if (!activePhase) { + lines.push("No active phase. All phases may be complete or verified."); + return lines.join("\n"); + } + + const completed = activePhase.tasks.filter(isTaskDone).length; + + lines.push(`### Current Phase: Phase ${activePhase.id} - ${activePhase.title}`); + lines.push(`Status: ${activePhase.status} | ${completed}/${activePhase.tasks.length} tasks completed`); + + const activeTask = activePhase.tasks.find((t) => t.id === blueprint.active_task_id); + if (activeTask) { + lines.push(""); + lines.push(buildTaskContext(activeTask)); + } + + const blockedTasks = activePhase.tasks.filter((t) => t.status === "blocked"); + if (blockedTasks.length > 0) { + lines.push(""); + lines.push("### Blocked Tasks"); + const allTasks = getAllTasks(blueprint.phases); + for (const task of blockedTasks) { + const blockers = getBlockingTasks(allTasks, task.id); + lines.push(`- ${task.id} "${task.title}" - blocked by ${blockers.join(", ")}`); + } + } + + if (activePhase.verification_gates.length > 0) { + lines.push(""); + lines.push(`### Phase ${activePhase.id} Verification Gates`); + for (const gate of activePhase.verification_gates) { + const check = gate.passed ? "x" : " "; + lines.push(`- [${check}] ${gate.description}`); + } + } + + return lines.join("\n"); +} + +function buildTaskContext(task: Task): string { + const lines: string[] = []; + lines.push(`### Current Task: ${task.id} - ${task.title}`); + + if (task.description) { + lines.push(task.description); + } + + if (task.acceptance_criteria.length > 0) { + lines.push(""); + lines.push("**Acceptance criteria:**"); + for (const c of task.acceptance_criteria) { + lines.push(`- ${c}`); + } + } + + if (task.file_targets.length > 0) { + lines.push(""); + lines.push("**File targets:**"); + for (const f of task.file_targets) { + lines.push(`- ${f}`); + } + } + + return lines.join("\n"); +} diff --git a/packages/pi-blueprint/src/state-machine.test.ts b/packages/pi-blueprint/src/state-machine.test.ts new file mode 100644 index 0000000..b3d5629 --- /dev/null +++ b/packages/pi-blueprint/src/state-machine.test.ts @@ -0,0 +1,448 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + startTask, + completeTask, + skipTask, + verifyGate, + advancePhase, + getNextTask, + recomputeBlocked, + createBlueprint, + abandonBlueprint, +} from "./state-machine.js"; +import type { Blueprint, Phase, Task, VerificationGate } from "./types.js"; + +const NOW = "2026-04-11T00:00:00.000Z"; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(NOW)); +}); + +function makeTask(overrides: Partial & { id: string }): Task { + return { + title: overrides.id, + description: "", + status: "pending", + acceptance_criteria: [], + file_targets: [], + dependencies: [], + started_at: null, + completed_at: null, + session_id: null, + notes: null, + ...overrides, + }; +} + +function makeGate(overrides?: Partial): VerificationGate { + return { + type: "tests_pass", + command: null, + description: "Tests pass", + passed: false, + last_checked_at: null, + error_message: null, + ...overrides, + }; +} + +function makePhase(overrides: Partial & { id: string }): Phase { + return { + title: `Phase ${overrides.id}`, + description: "", + status: "pending", + tasks: [], + verification_gates: [], + started_at: null, + completed_at: null, + ...overrides, + }; +} + +function makeBlueprint(phases: Phase[]): Blueprint { + return { + id: "bp-1", + objective: "Test objective", + project_id: "proj-1", + status: "active", + created_at: NOW, + updated_at: NOW, + phases, + active_phase_id: phases[0]?.id ?? null, + active_task_id: null, + }; +} + +describe("startTask", () => { + it("sets task to in_progress", () => { + const bp = makeBlueprint([ + makePhase({ id: "1", tasks: [makeTask({ id: "1.1" })] }), + ]); + const result = startTask(bp, "1.1", "session-1"); + const task = result.phases[0]!.tasks[0]!; + expect(task.status).toBe("in_progress"); + expect(task.session_id).toBe("session-1"); + expect(task.started_at).toBe(NOW); + }); + + it("activates phase if pending", () => { + const bp = makeBlueprint([ + makePhase({ id: "1", tasks: [makeTask({ id: "1.1" })] }), + ]); + const result = startTask(bp, "1.1", "s"); + expect(result.phases[0]!.status).toBe("active"); + expect(result.active_phase_id).toBe("1"); + expect(result.active_task_id).toBe("1.1"); + }); + + it("activates blueprint if draft", () => { + const bp = { ...makeBlueprint([ + makePhase({ id: "1", tasks: [makeTask({ id: "1.1" })] }), + ]), status: "draft" as const }; + const result = startTask(bp, "1.1", "s"); + expect(result.status).toBe("active"); + }); + + it("does not change completed task", () => { + const bp = makeBlueprint([ + makePhase({ id: "1", tasks: [makeTask({ id: "1.1", status: "completed" })] }), + ]); + const result = startTask(bp, "1.1", "s"); + expect(result.phases[0]!.tasks[0]!.status).toBe("completed"); + }); + + it("returns blueprint unchanged for nonexistent task", () => { + const bp = makeBlueprint([]); + expect(startTask(bp, "nope", "s")).toBe(bp); + }); + + it("does not overwrite existing started_at", () => { + const bp = makeBlueprint([ + makePhase({ id: "1", tasks: [makeTask({ id: "1.1", started_at: "earlier" })] }), + ]); + const result = startTask(bp, "1.1", "s"); + expect(result.phases[0]!.tasks[0]!.started_at).toBe("earlier"); + }); +}); + +describe("completeTask", () => { + it("sets task to completed with timestamp", () => { + const bp = makeBlueprint([ + makePhase({ id: "1", tasks: [makeTask({ id: "1.1", status: "in_progress" })] }), + ]); + const result = completeTask(bp, "1.1"); + const task = result.phases[0]!.tasks[0]!; + expect(task.status).toBe("completed"); + expect(task.completed_at).toBe(NOW); + }); + + it("unblocks downstream tasks", () => { + const bp = makeBlueprint([ + makePhase({ + id: "1", + status: "active", + tasks: [ + makeTask({ id: "1.1", status: "in_progress" }), + makeTask({ id: "1.2", status: "blocked", dependencies: ["1.1"] }), + ], + }), + ]); + const result = completeTask(bp, "1.1"); + expect(result.phases[0]!.tasks[1]!.status).toBe("pending"); + }); + + it("advances active_task_id to next task", () => { + const bp = { + ...makeBlueprint([ + makePhase({ + id: "1", + status: "active", + tasks: [ + makeTask({ id: "1.1", status: "in_progress" }), + makeTask({ id: "1.2" }), + ], + }), + ]), + active_task_id: "1.1", + }; + const result = completeTask(bp, "1.1"); + expect(result.active_task_id).toBe("1.2"); + }); + + it("marks phase completed when all tasks done", () => { + const bp = makeBlueprint([ + makePhase({ + id: "1", + status: "active", + tasks: [makeTask({ id: "1.1", status: "in_progress" })], + }), + ]); + const result = completeTask(bp, "1.1"); + expect(result.phases[0]!.status).toBe("completed"); + }); + + it("does not change already completed task", () => { + const bp = makeBlueprint([ + makePhase({ id: "1", tasks: [makeTask({ id: "1.1", status: "completed" })] }), + ]); + expect(completeTask(bp, "1.1")).toBe(bp); + }); +}); + +describe("skipTask", () => { + it("sets task to skipped", () => { + const bp = makeBlueprint([ + makePhase({ id: "1", tasks: [makeTask({ id: "1.1" })] }), + ]); + const result = skipTask(bp, "1.1"); + expect(result.phases[0]!.tasks[0]!.status).toBe("skipped"); + }); + + it("unblocks downstream tasks", () => { + const bp = makeBlueprint([ + makePhase({ + id: "1", + status: "active", + tasks: [ + makeTask({ id: "1.1" }), + makeTask({ id: "1.2", status: "blocked", dependencies: ["1.1"] }), + ], + }), + ]); + const result = skipTask(bp, "1.1"); + expect(result.phases[0]!.tasks[1]!.status).toBe("pending"); + }); +}); + +describe("verifyGate", () => { + it("updates gate passed status", () => { + const bp = makeBlueprint([ + makePhase({ id: "1", verification_gates: [makeGate()] }), + ]); + const result = verifyGate(bp, "1", 0, true); + const gate = result.phases[0]!.verification_gates[0]!; + expect(gate.passed).toBe(true); + expect(gate.last_checked_at).toBe(NOW); + expect(gate.error_message).toBeNull(); + }); + + it("records error message on failure", () => { + const bp = makeBlueprint([ + makePhase({ id: "1", verification_gates: [makeGate()] }), + ]); + const result = verifyGate(bp, "1", 0, false, "3 tests failed"); + const gate = result.phases[0]!.verification_gates[0]!; + expect(gate.passed).toBe(false); + expect(gate.error_message).toBe("3 tests failed"); + }); + + it("returns unchanged for invalid phase", () => { + const bp = makeBlueprint([]); + expect(verifyGate(bp, "nope", 0, true)).toBe(bp); + }); + + it("returns unchanged for invalid gate index", () => { + const bp = makeBlueprint([makePhase({ id: "1" })]); + expect(verifyGate(bp, "1", 5, true)).toBe(bp); + }); +}); + +describe("advancePhase", () => { + it("advances to next phase when all gates pass", () => { + const bp = makeBlueprint([ + makePhase({ + id: "1", + status: "completed", + tasks: [makeTask({ id: "1.1", status: "completed" })], + verification_gates: [makeGate({ passed: true })], + }), + makePhase({ + id: "2", + tasks: [makeTask({ id: "2.1" })], + }), + ]); + const withActive = { ...bp, active_phase_id: "1" }; + const result = advancePhase(withActive); + expect(result.phases[0]!.status).toBe("verified"); + expect(result.active_phase_id).toBe("2"); + expect(result.active_task_id).toBe("2.1"); + }); + + it("completes blueprint when last phase verified", () => { + const bp = makeBlueprint([ + makePhase({ + id: "1", + status: "completed", + tasks: [makeTask({ id: "1.1", status: "completed" })], + }), + ]); + const withActive = { ...bp, active_phase_id: "1" }; + const result = advancePhase(withActive); + expect(result.status).toBe("completed"); + expect(result.active_phase_id).toBeNull(); + }); + + it("does nothing if tasks incomplete", () => { + const bp = makeBlueprint([ + makePhase({ + id: "1", + status: "active", + tasks: [makeTask({ id: "1.1", status: "in_progress" })], + }), + ]); + const withActive = { ...bp, active_phase_id: "1" }; + const result = advancePhase(withActive); + expect(result.active_phase_id).toBe("1"); + }); + + it("does nothing if gates not passed", () => { + const bp = makeBlueprint([ + makePhase({ + id: "1", + status: "completed", + tasks: [makeTask({ id: "1.1", status: "completed" })], + verification_gates: [makeGate({ passed: false })], + }), + makePhase({ id: "2" }), + ]); + const withActive = { ...bp, active_phase_id: "1" }; + const result = advancePhase(withActive); + expect(result.active_phase_id).toBe("1"); + }); +}); + +describe("getNextTask", () => { + it("returns in_progress task first", () => { + const bp = { + ...makeBlueprint([ + makePhase({ + id: "1", + status: "active", + tasks: [ + makeTask({ id: "1.1", status: "in_progress" }), + makeTask({ id: "1.2" }), + ], + }), + ]), + active_phase_id: "1", + }; + expect(getNextTask(bp)?.id).toBe("1.1"); + }); + + it("returns first ready pending task", () => { + const bp = { + ...makeBlueprint([ + makePhase({ + id: "1", + status: "active", + tasks: [ + makeTask({ id: "1.1", status: "completed" }), + makeTask({ id: "1.2" }), + ], + }), + ]), + active_phase_id: "1", + }; + expect(getNextTask(bp)?.id).toBe("1.2"); + }); + + it("skips blocked tasks", () => { + const bp = { + ...makeBlueprint([ + makePhase({ + id: "1", + status: "active", + tasks: [ + makeTask({ id: "1.1" }), + makeTask({ id: "1.2", dependencies: ["1.1"] }), + ], + }), + ]), + active_phase_id: "1", + }; + expect(getNextTask(bp)?.id).toBe("1.1"); + }); + + it("returns null when all tasks done", () => { + const bp = makeBlueprint([ + makePhase({ + id: "1", + status: "completed", + tasks: [makeTask({ id: "1.1", status: "completed" })], + }), + ]); + expect(getNextTask(bp)).toBeNull(); + }); +}); + +describe("recomputeBlocked", () => { + it("marks tasks with incomplete deps as blocked", () => { + const bp = makeBlueprint([ + makePhase({ + id: "1", + tasks: [ + makeTask({ id: "1.1" }), + makeTask({ id: "1.2", dependencies: ["1.1"] }), + ], + }), + ]); + const result = recomputeBlocked(bp); + expect(result.phases[0]!.tasks[1]!.status).toBe("blocked"); + }); + + it("unblocks when deps complete", () => { + const bp = makeBlueprint([ + makePhase({ + id: "1", + tasks: [ + makeTask({ id: "1.1", status: "completed" }), + makeTask({ id: "1.2", status: "blocked", dependencies: ["1.1"] }), + ], + }), + ]); + const result = recomputeBlocked(bp); + expect(result.phases[0]!.tasks[1]!.status).toBe("pending"); + }); + + it("does not touch completed tasks", () => { + const bp = makeBlueprint([ + makePhase({ + id: "1", + tasks: [ + makeTask({ id: "1.1" }), + makeTask({ id: "1.2", status: "completed", dependencies: ["1.1"] }), + ], + }), + ]); + const result = recomputeBlocked(bp); + expect(result.phases[0]!.tasks[1]!.status).toBe("completed"); + }); +}); + +describe("createBlueprint", () => { + it("creates blueprint with initial blocked state computed", () => { + const phases: Phase[] = [ + makePhase({ + id: "1", + tasks: [ + makeTask({ id: "1.1" }), + makeTask({ id: "1.2", dependencies: ["1.1"] }), + ], + }), + ]; + const bp = createBlueprint("bp-1", "objective", "proj-1", phases); + expect(bp.status).toBe("active"); + expect(bp.active_phase_id).toBe("1"); + expect(bp.active_task_id).toBe("1.1"); + expect(bp.phases[0]!.tasks[1]!.status).toBe("blocked"); + }); +}); + +describe("abandonBlueprint", () => { + it("sets status to abandoned", () => { + const bp = makeBlueprint([]); + const result = abandonBlueprint(bp); + expect(result.status).toBe("abandoned"); + }); +}); diff --git a/packages/pi-blueprint/src/state-machine.ts b/packages/pi-blueprint/src/state-machine.ts new file mode 100644 index 0000000..1f2d1b5 --- /dev/null +++ b/packages/pi-blueprint/src/state-machine.ts @@ -0,0 +1,278 @@ +import type { Blueprint, Phase, Task, TaskStatus, PhaseStatus } from "./types.js"; +import { isTaskDone } from "./types.js"; +import { findBlockedTasks, isTaskReady, getAllTasks } from "./dependency-graph.js"; + +function updateTask(phase: Phase, taskId: string, patch: Partial): Phase { + return { + ...phase, + tasks: phase.tasks.map((t) => (t.id === taskId ? { ...t, ...patch } : t)), + }; +} + +function updatePhase(blueprint: Blueprint, phaseId: string, patch: Partial): Blueprint { + return { + ...blueprint, + updated_at: new Date().toISOString(), + phases: blueprint.phases.map((p) => (p.id === phaseId ? { ...p, ...patch } : p)), + }; +} + +function findTask( + blueprint: Blueprint, + taskId: string, +): { phase: Phase; task: Task } | null { + for (const phase of blueprint.phases) { + const task = phase.tasks.find((t) => t.id === taskId); + if (task) return { phase, task }; + } + return null; +} + +function findNextTaskInPhase(phase: Phase, allTasks: readonly Task[]): Task | null { + for (const task of phase.tasks) { + if (task.status === "in_progress") return task; + } + for (const task of phase.tasks) { + if (task.status === "pending" && isTaskReady(allTasks, task.id)) return task; + } + return null; +} + +function resolveTaskCompletion( + blueprint: Blueprint, + phaseId: string, + taskId: string, +): Blueprint { + let bp = recomputeBlocked(blueprint); + + if (bp.active_task_id === taskId) { + const next = getNextTask(bp); + bp = { ...bp, active_task_id: next?.id ?? null }; + } + + const currentPhase = bp.phases.find((p) => p.id === phaseId); + if (currentPhase && currentPhase.tasks.every(isTaskDone)) { + bp = updatePhase(bp, phaseId, { status: "completed" as PhaseStatus }); + } + + return bp; +} + +export function startTask( + blueprint: Blueprint, + taskId: string, + sessionId: string, +): Blueprint { + const found = findTask(blueprint, taskId); + if (!found) return blueprint; + + const { phase, task } = found; + if (isTaskDone(task)) return blueprint; + + const now = new Date().toISOString(); + const updatedPhase = updateTask(phase, taskId, { + status: "in_progress" as TaskStatus, + started_at: task.started_at ?? now, + session_id: sessionId, + }); + + let phaseStatus = phase.status; + if (phaseStatus === "pending") { + phaseStatus = "active"; + } + + let bp = updatePhase(blueprint, phase.id, { + ...updatedPhase, + status: phaseStatus, + started_at: phase.started_at ?? now, + }); + + bp = { + ...bp, + active_phase_id: phase.id, + active_task_id: taskId, + status: blueprint.status === "draft" ? "active" : blueprint.status, + }; + + return bp; +} + +export function completeTask(blueprint: Blueprint, taskId: string): Blueprint { + const found = findTask(blueprint, taskId); + if (!found) return blueprint; + + const { phase, task } = found; + if (task.status === "completed") return blueprint; + + const updatedPhase = updateTask(phase, taskId, { + status: "completed" as TaskStatus, + completed_at: new Date().toISOString(), + }); + + let bp = updatePhase(blueprint, phase.id, updatedPhase); + bp = resolveTaskCompletion(bp, phase.id, taskId); + + const allTasks = getAllTasks(bp.phases); + if (allTasks.every(isTaskDone)) { + const allVerified = bp.phases.every((p) => + p.verification_gates.length === 0 || p.verification_gates.every((g) => g.passed), + ); + if (allVerified) { + bp = { ...bp, status: "completed" }; + } + } + + return bp; +} + +export function skipTask(blueprint: Blueprint, taskId: string): Blueprint { + const found = findTask(blueprint, taskId); + if (!found) return blueprint; + + const { phase } = found; + const updatedPhase = updateTask(phase, taskId, { + status: "skipped" as TaskStatus, + completed_at: new Date().toISOString(), + }); + + let bp = updatePhase(blueprint, phase.id, updatedPhase); + bp = resolveTaskCompletion(bp, phase.id, taskId); + + return bp; +} + +export function verifyGate( + blueprint: Blueprint, + phaseId: string, + gateIndex: number, + passed: boolean, + errorMsg?: string, +): Blueprint { + const phase = blueprint.phases.find((p) => p.id === phaseId); + if (!phase) return blueprint; + + const gate = phase.verification_gates[gateIndex]; + if (!gate) return blueprint; + + const now = new Date().toISOString(); + const updatedGates = phase.verification_gates.map((g, i) => + i === gateIndex + ? { ...g, passed, last_checked_at: now, error_message: errorMsg ?? null } + : g, + ); + + return updatePhase(blueprint, phaseId, { verification_gates: updatedGates }); +} + +export function advancePhase(blueprint: Blueprint): Blueprint { + const currentPhase = blueprint.phases.find((p) => p.id === blueprint.active_phase_id); + if (!currentPhase) return blueprint; + + const allGatesPassed = + currentPhase.verification_gates.length === 0 || + currentPhase.verification_gates.every((g) => g.passed); + + if (!currentPhase.tasks.every(isTaskDone) || !allGatesPassed) return blueprint; + + let bp = updatePhase(blueprint, currentPhase.id, { + status: "verified" as PhaseStatus, + completed_at: new Date().toISOString(), + }); + + const currentIdx = bp.phases.findIndex((p) => p.id === currentPhase.id); + const nextPhase = bp.phases[currentIdx + 1]; + + if (nextPhase) { + bp = { + ...bp, + active_phase_id: nextPhase.id, + active_task_id: null, + }; + const next = getNextTask(bp); + if (next) { + bp = { ...bp, active_task_id: next.id }; + } + } else { + bp = { + ...bp, + active_phase_id: null, + active_task_id: null, + status: "completed", + }; + } + + return bp; +} + +export function getNextTask(blueprint: Blueprint): Task | null { + const allTasks = getAllTasks(blueprint.phases); + + if (blueprint.active_phase_id) { + const phase = blueprint.phases.find((p) => p.id === blueprint.active_phase_id); + if (phase) { + const found = findNextTaskInPhase(phase, allTasks); + if (found) return found; + } + } + + for (const phase of blueprint.phases) { + if (phase.status === "verified") continue; + const found = findNextTaskInPhase(phase, allTasks); + if (found) return found; + } + + return null; +} + +export function recomputeBlocked(blueprint: Blueprint): Blueprint { + const allTasks = getAllTasks(blueprint.phases); + const blockedIds = new Set(findBlockedTasks(allTasks)); + + return { + ...blueprint, + updated_at: new Date().toISOString(), + phases: blueprint.phases.map((phase) => ({ + ...phase, + tasks: phase.tasks.map((task) => { + if (isTaskDone(task)) return task; + if (blockedIds.has(task.id) && task.status !== "blocked") { + return { ...task, status: "blocked" as TaskStatus }; + } + if (!blockedIds.has(task.id) && task.status === "blocked") { + return { ...task, status: "pending" as TaskStatus }; + } + return task; + }), + })), + }; +} + +export function createBlueprint( + id: string, + objective: string, + projectId: string, + phases: readonly Phase[], +): Blueprint { + const now = new Date().toISOString(); + let bp: Blueprint = { + id, + objective, + project_id: projectId, + status: "active", + created_at: now, + updated_at: now, + phases, + active_phase_id: phases[0]?.id ?? null, + active_task_id: null, + }; + bp = recomputeBlocked(bp); + const next = getNextTask(bp); + if (next) { + bp = { ...bp, active_task_id: next.id }; + } + return bp; +} + +export function abandonBlueprint(blueprint: Blueprint): Blueprint { + return { ...blueprint, status: "abandoned", updated_at: new Date().toISOString() }; +} diff --git a/packages/pi-blueprint/src/storage.test.ts b/packages/pi-blueprint/src/storage.test.ts new file mode 100644 index 0000000..5290136 --- /dev/null +++ b/packages/pi-blueprint/src/storage.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { mkdtempSync, readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + loadIndex, + saveIndex, + loadBlueprint, + saveBlueprint, + appendHistory, + loadSessions, + saveSessions, +} from "./storage.js"; +import type { + Blueprint, + BlueprintIndex, + HistoryEntry, + SessionsState, +} from "./types.js"; + +let baseDir: string; + +beforeEach(() => { + baseDir = mkdtempSync(join(tmpdir(), "pi-blueprint-test-")); +}); + +function sampleBlueprint(): Blueprint { + return { + id: "bp-1", + objective: "Test objective", + project_id: "proj-1", + status: "active", + created_at: "2026-04-11T00:00:00.000Z", + updated_at: "2026-04-11T00:00:00.000Z", + phases: [ + { + id: "1", + title: "Phase 1", + description: "", + status: "active", + tasks: [ + { + id: "1.1", + title: "Task one", + description: "", + status: "pending", + acceptance_criteria: [], + file_targets: [], + dependencies: [], + started_at: null, + completed_at: null, + session_id: null, + notes: null, + }, + ], + verification_gates: [], + started_at: null, + completed_at: null, + }, + ], + active_phase_id: "1", + active_task_id: "1.1", + }; +} + +describe("index", () => { + it("returns null when no index exists", () => { + expect(loadIndex(baseDir)).toBeNull(); + }); + + it("round-trips index", () => { + const index: BlueprintIndex = { + active_blueprint_id: "bp-1", + blueprints: [ + { + id: "bp-1", + objective: "Test", + status: "active", + created_at: "2026-04-11T00:00:00.000Z", + project_id: "proj-1", + }, + ], + }; + saveIndex(index, baseDir); + expect(loadIndex(baseDir)).toEqual(index); + }); +}); + +describe("blueprint", () => { + it("returns null when no blueprint exists", () => { + expect(loadBlueprint("nonexistent", baseDir)).toBeNull(); + }); + + it("round-trips blueprint and generates plan.md", () => { + const bp = sampleBlueprint(); + saveBlueprint(bp, baseDir); + expect(loadBlueprint("bp-1", baseDir)).toEqual(bp); + + const planPath = join(baseDir, "bp-1", "plan.md"); + expect(existsSync(planPath)).toBe(true); + const planContent = readFileSync(planPath, "utf-8"); + expect(planContent).toContain("# Blueprint: Test objective"); + }); +}); + +describe("history", () => { + it("appends entries to history.jsonl", () => { + const entry: HistoryEntry = { + timestamp: "2026-04-11T00:00:00.000Z", + event: "task_completed", + phase_id: "1", + task_id: "1.1", + session_id: "s-1", + details: "Completed task", + }; + appendHistory("bp-1", entry, baseDir); + appendHistory("bp-1", { ...entry, task_id: "1.2" }, baseDir); + + const content = readFileSync(join(baseDir, "bp-1", "history.jsonl"), "utf-8"); + const lines = content.trim().split("\n"); + expect(lines).toHaveLength(2); + expect(JSON.parse(lines[0]!)).toEqual(entry); + }); +}); + +describe("sessions", () => { + it("returns null when no sessions file", () => { + expect(loadSessions("nonexistent", baseDir)).toBeNull(); + }); + + it("round-trips sessions", () => { + const sessions: SessionsState = { + sessions: [ + { + session_id: "s-1", + started_at: "2026-04-11T00:00:00.000Z", + ended_at: null, + tasks_worked: ["1.1"], + tasks_completed: [], + }, + ], + }; + saveSessions("bp-1", sessions, baseDir); + expect(loadSessions("bp-1", baseDir)).toEqual(sessions); + }); +}); diff --git a/packages/pi-blueprint/src/storage.ts b/packages/pi-blueprint/src/storage.ts new file mode 100644 index 0000000..87ab077 --- /dev/null +++ b/packages/pi-blueprint/src/storage.ts @@ -0,0 +1,83 @@ +import { mkdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { renderPlanMarkdown } from "./plan-renderer.js"; +import type { + Blueprint, + BlueprintIndex, + HistoryEntry, + SessionsState, +} from "./types.js"; + +export function getBaseDir(baseDir?: string): string { + return baseDir ?? join(homedir(), ".pi", "blueprints"); +} + +export function getBlueprintDir(blueprintId: string, baseDir?: string): string { + return join(getBaseDir(baseDir), blueprintId); +} + +export function ensureStorageLayout(blueprintId: string, baseDir?: string): void { + const dir = getBlueprintDir(blueprintId, baseDir); + mkdirSync(dir, { recursive: true }); +} + +export function ensureBaseDir(baseDir?: string): void { + mkdirSync(getBaseDir(baseDir), { recursive: true }); +} + +export function loadIndex(baseDir?: string): BlueprintIndex | null { + const path = join(getBaseDir(baseDir), "index.json"); + return readJson(path); +} + +export function saveIndex(index: BlueprintIndex, baseDir?: string): void { + ensureBaseDir(baseDir); + const path = join(getBaseDir(baseDir), "index.json"); + writeFileSync(path, JSON.stringify(index, null, 2) + "\n"); +} + +export function loadBlueprint(blueprintId: string, baseDir?: string): Blueprint | null { + const path = join(getBlueprintDir(blueprintId, baseDir), "state.json"); + return readJson(path); +} + +export function saveBlueprint(blueprint: Blueprint, baseDir?: string): void { + ensureStorageLayout(blueprint.id, baseDir); + const dir = getBlueprintDir(blueprint.id, baseDir); + writeFileSync(join(dir, "state.json"), JSON.stringify(blueprint, null, 2) + "\n"); + writeFileSync(join(dir, "plan.md"), renderPlanMarkdown(blueprint)); +} + +export function appendHistory( + blueprintId: string, + entry: HistoryEntry, + baseDir?: string, +): void { + ensureStorageLayout(blueprintId, baseDir); + const path = join(getBlueprintDir(blueprintId, baseDir), "history.jsonl"); + appendFileSync(path, JSON.stringify(entry) + "\n"); +} + +export function loadSessions(blueprintId: string, baseDir?: string): SessionsState | null { + const path = join(getBlueprintDir(blueprintId, baseDir), "sessions.json"); + return readJson(path); +} + +export function saveSessions( + blueprintId: string, + sessions: SessionsState, + baseDir?: string, +): void { + ensureStorageLayout(blueprintId, baseDir); + const path = join(getBlueprintDir(blueprintId, baseDir), "sessions.json"); + writeFileSync(path, JSON.stringify(sessions, null, 2) + "\n"); +} + +function readJson(path: string): T | null { + try { + return JSON.parse(readFileSync(path, "utf-8")) as T; + } catch { + return null; + } +} diff --git a/packages/pi-blueprint/src/types.ts b/packages/pi-blueprint/src/types.ts new file mode 100644 index 0000000..da0273d --- /dev/null +++ b/packages/pi-blueprint/src/types.ts @@ -0,0 +1,132 @@ +export type TaskStatus = "pending" | "in_progress" | "completed" | "blocked" | "skipped"; + +export type PhaseStatus = "pending" | "active" | "completed" | "verified"; + +export type VerificationType = "tests_pass" | "typecheck_clean" | "user_approval" | "custom_command"; + +export type BlueprintStatus = "draft" | "active" | "completed" | "abandoned"; + +export type HistoryEventType = + | "blueprint_created" + | "task_started" + | "task_completed" + | "task_blocked" + | "task_skipped" + | "phase_started" + | "phase_completed" + | "phase_verified" + | "verification_passed" + | "verification_failed" + | "blueprint_completed" + | "blueprint_abandoned"; + +export interface Task { + readonly id: string; + readonly title: string; + readonly description: string; + readonly status: TaskStatus; + readonly acceptance_criteria: readonly string[]; + readonly file_targets: readonly string[]; + readonly dependencies: readonly string[]; + readonly started_at: string | null; + readonly completed_at: string | null; + readonly session_id: string | null; + readonly notes: string | null; +} + +export interface VerificationGate { + readonly type: VerificationType; + readonly command: string | null; + readonly description: string; + readonly passed: boolean; + readonly last_checked_at: string | null; + readonly error_message: string | null; +} + +export interface Phase { + readonly id: string; + readonly title: string; + readonly description: string; + readonly status: PhaseStatus; + readonly tasks: readonly Task[]; + readonly verification_gates: readonly VerificationGate[]; + readonly started_at: string | null; + readonly completed_at: string | null; +} + +export interface Blueprint { + readonly id: string; + readonly objective: string; + readonly project_id: string; + readonly status: BlueprintStatus; + readonly created_at: string; + readonly updated_at: string; + readonly phases: readonly Phase[]; + readonly active_phase_id: string | null; + readonly active_task_id: string | null; +} + +export interface HistoryEntry { + readonly timestamp: string; + readonly event: HistoryEventType; + readonly phase_id: string | null; + readonly task_id: string | null; + readonly session_id: string; + readonly details: string; +} + +export interface SessionRecord { + readonly session_id: string; + readonly started_at: string; + readonly ended_at: string | null; + readonly tasks_worked: readonly string[]; + readonly tasks_completed: readonly string[]; +} + +export interface SessionsState { + readonly sessions: readonly SessionRecord[]; +} + +export interface BlueprintIndexEntry { + readonly id: string; + readonly objective: string; + readonly status: BlueprintStatus; + readonly created_at: string; + readonly project_id: string; +} + +export interface BlueprintIndex { + readonly active_blueprint_id: string | null; + readonly blueprints: readonly BlueprintIndexEntry[]; +} + +export interface ProjectInfo { + readonly id: string; + readonly name: string; + readonly root: string; +} + +export interface BlueprintExtensionState { + readonly project: ProjectInfo | null; + readonly blueprint: Blueprint | null; + readonly sessionId: string; +} + +export interface StateRef { + get: () => BlueprintExtensionState; + set: (s: BlueprintExtensionState) => void; +} + +export interface VerificationResult { + readonly passed: boolean; + readonly output: string; + readonly duration_ms: number; +} + +export function isTaskDone(task: Task): boolean { + return task.status === "completed" || task.status === "skipped"; +} + +export function getCompletedTaskIds(tasks: readonly Task[]): Set { + return new Set(tasks.filter(isTaskDone).map((t) => t.id)); +} diff --git a/packages/pi-blueprint/src/verification.test.ts b/packages/pi-blueprint/src/verification.test.ts new file mode 100644 index 0000000..453336c --- /dev/null +++ b/packages/pi-blueprint/src/verification.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi } from "vitest"; +import { runGate } from "./verification.js"; +import type { VerificationGate } from "./types.js"; + +function makeGate(overrides?: Partial): VerificationGate { + return { + type: "tests_pass", + command: null, + description: "Tests pass", + passed: false, + last_checked_at: null, + error_message: null, + ...overrides, + }; +} + +vi.mock("node:child_process", () => ({ + execSync: vi.fn(), +})); + +import { execSync } from "node:child_process"; +const mockExecSync = vi.mocked(execSync); + +describe("runGate", () => { + it("returns not passed for user_approval gate", () => { + const result = runGate(makeGate({ type: "user_approval" }), "/tmp"); + expect(result.passed).toBe(false); + expect(result.output).toContain("user approval"); + }); + + it("runs npm test for tests_pass gate", () => { + mockExecSync.mockReturnValue("all tests passed"); + const result = runGate(makeGate({ type: "tests_pass" }), "/project"); + expect(result.passed).toBe(true); + expect(mockExecSync).toHaveBeenCalledWith( + "npm test", + expect.objectContaining({ cwd: "/project" }), + ); + }); + + it("runs tsc for typecheck_clean gate", () => { + mockExecSync.mockReturnValue(""); + const result = runGate(makeGate({ type: "typecheck_clean" }), "/project"); + expect(result.passed).toBe(true); + expect(mockExecSync).toHaveBeenCalledWith( + "npx tsc --noEmit", + expect.objectContaining({ cwd: "/project" }), + ); + }); + + it("runs custom command", () => { + mockExecSync.mockReturnValue("ok"); + const result = runGate( + makeGate({ type: "custom_command", command: "make lint" }), + "/project", + ); + expect(result.passed).toBe(true); + expect(mockExecSync).toHaveBeenCalledWith( + "make lint", + expect.objectContaining({ cwd: "/project" }), + ); + }); + + it("returns not passed on command failure", () => { + mockExecSync.mockImplementation(() => { + const err = new Error("Command failed") as Error & { stderr: string }; + err.stderr = "3 tests failed"; + throw err; + }); + const result = runGate(makeGate({ type: "tests_pass" }), "/project"); + expect(result.passed).toBe(false); + expect(result.output).toContain("3 tests failed"); + }); + + it("returns not passed for custom_command with null command", () => { + const result = runGate( + makeGate({ type: "custom_command", command: null }), + "/project", + ); + expect(result.passed).toBe(false); + }); +}); diff --git a/packages/pi-blueprint/src/verification.ts b/packages/pi-blueprint/src/verification.ts new file mode 100644 index 0000000..c1f5d7b --- /dev/null +++ b/packages/pi-blueprint/src/verification.ts @@ -0,0 +1,60 @@ +import { execSync } from "node:child_process"; +import type { VerificationGate, VerificationResult } from "./types.js"; + +export function runGate(gate: VerificationGate, cwd: string): VerificationResult { + if (gate.type === "user_approval") { + return { passed: false, output: "Requires user approval", duration_ms: 0 }; + } + + const command = resolveCommand(gate); + if (!command) { + return { passed: false, output: `No command for gate type: ${gate.type}`, duration_ms: 0 }; + } + + const start = Date.now(); + try { + const output = execSync(command, { + cwd, + encoding: "utf-8", + timeout: 120_000, + stdio: ["pipe", "pipe", "pipe"], + }); + return { passed: true, output, duration_ms: Date.now() - start }; + } catch (err: unknown) { + const duration_ms = Date.now() - start; + const output = extractExecError(err); + return { passed: false, output, duration_ms }; + } +} + +export function runAllGates( + gates: readonly VerificationGate[], + cwd: string, +): readonly VerificationResult[] { + return gates + .filter((g) => g.type !== "user_approval") + .map((gate) => runGate(gate, cwd)); +} + +function resolveCommand(gate: VerificationGate): string | null { + switch (gate.type) { + case "tests_pass": + return "npm test"; + case "typecheck_clean": + return "npx tsc --noEmit"; + case "custom_command": + return gate.command; + case "user_approval": + return null; + } +} + +function extractExecError(err: unknown): string { + if (err && typeof err === "object") { + const obj = err as Record; + if (typeof obj["stderr"] === "string" && obj["stderr"]) return obj["stderr"]; + if (typeof obj["stdout"] === "string" && obj["stdout"]) return obj["stdout"]; + if (typeof obj["message"] === "string") return obj["message"]; + } + return "Unknown error"; +} diff --git a/packages/pi-blueprint/tsconfig.build.json b/packages/pi-blueprint/tsconfig.build.json new file mode 100644 index 0000000..af8cdbb --- /dev/null +++ b/packages/pi-blueprint/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} diff --git a/packages/pi-blueprint/tsconfig.json b/packages/pi-blueprint/tsconfig.json new file mode 100644 index 0000000..b1f1a4c --- /dev/null +++ b/packages/pi-blueprint/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/pi-blueprint/vitest.config.ts b/packages/pi-blueprint/vitest.config.ts new file mode 100644 index 0000000..1c93b60 --- /dev/null +++ b/packages/pi-blueprint/vitest.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["src/**/*.test.ts"], + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + thresholds: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80, + }, + }, + }, + }, +}); diff --git a/release-please-config.json b/release-please-config.json index 6d0922f..27b3ee8 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -16,6 +16,7 @@ "packages/pi-red-green": {}, "packages/pi-compass": {}, "packages/pi-simplify": {}, - "packages/pi-code-review": {} + "packages/pi-code-review": {}, + "packages/pi-blueprint": {} } } diff --git a/tsconfig.json b/tsconfig.json index 4c1b390..411edcc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ { "path": "packages/pi-red-green" }, { "path": "packages/pi-compass" }, { "path": "packages/pi-simplify" }, - { "path": "packages/pi-code-review" } + { "path": "packages/pi-code-review" }, + { "path": "packages/pi-blueprint" } ] }