Skip to content

Commit bbda0d6

Browse files
authored
feat: add pi-blueprint multi-session planning extension (#98)
## Summary - Add `pi-blueprint` extension: turns high-level objectives into phased, multi-session construction plans with dependency tracking, verification gates, and progress persistence - 4 commands (`/blueprint`, `/plan-status`, `/plan-verify`, `/plan-next`), 4 LLM tools (`blueprint_create`, `blueprint_status`, `blueprint_update`, `blueprint_next`), 4 hooks (`session_start`, `session_shutdown`, `before_agent_start`, `turn_end`) - 103 tests across 12 test files, 80%+ coverage thresholds Closes #83 ## Test plan - [ ] `npm run check` passes across full workspace - [ ] `npm run check -w packages/pi-blueprint` passes (103 tests, lint, typecheck) - [ ] Install in Pi, run `/blueprint "add user auth"`, verify plan generated - [ ] `/plan-status` shows progress with completion percentage - [ ] `/plan-next` returns actionable task and sends follow-up message - [ ] `/plan-verify` runs verification gates and reports results - [ ] Blueprint state persists across session restart (check `~/.pi/blueprints/`)
1 parent a509c3f commit bbda0d6

38 files changed

Lines changed: 3508 additions & 3 deletions

.release-please-manifest.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@
33
"packages/pi-red-green": "0.2.1",
44
"packages/pi-compass": "0.2.0",
55
"packages/pi-simplify": "0.2.0",
6-
"packages/pi-code-review": "0.2.0"
6+
"packages/pi-code-review": "0.2.0",
7+
"packages/pi-blueprint": "0.1.0"
78
}

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ packages/
2323
pi-code-review/ # Pi extension: automated language-aware code review
2424
src/ # TypeScript source + tests (*.test.ts alongside source)
2525
CHANGELOG.md # Release history (managed by release-please)
26+
pi-blueprint/ # Pi extension: multi-session planning with dependency tracking
27+
src/ # TypeScript source + tests (*.test.ts alongside source)
28+
CHANGELOG.md # Release history (managed by release-please)
2629
```
2730

2831
## Commands (run from repo root)

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ A monorepo of [Pi](https://github.com/nicholasgasior/pi-coding-agent) extensions
1414
| [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) |
1515
| [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) |
1616
| [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) |
17+
| [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) |
1718

1819
## Development
1920

package-lock.json

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/pi-blueprint/README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# pi-blueprint
2+
3+
A Pi extension that turns high-level objectives into phased, multi-session construction plans with dependency tracking and verification gates.
4+
5+
## Installation
6+
7+
```bash
8+
pi install npm:pi-blueprint
9+
```
10+
11+
## Commands
12+
13+
| Command | Description |
14+
|---|---|
15+
| `/blueprint <objective>` | Generate a phased plan from an objective |
16+
| `/blueprint abandon` | Abandon the active blueprint |
17+
| `/plan-status` | Show detailed progress with completion percentage |
18+
| `/plan-verify` | Run verification gates for the current phase |
19+
| `/plan-next` | Get and start the next actionable task |
20+
21+
## LLM Tools
22+
23+
| Tool | Description |
24+
|---|---|
25+
| `blueprint_create` | Create a new blueprint from structured phases |
26+
| `blueprint_status` | Get current plan progress |
27+
| `blueprint_update` | Mark tasks as completed, in_progress, or skipped |
28+
| `blueprint_next` | Get the next actionable task |
29+
30+
## How It Works
31+
32+
1. Run `/blueprint "Add OAuth2 authentication"` to start
33+
2. The LLM generates a phased plan with tasks, dependencies, and verification gates
34+
3. On each session start, the active blueprint context is injected into the system prompt
35+
4. Use `/plan-next` to work through tasks sequentially
36+
5. Use `/plan-verify` to run phase verification gates (tests, typecheck) before advancing
37+
6. Progress persists across sessions in `~/.pi/blueprints/`
38+
39+
## Storage
40+
41+
```
42+
~/.pi/blueprints/
43+
index.json # Active blueprint pointer
44+
<blueprint-id>/
45+
plan.md # Human-readable plan (auto-generated)
46+
state.json # Machine-readable state (source of truth)
47+
history.jsonl # Audit log of state transitions
48+
sessions.json # Session-to-task mapping
49+
```
50+
51+
## Features
52+
53+
- **Phased execution**: Work is decomposed into ordered phases with verification gates
54+
- **Dependency tracking**: Tasks declare dependencies; blocked tasks are surfaced automatically
55+
- **Verification gates**: Tests, type-check, user approval, or custom commands gate phase advancement
56+
- **Multi-session persistence**: Plan state survives session restarts with context injection
57+
- **Cycle detection**: Dependency cycles are rejected at blueprint creation time

packages/pi-blueprint/package.json

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
{
2+
"name": "pi-blueprint",
3+
"version": "0.1.0",
4+
"description": "A Pi extension that turns high-level objectives into phased, multi-session construction plans with dependency tracking and verification gates.",
5+
"type": "module",
6+
"license": "MIT",
7+
"author": "Matt Devy",
8+
"repository": {
9+
"type": "git",
10+
"url": "https://github.com/MattDevy/pi-extensions.git",
11+
"directory": "packages/pi-blueprint"
12+
},
13+
"homepage": "https://github.com/MattDevy/pi-extensions/tree/main/packages/pi-blueprint#readme",
14+
"bugs": {
15+
"url": "https://github.com/MattDevy/pi-extensions/issues"
16+
},
17+
"keywords": [
18+
"pi-package",
19+
"pi-extension",
20+
"pi-coding-agent",
21+
"blueprint",
22+
"planning",
23+
"multi-session",
24+
"dependency-tracking",
25+
"ai",
26+
"llm",
27+
"ai-agent",
28+
"coding-assistant",
29+
"developer-tools"
30+
],
31+
"engines": {
32+
"node": ">=18"
33+
},
34+
"files": [
35+
"dist",
36+
"src",
37+
"!src/**/*.test.ts",
38+
"README.md",
39+
"LICENSE"
40+
],
41+
"main": "./dist/index.js",
42+
"types": "./dist/index.d.ts",
43+
"pi": {
44+
"extensions": [
45+
"dist/index.js"
46+
]
47+
},
48+
"scripts": {
49+
"clean": "rm -rf dist",
50+
"build": "npm run clean && tsc -p tsconfig.build.json",
51+
"typecheck": "tsc --noEmit",
52+
"test": "vitest run",
53+
"lint": "eslint src/",
54+
"check": "vitest run && eslint src/ && tsc --noEmit",
55+
"prepublishOnly": "npm run build && npm run check",
56+
"prepack": "test -d dist || { echo 'Error: dist/ missing. Run npm run build first.' && exit 1; }"
57+
},
58+
"peerDependencies": {
59+
"@mariozechner/pi-coding-agent": "^0.62.0",
60+
"@mariozechner/pi-ai": "^0.62.0",
61+
"@mariozechner/pi-tui": "^0.62.0",
62+
"@sinclair/typebox": "^0.34.0"
63+
},
64+
"devDependencies": {
65+
"@types/node": "^24.0.0",
66+
"@typescript-eslint/eslint-plugin": "^8.0.0",
67+
"@typescript-eslint/parser": "^8.0.0",
68+
"eslint": "^9.0.0",
69+
"typescript": "^5.7.0",
70+
"vitest": "^3.0.0"
71+
}
72+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
3+
import { handleBlueprintCommand } from "./blueprint-command.js";
4+
import type { StateRef, BlueprintExtensionState, Blueprint, Phase, Task } from "./types.js";
5+
6+
vi.mock("./storage.js", () => ({
7+
saveBlueprint: vi.fn(),
8+
appendHistory: vi.fn(),
9+
saveIndex: vi.fn(),
10+
loadIndex: vi.fn().mockReturnValue(null),
11+
}));
12+
13+
function makeTask(overrides: Partial<Task> & { id: string }): Task {
14+
return {
15+
title: overrides.id, description: "", status: "pending",
16+
acceptance_criteria: [], file_targets: [], dependencies: [],
17+
started_at: null, completed_at: null, session_id: null, notes: null,
18+
...overrides,
19+
};
20+
}
21+
22+
function makePhase(overrides: Partial<Phase> & { id: string }): Phase {
23+
return {
24+
title: `Phase ${overrides.id}`, description: "", status: "pending",
25+
tasks: [], verification_gates: [], started_at: null, completed_at: null,
26+
...overrides,
27+
};
28+
}
29+
30+
function makeBlueprint(): Blueprint {
31+
return {
32+
id: "bp-1", objective: "Test", project_id: "proj-1", status: "active",
33+
created_at: "2026-04-11T00:00:00.000Z", updated_at: "2026-04-11T00:00:00.000Z",
34+
phases: [makePhase({ id: "1", tasks: [makeTask({ id: "1.1" })] })],
35+
active_phase_id: "1", active_task_id: "1.1",
36+
};
37+
}
38+
39+
function createMocks() {
40+
const notifications: { text: string; level: string }[] = [];
41+
const sentMessages: string[] = [];
42+
const ctx = {
43+
ui: {
44+
notify: vi.fn((text: string, level: string) => notifications.push({ text, level })),
45+
},
46+
};
47+
const pi = {
48+
sendUserMessage: vi.fn((text: string) => sentMessages.push(text)),
49+
};
50+
return { ctx, pi, notifications, sentMessages };
51+
}
52+
53+
describe("handleBlueprintCommand", () => {
54+
let state: BlueprintExtensionState;
55+
let stateRef: StateRef;
56+
57+
beforeEach(() => {
58+
state = { project: { id: "p", name: "test", root: "/tmp" }, blueprint: null, sessionId: "s" };
59+
stateRef = { get: () => state, set: (s) => { state = s; } };
60+
});
61+
62+
it("shows status when no args and no blueprint", async () => {
63+
const { ctx, pi } = createMocks();
64+
await handleBlueprintCommand("", ctx as unknown as ExtensionCommandContext, stateRef, pi as unknown as ExtensionAPI);
65+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("No active blueprint"), "info");
66+
});
67+
68+
it("shows plan when no args and blueprint exists", async () => {
69+
state = { ...state, blueprint: makeBlueprint() };
70+
const { ctx, pi } = createMocks();
71+
await handleBlueprintCommand("", ctx as unknown as ExtensionCommandContext, stateRef, pi as unknown as ExtensionAPI);
72+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Blueprint: Test"), "info");
73+
});
74+
75+
it("sends generation prompt for new objective", async () => {
76+
const { ctx, pi } = createMocks();
77+
await handleBlueprintCommand("Add OAuth2 auth", ctx as unknown as ExtensionCommandContext, stateRef, pi as unknown as ExtensionAPI);
78+
expect(pi.sendUserMessage).toHaveBeenCalledWith(
79+
expect.stringContaining("Add OAuth2 auth"),
80+
expect.objectContaining({ deliverAs: "followUp" }),
81+
);
82+
});
83+
84+
it("warns when active blueprint exists", async () => {
85+
state = { ...state, blueprint: makeBlueprint() };
86+
const { ctx, pi } = createMocks();
87+
await handleBlueprintCommand("New thing", ctx as unknown as ExtensionCommandContext, stateRef, pi as unknown as ExtensionAPI);
88+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("already exists"), "warning");
89+
expect(pi.sendUserMessage).not.toHaveBeenCalled();
90+
});
91+
92+
it("abandons active blueprint", async () => {
93+
state = { ...state, blueprint: makeBlueprint() };
94+
const { ctx, pi } = createMocks();
95+
await handleBlueprintCommand("abandon", ctx as unknown as ExtensionCommandContext, stateRef, pi as unknown as ExtensionAPI);
96+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("abandoned"), "info");
97+
expect(state.blueprint).toBeNull();
98+
});
99+
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import type {
2+
ExtensionAPI,
3+
ExtensionCommandContext,
4+
} from "@mariozechner/pi-coding-agent";
5+
import type { StateRef } from "./types.js";
6+
import { renderPlanMarkdown } from "./plan-renderer.js";
7+
import { getBlueprintGeneratePrompt } from "./prompts/blueprint-generate.js";
8+
import { abandonBlueprint } from "./state-machine.js";
9+
import { saveBlueprint, appendHistory, saveIndex, loadIndex } from "./storage.js";
10+
11+
export const COMMAND_NAME = "blueprint";
12+
13+
export async function handleBlueprintCommand(
14+
args: string,
15+
ctx: ExtensionCommandContext,
16+
stateRef: StateRef,
17+
pi: ExtensionAPI,
18+
): Promise<void> {
19+
const trimmed = args.trim();
20+
21+
if (trimmed === "") {
22+
return showBriefStatus(ctx, stateRef);
23+
}
24+
25+
if (trimmed.toLowerCase() === "abandon") {
26+
return handleAbandon(ctx, stateRef);
27+
}
28+
29+
const state = stateRef.get();
30+
if (state.blueprint && state.blueprint.status === "active") {
31+
ctx.ui.notify(
32+
`An active blueprint already exists: "${state.blueprint.objective}"\nUse /blueprint abandon to discard it, or /plan-status for details.`,
33+
"warning",
34+
);
35+
return;
36+
}
37+
38+
const prompt = getBlueprintGeneratePrompt(trimmed);
39+
pi.sendUserMessage(prompt, { deliverAs: "followUp" });
40+
}
41+
42+
function showBriefStatus(ctx: ExtensionCommandContext, stateRef: StateRef): void {
43+
const state = stateRef.get();
44+
if (!state.blueprint) {
45+
ctx.ui.notify(
46+
"No active blueprint. Use /blueprint <objective> to create one.",
47+
"info",
48+
);
49+
return;
50+
}
51+
ctx.ui.notify(renderPlanMarkdown(state.blueprint), "info");
52+
}
53+
54+
function handleAbandon(ctx: ExtensionCommandContext, stateRef: StateRef): void {
55+
const state = stateRef.get();
56+
if (!state.blueprint || state.blueprint.status !== "active") {
57+
ctx.ui.notify("No active blueprint to abandon.", "info");
58+
return;
59+
}
60+
61+
const bp = abandonBlueprint(state.blueprint);
62+
saveBlueprint(bp);
63+
appendHistory(bp.id, {
64+
timestamp: new Date().toISOString(),
65+
event: "blueprint_abandoned",
66+
phase_id: null,
67+
task_id: null,
68+
session_id: state.sessionId,
69+
details: "User abandoned blueprint",
70+
});
71+
72+
const index = loadIndex();
73+
if (index) {
74+
saveIndex({
75+
active_blueprint_id: null,
76+
blueprints: index.blueprints.map((e) =>
77+
e.id === bp.id ? { ...e, status: "abandoned" as const } : e,
78+
),
79+
});
80+
}
81+
82+
stateRef.set({ ...state, blueprint: null });
83+
ctx.ui.notify(`Blueprint "${bp.objective}" abandoned.`, "info");
84+
}

0 commit comments

Comments
 (0)