Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
25 changes: 25 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

57 changes: 57 additions & 0 deletions packages/pi-blueprint/README.md
Original file line number Diff line number Diff line change
@@ -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 <objective>` | 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
<blueprint-id>/
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
72 changes: 72 additions & 0 deletions packages/pi-blueprint/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
99 changes: 99 additions & 0 deletions packages/pi-blueprint/src/blueprint-command.test.ts
Original file line number Diff line number Diff line change
@@ -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<Task> & { 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<Phase> & { 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();
});
});
84 changes: 84 additions & 0 deletions packages/pi-blueprint/src/blueprint-command.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 <objective> 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");
}
Loading
Loading