pi-plans 0.1.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.
package/src/exec.ts ADDED
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Plan-execution loop: the tracked execution mode for accepted plans.
3
+ *
4
+ * When the user approves the execution handoff, the extension switches into
5
+ * execution mode: every agent turn is injected with the remaining verifier
6
+ * checklist, assistant messages are scanned for [DONE:VC-xxx] markers, and the
7
+ * below-editor panel tracks progress until every item passes.
8
+ */
9
+
10
+ import * as fs from "node:fs";
11
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+ import {
13
+ attachPanelBaseline,
14
+ clearExecutionPanel,
15
+ completeCompletedItems,
16
+ createExecutionPanelState,
17
+ executionPanelFromEntryData,
18
+ refreshExecutionPanel,
19
+ snapshotPanelState,
20
+ toggleExpanded,
21
+ type ExecutionPanelExecutionLike,
22
+ type ExecutionPanelState,
23
+ type ItemDiffSummary,
24
+ } from "./execution-panel.ts";
25
+ import { readActive, setRunStatus, utcNow } from "./state.ts";
26
+ import { scanDoneMarkers, type CheckItem } from "./plan.ts";
27
+
28
+ export interface ExecState extends ExecutionPanelExecutionLike {
29
+ startedAt: string;
30
+ panel?: ExecutionPanelState;
31
+ }
32
+
33
+ let execution: ExecState | null = null;
34
+
35
+ // Set when the user toggles the panel while a turn is streaming; consumed by
36
+ // index.ts on turn_end so view state converges without touching the live run.
37
+ let pendingPanelSync = false;
38
+
39
+ export function consumePendingPanelSync(): boolean {
40
+ const pending = pendingPanelSync;
41
+ pendingPanelSync = false;
42
+ return pending;
43
+ }
44
+
45
+ export function getExecution(): ExecState | null {
46
+ return execution;
47
+ }
48
+
49
+ export function executionProgress(): { done: number; total: number } | null {
50
+ if (!execution) return null;
51
+ return {
52
+ done: execution.items.filter((item) => item.done).length,
53
+ total: execution.items.length,
54
+ };
55
+ }
56
+
57
+ export function updateStatusWidget(ctx: ExtensionContext): void {
58
+ const progress = executionProgress();
59
+ if (progress) {
60
+ // The below-editor panel owns the in-execution progress display; keep the
61
+ // status bar free of a duplicate count (and clear stale ones from before).
62
+ ctx.ui.setStatus("pi-plans", undefined);
63
+ return;
64
+ }
65
+ const active = readActive(ctx.cwd);
66
+ if (active) {
67
+ ctx.ui.setStatus("pi-plans", ctx.ui.theme.fg("warning", `⏸ plans: ${active.run_id}`));
68
+ return;
69
+ }
70
+ ctx.ui.setStatus("pi-plans", undefined);
71
+ }
72
+
73
+ function persist(pi: ExtensionAPI): void {
74
+ if (!execution) return;
75
+ pi.appendEntry("pi-plans-exec", {
76
+ planPath: execution.planPath,
77
+ items: execution.items,
78
+ startedAt: execution.startedAt,
79
+ panel: snapshotPanelState(execution),
80
+ });
81
+ }
82
+
83
+ export function syncExecutionPanel(ctx: ExtensionContext): void {
84
+ if (!execution) {
85
+ clearExecutionPanel(ctx);
86
+ return;
87
+ }
88
+ refreshExecutionPanel(ctx, execution);
89
+ }
90
+
91
+ export function startExecution(
92
+ pi: ExtensionAPI,
93
+ ctx: ExtensionContext,
94
+ planPath: string,
95
+ items: CheckItem[],
96
+ ): void {
97
+ execution = { planPath, items, startedAt: utcNow(), panel: createExecutionPanelState() };
98
+ attachPanelBaseline(execution, ctx.cwd);
99
+ consumePendingPanelSync(); // fresh run: drop any stale deferral from a previous one
100
+ persist(pi);
101
+ const active = readActive(ctx.cwd);
102
+ if (active) {
103
+ try {
104
+ setRunStatus(ctx.cwd, active.run_id, "executing");
105
+ } catch {
106
+ /* status bookkeeping is best-effort */
107
+ }
108
+ }
109
+ pi.sendMessage(
110
+ {
111
+ customType: "pi-plans-exec-start",
112
+ content: `**pi-plans: executing** \`${planPath}\` — ${items.length} verifier item(s). Progress appears below the editor; mark verified items with \`[DONE:VC-xxx]\`.`,
113
+ display: true,
114
+ },
115
+ { triggerTurn: false },
116
+ );
117
+ updateStatusWidget(ctx);
118
+ syncExecutionPanel(ctx);
119
+ }
120
+
121
+ export function toggleExecutionPanelView(pi: ExtensionAPI, ctx: ExtensionContext): boolean | null {
122
+ if (!execution) return null;
123
+ const expanded = toggleExpanded(execution);
124
+ // While a turn is streaming keep this zero-side-effect: flipping the flag is
125
+ // pure memory; persisting and re-rendering here would write to the session
126
+ // file and force a TUI relayout under the running agent. The next turn_end
127
+ // consumes the pending marker and brings the view in line.
128
+ const idle = typeof ctx.isIdle !== "function" || ctx.isIdle();
129
+ if (idle) {
130
+ persist(pi);
131
+ syncExecutionPanel(ctx);
132
+ } else {
133
+ pendingPanelSync = true;
134
+ }
135
+ return expanded;
136
+ }
137
+
138
+ export function recordTouchedPaths(_workdir: string, paths: string[]): void {
139
+ if (!execution || !paths.length) return;
140
+ const panel = execution.panel ?? createExecutionPanelState();
141
+ execution.panel = panel;
142
+ const merged = new Set(panel.touchedPaths);
143
+ for (const raw of paths) {
144
+ const normalized = raw.trim().replace(/[\u0000]+/g, "");
145
+ if (!normalized) continue;
146
+ merged.add(normalized);
147
+ }
148
+ panel.touchedPaths = [...merged];
149
+ }
150
+
151
+ export function recordExecutionCompletion(pi: ExtensionAPI, ctx: ExtensionContext, completedIds: string[]): ItemDiffSummary | null {
152
+ if (!execution) return null;
153
+ const summary = completeCompletedItems(execution, ctx.cwd, completedIds);
154
+ persist(pi);
155
+ syncExecutionPanel(ctx);
156
+ return summary;
157
+ }
158
+
159
+ export function stopExecution(pi: ExtensionAPI, ctx: ExtensionContext, reason: string): void {
160
+ if (!execution) return;
161
+ clearExecutionPanel(ctx);
162
+ execution = null;
163
+ pi.appendEntry("pi-plans-exec-cleared", { reason });
164
+ pi.sendMessage(
165
+ {
166
+ customType: "pi-plans-exec-stop",
167
+ content: `**pi-plans: execution stopped** — ${reason}`,
168
+ display: true,
169
+ },
170
+ { triggerTurn: false },
171
+ );
172
+ const active = readActive(ctx.cwd);
173
+ if (active) {
174
+ try {
175
+ setRunStatus(ctx.cwd, active.run_id, "stopped");
176
+ } catch {
177
+ /* best-effort */
178
+ }
179
+ }
180
+ updateStatusWidget(ctx);
181
+ }
182
+
183
+ /** Apply [DONE:VC-xxx] markers from an assistant message. Returns changed ids. */
184
+ export function applyDoneMarkers(text: string): string[] {
185
+ if (!execution) return [];
186
+ const changed: string[] = [];
187
+ for (const id of scanDoneMarkers(text)) {
188
+ const item = execution.items.find((candidate) => candidate.id === id && !candidate.done);
189
+ if (item) {
190
+ item.done = true;
191
+ changed.push(id);
192
+ }
193
+ }
194
+ return changed;
195
+ }
196
+
197
+ export function isExecutionComplete(): boolean {
198
+ return execution !== null && execution.items.length > 0 && execution.items.every((item) => item.done);
199
+ }
200
+
201
+ export function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext): void {
202
+ if (!execution) return;
203
+ const summary = execution.items.map((item) => `- ✅ \`${item.id}\` ${item.text.split(";")[0]}`).join("\n");
204
+ const planPath = execution.planPath;
205
+ clearExecutionPanel(ctx);
206
+ execution = null;
207
+ pi.appendEntry("pi-plans-exec-cleared", { reason: "complete" });
208
+ pi.sendMessage(
209
+ {
210
+ customType: "pi-plans-complete",
211
+ content: `**Plan complete!** ✅ \`${planPath}\`\n\n${summary}`,
212
+ display: true,
213
+ },
214
+ { triggerTurn: false },
215
+ );
216
+ const active = readActive(ctx.cwd);
217
+ if (active) {
218
+ try {
219
+ setRunStatus(ctx.cwd, active.run_id, "done");
220
+ } catch {
221
+ /* best-effort */
222
+ }
223
+ }
224
+ updateStatusWidget(ctx);
225
+ }
226
+
227
+ /** Injection text for before_agent_start while executing. */
228
+ export function executionContextMessage(): string | null {
229
+ if (!execution) return null;
230
+ const remaining = execution.items.filter((item) => !item.done);
231
+ const list =
232
+ remaining.map((item) => `- \`${item.id}\` ${item.text}`).join("\n") || "(none — report completion now)";
233
+ return `[PI-PLANS EXECUTION — write access enabled]
234
+ Implement the accepted plan at ${execution.planPath} (${execution.items.length - remaining.length}/${execution.items.length} verifier items done).
235
+
236
+ Remaining verifier items:
237
+ ${list}
238
+
239
+ Execution rules:
240
+ - Implement implementation items in dependency order.
241
+ - Ponytail discipline: for each item, take the laziest rung that holds (does it need to exist; already in this codebase; stdlib; native platform feature; already-installed dependency; one line). Mark deliberate simplifications with \`# ponytail: <ceiling>, <upgrade path>\`.
242
+ - MINIMUM tests: trivial one-liners get no test; non-trivial logic gets exactly one minimal check; reuse the repo's test runner when one exists; when unsure, skip and emit \`[test skipped: <name>, add when <trigger>]\`.
243
+ - After verifying an item's pass condition with its stated evidence, include \`[DONE:VC-xxx]\` in your reply.
244
+ - When every item is done, report a completion summary.`;
245
+ }
246
+
247
+ interface SessionEntry {
248
+ type: string;
249
+ customType?: string;
250
+ data?: ExecState;
251
+ message?: { role: string; content: Array<{ type: string; text?: string }> };
252
+ }
253
+
254
+ /**
255
+ * Rebuild execution state from the session on start/resume. Finds the last
256
+ * pi-plans-exec snapshot, then re-scans assistant messages after it for
257
+ * [DONE:VC-xxx] markers so progress survives restarts.
258
+ */
259
+ export function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext, entries: SessionEntry[]): void {
260
+ let snapshotIndex = -1;
261
+ let snapshot: ExecState | null = null;
262
+ for (let i = entries.length - 1; i >= 0; i--) {
263
+ const entry = entries[i];
264
+ if (entry.type === "custom" && entry.customType === "pi-plans-exec" && entry.data) {
265
+ snapshot = entry.data;
266
+ snapshotIndex = i;
267
+ break;
268
+ }
269
+ if (entry.type === "custom" && entry.customType === "pi-plans-exec-cleared") {
270
+ // Execution was explicitly stopped or completed after the last snapshot.
271
+ execution = null;
272
+ syncExecutionPanel(ctx);
273
+ updateStatusWidget(ctx);
274
+ return;
275
+ }
276
+ }
277
+ if (!snapshot) {
278
+ execution = null;
279
+ syncExecutionPanel(ctx);
280
+ updateStatusWidget(ctx);
281
+ return;
282
+ }
283
+ // Ignore stale plans whose file vanished.
284
+ if (!fs.existsSync(snapshot.planPath)) {
285
+ execution = null;
286
+ syncExecutionPanel(ctx);
287
+ updateStatusWidget(ctx);
288
+ return;
289
+ }
290
+ execution = {
291
+ ...snapshot,
292
+ items: snapshot.items.map((item) => ({ ...item })),
293
+ panel: executionPanelFromEntryData(snapshot.panel) ?? createExecutionPanelState(),
294
+ };
295
+ for (let i = snapshotIndex + 1; i < entries.length; i++) {
296
+ const entry = entries[i];
297
+ if (entry.type === "custom" && entry.customType === "pi-plans-exec-cleared") {
298
+ execution = null;
299
+ syncExecutionPanel(ctx);
300
+ break;
301
+ }
302
+ const message = entry.message;
303
+ if (message && message.role === "assistant") {
304
+ const text = message.content
305
+ .filter((part) => part.type === "text")
306
+ .map((part) => part.text ?? "")
307
+ .join("\n");
308
+ applyDoneMarkers(text);
309
+ }
310
+ }
311
+ if (execution) {
312
+ persist(pi); // refresh snapshot so the next resume has less to rescan
313
+ if (isExecutionComplete()) completeExecution(pi, ctx);
314
+ }
315
+ syncExecutionPanel(ctx);
316
+ updateStatusWidget(ctx);
317
+ }