pi-blueprint 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +57 -0
  2. package/dist/blueprint-command.d.ts +5 -0
  3. package/dist/blueprint-command.d.ts.map +1 -0
  4. package/dist/blueprint-command.js +56 -0
  5. package/dist/blueprint-command.js.map +1 -0
  6. package/dist/blueprint-injector.d.ts +3 -0
  7. package/dist/blueprint-injector.d.ts.map +1 -0
  8. package/dist/blueprint-injector.js +11 -0
  9. package/dist/blueprint-injector.js.map +1 -0
  10. package/dist/blueprint-tools.d.ts +4 -0
  11. package/dist/blueprint-tools.d.ts.map +1 -0
  12. package/dist/blueprint-tools.js +302 -0
  13. package/dist/blueprint-tools.js.map +1 -0
  14. package/dist/dependency-graph.d.ts +10 -0
  15. package/dist/dependency-graph.d.ts.map +1 -0
  16. package/dist/dependency-graph.js +101 -0
  17. package/dist/dependency-graph.js.map +1 -0
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +88 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/plan-next-command.d.ts +5 -0
  23. package/dist/plan-next-command.d.ts.map +1 -0
  24. package/dist/plan-next-command.js +36 -0
  25. package/dist/plan-next-command.js.map +1 -0
  26. package/dist/plan-renderer.d.ts +3 -0
  27. package/dist/plan-renderer.d.ts.map +1 -0
  28. package/dist/plan-renderer.js +61 -0
  29. package/dist/plan-renderer.js.map +1 -0
  30. package/dist/plan-status-command.d.ts +5 -0
  31. package/dist/plan-status-command.d.ts.map +1 -0
  32. package/dist/plan-status-command.js +20 -0
  33. package/dist/plan-status-command.js.map +1 -0
  34. package/dist/plan-verify-command.d.ts +5 -0
  35. package/dist/plan-verify-command.d.ts.map +1 -0
  36. package/dist/plan-verify-command.js +65 -0
  37. package/dist/plan-verify-command.js.map +1 -0
  38. package/dist/prompts/blueprint-generate.d.ts +2 -0
  39. package/dist/prompts/blueprint-generate.d.ts.map +1 -0
  40. package/dist/prompts/blueprint-generate.js +35 -0
  41. package/dist/prompts/blueprint-generate.js.map +1 -0
  42. package/dist/prompts/phase-context.d.ts +3 -0
  43. package/dist/prompts/phase-context.d.ts.map +1 -0
  44. package/dist/prompts/phase-context.js +62 -0
  45. package/dist/prompts/phase-context.js.map +1 -0
  46. package/dist/state-machine.d.ts +11 -0
  47. package/dist/state-machine.d.ts.map +1 -0
  48. package/dist/state-machine.js +224 -0
  49. package/dist/state-machine.js.map +1 -0
  50. package/dist/storage.d.ts +13 -0
  51. package/dist/storage.d.ts.map +1 -0
  52. package/dist/storage.js +59 -0
  53. package/dist/storage.js.map +1 -0
  54. package/dist/types.d.ts +98 -0
  55. package/dist/types.d.ts.map +1 -0
  56. package/dist/types.js +7 -0
  57. package/dist/types.js.map +1 -0
  58. package/dist/verification.d.ts +4 -0
  59. package/dist/verification.d.ts.map +1 -0
  60. package/dist/verification.js +55 -0
  61. package/dist/verification.js.map +1 -0
  62. package/package.json +72 -0
  63. package/src/blueprint-command.ts +84 -0
  64. package/src/blueprint-injector.ts +10 -0
  65. package/src/blueprint-tools.ts +380 -0
  66. package/src/dependency-graph.ts +113 -0
  67. package/src/index.ts +118 -0
  68. package/src/plan-next-command.ts +56 -0
  69. package/src/plan-renderer.ts +70 -0
  70. package/src/plan-status-command.ts +30 -0
  71. package/src/plan-verify-command.ts +82 -0
  72. package/src/prompts/blueprint-generate.ts +34 -0
  73. package/src/prompts/phase-context.ts +76 -0
  74. package/src/state-machine.ts +278 -0
  75. package/src/storage.ts +83 -0
  76. package/src/types.ts +132 -0
  77. package/src/verification.ts +60 -0
