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/tools/plans.ts CHANGED
@@ -3,6 +3,21 @@
3
3
  * typed tool.
4
4
  */
5
5
 
6
+ import { bindRun, boundRunId, resolveActiveRun } from "../src/run-context.ts";
7
+ import { getExecution, markPrePlanCompactPending } from "../src/exec.ts";
8
+ import { loadVccSettings, scaffoldVccSettings } from "../src/compaction.ts";
9
+ import {
10
+ applyCompleted,
11
+ applyImplementationReviewConfigured,
12
+ applyImplementationRoundFinished,
13
+ applyPlanWritten,
14
+ applyReviewConsolidated,
15
+ createCheckpoint,
16
+ mutateCheckpoint,
17
+ planIdentityOf,
18
+ type WorkflowCheckpoint,
19
+ } from "../src/workflow-state.ts";
20
+ import * as path from "node:path";
6
21
  import { StringEnum } from "@earendil-works/pi-ai";
7
22
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
23
  import { Type } from "typebox";
@@ -47,6 +62,7 @@ const PlansParams = Type.Object({
47
62
  "record-decision",
48
63
  "record-ref",
49
64
  "record-subagent",
65
+ "record-checkpoint",
50
66
  ] as const,
51
67
  { description: "State command to run" },
52
68
  ),
@@ -108,6 +124,29 @@ const PlansParams = Type.Object({
108
124
  sessionDir: Type.Optional(Type.String()),
109
125
  }),
110
126
  ),
127
+ checkpoint: Type.Optional(
128
+ Type.Object({
129
+ /** Whitelisted semantic transition (I-003). State-machine validated; approval cannot be forged here. */
130
+ transition: StringEnum(
131
+ [
132
+ "plan-written",
133
+ "review-consolidated",
134
+ "implementation-review-configured",
135
+ "implementation-round-finished",
136
+ "completed",
137
+ ] as const,
138
+ ),
139
+ /** plan-written: absolute or workdir-relative PLAN_vN.md path. */
140
+ planPath: Type.Optional(Type.String()),
141
+ /** review-consolidated: round id + optional disposition artifact (run-dir relative). */
142
+ roundId: Type.Optional(Type.String()),
143
+ dispositionArtifact: Type.Optional(Type.String()),
144
+ /** implementation-review-configured: the serialized termination condition chosen by the user. */
145
+ terminationCondition: Type.Optional(Type.String()),
146
+ /** completed: non-empty evidence that the termination condition is satisfied. */
147
+ evidence: Type.Optional(Type.String()),
148
+ }),
149
+ ),
111
150
  });
112
151
 
113
152
  // Module-level reference so the start-run action can append a session entry
@@ -168,6 +207,59 @@ export async function finalCommit(
168
207
  }
169
208
  }
170
209
 
