pi-plans 0.3.2 → 0.3.3

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 CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import * as fs from "node:fs";
11
+ import * as path from "node:path";
11
12
  import { randomUUID } from "node:crypto";
12
13
  import type {
13
14
  CompactionResult,
@@ -25,6 +26,7 @@ import {
25
26
  entryCurrentIMarkers,
26
27
  formatVccCompactionStats,
27
28
  loadVccSettings,
29
+ PLANNING_PREPLAN_COMPACT_HINT,
28
30
  scaffoldVccSettings,
29
31
  shouldScheduleAutoContinue,
30
32
  type CompactionEntryLike,
@@ -34,13 +36,35 @@ import {
34
36
  type VccCompactionBuildResult,
35
37
  type VccCompactionStats,
36
38
  } from "./compaction.ts";
37
- import { getRun, readActive, resolveStateRootOrNull, setRunStatus, utcNow } from "./state.ts";
39
+ import { getRun, readActive, resolveStateRootOrNull, setRunStatus, StateError, utcNow } from "./state.ts";
40
+ import { bindRun, resolveActiveRun } from "./run-context.ts";
41
+ import { OwnershipError } from "./run-ownership.ts";
42
+ import {
43
+ applyExecutionApproved,
44
+ applyExecutionCompleted,
45
+ applyExecutionHeadChanged,
46
+ applyExecutionProgress,
47
+ applyExecutionStopped,
48
+ applyQuestionAsked,
49
+ applyReviewRoundStarted,
50
+ createCheckpoint,
51
+ loadCheckpoint,
52
+ mutateCheckpoint,
53
+ StaleCheckpointError,
54
+ planIdentityOf,
55
+ resolveHeadAt,
56
+ resolveWorktreeRoot,
57
+ sha256File,
58
+ type ExecutionApproval,
59
+ } from "./workflow-state.ts";
38
60
  import { graphBlockForExecutor } from "./code-graph/prompts.ts";
39
61
  import { resolveGraphMode } from "./code-graph/mode.ts";
40
- import { TERMINATION_QUESTION, TERMINATION_OPTIONS, renderTerminationOptions } from "./termination-prompt.ts";
62
+ import { TERMINATION_QUESTION, TERMINATION_OPTIONS, TERMINATION_RECORDING_INSTRUCTIONS, renderTerminationOptions } from "./termination-prompt.ts";
41
63
  import {
42
64
  extractCoverage,
43
65
  latestPlanVersion,
66
+ parseChecklist,
67
+ parseImplItems,
44
68
  resolveImplStatuses,
45
69
  scanDoneMarkers,
46
70
  scanImplMarkers,
@@ -131,6 +155,105 @@ export function getExecution(): ExecState | null {
131
155
  return execution;
132
156
  }
133
157
 
158
+ export interface CheckpointExecutionLoad {
159
+ status: "loaded" | "no-execution" | "plan-missing" | "plan-mismatch" | "no-checkpoint" | "corrupt";
160
+ planPath?: string;
161
+ doneVcIds?: string[];
162
+ reverifyAll?: boolean;
163
+ pausedReason?: string;
164
+ error?: string;
165
+ }
166
+
167
+ /**
168
+ * Shared restore primitive (I-005/I-006): load the executing state from a run
169
+ * checkpoint into THIS session. Authorization is kept only when the recorded
170
+ * approval matches the current plan digest; a HEAD change keeps the
171
+ * authorization but re-verifies previously verified VCs (D-011/F-001).
172
+ * F-002: the loaded state is persisted to the current session IMMEDIATELY so
173
+ * session_start/session_tree restore paths cannot silently clear it.
174
+ */
175
+ export function loadExecutionFromCheckpoint(
176
+ pi: ExtensionAPI,
177
+ ctx: ExtensionContext,
178
+ runId: string,
179
+ ): CheckpointExecutionLoad {
180
+ const load = loadCheckpoint(ctx.cwd, runId);
181
+ if (load.status === "missing") return { status: "no-checkpoint" };
182
+ if (load.status === "corrupt") return { status: "corrupt", error: load.error };
183
+ const cp = load.checkpoint;
184
+ if (!cp.execution || cp.phase !== "executing") return { status: "no-execution" };
185
+ const planPath = cp.plan?.path;
186
+ if (!planPath || !fs.existsSync(planPath)) {
187
+ return { status: "plan-missing", error: planPath ? `plan file vanished: ${planPath}` : "checkpoint has no plan identity" };
188
+ }
189
+ const planText = fs.readFileSync(planPath, "utf8");
190
+ // F-002 (implementation review): the recorded plan identity is over BYTES —
191
+ // an in-place edit at the same path must not inherit the authorization or
192
+ // the verified VCs. Refuse the load and require a fresh handoff.
193
+ if (sha256File(planPath) !== cp.plan.sha256) {
194
+ return {
195
+ status: "plan-mismatch" as const,
196
+ error: `plan file changed since the approval record (${planPath}); re-approve via /plans-execute before executing`,
197
+ };
198
+ }
199
+ const items = parseChecklist(planText);
200
+ if (items.length === 0) {
201
+ return { status: "plan-missing", error: `${planPath} has no parsable verifier checklist` };
202
+ }
203
+ const implItems = parseImplItems(planText);
204
+ const doneIds = new Set(cp.execution.doneVcIds);
205
+ // D-011/F-001: an unchanged plan digest keeps the recorded authorization;
206
+ // a changed HEAD under it forces re-verification of previously verified VCs.
207
+ // F-006 (implementation review): an approval without a resolvable HEAD
208
+ // recorded an unverifiable code state — re-verify instead of trusting.
209
+ const headNow = resolveHeadAt(ctx.cwd);
210
+ const headUnverifiable = cp.execution.approval === null || cp.execution.approval.headAtApproval === null;
211
+ const headChanged =
212
+ cp.execution.approval !== null &&
213
+ cp.execution.approval.headAtApproval !== null &&
214
+ cp.execution.approval.headAtApproval !== headNow;
215
+ const reverifyAll = cp.execution.reverifyAll === true || headChanged || headUnverifiable;
216
+ if (!reverifyAll) {
217
+ for (const item of items) {
218
+ if (doneIds.has(item.id)) item.done = true;
219
+ }
220
+ }
221
+ execution = {
222
+ planPath,
223
+ items,
224
+ startedAt: utcNow(),
225
+ usage: { inToks: cp.execution.usage.inToks, outToks: cp.execution.usage.outToks },
226
+ implItems,
227
+ implStatus: { ...cp.execution.implStatus },
228
+ currentI: cp.execution.currentI,
229
+ goalWait: {
230
+ noProgressRounds: 0,
231
+ waitRounds: 0,
232
+ lastMarkers: null,
233
+ paused: cp.execution.pausedReason !== undefined,
234
+ pausedReason: cp.execution.pausedReason,
235
+ },
236
+ };
237
+ executionRunId = runId;
238
+ bindRun(ctx.sessionManager, ctx.cwd, runId);
239
+ resetGoalWaitRuntime(ctx);
240
+ pendingExecutionFlush = false; // restored state: no inherited flush debt
241
+ resetExecutionCompactionState(ctx);
242
+ if (headChanged) {
243
+ withExecutionCheckpoint(ctx, (current) => applyExecutionHeadChanged(current));
244
+ }
245
+ if (execution.goalWait) execution.goalWait.lastMarkers = goalWaitSnapshot();
246
+ persist(pi); // F-002: immediate session snapshot
247
+ updateStatusWidget(ctx);
248
+ return {
249
+ status: "loaded",
250
+ planPath,
251
+ doneVcIds: [...doneIds],
252
+ reverifyAll,
253
+ pausedReason: cp.execution.pausedReason,
254
+ };
255
+ }
256
+
134
257
  const EXECUTION_COMPACTION_RESUME_MESSAGE = "Continue execution.";
135
258
 
136
259
  interface ExecutionCompactionState {
@@ -255,7 +378,7 @@ export function updateStatusWidget(ctx: ExtensionContext): void {
255
378
  ctx.ui.setStatus("pi-plans", ctx.ui.theme.fg("accent", line));
256
379
  return;
257
380
  }
258
- const active = readActive(ctx.cwd);
381
+ const active = resolveActiveRun(ctx.sessionManager, ctx.cwd);
259
382
  if (active) {
260
383
  // Idle indicator depends on the run's lifecycle, not just its existence:
261
384
  // done reads as finished, abandoned as closed, stopped/accepted as paused.
@@ -302,6 +425,24 @@ function persist(pi: ExtensionAPI): void {
302
425
  });
303
426
  }
304
427
 
428
+ /** Checkpoint bookkeeping for the executing run; best-effort for legacy runs
429
+ * without checkpoints (their cross-session resume degrades to R-008 rules). */
430
+ function withExecutionCheckpoint(ctx: ExtensionContext, mutator: (cp: import("./workflow-state.ts").WorkflowCheckpoint) => import("./workflow-state.ts").WorkflowCheckpoint): void {
431
+ if (!execution) return;
432
+ const active = resolveActiveRun(ctx.sessionManager, ctx.cwd);
433
+ if (!active || active.run_id !== executionRunId) return;
434
+ try {
435
+ mutateCheckpoint(ctx.cwd, active.run_id, mutator);
436
+ } catch (error) {
437
+ // F-005 (implementation review): ownership loss and revision staleness
438
+ // must stop the advance, not vanish into the catch block.
439
+ if (error instanceof OwnershipError || error instanceof StaleCheckpointError) throw error;
440
+ /* legacy run or corrupt checkpoint: session snapshot still carries the loop */
441
+ }
442
+ }
443
+
444
+ let executionRunId: string | null = null;
445
+
305
446
  export async function startExecution(
306
447
  pi: ExtensionAPI,
307
448
  ctx: ExtensionContext,
@@ -317,8 +458,40 @@ export async function startExecution(
317
458
  pendingExecutionFlush = false; // fresh run: no inherited flush debt
318
459
  resetExecutionCompactionState(ctx);
319
460
  persist(pi);
320
- const active = readActive(ctx.cwd);
461
+ const active = resolveActiveRun(ctx.sessionManager, ctx.cwd);
462
+ executionRunId = active?.run_id ?? null;
321
463
  if (active) {
464
+ bindRun(ctx.sessionManager, ctx.cwd, active.run_id);
465
+ // I-005: durable approval evidence — run + plan digest + HEAD at
466
+ // approval (D-003/D-011). Sets phase executing via the state machine.
467
+ try {
468
+ const load = loadCheckpoint(ctx.cwd, active.run_id);
469
+ if (load.status === "missing") {
470
+ createCheckpoint(ctx.cwd, { runId: active.run_id, originWorkdir: ctx.cwd, workdir: ctx.cwd });
471
+ }
472
+ const approval: ExecutionApproval = {
473
+ plan: planIdentityOf(path.resolve(planPath), 1),
474
+ worktree: resolveWorktreeRoot(ctx.cwd) ?? path.resolve(ctx.cwd),
475
+ headAtApproval: resolveHeadAt(ctx.cwd),
476
+ approvedAt: utcNow(),
477
+ };
478
+ mutateCheckpoint(ctx.cwd, active.run_id, (cp) => {
479
+ // Plan refinement may not have recorded the plan identity yet.
480
+ const withPlan = cp.plan === null ? { ...cp, plan: approval.plan } : cp;
481
+ // The checkpoint may not carry accept-execute (legacy flow);
482
+ // approval here came from the explicit handoff confirmation.
483
+ const aligned = withPlan.nextAction === "accept-execute"
484
+ ? withPlan
485
+ : { ...withPlan, nextAction: "accept-execute" as const };
486
+ return applyExecutionApproved(aligned, approval);
487
+ });
488
+ } catch (error) {
489
+ // F-002 (implementation review): a plan-digest mismatch between the
490
+ // recorded checkpoint plan and the approval must fail closed and
491
+ // visibly — never silently execute without durable approval.
492
+ if (error instanceof StateError && /does not match/.test(error.message)) throw error;
493
+ /* legacy/corrupt checkpoint: run status still transitions below */
494
+ }
322
495
  try {
323
496
  setRunStatus(ctx.cwd, active.run_id, "executing");
324
497
  } catch {
@@ -348,10 +521,30 @@ export function recordExecutionTurn(
348
521
  execution.usage.inToks += usage.input;
349
522
  execution.usage.outToks += usage.output;
350
523
  }
524
+ // I-005: mirror progress into the run checkpoint so a different session
525
+ // can resume with the verified VC/I set (R-004).
526
+ withExecutionCheckpoint(_ctx, (cp) =>
527
+ applyExecutionProgress(cp, {
528
+ doneVcIds: execution!.items.filter((item) => item.done).map((item) => item.id),
529
+ implStatus: implStatusSnapshot(),
530
+ currentI: execution!.currentI,
531
+ usage: usage ? { inToks: usage.input, outToks: usage.output } : undefined,
532
+ }),
533
+ );
351
534
  requestExecutionFlush(pi, _ctx);
352
535
  updateStatusWidget(_ctx);
353
536
  }
354
537
 
538
+ function implStatusSnapshot(): Record<string, string> {
539
+ const snapshot: Record<string, string> = {};
540
+ if (!execution?.implItems) return snapshot;
541
+ for (const item of execution.implItems) {
542
+ const state = execution.implStatus?.[item.id];
543
+ if (state) snapshot[item.id] = state;
544
+ }
545
+ return snapshot;
546
+ }
547
+
355
548
  export function registerExecutionTurnHandlers(
356
549
  pi: ExtensionAPI,
357
550
  onTurnEnd?: (ctx: ExtensionContext) => Promise<void> | void,
@@ -380,6 +573,7 @@ export function registerExecutionTurnHandlers(
380
573
  pi.on("session_shutdown", async (_event, ctx) => {
381
574
  drainExecutionFlush(pi, ctx);
382
575
  execution = null;
576
+ executionRunId = null;
383
577
  goalWaitRuntime = null;
384
578
  lastAssistantUsage = null;
385
579
  });
@@ -428,7 +622,7 @@ const EXECUTION_RESUME_CUSTOM_TYPE = "pi-plans-exec-resume";
428
622
  function activeVccSettings(ctx: ExtensionContext, phase: PiPlansCompactionPhase): { settings: PiPlansVccSettings; runId: string; artifactDir: string } | null {
429
623
  const stateRoot = resolveStateRootOrNull(ctx.cwd);
430
624
  if (!stateRoot) return null;
431
- const active = readActive(ctx.cwd);
625
+ const active = resolveActiveRun(ctx.sessionManager, ctx.cwd);
432
626
  if (!active) return null;
433
627
  const run = getRun(ctx.cwd, active.run_id);
434
628
  if (!run) return null;
@@ -623,6 +817,49 @@ export const PLANNING_RUN_START_CUSTOM_TYPE = "pi-plans-run-start";
623
817
  export const PLANNING_PLAN_WRITTEN_CUSTOM_TYPE = "pi-plans-plan-written";
624
818
  const PLANNING_RESUME_CUSTOM_TYPE = "pi-plans-plan-resume";
625
819
 
820
+ // ---------------------------------------------------------------------------
821
+ // Pre-plan compaction: right after `plans start-run` creates a new planning
822
+ // run, the extension triggers one VCC compaction so the new plan starts on a
823
+ // lean context (LLM reasoning degrades with longer context; see PLAN
824
+ // preplan-compact). The pending flag is session-scoped and opportunistic: it
825
+ // is set by the start-run tool case and consumed by the plans tool_result
826
+ // hook in index.ts, which requests the extension-context compact action and
827
+ // resumes planning exactly once regardless of success or failure.
828
+ // ---------------------------------------------------------------------------
829
+
830
+ export { PLANNING_PREPLAN_COMPACT_HINT };
831
+ export const PLANNING_PREPLAN_RESUME_CUSTOM_TYPE = "pi-plans-preplan-resume";
832
+
833
+ interface PrePlanCompactPending {
834
+ runId: string;
835
+ }
836
+
837
+ export function markPrePlanCompactPending(ctx: ExtensionContext, runId: string): void {
838
+ const session = ctx.sessionManager as unknown as { __piPlansPrePlanCompact?: PrePlanCompactPending | null };
839
+ session.__piPlansPrePlanCompact = { runId };
840
+ }
841
+
842
+ export function consumePrePlanCompactPending(ctx: ExtensionContext): PrePlanCompactPending | null {
843
+ const session = ctx.sessionManager as unknown as { __piPlansPrePlanCompact?: PrePlanCompactPending | null };
844
+ const pending = session.__piPlansPrePlanCompact ?? null;
845
+ session.__piPlansPrePlanCompact = null;
846
+ return pending;
847
+ }
848
+
849
+ /** Hidden resume message after the pre-plan compaction settles (success or
850
+ * failure): Pi's manual compaction never continues the aborted turn, so the
851
+ * planning workflow is continued exactly once from here. */
852
+ export function sendPrePlanCompactResume(pi: ExtensionAPI): void {
853
+ pi.sendMessage?.(
854
+ {
855
+ customType: PLANNING_PREPLAN_RESUME_CUSTOM_TYPE,
856
+ content: "Continue planning.",
857
+ display: false,
858
+ },
859
+ { triggerTurn: true },
860
+ );
861
+ }
862
+
626
863
  interface PlanningCompactionState {
627
864
  inFlight: boolean;
628
865
  resumeGuard: boolean;
@@ -752,8 +989,11 @@ export function refreshPlanningCompactionCooldown(_ctx: ExtensionContext): void
752
989
  }
753
990
 
754
991
  export function requestPlanningCompaction(_ctx: ExtensionContext): void {
755
- // Proactive pi-plans compaction is intentionally disabled. Manual,
992
+ // Generic proactive pi-plans compaction is intentionally disabled. Manual,
756
993
  // threshold, and overflow compactions are handled by session_before_compact.
994
+ // The single exception is the pre-plan compaction: index.ts requests the
995
+ // extension-context compact action from the plans tool_result hook right
996
+ // after start-run (see PLANNING_PREPLAN_COMPACT_HINT).
757
997
  }
758
998
 
759
999
  function buildPlanningVccResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): VccCompactionBuildResult | null {
@@ -887,7 +1127,7 @@ export function handlePlanningCompactFailed(pi: ExtensionAPI, ctx: ExtensionCont
887
1127
  }
888
1128
 
889
1129
  export function filterPlanningResumeMessages<T extends { customType?: string }>(messages: T[]): T[] {
890
- return messages.filter((message) => message.customType !== PLANNING_RESUME_CUSTOM_TYPE);
1130
+ return messages.filter((message) => message.customType !== PLANNING_RESUME_CUSTOM_TYPE && message.customType !== PLANNING_PREPLAN_RESUME_CUSTOM_TYPE);
891
1131
  }
892
1132
 
893
1133
  export async function stopExecution(pi: ExtensionAPI, ctx: ExtensionContext, reason: string): Promise<void> {
@@ -896,7 +1136,10 @@ export async function stopExecution(pi: ExtensionAPI, ctx: ExtensionContext, rea
896
1136
  // Final synchronous write: drain any deferred flush and land the last snapshot.
897
1137
  pendingExecutionFlush = false;
898
1138
  persist(pi);
1139
+ // Checkpoint first: withExecutionCheckpoint guards on the live execution.
1140
+ withExecutionCheckpoint(ctx, (cp) => applyExecutionStopped(cp, reason));
899
1141
  execution = null;
1142
+ executionRunId = null;
900
1143
  goalWaitRuntime = null;
901
1144
  pi.appendEntry("pi-plans-exec-cleared", { reason });
902
1145
  pi.sendMessage(
@@ -907,7 +1150,7 @@ export async function stopExecution(pi: ExtensionAPI, ctx: ExtensionContext, rea
907
1150
  },
908
1151
  { triggerTurn: false },
909
1152
  );
910
- const active = readActive(ctx.cwd);
1153
+ const active = resolveActiveRun(ctx.sessionManager, ctx.cwd);
911
1154
  if (active) {
912
1155
  try {
913
1156
  setRunStatus(ctx.cwd, active.run_id, "stopped");
@@ -1105,7 +1348,10 @@ export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext)
1105
1348
 
1106
1349
  const summary = execution.items.map((item) => `- ✅ \`${item.id}\` ${item.text.split(";")[0]}`).join("\n");
1107
1350
  const planPath = execution.planPath;
1351
+ // Checkpoint first (live-execution guard), then clear the session state.
1352
+ withExecutionCheckpoint(ctx, (cp) => applyExecutionCompleted(cp));
1108
1353
  execution = null;
1354
+ executionRunId = null;
1109
1355
  goalWaitRuntime = null;
1110
1356
  pi.appendEntry("pi-plans-exec-cleared", { reason: "complete" });
1111
1357
  // Post-execution goal-running continuation: in interactive sessions, attach
@@ -1128,7 +1374,7 @@ export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext)
1128
1374
  if (interactive) {
1129
1375
  pi.appendEntry("pi-plans-ameliorate", { planPath, phase: "goal-started", rounds: null, currentRound: 0 });
1130
1376
  }
1131
- const active = readActive(ctx.cwd);
1377
+ const active = resolveActiveRun(ctx.sessionManager, ctx.cwd);
1132
1378
  if (active) {
1133
1379
  try {
1134
1380
  setRunStatus(ctx.cwd, active.run_id, "done");
@@ -1144,7 +1390,7 @@ export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext)
1144
1390
  * implementation-review loop. Termination options are single-sourced from
1145
1391
  * src/termination-prompt.ts (shared with the ask_choice trailing branch). */
1146
1392
  export const AMELIORATION_PROMPT_TEXT = `---
1147
- Goal-running continuation: immediately ask the user now via ask_choice (autoComplete: false, in the session language) the termination question: "${TERMINATION_QUESTION}" Options (recommended first): ${renderTerminationOptions()}. Then keep running the implementation-review loop without asking whether to continue; the goal-wait option keeps the loop running until no unpassed VCs remain.`;
1393
+ Goal-running continuation: immediately ask the user now via ask_choice (autoComplete: false, in the session language) the termination question: "${TERMINATION_QUESTION}" Options (recommended first): ${renderTerminationOptions()}. ${TERMINATION_RECORDING_INSTRUCTIONS} Then keep running the implementation-review loop without asking whether to continue; the goal-wait option keeps the loop running until no unpassed VCs remain.`;
1148
1394
 
1149
1395
  /** Injection text for before_agent_start while executing. */
1150
1396
  export function executionContextMessage(ctx: ExtensionContext): string | null {
package/src/guard.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  import * as os from "node:os";
9
9
  import * as path from "node:path";
10
10
  import { getRun, loadConfig, readActive, resolveStateRootOrNull } from "./state.ts";
11
+ import { activeInfoById } from "./run-context.ts";
11
12
 
12
13
  const GUARDED_TOOLS = new Set(["write", "edit"]);
13
14
  const GUARDED_STATUSES = new Set(["planning", "accepted"]);
@@ -16,12 +17,17 @@ export interface GuardInput {
16
17
  workdir: string;
17
18
  toolName: string;
18
19
  rawPath: string;
20
+ /** Session-bound run id (I-002); when provided it overrides the shared active pointer. null = no binding. */
21
+ activeRunId?: string | null;
19
22
  }
20
23
 
21
24
  /** Returns a block reason when the write must be blocked, or null when allowed. */
22
25
  export function planningWriteBlockReason(input: GuardInput): string | null {
23
26
  if (!GUARDED_TOOLS.has(input.toolName)) return null;
24
- const active = readActive(input.workdir);
27
+ const active =
28
+ input.activeRunId !== undefined && input.activeRunId !== null
29
+ ? activeInfoById(input.workdir, input.activeRunId)
30
+ : readActive(input.workdir);
25
31
  if (!active) return null;
26
32
  const run = getRun(input.workdir, active.run_id);
27
33
  if (!run || !GUARDED_STATUSES.has(run.status)) return null;