@@ -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,0 +1,10 @@
1
+ import type { Blueprint } from "./types.js";
2
+ import { buildPhaseContext } from "./prompts/phase-context.js";
3
+
4
+ export function buildInjectionBlock(blueprint: Blueprint | null): string | null {
5
+ if (!blueprint) return null;
6
+ if (blueprint.status !== "active") return null;
7
+ if (!blueprint.active_phase_id) return null;
8
+
9
+ return "\n\n" + buildPhaseContext(blueprint);
10
+ }
@@ -0,0 +1,380 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+ import type { StateRef, Phase, Task, VerificationGate } from "./types.js";
4
+ import {
5
+ createBlueprint,
6
+ startTask,
7
+ completeTask,
8
+ skipTask,
9
+ getNextTask,
10
+ } from "./state-machine.js";
11
+ import { detectCycles, getAllTasks } from "./dependency-graph.js";
12
+ import { saveBlueprint, saveIndex, appendHistory, loadIndex } from "./storage.js";
13
+ import { renderPlanMarkdown } from "./plan-renderer.js";
14
+
15
+ const PhaseSchema = Type.Object({
16
+ id: Type.String({ description: "Phase ID, e.g. '1', '2'" }),
17
+ title: Type.String({ description: "Phase title" }),
18
+ description: Type.String({ description: "Phase description" }),
19
+ tasks: Type.Array(
20
+ Type.Object({
21
+ id: Type.String({ description: "Task ID, e.g. '1.1', '2.3'" }),
22
+ title: Type.String({ description: "Imperative task title" }),
23
+ description: Type.String({ description: "What to implement" }),
24
+ acceptance_criteria: Type.Array(Type.String(), { description: "Testable acceptance criteria" }),
25
+ file_targets: Type.Array(Type.String(), { description: "Files to create or modify" }),
26
+ dependencies: Type.Array(Type.String(), { description: "Task IDs this depends on" }),
27
+ }),
28
+ ),
29
+ verification_gates: Type.Array(
30
+ Type.Object({
31
+ type: Type.Union([
32
+ Type.Literal("tests_pass"),
33
+ Type.Literal("typecheck_clean"),
34
+ Type.Literal("user_approval"),
35
+ Type.Literal("custom_command"),
36
+ ]),
37
+ description: Type.String(),
38
+ command: Type.Optional(Type.String({ description: "Shell command for custom_command type" })),
39
+ }),
40
+ ),
41
+ });
42
+
43
+ const CreateParams = Type.Object({
44
+ objective: Type.String({ description: "High-level objective for the blueprint" }),
45
+ phases: Type.Array(PhaseSchema, { description: "Ordered phases of work" }),
46
+ });
47
+
48
+ const StatusParams = Type.Object({});
49
+
50
+ const UpdateParams = Type.Object({
51
+ task_id: Type.String({ description: "Task ID to update (e.g. '1.1')" }),
52
+ status: Type.Union([
53
+ Type.Literal("in_progress"),
54
+ Type.Literal("completed"),
55
+ Type.Literal("skipped"),
56
+ ], { description: "New status" }),
57
+ notes: Type.Optional(Type.String({ description: "Optional notes about the change" })),
58
+ });
59
+
60
+ const NextParams = Type.Object({});
61
+
62
+ function generateId(): string {
63
+ return `bp-${Date.now().toString(36)}`;
64
+ }
65
+
66
+ export function registerBlueprintTools(pi: ExtensionAPI, stateRef: StateRef): void {
67
+ const guidelines = [
68
+ "Use blueprint_create to generate a new multi-session plan from an objective.",
69
+ "Use blueprint_status to check current plan progress.",
70
+ "Use blueprint_update to mark tasks as completed, in_progress, or skipped.",
71
+ "Use blueprint_next to get the next actionable task.",
72
+ ];
73
+
74
+ pi.registerTool({
75
+ name: "blueprint_create" as const,
76
+ label: "Create Blueprint",
77
+ description: "Create a new phased construction plan from an objective and structured phases",
78
+ promptSnippet: "Create a multi-session blueprint plan",
79
+ parameters: CreateParams,
80
+ promptGuidelines: guidelines,
81
+ async execute(
82
+ _toolCallId: string,
83
+ params: { objective: string; phases: readonly RawPhase[] },
84
+ _signal: AbortSignal | undefined,
85
+ _onUpdate: unknown,
86
+ _ctx: unknown,
87
+ ) {
88
+ const state = stateRef.get();
89
+ if (state.blueprint && state.blueprint.status === "active") {
90
+ return {
91
+ content: [{
92
+ type: "text" as const,
93
+ text: `Error: An active blueprint already exists ("${state.blueprint.objective}"). Complete or abandon it first.`,
94
+ }],
95
+ details: { error: "active_blueprint_exists" } as Record<string, unknown>,
96
+ };
97
+ }
98
+
99
+ const phases = buildPhases(params.phases);
100
+ const allTasks = getAllTasks(phases);
101
+ const cycles = detectCycles(allTasks);
102
+ if (cycles.length > 0) {
103
+ return {
104
+ content: [{
105
+ type: "text" as const,
106
+ text: `Error: Dependency cycles detected: ${JSON.stringify(cycles)}`,
107
+ }],
108
+ details: { error: "dependency_cycles", cycles } as Record<string, unknown>,
109
+ };
110
+ }
111
+
112
+ const projectId = state.project?.id ?? "unknown";
113
+ const id = generateId();
114
+ const blueprint = createBlueprint(id, params.objective, projectId, phases);
115
+
116
+ saveBlueprint(blueprint);
117
+ const index = loadIndex() ?? { active_blueprint_id: null, blueprints: [] };
118
+ saveIndex({
119
+ active_blueprint_id: id,
120
+ blueprints: [
121
+ ...index.blueprints,
122
+ {
123
+ id,
124
+ objective: params.objective,
125
+ status: "active",
126
+ created_at: blueprint.created_at,
127
+ project_id: projectId,
128
+ },
129
+ ],
130
+ });
131
+ appendHistory(id, {
132
+ timestamp: new Date().toISOString(),
133
+ event: "blueprint_created",
134
+ phase_id: null,
135
+ task_id: null,
136
+ session_id: state.sessionId,
137
+ details: params.objective,
138
+ });
139
+
140
+ stateRef.set({ ...state, blueprint });
141
+
142
+ const summary = renderPlanMarkdown(blueprint);
143
+ return {
144
+ content: [{ type: "text" as const, text: `Blueprint created: ${id}\n\n${summary}` }],
145
+ details: { blueprint_id: id, phases: phases.length, tasks: allTasks.length } as Record<string, unknown>,
146
+ };
147
+ },
148
+ });
149
+
150
+ pi.registerTool({
151
+ name: "blueprint_status" as const,
152
+ label: "Blueprint Status",
153
+ description: "Get the current blueprint progress, active phase, and task status",
154
+ promptSnippet: "Check current blueprint plan progress",
155
+ parameters: StatusParams,
156
+ promptGuidelines: guidelines,
157
+ async execute() {
158
+ const state = stateRef.get();
159
+ if (!state.blueprint) {
160
+ return {
161
+ content: [{ type: "text" as const, text: "No active blueprint." }],
162
+ details: { has_blueprint: false } as Record<string, unknown>,
163
+ };
164
+ }
165
+
166
+ const summary = renderPlanMarkdown(state.blueprint);
167
+ return {
168
+ content: [{ type: "text" as const, text: summary }],
169
+ details: {
170
+ blueprint_id: state.blueprint.id,
171
+ status: state.blueprint.status,
172
+ active_phase: state.blueprint.active_phase_id,
173
+ active_task: state.blueprint.active_task_id,
174
+ } as Record<string, unknown>,
175
+ };
176
+ },
177
+ });
178
+
179
+ pi.registerTool({
180
+ name: "blueprint_update" as const,
181
+ label: "Update Blueprint",
182
+ description: "Update a blueprint task status (mark as completed, in_progress, or skipped)",
183
+ promptSnippet: "Mark a blueprint task as completed or update its status",
184
+ parameters: UpdateParams,
185
+ promptGuidelines: guidelines,
186
+ async execute(
187
+ _toolCallId: string,
188
+ params: { task_id: string; status: "in_progress" | "completed" | "skipped"; notes?: string },
189
+ _signal: AbortSignal | undefined,
190
+ _onUpdate: unknown,
191
+ _ctx: unknown,
192
+ ) {
193
+ const state = stateRef.get();
194
+ if (!state.blueprint) {
195
+ return {
196
+ content: [{ type: "text" as const, text: "No active blueprint." }],
197
+ details: { error: "no_blueprint" } as Record<string, unknown>,
198
+ };
199
+ }
200
+
201
+ let bp = state.blueprint;
202
+ const allTasks = getAllTasks(bp.phases);
203
+ const task = allTasks.find((t) => t.id === params.task_id);
204
+ if (!task) {
205
+ return {
206
+ content: [{ type: "text" as const, text: `Task ${params.task_id} not found.` }],
207
+ details: { error: "task_not_found" } as Record<string, unknown>,
208
+ };
209
+ }
210
+
211
+ switch (params.status) {
212
+ case "in_progress":
213
+ bp = startTask(bp, params.task_id, state.sessionId);
214
+ break;
215
+ case "completed":
216
+ bp = completeTask(bp, params.task_id);
217
+ break;
218
+ case "skipped":
219
+ bp = skipTask(bp, params.task_id);
220
+ break;
221
+ }
222
+
223
+ if (params.notes) {
224
+ bp = {
225
+ ...bp,
226
+ phases: bp.phases.map((p) => ({
227
+ ...p,
228
+ tasks: p.tasks.map((t) =>
229
+ t.id === params.task_id ? { ...t, notes: params.notes ?? null } : t,
230
+ ),
231
+ })),
232
+ };
233
+ }
234
+
235
+ saveBlueprint(bp);
236
+ appendHistory(bp.id, {
237
+ timestamp: new Date().toISOString(),
238
+ event: params.status === "completed" ? "task_completed"
239
+ : params.status === "skipped" ? "task_skipped"
240
+ : "task_started",
241
+ phase_id: bp.active_phase_id,
242
+ task_id: params.task_id,
243
+ session_id: state.sessionId,
244
+ details: params.notes ?? `Task ${params.task_id} -> ${params.status}`,
245
+ });
246
+
247
+ stateRef.set({ ...state, blueprint: bp });
248
+
249
+ const next = getNextTask(bp);
250
+ const nextInfo = next ? `\nNext task: ${next.id} - ${next.title}` : "\nNo more tasks.";
251
+ return {
252
+ content: [{
253
+ type: "text" as const,
254
+ text: `Task ${params.task_id} updated to ${params.status}.${nextInfo}`,
255
+ }],
256
+ details: {
257
+ task_id: params.task_id,
258
+ new_status: params.status,
259
+ next_task: next?.id ?? null,
260
+ blueprint_status: bp.status,
261
+ } as Record<string, unknown>,
262
+ };
263
+ },
264
+ });
265
+
266
+ pi.registerTool({
267
+ name: "blueprint_next" as const,
268
+ label: "Next Blueprint Task",
269
+ description: "Get the next actionable task from the active blueprint",
270
+ promptSnippet: "Get the next task to work on",
271
+ parameters: NextParams,
272
+ promptGuidelines: guidelines,
273
+ async execute() {
274
+ const state = stateRef.get();
275
+ if (!state.blueprint) {
276
+ return {
277
+ content: [{ type: "text" as const, text: "No active blueprint." }],
278
+ details: { has_blueprint: false } as Record<string, unknown>,
279
+ };
280
+ }
281
+
282
+ const next = getNextTask(state.blueprint);
283
+ if (!next) {
284
+ return {
285
+ content: [{
286
+ type: "text" as const,
287
+ text: state.blueprint.status === "completed"
288
+ ? "Blueprint is complete. All tasks and verifications are done."
289
+ : "No actionable tasks. Some may be blocked or awaiting verification.",
290
+ }],
291
+ details: { blueprint_status: state.blueprint.status } as Record<string, unknown>,
292
+ };
293
+ }
294
+
295
+ const lines = [
296
+ `## Next Task: ${next.id} - ${next.title}`,
297
+ "",
298
+ next.description,
299
+ ];
300
+
301
+ if (next.acceptance_criteria.length > 0) {
302
+ lines.push("", "**Acceptance criteria:**");
303
+ for (const c of next.acceptance_criteria) {
304
+ lines.push(`- ${c}`);
305
+ }
306
+ }
307
+
308
+ if (next.file_targets.length > 0) {
309
+ lines.push("", "**File targets:**");
310
+ for (const f of next.file_targets) {
311
+ lines.push(`- ${f}`);
312
+ }
313
+ }
314
+
315
+ if (next.dependencies.length > 0) {
316
+ lines.push("", `**Dependencies:** ${next.dependencies.join(", ")} (all completed)`);
317
+ }
318
+
319
+ return {
320
+ content: [{ type: "text" as const, text: lines.join("\n") }],
321
+ details: { task_id: next.id, phase_id: state.blueprint.active_phase_id } as Record<string, unknown>,
322
+ };
323
+ },
324
+ });
325
+ }
326
+
327
+ interface RawPhase {
328
+ readonly id: string;
329
+ readonly title: string;
330
+ readonly description: string;
331
+ readonly tasks: readonly RawTask[];
332
+ readonly verification_gates: readonly RawGate[];
333
+ }
334
+
335
+ interface RawTask {
336
+ readonly id: string;
337
+ readonly title: string;
338
+ readonly description: string;
339
+ readonly acceptance_criteria: readonly string[];
340
+ readonly file_targets: readonly string[];
341
+ readonly dependencies: readonly string[];
342
+ }
343
+
344
+ interface RawGate {
345
+ readonly type: "tests_pass" | "typecheck_clean" | "user_approval" | "custom_command";
346
+ readonly description: string;
347
+ readonly command?: string;
348
+ }
349
+
350
+ function buildPhases(raw: readonly RawPhase[]): Phase[] {
351
+ return raw.map((rp): Phase => ({
352
+ id: rp.id,
353
+ title: rp.title,
354
+ description: rp.description,
355
+ status: "pending",
356
+ tasks: rp.tasks.map((rt): Task => ({
357
+ id: rt.id,
358
+ title: rt.title,
359
+ description: rt.description,
360
+ status: "pending",
361
+ acceptance_criteria: [...rt.acceptance_criteria],
362
+ file_targets: [...rt.file_targets],
363
+ dependencies: [...rt.dependencies],
364
+ started_at: null,
365
+ completed_at: null,
366
+ session_id: null,
367
+ notes: null,
368
+ })),
369
+ verification_gates: rp.verification_gates.map((rg): VerificationGate => ({
370
+ type: rg.type,
371
+ command: rg.command ?? null,
372
+ description: rg.description,
373
+ passed: false,
374
+ last_checked_at: null,
375
+ error_message: null,
376
+ })),
377
+ started_at: null,
378
+ completed_at: null,
379
+ }));
380
+ }
@@ -0,0 +1,113 @@
1
+ import type { Task } from "./types.js";
2
+ import { isTaskDone, getCompletedTaskIds } from "./types.js";
3
+
4
+ export function findBlockedTasks(tasks: readonly Task[]): readonly string[] {
5
+ const completedIds = getCompletedTaskIds(tasks);
6
+ return tasks
7
+ .filter(
8
+ (t) =>
9
+ !isTaskDone(t) &&
10
+ t.dependencies.length > 0 &&
11
+ t.dependencies.some((dep) => !completedIds.has(dep)),
12
+ )
13
+ .map((t) => t.id);
14
+ }
15
+
16
+ export function isTaskReady(tasks: readonly Task[], taskId: string): boolean {
17
+ const task = tasks.find((t) => t.id === taskId);
18
+ if (!task) return false;
19
+ if (isTaskDone(task)) return false;
20
+ if (task.dependencies.length === 0) return true;
21
+ const completedIds = getCompletedTaskIds(tasks);
22
+ return task.dependencies.every((dep) => completedIds.has(dep));
23
+ }
24
+
25
+ export function getBlockingTasks(tasks: readonly Task[], taskId: string): readonly string[] {
26
+ const task = tasks.find((t) => t.id === taskId);
27
+ if (!task) return [];
28
+ const completedIds = getCompletedTaskIds(tasks);
29
+ return task.dependencies.filter((dep) => !completedIds.has(dep));
30
+ }
31
+
32
+ export function detectCycles(tasks: readonly Task[]): readonly (readonly string[])[] {
33
+ const ids = new Set(tasks.map((t) => t.id));
34
+ const adj = new Map<string, readonly string[]>();
35
+ for (const t of tasks) {
36
+ adj.set(t.id, t.dependencies.filter((d) => ids.has(d)));
37
+ }
38
+
39
+ const WHITE = 0;
40
+ const GRAY = 1;
41
+ const BLACK = 2;
42
+ const color = new Map<string, number>();
43
+ for (const id of ids) color.set(id, WHITE);
44
+
45
+ const cycles: string[][] = [];
46
+ const stack: string[] = [];
47
+
48
+ function dfs(node: string): void {
49
+ color.set(node, GRAY);
50
+ stack.push(node);
51
+
52
+ for (const neighbor of adj.get(node) ?? []) {
53
+ const c = color.get(neighbor);
54
+ if (c === GRAY) {
55
+ const cycleStart = stack.indexOf(neighbor);
56
+ cycles.push(stack.slice(cycleStart));
57
+ } else if (c === WHITE) {
58
+ dfs(neighbor);
59
+ }
60
+ }
61
+
62
+ stack.pop();
63
+ color.set(node, BLACK);
64
+ }
65
+
66
+ for (const id of ids) {
67
+ if (color.get(id) === WHITE) dfs(id);
68
+ }
69
+
70
+ return cycles;
71
+ }
72
+
73
+ export function topologicalSort(tasks: readonly Task[]): readonly string[] {
74
+ const ids = new Set(tasks.map((t) => t.id));
75
+ const adj = new Map<string, string[]>();
76
+ const inDegree = new Map<string, number>();
77
+
78
+ for (const id of ids) {
79
+ adj.set(id, []);
80
+ inDegree.set(id, 0);
81
+ }
82
+
83
+ for (const t of tasks) {
84
+ for (const dep of t.dependencies) {
85
+ if (ids.has(dep)) {
86
+ adj.get(dep)!.push(t.id);
87
+ inDegree.set(t.id, (inDegree.get(t.id) ?? 0) + 1);
88
+ }
89
+ }
90
+ }
91
+
92
+ const queue: string[] = [];
93
+ for (const [id, deg] of inDegree) {
94
+ if (deg === 0) queue.push(id);
95
+ }
96
+
97
+ const result: string[] = [];
98
+ while (queue.length > 0) {
99
+ const node = queue.shift()!;
100
+ result.push(node);
101
+ for (const neighbor of adj.get(node) ?? []) {
102
+ const newDeg = (inDegree.get(neighbor) ?? 1) - 1;
103
+ inDegree.set(neighbor, newDeg);
104
+ if (newDeg === 0) queue.push(neighbor);
105
+ }
106
+ }
107
+
108
+ return result;
109
+ }
110
+
111
+ export function getAllTasks(phases: readonly { readonly tasks: readonly Task[] }[]): readonly Task[] {
112
+ return phases.flatMap((p) => p.tasks);
113
+ }
package/src/index.ts ADDED
@@ -0,0 +1,118 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ } from "@mariozechner/pi-coding-agent";
5
+ import type { BlueprintExtensionState } from "./types.js";
6
+ import {
7
+ ensureBaseDir,
8
+ loadIndex,
9
+ loadBlueprint,
10
+ saveBlueprint,
11
+ } from "./storage.js";
12
+ import { buildInjectionBlock } from "./blueprint-injector.js";
13
+ import { registerBlueprintTools } from "./blueprint-tools.js";
14
+ import {
15
+ handleBlueprintCommand,
16
+ COMMAND_NAME as BLUEPRINT_CMD,
17
+ } from "./blueprint-command.js";
18
+ import {
19
+ handlePlanStatusCommand,
20
+ COMMAND_NAME as STATUS_CMD,
21
+ } from "./plan-status-command.js";
22
+ import {
23
+ handlePlanVerifyCommand,
24
+ COMMAND_NAME as VERIFY_CMD,
25
+ } from "./plan-verify-command.js";
26
+ import {
27
+ handlePlanNextCommand,
28
+ COMMAND_NAME as NEXT_CMD,
29
+ } from "./plan-next-command.js";
30
+
31
+ export default function (pi: ExtensionAPI): void {
32
+ let state: BlueprintExtensionState = {
33
+ project: null,
34
+ blueprint: null,
35
+ sessionId: "",
36
+ };
37
+ let dirty = false;
38
+
39
+ const stateRef = {
40
+ get: () => state,
41
+ set: (s: BlueprintExtensionState) => {
42
+ state = s;
43
+ dirty = true;
44
+ },
45
+ };
46
+
47
+ pi.on("session_start", (_event, _ctx) => {
48
+ try {
49
+ ensureBaseDir();
50
+ const index = loadIndex();
51
+ if (index?.active_blueprint_id) {
52
+ const blueprint = loadBlueprint(index.active_blueprint_id);
53
+ if (blueprint && blueprint.status === "active") {
54
+ state = { ...state, blueprint };
55
+ }
56
+ }
57
+ registerBlueprintTools(pi, stateRef);
58
+ } catch (err) {
59
+ console.error("[pi-blueprint] session_start error:", err);
60
+ }
61
+ });
62
+
63
+ pi.on("session_shutdown", (_event, _ctx) => {
64
+ try {
65
+ if (state.blueprint) {
66
+ saveBlueprint(state.blueprint);
67
+ }
68
+ } catch (err) {
69
+ console.error("[pi-blueprint] session_shutdown error:", err);
70
+ }
71
+ });
72
+
73
+ pi.on("before_agent_start", (event, _ctx) => {
74
+ try {
75
+ const block = buildInjectionBlock(state.blueprint);
76
+ if (!block) return;
77
+ const e = event as { systemPrompt?: string };
78
+ return { systemPrompt: (e.systemPrompt ?? "") + block };
79
+ } catch (err) {
80
+ console.error("[pi-blueprint] before_agent_start error:", err);
81
+ }
82
+ });
83
+
84
+ pi.on("turn_end", (_event, _ctx) => {
85
+ try {
86
+ if (state.blueprint && dirty) {
87
+ saveBlueprint(state.blueprint);
88
+ dirty = false;
89
+ }
90
+ } catch (err) {
91
+ console.error("[pi-blueprint] turn_end error:", err);
92
+ }
93
+ });
94
+
95
+ pi.registerCommand(BLUEPRINT_CMD, {
96
+ description: "Create or manage a multi-session blueprint plan",
97
+ handler: (args: string, ctx: ExtensionCommandContext) =>
98
+ handleBlueprintCommand(args, ctx, stateRef, pi),
99
+ });
100
+
101
+ pi.registerCommand(STATUS_CMD, {
102
+ description: "Show detailed blueprint progress",
103
+ handler: (args: string, ctx: ExtensionCommandContext) =>
104
+ handlePlanStatusCommand(args, ctx, stateRef),
105
+ });
106
+
107
+ pi.registerCommand(VERIFY_CMD, {
108
+ description: "Run verification gates for the current phase",
109
+ handler: (args: string, ctx: ExtensionCommandContext) =>
110
+ handlePlanVerifyCommand(args, ctx, stateRef),
111
+ });
112
+
113
+ pi.registerCommand(NEXT_CMD, {
114
+ description: "Get and start the next blueprint task",
115
+ handler: (args: string, ctx: ExtensionCommandContext) =>
116
+ handlePlanNextCommand(args, ctx, stateRef, pi),
117
+ });
118
+ }