210
+ /** Whitelisted, state-machine-validated checkpoint transitions (I-003). */
211
+ export function recordCheckpointTransition(
212
+ ctx: { sessionManager: unknown },
213
+ workdir: string,
214
+ runIdArg: string | undefined,
215
+ checkpoint: {
216
+ transition: "plan-written" | "review-consolidated" | "implementation-review-configured" | "implementation-round-finished" | "completed";
217
+ planPath?: string;
218
+ roundId?: string;
219
+ dispositionArtifact?: string;
220
+ terminationCondition?: string;
221
+ evidence?: string;
222
+ },
223
+ ): WorkflowCheckpoint {
224
+ const runId =
225
+ runIdArg ??
226
+ boundRunId(ctx.sessionManager, workdir) ??
227
+ resolveActiveRun(ctx.sessionManager, workdir)?.run_id ??
228
+ null;
229
+ if (!runId) throw new StateError("record-checkpoint requires runId (or an active/bound run)");
230
+ switch (checkpoint.transition) {
231
+ case "plan-written": {
232
+ if (!checkpoint.planPath) throw new StateError("plan-written requires planPath");
233
+ const planPath = path.isAbsolute(checkpoint.planPath)
234
+ ? checkpoint.planPath
235
+ : path.resolve(workdir, checkpoint.planPath.replace(/^@/, ""));
236
+ const identity = planIdentityOf(planPath, 1);
237
+ return mutateCheckpoint(workdir, runId, (cp) => applyPlanWritten(cp, identity));
238
+ }
239
+ case "review-consolidated": {
240
+ if (!checkpoint.roundId) throw new StateError("review-consolidated requires roundId");
241
+ return mutateCheckpoint(workdir, runId, (cp) =>
242
+ applyReviewConsolidated(cp, checkpoint.roundId!, checkpoint.dispositionArtifact),
243
+ );
244
+ }
245
+ case "implementation-review-configured": {
246
+ if (!checkpoint.terminationCondition) {
247
+ throw new StateError("implementation-review-configured requires terminationCondition");
248
+ }
249
+ return mutateCheckpoint(workdir, runId, (cp) =>
250
+ applyImplementationReviewConfigured(cp, checkpoint.terminationCondition!),
251
+ );
252
+ }
253
+ case "implementation-round-finished": {
254
+ return mutateCheckpoint(workdir, runId, (cp) => applyImplementationRoundFinished(cp));
255
+ }
256
+ case "completed": {
257
+ if (!checkpoint.evidence) throw new StateError("completed requires evidence");
258
+ return mutateCheckpoint(workdir, runId, (cp) => applyCompleted(cp, checkpoint.evidence!));
259
+ }
260
+ }
261
+ }
262
+
171
263
  export function registerPlansTool(pi: ExtensionAPI): void {
172
264
  setRunStartAppender((runId, artifactDir) => {
173
265
  pi.appendEntry("pi-plans-run-start", { runId, artifactDir });
@@ -264,6 +356,28 @@ export function registerPlansTool(pi: ExtensionAPI): void {
264
356
  runStartAppender?.(run.run_id, run.artifact_dir);
265
357
  },
266
358
  });
359
+ // I-002: attribute this session's work to the run it started.
360
+ bindRun(ctx.sessionManager, workdir, result.run.run_id);
361
+ // I-003: durable cross-session state starts with the run.
362
+ createCheckpoint(workdir, {
363
+ runId: result.run.run_id,
364
+ originWorkdir: workdir,
365
+ workdir,
366
+ });
367
+ // Pre-plan compaction: mark the session so the plans tool_result
368
+ // hook compacts once with the VCC planning path before the first
369
+ // planning question. Opportunistic: never blocks run creation.
370
+ try {
371
+ const prePlanStateRoot = resolveStateRootOrNull(workdir);
372
+ if (prePlanStateRoot && !getExecution()) {
373
+ scaffoldVccSettings(prePlanStateRoot);
374
+ if (loadVccSettings(prePlanStateRoot).prePlanCompact) {
375
+ markPrePlanCompactPending(ctx, result.run.run_id);
376
+ }
377
+ }
378
+ } catch {
379
+ // best-effort: pre-plan compaction is an optimization only
380
+ }
267
381
  break;
268
382
  }
269
383
  case "set-status": {
@@ -295,6 +409,11 @@ export function registerPlansTool(pi: ExtensionAPI): void {
295
409
  result = recordSubagent(workdir, params.runId, params.subagent);
296
410
  break;
297
411
  }
412
+ case "record-checkpoint": {
413
+ if (!params.checkpoint) throw new StateError("record-checkpoint requires checkpoint");
414
+ result = recordCheckpointTransition(ctx, workdir, params.runId, params.checkpoint);
415
+ break;
416
+ }
298
417
  }
