moonpi 0.4.2

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.
package/src/sprint.ts ADDED
@@ -0,0 +1,319 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
4
+ import { Type, type Static } from "typebox";
5
+ import type { MoonpiController } from "./modes.js";
6
+
7
+ interface Phase {
8
+ id: string;
9
+ title: string;
10
+ startLine: number;
11
+ endLine: number;
12
+ section: string;
13
+ complete: boolean;
14
+ }
15
+
16
+ const EndPhaseParamsSchema = Type.Object({
17
+ sprintNumber: Type.Optional(Type.Number({ description: "Sprint number. Defaults to the active sprint loop." })),
18
+ phaseId: Type.Optional(Type.String({ description: "Phase id. Defaults to the active phase." })),
19
+ summary: Type.Optional(Type.String({ description: "Short summary of work completed in this phase." })),
20
+ });
21
+
22
+ type EndPhaseParams = Static<typeof EndPhaseParamsSchema>;
23
+
24
+ function sprintsDir(cwd: string): string {
25
+ return join(cwd, "sprints");
26
+ }
27
+
28
+ function sprintDir(cwd: string, sprintNumber: number): string {
29
+ return join(sprintsDir(cwd), String(sprintNumber));
30
+ }
31
+
32
+ function tasksPath(cwd: string, sprintNumber: number): string {
33
+ return join(sprintDir(cwd, sprintNumber), "TASKS.md");
34
+ }
35
+
36
+ function sprintPath(cwd: string, sprintNumber: number): string {
37
+ return join(sprintDir(cwd, sprintNumber), "SPRINT.md");
38
+ }
39
+
40
+ function listSprintNumbers(cwd: string): number[] {
41
+ const dir = sprintsDir(cwd);
42
+ if (!existsSync(dir)) return [];
43
+ return readdirSync(dir, { withFileTypes: true })
44
+ .filter((entry) => entry.isDirectory())
45
+ .map((entry) => Number.parseInt(entry.name, 10))
46
+ .filter((value) => Number.isInteger(value) && value > 0)
47
+ .sort((a, b) => a - b);
48
+ }
49
+
50
+ function nextSprintNumber(cwd: string): number {
51
+ const numbers = listSprintNumbers(cwd);
52
+ return numbers.length === 0 ? 1 : Math.max(...numbers) + 1;
53
+ }
54
+
55
+ function readTasks(cwd: string, sprintNumber: number): string {
56
+ const filePath = tasksPath(cwd, sprintNumber);
57
+ if (!existsSync(filePath)) throw new Error(`TASKS.md not found for sprint ${sprintNumber}`);
58
+ return readFileSync(filePath, "utf-8");
59
+ }
60
+
61
+ function parsePhases(tasks: string): Phase[] {
62
+ const lines = tasks.split("\n");
63
+ const headings: Array<{ id: string; title: string; line: number }> = [];
64
+ for (let index = 0; index < lines.length; index += 1) {
65
+ const match = /^## Phase\s+([^:—\-–]+)\s*[:—–-]\s*(.+)$/.exec(lines[index] ?? "");
66
+ if (match) headings.push({ id: match[1]?.trim() ?? "", title: match[2]?.trim() ?? "", line: index });
67
+ }
68
+
69
+ return headings.map((heading, index) => {
70
+ const endLine = headings[index + 1]?.line ?? lines.length;
71
+ const section = lines.slice(heading.line, endLine).join("\n");
72
+ return {
73
+ id: heading.id,
74
+ title: heading.title,
75
+ startLine: heading.line,
76
+ endLine,
77
+ section,
78
+ complete: !section.includes("- [ ]"),
79
+ };
80
+ });
81
+ }
82
+
83
+ function nextIncompletePhase(cwd: string, sprintNumber: number): Phase | undefined {
84
+ return parsePhases(readTasks(cwd, sprintNumber)).find((phase) => !phase.complete);
85
+ }
86
+
87
+ function phaseById(cwd: string, sprintNumber: number, phaseId: string): Phase | undefined {
88
+ return parsePhases(readTasks(cwd, sprintNumber)).find((phase) => phase.id === phaseId);
89
+ }
90
+
91
+ function buildPhaseInstruction(sprintNumber: number, phase: Phase): string {
92
+ return `Moonpi sprint loop: complete Sprint ${sprintNumber}, Phase ${phase.id}: ${phase.title}.
93
+
94
+ Work only on this phase. Update files as needed, run or document the verification listed for this phase, and update TODO items as work progresses. When this phase is complete, call end_phase with sprintNumber ${sprintNumber}, phaseId "${phase.id}", and a concise summary.
95
+
96
+ Current phase section:
97
+
98
+ ${phase.section}`;
99
+ }
100
+
101
+ function markPhaseComplete(cwd: string, sprintNumber: number, phaseId: string, summary: string | undefined): Phase | undefined {
102
+ const filePath = tasksPath(cwd, sprintNumber);
103
+ const tasks = readTasks(cwd, sprintNumber);
104
+ const lines = tasks.split("\n");
105
+ const phase = parsePhases(tasks).find((candidate) => candidate.id === phaseId);
106
+ if (!phase) return undefined;
107
+
108
+ const before = lines.slice(0, phase.startLine);
109
+ const section = lines.slice(phase.startLine, phase.endLine).map((line) => line.replace(/- \[ \]/g, "- [x]"));
110
+ if (summary) {
111
+ section.push("");
112
+ section.push(`Completion notes: ${summary}`);
113
+ }
114
+ const after = lines.slice(phase.endLine);
115
+ writeFileSync(filePath, [...before, ...section, ...after].join("\n"), "utf-8");
116
+ return phase;
117
+ }
118
+
119
+ function continueAfterCompaction(pi: ExtensionAPI, ctx: ExtensionContext, prompt: string): void {
120
+ ctx.compact({
121
+ customInstructions: "Moonpi sprint loop completed one phase. Preserve the sprint goal, completed phase summary, and next phase instructions.",
122
+ onComplete: () => pi.sendUserMessage(prompt),
123
+ onError: () => pi.sendUserMessage(prompt),
124
+ });
125
+ }
126
+
127
+ export function installSprintWorkflow(pi: ExtensionAPI, controller: MoonpiController): void {
128
+ pi.registerCommand("sprint:init", {
129
+ description: "Create a moonpi sprint: ask for the objective, then delegate SPRINT.md and TASKS.md creation to the agent",
130
+ handler: async (_args, ctx) => {
131
+ const objective = await ctx.ui.editor("Sprint objective", "");
132
+ if (!objective?.trim()) return;
133
+
134
+ const sprintNumber = nextSprintNumber(ctx.cwd);
135
+ const dir = sprintDir(ctx.cwd, sprintNumber);
136
+ mkdirSync(dir, { recursive: true });
137
+
138
+ controller.state.sprintLoop = { sprintNumber };
139
+ controller.applyMode(ctx);
140
+ controller.persist();
141
+
142
+ pi.sendUserMessage(
143
+ `Create the sprint files for Sprint ${sprintNumber} in ./sprints/${sprintNumber}/.
144
+
145
+ Sprint objective: ${objective.trim()}
146
+
147
+ Before writing any files, **ask clarifying questions** using the question tool to understand the objective better. You should ask about:
148
+ - Scope and boundaries (what's in scope, what's explicitly out of scope)
149
+ - Technical constraints and preferences (frameworks, patterns, existing code to integrate with)
150
+ - Acceptance criteria (what does "done" look like?)
151
+ - Priority and ordering (what matters most?)
152
+ - Any ambiguous aspects of the objective
153
+
154
+ Only after you have enough clarity, do exactly two things:
155
+
156
+ 1. Write SPRINT.md at ${sprintPath(ctx.cwd, sprintNumber)} — a clear and detailed sprint document that includes:
157
+ - **Goal**: One-sentence summary of what this sprint delivers
158
+ - **Scope**: What's included and excluded
159
+ - **Context**: Relevant background, existing code, dependencies
160
+ - **Constraints**: Technical constraints, patterns to follow, things to avoid
161
+ - **Acceptance Criteria**: Concrete, testable conditions that define "done"
162
+ - **Risks & Open Questions**: Known risks and unresolved items
163
+
164
+ 2. Write TASKS.md at ${tasksPath(ctx.cwd, sprintNumber)} — break the objective into concrete phases with tasks and verification items.
165
+
166
+ TASKS.md format requirements:
167
+ - Each phase must be a level-2 heading: \`## Phase <number>: <title>\` (use a colon between the number and title)
168
+ - Within each phase, list tasks as unchecked markdown checkboxes: \`- [ ] Task description\`
169
+ - End each phase with a **Verification:** section listing how to confirm the phase is done
170
+ - Each phase should be independently completable and verifiable
171
+
172
+ Example:
173
+ \`\`\`
174
+ ## Phase 1: Project Scaffolding
175
+
176
+ - [ ] Initialize project structure
177
+ - [ ] Create base HTML shell
178
+ - [ ] Set up dev server
179
+
180
+ **Verification:**
181
+ - npm run dev loads without errors
182
+ \`\`\`
183
+
184
+ Do not start implementing anything. Only create the sprint planning files.`,
185
+ );
186
+ },
187
+ });
188
+
189
+ pi.registerCommand("sprint:loop", {
190
+ description: "Execute the next incomplete phase in the latest sprint, compacting after each phase",
191
+ handler: async (_args, ctx) => {
192
+ const sprints = listSprintNumbers(ctx.cwd);
193
+ if (sprints.length === 0) {
194
+ ctx.ui.notify("No sprints found. Use /sprint:init to create one.", "error");
195
+ return;
196
+ }
197
+
198
+ let sprintNumber: number;
199
+ if (sprints.length === 1) {
200
+ sprintNumber = sprints[0]!;
201
+ } else {
202
+ const options = sprints
203
+ .slice()
204
+ .reverse()
205
+ .map((n) => `Sprint ${n}`);
206
+ const selected = await ctx.ui.select("Select sprint", options);
207
+ if (!selected) return;
208
+ const match = /^Sprint (\d+)$/.exec(selected);
209
+ if (!match) return;
210
+ sprintNumber = Number.parseInt(match[1]!, 10);
211
+ }
212
+
213
+ let phase: Phase | undefined;
214
+ try {
215
+ phase = nextIncompletePhase(ctx.cwd, sprintNumber);
216
+ } catch (error) {
217
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
218
+ return;
219
+ }
220
+ if (!phase) {
221
+ ctx.ui.notify(`Sprint ${sprintNumber} has no incomplete phases.`, "info");
222
+ return;
223
+ }
224
+
225
+ controller.state.sprintLoop = { sprintNumber, currentPhaseId: phase.id };
226
+ controller.state.setMode("sprint:plan");
227
+ controller.applyMode(ctx);
228
+ controller.persist();
229
+ pi.sendUserMessage(buildPhaseInstruction(sprintNumber, phase));
230
+ },
231
+ });
232
+
233
+ pi.registerTool({
234
+ name: "end_phase",
235
+ label: "end phase",
236
+ description:
237
+ "Finish the active moonpi sprint phase. This marks the phase complete in TASKS.md, then moonpi compacts context and continues with the next phase.",
238
+ promptSnippet: "Finish the active sprint phase",
239
+ promptGuidelines: [
240
+ "Use end_phase only when a Moonpi sprint loop is active and the current sprint phase is complete.",
241
+ "Do not call end_phase for ordinary Plan, Auto, Act, or Fast mode work.",
242
+ ],
243
+ parameters: EndPhaseParamsSchema,
244
+ async execute(_toolCallId, params: EndPhaseParams, _signal, _onUpdate, ctx) {
245
+ const sprintNumber = params.sprintNumber ?? controller.state.sprintLoop?.sprintNumber;
246
+ const phaseId = params.phaseId ?? controller.state.sprintLoop?.currentPhaseId;
247
+ if (!sprintNumber || !phaseId) {
248
+ return {
249
+ content: [{ type: "text", text: "Error: no active sprint phase to end." }],
250
+ details: { error: "no active sprint phase" },
251
+ };
252
+ }
253
+
254
+ const completed = markPhaseComplete(ctx.cwd, sprintNumber, phaseId, params.summary);
255
+ if (!completed) {
256
+ return {
257
+ content: [{ type: "text", text: `Error: phase ${phaseId} not found in sprint ${sprintNumber}.` }],
258
+ details: { error: "phase not found" },
259
+ };
260
+ }
261
+
262
+ const next = nextIncompletePhase(ctx.cwd, sprintNumber);
263
+ if (!next) {
264
+ controller.state.sprintLoop = undefined;
265
+ controller.state.clearTodos();
266
+ controller.state.setMode("auto");
267
+ controller.applyMode(ctx);
268
+ controller.persist();
269
+ return {
270
+ content: [{ type: "text", text: `Sprint ${sprintNumber} is complete.` }],
271
+ details: { sprintNumber, completedPhaseId: phaseId, nextPhaseId: null },
272
+ terminate: true,
273
+ };
274
+ }
275
+
276
+ controller.state.sprintLoop = {
277
+ sprintNumber,
278
+ currentPhaseId: phaseId,
279
+ pendingNextPhaseId: next.id,
280
+ };
281
+ controller.state.clearTodos();
282
+ controller.state.setMode("sprint:plan");
283
+ controller.applyMode(ctx);
284
+ controller.persist();
285
+ return {
286
+ content: [
287
+ {
288
+ type: "text",
289
+ text: `Phase ${phaseId} complete. Moonpi will compact context and continue with phase ${next.id}.`,
290
+ },
291
+ ],
292
+ details: { sprintNumber, completedPhaseId: phaseId, nextPhaseId: next.id },
293
+ terminate: true,
294
+ };
295
+ },
296
+ });
297
+
298
+ pi.on("agent_end", async (_event, ctx) => {
299
+ const loop = controller.state.sprintLoop;
300
+ if (!loop?.pendingNextPhaseId) return;
301
+ const phase = phaseById(ctx.cwd, loop.sprintNumber, loop.pendingNextPhaseId);
302
+ if (!phase) {
303
+ controller.state.sprintLoop = undefined;
304
+ controller.applyMode(ctx);
305
+ controller.persist();
306
+ ctx.ui.notify(`Pending phase ${loop.pendingNextPhaseId} was not found. Sprint loop stopped.`, "error");
307
+ return;
308
+ }
309
+
310
+ controller.state.sprintLoop = {
311
+ sprintNumber: loop.sprintNumber,
312
+ currentPhaseId: phase.id,
313
+ };
314
+ controller.state.setMode("sprint:plan");
315
+ controller.applyMode(ctx);
316
+ controller.persist();
317
+ continueAfterCompaction(pi, ctx, buildPhaseInstruction(loop.sprintNumber, phase));
318
+ });
319
+ }
package/src/state.ts ADDED
@@ -0,0 +1,137 @@
1
+ import type {
2
+ AutoPhase,
3
+ MoonpiMode,
4
+ MoonpiSnapshot,
5
+ SprintLoopState,
6
+ TodoItem,
7
+ TodoStatus,
8
+ } from "./types.js";
9
+
10
+ export class MoonpiState {
11
+ mode: MoonpiMode = "auto";
12
+ autoPhase: AutoPhase = "plan";
13
+ todos: TodoItem[] = [];
14
+ nextTodoId = 1;
15
+ readFiles = new Set<string>();
16
+ endConversationRequested = false;
17
+ selectedContextFilePaths: string[] | undefined;
18
+ sprintLoop: SprintLoopState | undefined;
19
+
20
+ restore(snapshot: MoonpiSnapshot | undefined): void {
21
+ if (!snapshot) return;
22
+ this.mode = snapshot.mode;
23
+ this.autoPhase = snapshot.autoPhase;
24
+ this.todos = snapshot.todos.map((item) => ({ ...item }));
25
+ this.nextTodoId = snapshot.nextTodoId;
26
+ this.readFiles = new Set(snapshot.readFiles);
27
+ this.endConversationRequested = snapshot.endConversationRequested;
28
+ this.selectedContextFilePaths = Array.isArray(snapshot.selectedContextFilePaths)
29
+ ? [...snapshot.selectedContextFilePaths]
30
+ : undefined;
31
+ this.sprintLoop = snapshot.sprintLoop ? { ...snapshot.sprintLoop } : undefined;
32
+ }
33
+
34
+ snapshot(): MoonpiSnapshot {
35
+ const snapshot: MoonpiSnapshot = {
36
+ mode: this.mode,
37
+ autoPhase: this.autoPhase,
38
+ todos: this.todos.map((item) => ({ ...item })),
39
+ nextTodoId: this.nextTodoId,
40
+ readFiles: [...this.readFiles],
41
+ endConversationRequested: this.endConversationRequested,
42
+ };
43
+ if (this.selectedContextFilePaths !== undefined) {
44
+ snapshot.selectedContextFilePaths = [...this.selectedContextFilePaths];
45
+ }
46
+ if (this.sprintLoop) snapshot.sprintLoop = { ...this.sprintLoop };
47
+ return snapshot;
48
+ }
49
+
50
+ resetForUserPrompt(): void {
51
+ this.endConversationRequested = false;
52
+ if (this.mode === "sprint:plan" || this.mode === "sprint:act") {
53
+ this.autoPhase = "plan";
54
+ this.todos = [];
55
+ this.nextTodoId = 1;
56
+ }
57
+ // In auto mode, keep autoPhase sticky — once it transitions to "act" it stays there
58
+ // until the user explicitly switches mode or starts a new session.
59
+ if (this.mode === "auto") {
60
+ this.todos = [];
61
+ this.nextTodoId = 1;
62
+ }
63
+ }
64
+
65
+ setMode(mode: MoonpiMode): void {
66
+ this.mode = mode;
67
+ this.endConversationRequested = false;
68
+ if (mode === "auto" || mode === "sprint:plan" || mode === "sprint:act") {
69
+ this.autoPhase = mode === "sprint:act" ? "act" : "plan";
70
+ }
71
+ }
72
+
73
+ addTodo(text: string, status: TodoStatus = "todo", notes?: string): TodoItem {
74
+ const todo: TodoItem = {
75
+ id: this.nextTodoId,
76
+ text,
77
+ status,
78
+ ...(notes ? { notes } : {}),
79
+ };
80
+ this.nextTodoId += 1;
81
+ this.todos.push(todo);
82
+ return todo;
83
+ }
84
+
85
+ replaceTodos(items: Array<{ text: string; status?: TodoStatus; notes?: string }>): void {
86
+ this.todos = [];
87
+ this.nextTodoId = 1;
88
+ for (const item of items) {
89
+ this.addTodo(item.text, item.status ?? "todo", item.notes);
90
+ }
91
+ }
92
+
93
+ updateTodo(id: number, patch: { text?: string; status?: TodoStatus; notes?: string }): TodoItem | undefined {
94
+ const todo = this.todos.find((item) => item.id === id);
95
+ if (!todo) return undefined;
96
+ if (patch.text !== undefined) todo.text = patch.text;
97
+ if (patch.status !== undefined) todo.status = patch.status;
98
+ if (patch.notes !== undefined) {
99
+ if (patch.notes) {
100
+ todo.notes = patch.notes;
101
+ } else {
102
+ delete todo.notes;
103
+ }
104
+ }
105
+ return todo;
106
+ }
107
+
108
+ removeTodo(id: number): boolean {
109
+ const before = this.todos.length;
110
+ this.todos = this.todos.filter((item) => item.id !== id);
111
+ return this.todos.length !== before;
112
+ }
113
+
114
+ clearTodos(): void {
115
+ this.todos = [];
116
+ this.nextTodoId = 1;
117
+ }
118
+
119
+ markRead(filePath: string): void {
120
+ this.readFiles.add(filePath);
121
+ }
122
+
123
+ hasRead(filePath: string): boolean {
124
+ return this.readFiles.has(filePath);
125
+ }
126
+ }
127
+
128
+ export function formatTodoList(todos: TodoItem[]): string {
129
+ if (todos.length === 0) return "No TODO items.";
130
+ return todos
131
+ .map((todo) => {
132
+ const statusSymbol = todo.status === "done" ? "✓" : todo.status === "in_progress" ? "~" : " ";
133
+ const notes = todo.notes ? ` (${todo.notes})` : "";
134
+ return `#${todo.id} [${statusSymbol}] ${todo.text}${notes}`;
135
+ })
136
+ .join("\n");
137
+ }