299
418
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: {} };
300
419
  } catch (error) {
package/tools/refine.ts CHANGED
@@ -15,6 +15,14 @@ import { Type } from "typebox";
15
15
  import * as fs from "node:fs";
16
16
  import * as path from "node:path";
17
17
  import { loadConfig, normalizeWorkdir, readActive, recordSubagent, resolveStateRootOrNull, StateError, type RoleConfig } from "../src/state.ts";
18
+ import { resolveActiveRun } from "../src/run-context.ts";
19
+ import {
20
+ loadCheckpoint,
21
+ readReviewOutput,
22
+ recordLaneOutcome,
23
+ reusableLaneOutputs,
24
+ startReviewRound,
25
+ } from "../src/workflow-state.ts";
18
26
  import { buildCriticizerTask, buildImplementationCriticizerTask, buildImplementationReviewerTask, buildReviewerTask, reviewerLanes } from "../src/refine-prompts.ts";
19
27
  import { graphBlockForRefiner } from "../src/code-graph/prompts.ts";
20
28
  import { runPiSubagent, stripFrontmatter } from "../src/subagent.ts";
@@ -41,6 +49,12 @@ const RefineParams = Type.Object({
41
49
  context: Type.Optional(
42
50
  Type.String({ description: "Context for the subagents: user goals, repo evidence, constraints, open questions" }),
43
51
  ),
52
+ resumeRoundId: Type.Optional(
53
+ Type.String({
54
+ description:
55
+ 'Round id to resume (I-004). Lanes already complete for this round in the run checkpoint are reused from their persisted outputs; only pending/failed/missing lanes run. Never reuse a round id across plan versions.',
56
+ }),
57
+ ),
44
58
  workdir: Type.Optional(Type.String({ description: "Target workspace; default current working directory" })),
45
59
  });
46
60
 
@@ -119,7 +133,7 @@ export function registerRefineTool(pi: ExtensionAPI, baseDir: string): void {
119
133
  const planText = fs.readFileSync(planPath, "utf8");
120
134
 
121
135
  // Record spawns against the active run when one exists.
122
- const active = readActive(workdir);
136
+ const active = resolveActiveRun(ctx.sessionManager, workdir);
123
137
  const record = (name: string, model?: string | null) => {
124
138
  if (!active) return;
125
139
  try {
@@ -130,6 +144,44 @@ export function registerRefineTool(pi: ExtensionAPI, baseDir: string): void {
130
144
  };
131
145
 
132
146
  const target = params.target ?? "plan";
147
+ // I-004: durable round bookkeeping. Rounds start (or resume) in the
148
+ // checkpoint BEFORE any lane spawns; successful outputs are persisted
149
+ // BEFORE the tool result returns (C-007).
150
+ const checkpointLoad = active ? loadCheckpoint(workdir, active.run_id) : null;
151
+ const useCheckpoint = checkpointLoad?.status === "ok" ? checkpointLoad.checkpoint : null;
152
+ const roundId =
153
+ params.resumeRoundId ?? `${target}-${params.role}-r${Date.now().toString(36)}`;
154
+ const roundReviewerCount = params.role === "reviewer" ? Math.min(3, Math.max(1, params.reviewers ?? 1)) : 1;
155
+ // F-001 (implementation review): the spec MUST carry lanes —
156
+ // reviewerLanes(count) for reviewer rounds, one lane for criticizer.
157
+ const roundLanes =
158
+ params.role === "reviewer"
159
+ ? reviewerLanes(roundReviewerCount).map((lane) => ({ laneId: lane.id, lens: lane.lens ?? undefined }))
160
+ : [{ laneId: "criticizer" }];
161
+ const roundSpec = {
162
+ roundId,
163
+ role: params.role,
164
+ target,
165
+ reviewers: roundReviewerCount,
166
+ planPath,
167
+ focus: params.focus,
168
+ context: params.context,
169
+ lanes: roundLanes,
170
+ };
171
+ if (useCheckpoint) {
172
+ startReviewRound(workdir, active!.run_id, roundSpec);
173
+ }
174
+ const reusable = useCheckpoint
175
+ ? Object.fromEntries(reusableLaneOutputs(useCheckpoint, roundId).map((entry) => [entry.laneId, entry.resultFile]))
176
+ : {};
177
+ const persistOutcome = (laneId: string, result: { ok: boolean; output?: string; error?: string }): void => {
178
+ if (!active || !useCheckpoint) return;
179
+ try {
180
+ recordLaneOutcome(workdir, active.run_id, roundId, laneId, result);
181
+ } catch {
182
+ /* the subagents ledger still records the spawn; resume treats the lane as unfinished */
183
+ }
184
+ };
133
185
  const pickTask = (role: "reviewer" | "criticizer", lens: string | null): string => {
134
186
  if (role === "reviewer") {
135
187
  return target === "implementation"
@@ -163,6 +215,19 @@ export function registerRefineTool(pi: ExtensionAPI, baseDir: string): void {
163
215
  }
164
216
 
165
217
  if (params.role === "criticizer") {
218
+ const laneId = "criticizer";
219
+ const persisted = reusable[laneId];
220
+ if (persisted) {
221
+ return {
222
+ content: [
223
+ {
224
+ type: "text",
225
+ text: `${readReviewOutput(workdir, active!.run_id, persisted)}\n\n---\nReused the persisted criticizer result for round ${roundId} (no re-run). Ask each criticizer question with ask_choice (one call per question, in the configured language), record every answer, then revise the plan only after every question has an answer.`,
226
+ },
227
+ ],
228
+ details: { mode: "delegated-subagent", role: params.role, planPath, target, roundId, reused: true },
229
+ };
230
+ }
166
231
  const name = `${roleConfig.name_prefix}-criticizer-${Date.now().toString(36)}`;
167
232
  const execution = setupRefinementExecution(ctx, signal, "criticizer", [{ id: name, label: "criticizer" }], modelLabel);
168
233
  try {
@@ -177,6 +242,7 @@ export function registerRefineTool(pi: ExtensionAPI, baseDir: string): void {
177
242
  });
178
243
  execution.overlay?.complete(name, result);
179
244
  record(name, result.ok ? result.model ?? model : null);
245
+ persistOutcome(laneId, result.ok ? { ok: true, output: result.output } : { ok: false, error: result.errorMessage });
180
246
  if (!result.ok) {
181
247
  throw new Error(
182
248
  `criticizer subagent failed: ${result.errorMessage ?? "unknown error"}${result.stderr ? `\nstderr: ${result.stderr.slice(0, 2000)}` : ""}`,
@@ -186,10 +252,10 @@ export function registerRefineTool(pi: ExtensionAPI, baseDir: string): void {
186
252
  content: [
187
253
  {
188
254
  type: "text",
189
- text: `${result.output}\n\n---\nAsk each criticizer question with ask_choice (one call per question, in the configured language), record every answer, then revise the plan only after every question has an answer.`,
255
+ text: `${result.output}\n\n---\nAsk each criticizer question with ask_choice (one call per question, in the configured language, with a stable questionId per question), record every answer, then revise the plan only after every question has an answer. After the revision, record the boundary: plans record-checkpoint (checkpoint: { transition: "review-consolidated", roundId: "${roundId}" }).`,
190
256
  },
191
257
  ],
192
- details: { mode: "delegated-subagent", role: params.role, planPath, target, model: result.model ?? model },
258
+ details: { mode: "delegated-subagent", role: params.role, planPath, target, roundId, model: result.model ?? model },
193
259
  };
194
260
  } finally {
195
261
  await execution.close();
@@ -213,16 +279,19 @@ export function registerRefineTool(pi: ExtensionAPI, baseDir: string): void {
213
279
  return round;
214
280
  };
215
281
 
282
+ // Lane-level resume (F-007): completed lanes are reused from their
283
+ // persisted outputs; only pending/failed/missing lanes spawn.
284
+ const runnableJobs = jobs.filter((job) => reusable[job.lane.id] === undefined);
216
285
  const execution = setupRefinementExecution(
217
286
  ctx,
218
287
  signal,
219
288
  "reviewer",
220
- jobs.map((job) => ({ id: job.lane.id, label: job.lane.id })),
289
+ runnableJobs.map((job) => ({ id: job.lane.id, label: job.lane.id })),
221
290
  modelLabel,
222
291
  );
223
292
  try {
224
293
  const results = await Promise.all(
225
- jobs.map(async (job) => {
294
+ runnableJobs.map(async (job) => {
226
295
  try {
227
296
  const result = await runPiSubagent({
228
297
  systemPrompt: `${systemPrompt}\n\n${graphPrompt}`,
@@ -235,6 +304,9 @@ export function registerRefineTool(pi: ExtensionAPI, baseDir: string): void {
235
304
  });
236
305
  execution.overlay?.complete(job.lane.id, result);
237
306
  record(job.name, result.ok ? result.model ?? model : null);
307
+ // Persist BEFORE returning (C-007): a crash after this point
308
+ // still leaves the lane reusable.
309
+ persistOutcome(job.lane.id, result.ok ? { ok: true, output: result.output } : { ok: false, error: result.errorMessage });
238
310
  if (target === "implementation" && result.ok) {
239
311
  try {
240
312
  pi.appendEntry("pi-plans-ameliorate", {
@@ -251,6 +323,7 @@ export function registerRefineTool(pi: ExtensionAPI, baseDir: string): void {
251
323
  } catch (error) {
252
324
  record(job.name, null);
253
325
  const message = error instanceof Error ? error.message : String(error);
326
+ persistOutcome(job.lane.id, { ok: false, error: message });
254
327
  const result = {
255
328
  ok: false,
256
329
  output: "",
@@ -267,6 +340,15 @@ export function registerRefineTool(pi: ExtensionAPI, baseDir: string): void {
267
340
 
268
341
  const sections: string[] = [];
269
342
  let failures = 0;
343
+ let reusedCount = 0;
344
+ // Reused lanes first (stable order): outputs come from the persisted files.
345
+ for (const job of jobs) {
346
+ const persisted = reusable[job.lane.id];
347
+ if (persisted === undefined) continue;
348
+ reusedCount += 1;
349
+ const title = job.lane.lens ? `${job.name} — ${job.lane.lens}` : job.name;
350
+ sections.push(`### ${title} — REUSED (round ${roundId}, no re-run)\n${readReviewOutput(workdir, active!.run_id, persisted)}`);
351
+ }
270
352
  for (const { job, result } of results) {
271
353
  const title = job.lane.lens ? `${job.name} — ${job.lane.lens}` : job.name;
272
354
  if (!result.ok) {
@@ -276,7 +358,7 @@ export function registerRefineTool(pi: ExtensionAPI, baseDir: string): void {
276
358
  }
277
359
  sections.push(`### ${title}\n${result.output}`);
278
360
  }
279
- if (failures === results.length) {
361
+ if (failures === results.length && reusedCount === 0) {
280
362
  const first = results[0];
281
363
  throw new Error(
282
364
  `all reviewer subagents failed: ${first?.result.errorMessage ?? "unknown error"}${first?.result.stderr ? `\nstderr: ${first.result.stderr.slice(0, 2000)}` : ""}${model ? `\nIf the model selector "${model}" is unavailable, reset the confirmation (plans set-role --reset-confirmation) and re-ask the model-confirmation question.` : ""}`,
@@ -292,13 +374,15 @@ export function registerRefineTool(pi: ExtensionAPI, baseDir: string): void {
292
374
  content: [
293
375
  {
294
376
  type: "text",
295
- text: `${text}\n\n---\nConsolidate: merge and dedupe findings into PLAN_vN_reviewer_comments.md${count === 3 ? " (one consolidated file; keep each finding's source reviewer, severity, evidence, and disposition)" : ""}, accept or reject each finding on repo/reference evidence, surface at most five high-priority findings to the user, then immediately ask the next refinement-mode question with ask_choice.`,
377
+ text: `${text}\n\n---\nConsolidate: merge and dedupe findings into PLAN_vN_reviewer_comments.md${count === 3 ? " (one consolidated file; keep each finding's source reviewer, severity, evidence, and disposition)" : ""}, accept or reject each finding on repo/reference evidence, surface at most five high-priority findings to the user, then immediately ask the next refinement-mode question with ask_choice. Then record the boundary: plans record-checkpoint (checkpoint: { transition: "review-consolidated", roundId: "${roundId}", dispositionArtifact: "<comments file, run-dir relative>" }).${target === "implementation" ? ' When the whole round is disposed, also record (checkpoint: { transition: "implementation-round-finished" }); when the termination condition is met, close with (checkpoint: { transition: "completed", evidence: "<why the condition is satisfied>" }).' : ""}`,
296
378
  },
297
379
  ],
298
380
  details: {
299
381
  mode: "delegated-subagent",
300
382
  role: "reviewer",
301
383
  planPath,
384
+ roundId,
385
+ reusedLanes: Object.keys(reusable),
302
386
  reviewers: count,
303
387
  model,
304
388
  outputs: results.map(({ job, result }) => ({ name: job.name, lane: job.lane.id, lens: job.lane.lens, ok: result.ok, output: result.output, stderr: result.stderr, turns: result.turns })),