pi-plans 0.2.0 → 0.3.1

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 (75) hide show
  1. package/README.md +90 -26
  2. package/agents/ref-analyst.md +18 -0
  3. package/index.ts +121 -9
  4. package/package.json +16 -1
  5. package/references/pi-planning-workflow.md +21 -6
  6. package/references/state-and-config.md +52 -5
  7. package/scripts/validate.ts +5 -0
  8. package/skills/plan-with-refs/SKILL.md +3 -3
  9. package/src/code-graph/commands.ts +483 -0
  10. package/src/code-graph/discovery.ts +118 -0
  11. package/src/code-graph/git.ts +108 -0
  12. package/src/code-graph/identity.ts +59 -0
  13. package/src/code-graph/indexer.ts +281 -0
  14. package/src/code-graph/materialize.ts +166 -0
  15. package/src/code-graph/mode.ts +28 -0
  16. package/src/code-graph/mutations.ts +160 -0
  17. package/src/code-graph/parser.ts +51 -0
  18. package/src/code-graph/parsers/javascript.ts +35 -0
  19. package/src/code-graph/parsers/python.ts +160 -0
  20. package/src/code-graph/parsers/tree-sitter.ts +316 -0
  21. package/src/code-graph/paths.ts +85 -0
  22. package/src/code-graph/prompts.ts +18 -0
  23. package/src/code-graph/resolver.ts +69 -0
  24. package/src/code-graph/runtime.ts +158 -0
  25. package/src/code-graph/schema.ts +135 -0
  26. package/src/code-graph/screening.ts +82 -0
  27. package/src/code-graph/store.ts +278 -0
  28. package/src/code-graph/summary.ts +435 -0
  29. package/src/code-graph/types.ts +163 -0
  30. package/src/compaction.ts +1125 -371
  31. package/src/config-command.ts +361 -0
  32. package/src/exec.ts +508 -693
  33. package/src/guard.ts +14 -1
  34. package/src/refine-prompts.ts +109 -0
  35. package/src/refine-ui-helpers.ts +71 -18
  36. package/src/refine-ui-state.ts +88 -22
  37. package/src/refine-ui.ts +210 -102
  38. package/src/state.ts +36 -7
  39. package/src/subagent.ts +164 -61
  40. package/src/termination-prompt.ts +22 -0
  41. package/tests/analyze-refs.test.ts +265 -0
  42. package/tests/ask-choice.test.ts +264 -0
  43. package/tests/autocomplete.test.ts +6 -1
  44. package/tests/code-graph-apply-action.test.ts +173 -0
  45. package/tests/code-graph-apply.test.ts +185 -0
  46. package/tests/code-graph-commands.test.ts +211 -0
  47. package/tests/code-graph-db.test.ts +166 -0
  48. package/tests/code-graph-discovery.test.ts +38 -0
  49. package/tests/code-graph-git.test.ts +94 -0
  50. package/tests/code-graph-index.test.ts +175 -0
  51. package/tests/code-graph-loop.e2e.test.ts +159 -0
  52. package/tests/code-graph-mutations.test.ts +117 -0
  53. package/tests/code-graph-parser.test.ts +85 -0
  54. package/tests/code-graph-rollback.test.ts +100 -0
  55. package/tests/code-graph-summary-batching.test.ts +518 -0
  56. package/tests/code-graph-summary.test.ts +148 -0
  57. package/tests/compaction.test.ts +371 -57
  58. package/tests/config-command.test.ts +263 -0
  59. package/tests/exec.test.ts +808 -241
  60. package/tests/fixtures/code-graph/sample.js +36 -0
  61. package/tests/fixtures/code-graph/sample.py +20 -0
  62. package/tests/fixtures/code-graph/sample.ts +15 -0
  63. package/tests/graph-aware-file-tools.test.ts +411 -0
  64. package/tests/guard.test.ts +27 -1
  65. package/tests/plans.test.ts +10 -0
  66. package/tests/refine-prompts.test.ts +101 -2
  67. package/tests/refine-ui.test.ts +371 -72
  68. package/tests/state.test.ts +32 -0
  69. package/tests/subagent.test.ts +48 -20
  70. package/tools/analyze-refs.ts +263 -0
  71. package/tools/ask-choice.ts +159 -11
  72. package/tools/code-graph.ts +277 -0
  73. package/tools/graph-aware-file-tools.ts +392 -0
  74. package/tools/plans.ts +97 -2
  75. package/tools/refine.ts +61 -15
package/src/exec.ts CHANGED
@@ -7,7 +7,6 @@
7
7
  * progress is reported through the bottom status bar until every item passes.
8
8
  */
9
9
 
10
- import { randomUUID } from "node:crypto";
11
10
  import * as fs from "node:fs";
12
11
  import type {
13
12
  CompactionResult,
@@ -18,20 +17,26 @@ import type {
18
17
  SessionCompactEvent,
19
18
  SessionCompactFailedEvent,
20
19
  } from "@earendil-works/pi-coding-agent";
21
- import { AUTOCOMPLETE_ENTRY } from "./autocomplete.ts";
20
+ import { VERSION } from "@earendil-works/pi-coding-agent";
22
21
  import {
23
- compactText as boundedCompactionText,
22
+ buildPiPlansVccCompaction,
24
23
  compactionCurrentI,
25
- currentIExceedsTrigger,
26
24
  entryCurrentIMarkers,
27
- extractReadRecords,
28
- formatReadRecord,
29
- mergeCompactionDetails,
30
- planIAwareCompaction,
31
- type CompactionDetailsLike,
25
+ formatVccCompactionStats,
26
+ loadVccSettings,
27
+ scaffoldVccSettings,
28
+ shouldScheduleAutoContinue,
32
29
  type CompactionEntryLike,
30
+ type PiPlansCompactionPhase,
31
+ type PiPlansVccPhaseContext,
32
+ type PiPlansVccSettings,
33
+ type VccCompactionBuildResult,
34
+ type VccCompactionStats,
33
35
  } from "./compaction.ts";
34
- import { getRun, readActive, setRunStatus, utcNow } from "./state.ts";
36
+ import { getRun, readActive, resolveStateRootOrNull, setRunStatus, utcNow } from "./state.ts";
37
+ import { graphBlockForExecutor } from "./code-graph/prompts.ts";
38
+ import { resolveGraphMode } from "./code-graph/mode.ts";
39
+ import { TERMINATION_QUESTION, TERMINATION_OPTIONS, renderTerminationOptions } from "./termination-prompt.ts";
35
40
  import {
36
41
  extractCoverage,
37
42
  latestPlanVersion,
@@ -54,8 +59,21 @@ export interface ExecState {
54
59
  implItems?: ImplItem[];
55
60
  implStatus?: Record<string, ImplMarkerState>;
56
61
  currentI?: string;
62
+ goalWait?: GoalWaitState;
57
63
  }
58
64
 
65
+ export interface GoalWaitState {
66
+ noProgressRounds: number;
67
+ waitRounds: number;
68
+ /** Marker/progress snapshot of the last goal-wait round; null = baseline not set. */
69
+ lastMarkers: string | null;
70
+ paused: boolean;
71
+ pausedReason?: string;
72
+ }
73
+
74
+ const GOAL_WAIT_MAX_NO_PROGRESS = 3;
75
+ const GOAL_WAIT_MAX_WAITING = 6;
76
+
59
77
  let execution: ExecState | null = null;
60
78
 
61
79
  // Execution-loop persistence is deferred until the agent settles so turn_end
@@ -86,9 +104,6 @@ export function getExecution(): ExecState | null {
86
104
  return execution;
87
105
  }
88
106
 
89
- const EXECUTION_COMPACTION_TRIGGER_PERCENT = 20;
90
- const EXECUTION_COMPACTION_REARM_PERCENT = 80;
91
- const EXECUTION_COMPACTION_REARM_HIGH_PERCENT = 95;
92
107
  const EXECUTION_COMPACTION_RESUME_MESSAGE = "Continue execution.";
93
108
 
94
109
  interface ExecutionCompactionState {
@@ -99,6 +114,11 @@ interface ExecutionCompactionState {
99
114
  lastSuccessfulUsagePercent: number | null;
100
115
  lastSuccessfulAt: string | null;
101
116
  rearmPending: boolean;
117
+ /** Terminal failure metadata is retained for diagnostics, not proactive retry. */
118
+ terminalBackoffTokens: number | null;
119
+ pendingStats: VccCompactionStats | null;
120
+ pendingFollowUpPrompt: string | null;
121
+ pendingContinueAfterThresholdCompact: boolean;
102
122
  }
103
123
 
104
124
  type ExecutionCompactionSession = { __executionCompaction?: ExecutionCompactionState };
@@ -126,6 +146,10 @@ function ensureExecutionCompactionState(ctx: ExtensionContext): ExecutionCompact
126
146
  lastSuccessfulUsagePercent: null,
127
147
  lastSuccessfulAt: null,
128
148
  rearmPending: false,
149
+ terminalBackoffTokens: null,
150
+ pendingStats: null,
151
+ pendingFollowUpPrompt: null,
152
+ pendingContinueAfterThresholdCompact: false,
129
153
  });
130
154
  }
131
155
 
@@ -142,79 +166,12 @@ function consumeExecutionCompactionResumeGuard(ctx: ExtensionContext): boolean {
142
166
  return true;
143
167
  }
144
168
 
145
- function refreshExecutionCompactionCooldown(ctx: ExtensionContext): void {
146
- const state = executionCompactionState(ctx);
147
- if (!state) return;
148
- const percent = ctx.getContextUsage()?.percent ?? null;
149
- if (percent !== null && percent < EXECUTION_COMPACTION_REARM_PERCENT && state.cooldownActive) {
150
- state.cooldownActive = false;
151
- state.rearmPending = true;
152
- }
153
- }
154
-
155
- function executionCurrentIUsage(ctx: ExtensionContext): { tokens: number; contextWindow: number; eligible?: boolean } | null {
156
- const usage = ctx.getContextUsage();
157
- if (!usage || typeof usage.contextWindow !== "number" || usage.contextWindow <= 0) return null;
158
- const manager = ctx.sessionManager as unknown as { getBranch?: () => CompactionEntryLike[] };
159
- if (execution?.implItems?.length && typeof manager.getBranch === "function") {
160
- try {
161
- const entries = manager.getBranch();
162
- if (entries.length) {
163
- const plan = planIAwareCompaction({
164
- entries,
165
- currentI: execution.currentI,
166
- knownIIds: execution.implItems.map((item) => item.id),
167
- contextWindow: usage.contextWindow,
168
- tokensBefore: usage.tokens ?? undefined,
169
- });
170
- if (plan.currentI || execution.currentI) {
171
- return {
172
- tokens: plan.currentITokens,
173
- contextWindow: usage.contextWindow,
174
- eligible: plan.firstKeptEntryIndex !== null && plan.firstKeptEntryIndex > 0,
175
- };
176
- }
177
- }
178
- } catch {
179
- // A read-only session projection is optional in test and startup contexts.
180
- }
181
- }
182
- if (typeof usage.tokens !== "number") return null;
183
- return { tokens: usage.tokens, contextWindow: usage.contextWindow };
184
- }
185
- export function shouldTriggerExecutionCompaction(ctx: ExtensionContext): boolean {
186
- const currentUsage = executionCurrentIUsage(ctx);
187
- if (!currentUsage || currentUsage.eligible === false || !currentIExceedsTrigger(currentUsage.tokens, currentUsage.contextWindow)) return false;
188
- const percent = (currentUsage.tokens / currentUsage.contextWindow) * 100;
189
- const state = executionCompactionState(ctx);
190
- if (!state) return true;
191
- if (state.inFlight || state.resumeGuard || state.cooldownActive) return false;
192
- if (state.rearmPending) {
193
- if (percent < EXECUTION_COMPACTION_REARM_HIGH_PERCENT) return false;
194
- state.rearmPending = false;
195
- }
196
- return true;
197
- }
198
-
199
- function requestExecutionCompaction(ctx: ExtensionContext): void {
200
- const state = ensureExecutionCompactionState(ctx);
201
- if (state.inFlight || state.resumeGuard) return;
202
- state.inFlight = true;
203
- state.lastAttemptReason = "threshold";
204
- try {
205
- ctx.compact({ customInstructions: "pi-plans execution auto compact" });
206
- } catch (error) {
207
- state.inFlight = false;
208
- ctx.ui.notify(`pi-plans: could not request execution compaction (${String(error)}).`, "warning");
209
- }
169
+ export function shouldTriggerExecutionCompaction(_ctx: ExtensionContext): boolean {
170
+ return false;
210
171
  }
211
172
 
212
173
  export function handleExecutionTurnCompaction(ctx: ExtensionContext): void {
213
- refreshExecutionCompactionCooldown(ctx);
214
- if (consumeExecutionCompactionResumeGuard(ctx)) return;
215
- if (shouldTriggerExecutionCompaction(ctx)) {
216
- requestExecutionCompaction(ctx);
217
- }
174
+ consumeExecutionCompactionResumeGuard(ctx);
218
175
  }
219
176
 
220
177
  export function computeExecutionProgress(execution: ExecState): { done: number; total: number } {
@@ -240,11 +197,6 @@ export function computeExecutionProgress(execution: ExecState): { done: number;
240
197
  };
241
198
  }
242
199
 
243
- export function executionProgress(): { done: number; total: number } | null {
244
- if (!execution) return null;
245
- return computeExecutionProgress(execution);
246
- }
247
-
248
200
  function formatElapsed(startedAt: string): string {
249
201
  const total = Math.max(0, Math.floor((Date.now() - Date.parse(startedAt)) / 1000));
250
202
  const h = String(Math.floor(total / 3600)).padStart(2, "0");
@@ -260,7 +212,14 @@ function formatToks(tokens: number): string {
260
212
 
261
213
  export function formatExecutionStatusLine(execution: ExecState): string {
262
214
  const progress = computeExecutionProgress(execution);
263
- return `⌛ plans ${progress.done}/${progress.total}: spent ${formatElapsed(execution.startedAt)} · ${formatToks(execution.usage.inToks)} in-toks · ${formatToks(execution.usage.outToks)} out-toks`;
215
+ let line = `⌛ plans ${progress.done}/${progress.total}: spent ${formatElapsed(execution.startedAt)} · ${formatToks(execution.usage.inToks)} in-toks · ${formatToks(execution.usage.outToks)} out-toks`;
216
+ const goalWait = execution.goalWait;
217
+ if (goalWait?.paused) {
218
+ line += ` · ⏸ goal-wait paused (${goalWait.pausedReason ?? "paused"})`;
219
+ } else if (goalWait && (goalWait.noProgressRounds > 0 || goalWait.waitRounds > 0)) {
220
+ line += ` · 🔁 goal-wait · 无进展 ${goalWait.noProgressRounds}/3 · 等待 ${goalWait.waitRounds}/6`;
221
+ }
222
+ return line;
264
223
  }
265
224
 
266
225
  export function updateStatusWidget(ctx: ExtensionContext): void {
@@ -312,6 +271,7 @@ function persist(pi: ExtensionAPI): void {
312
271
  implItems: execution.implItems,
313
272
  implStatus: execution.implStatus,
314
273
  currentI: execution.currentI,
274
+ goalWait: execution.goalWait,
315
275
  });
316
276
  }
317
277
 
@@ -322,7 +282,10 @@ export async function startExecution(
322
282
  items: CheckItem[],
323
283
  implItems?: ImplItem[],
324
284
  ): Promise<void> {
325
- execution = { planPath, items, startedAt: utcNow(), usage: { inToks: 0, outToks: 0 }, implItems: implItems ?? [], implStatus: {} };
285
+ execution = { planPath, items, startedAt: utcNow(), usage: { inToks: 0, outToks: 0 }, implItems: implItems ?? [], implStatus: {}, goalWait: { noProgressRounds: 0, waitRounds: 0, lastMarkers: null, paused: false } };
286
+ // Seed the marker baseline so the first quiet round is counted against a
287
+ // real snapshot instead of counting unconditionally (F-006).
288
+ if (execution.goalWait) execution.goalWait.lastMarkers = goalWaitSnapshot();
326
289
  pendingExecutionFlush = false; // fresh run: no inherited flush debt
327
290
  resetExecutionCompactionState(ctx);
328
291
  persist(pi);
@@ -398,6 +361,8 @@ export function registerExecutionTurnHandlers(
398
361
  }
399
362
  if (getExecution() && isExecutionComplete()) {
400
363
  await completeExecution(pi, ctx);
364
+ } else if (getExecution()) {
365
+ maybeGoalWaitFollowUp(pi, ctx, text);
401
366
  }
402
367
  await onTurnEnd?.(ctx);
403
368
  });
@@ -405,368 +370,171 @@ export function registerExecutionTurnHandlers(
405
370
 
406
371
  const EXECUTION_RESUME_CUSTOM_TYPE = "pi-plans-exec-resume";
407
372
 
408
- type CompactBranchEntry = SessionBeforeCompactEvent["branchEntries"][number];
409
- type CompactMessage = { role?: string; content?: Array<{ type: string; text?: string }> };
410
-
411
- function compactText(text: string, limit = 180): string {
412
- const normalized = text.replace(/\s+/g, " ").trim();
413
- if (normalized.length <= limit) return normalized;
414
- return `${normalized.slice(0, Math.max(0, limit - 1))}…`;
415
- }
416
-
417
- function messageText(message: CompactMessage | undefined): string {
418
- if (!message) return "";
419
- return (message.content ?? [])
420
- .filter((part) => part.type === "text")
421
- .map((part) => part.text ?? "")
422
- .join("\n")
423
- .trim();
424
- }
425
-
426
- function messageRoleLabel(role?: string): string {
427
- switch (role) {
428
- case "assistant": return "Assistant";
429
- case "user": return "User";
430
- case "toolResult": return "Tool";
431
- case "custom": return "Custom";
432
- default: return role ? role : "Message";
433
- }
434
- }
435
-
436
- function isInternalExecutionCustomType(customType?: string): boolean {
437
- return customType === "pi-plans-exec"
438
- || customType === "pi-plans-exec-cleared"
439
- || customType === "pi-plans-exec-start"
440
- || customType === "pi-plans-exec-context"
441
- || customType === EXECUTION_RESUME_CUSTOM_TYPE;
442
- }
443
-
444
- function isSummarizableEntry(entry: CompactBranchEntry): boolean {
445
- return entry.type === "message" && !!entry.message;
446
- }
447
-
448
- function renderMessageLine(entry: CompactBranchEntry): string {
449
- if (!isSummarizableEntry(entry)) return "";
450
- const text = messageText(entry.message);
451
- if (!text) return "";
452
- return `- [${messageRoleLabel(entry.message?.role)}] ${compactText(text)}`;
373
+ function activeVccSettings(ctx: ExtensionContext, phase: PiPlansCompactionPhase): { settings: PiPlansVccSettings; runId: string; artifactDir: string } | null {
374
+ const stateRoot = resolveStateRootOrNull(ctx.cwd);
375
+ if (!stateRoot) return null;
376
+ const active = readActive(ctx.cwd);
377
+ if (!active) return null;
378
+ const run = getRun(ctx.cwd, active.run_id);
379
+ if (!run) return null;
380
+ if (phase === "planning" && run.status !== "planning") return null;
381
+ if (phase === "execution" && run.status !== "executing") return null;
382
+ scaffoldVccSettings(stateRoot);
383
+ return { settings: loadVccSettings(stateRoot), runId: run.run_id, artifactDir: run.artifact_dir };
453
384
  }
454
385
 
455
- function findExecutionCompactionCutEntryId(branchEntries: CompactBranchEntry[], fallback: string): string {
456
- const completionIds = new Set(execution?.items.filter((item) => item.done).map((item) => item.id) ?? []);
457
- let lastCompletionIndex = -1;
458
- for (let i = 0; i < branchEntries.length; i++) {
459
- const entry = branchEntries[i];
460
- if (!isSummarizableEntry(entry)) continue;
461
- const markers = scanDoneMarkers(messageText(entry.message));
462
- if (markers.some((marker) => completionIds.has(marker))) {
463
- lastCompletionIndex = i;
464
- }
465
- }
466
- if (lastCompletionIndex >= 0) {
467
- const next = branchEntries.slice(lastCompletionIndex + 1).find((entry) => entry.id && !isInternalExecutionCustomType(entry.customType));
468
- if (next?.id) return next.id;
469
- }
470
- const startIndex = branchEntries.findIndex((entry) => entry.type === "custom" && entry.customType === "pi-plans-exec-start");
471
- if (startIndex >= 0) {
472
- const next = branchEntries.slice(startIndex + 1).find((entry) => entry.id && !isInternalExecutionCustomType(entry.customType));
473
- if (next?.id) return next.id;
474
- }
475
- return fallback;
386
+ function executionVccContext(): PiPlansVccPhaseContext {
387
+ return {
388
+ phase: "execution",
389
+ planPath: execution?.planPath ?? null,
390
+ currentI: execution?.currentI ?? null,
391
+ remainingVerifierIds: execution?.items.filter((item) => !item.done).map((item) => item.id) ?? [],
392
+ implementationIds: execution?.implItems?.map((item) => item.id) ?? [],
393
+ };
476
394
  }
477
395
 
478
- function buildFinishedItemSections(summaryEntries: CompactBranchEntry[]): string[] {
479
- if (!execution) return [];
480
- const completedItems = execution.items.filter((item) => item.done);
481
- const sections: string[] = [];
482
- let completedIndex = 0;
483
- let currentLines: string[] = [];
484
- for (const entry of summaryEntries) {
485
- if (!isSummarizableEntry(entry)) continue;
486
- const line = renderMessageLine(entry);
487
- if (line) currentLines.push(line);
488
- for (const marker of scanDoneMarkers(messageText(entry.message))) {
489
- const itemIndex = completedItems.findIndex((item, index) => index >= completedIndex && item.id === marker);
490
- if (itemIndex < 0) continue;
491
- const item = completedItems[itemIndex];
492
- sections.push(`### \`${item.id}\` ${item.text.split(";")[0]}
493
- ${currentLines.length ? currentLines.join("\n") : "- (no transcript captured)"}`);
494
- currentLines = [];
495
- completedIndex = itemIndex + 1;
396
+ function planningVccContext(branchEntries: CompactionEntryLike[], fallback: { runId?: string; artifactDir?: string }): PiPlansVccPhaseContext {
397
+ let runId: string | null = fallback.runId ?? null;
398
+ let artifactDir: string | null = fallback.artifactDir ?? null;
399
+ let planPath: string | null = null;
400
+ let currentI: string | null = null;
401
+ for (const entry of branchEntries) {
402
+ if (entry.type === "custom" && entry.customType === PLANNING_RUN_START_CUSTOM_TYPE) {
403
+ runId = typeof entry.data?.runId === "string" ? entry.data.runId : runId;
404
+ artifactDir = typeof entry.data?.artifactDir === "string" ? entry.data.artifactDir : artifactDir;
496
405
  }
497
- }
498
- return sections;
499
- }
500
-
501
- function splitTurnBoundaryIndex(event: SessionBeforeCompactEvent): number | undefined {
502
- if (!event.preparation.isSplitTurn) return undefined;
503
- const index = event.branchEntries.findIndex((entry) => entry.id === event.preparation.firstKeptEntryId);
504
- return index >= 0 ? index : undefined;
505
- }
506
-
507
- function buildExecutionIPlan(event: SessionBeforeCompactEvent, ctx?: ExtensionContext): ReturnType<typeof planIAwareCompaction> {
508
- if (!execution) throw new Error("execution state is unavailable");
509
- const usage = eventPreparationUsage(event, ctx);
510
- return planIAwareCompaction({
511
- entries: event.branchEntries as unknown as CompactionEntryLike[],
512
- currentI: execution.currentI,
513
- knownIIds: execution.implItems?.map((item) => item.id),
514
- contextWindow: usage.contextWindow,
515
- tokensBefore: event.preparation.tokensBefore,
516
- fallbackFirstKeptEntryId: event.preparation.firstKeptEntryId,
517
- maxFirstKeptEntryIndex: splitTurnBoundaryIndex(event),
518
- });
519
- }
520
-
521
- function eventPreparationUsage(event: SessionBeforeCompactEvent, ctx?: ExtensionContext): { contextWindow: number | null } {
522
- const fromContext = ctx?.getContextUsage()?.contextWindow;
523
- const contextWindow = (event as SessionBeforeCompactEvent & { contextWindow?: number }).contextWindow ?? fromContext;
524
- return { contextWindow: typeof contextWindow === "number" && contextWindow > 0 ? contextWindow : null };
525
- }
526
-
527
- function previousCompactionDetails(entries: CompactionEntryLike[]): CompactionDetailsLike | undefined {
528
- let merged: CompactionDetailsLike | undefined;
529
- for (const entry of entries) {
530
- if (entry.type !== "compaction") continue;
531
- const raw = entry.details ?? entry.data;
532
- if (!raw || typeof raw !== "object") continue;
533
- const details = raw as CompactionDetailsLike;
534
- if (!details.readRecords && !details.metrics && !details.iSections) continue;
535
- merged = mergeCompactionDetails(merged, details);
536
- }
537
- return merged;
538
- }
539
-
540
- function buildImplementationSections(plan: ReturnType<typeof planIAwareCompaction>): string[] {
541
- const sections: string[] = [];
542
- for (const slice of plan.slices) {
543
- if (slice.id === null) continue;
544
- const lines = slice.entries
545
- .filter((entry) => plan.summaryEntries.includes(entry))
546
- .map((entry) => renderMessageLine(entry as CompactBranchEntry))
547
- .filter(Boolean);
548
- if (lines.length) {
549
- sections.push(`### ${slice.current ? "Current I" : "Implementation I"} \`${slice.id}\`\n${lines.join("\\n")}`);
406
+ if (entry.type === "custom" && entry.customType === PLANNING_PLAN_WRITTEN_CUSTOM_TYPE) {
407
+ planPath = typeof entry.data?.planPath === "string" ? entry.data.planPath : planPath;
550
408
  }
409
+ for (const id of entryCurrentIMarkers(entry)) currentI = id;
410
+ currentI = compactionCurrentI(entry) ?? currentI;
551
411
  }
552
- return sections;
412
+ return { phase: "planning", runId, artifactDir, planPath, currentI };
553
413
  }
554
414
 
555
- function executionSummaryParts(
556
- event: SessionBeforeCompactEvent,
557
- ctx: ExtensionContext,
558
- firstKeptEntryId: string,
559
- boundaryIndex: number,
560
- plan: ReturnType<typeof planIAwareCompaction> | null,
561
- ): { parts: string[]; details: CompactionDetailsLike } {
562
- if (!execution) return { parts: [], details: {} };
563
- const active = readActive(ctx.cwd);
564
- const run = active ? getRun(ctx.cwd, active.run_id) : null;
565
- const branchEntries = event.branchEntries as unknown as CompactionEntryLike[];
566
- const summaryEntries = boundaryIndex >= 0
567
- ? (branchEntries.slice(0, boundaryIndex) as CompactBranchEntry[])
568
- : (branchEntries as CompactBranchEntry[]);
569
- const sections = buildFinishedItemSections(summaryEntries);
570
- const readRecords = plan?.readRecords ?? extractReadRecords(summaryEntries as unknown as CompactionEntryLike[]);
571
- const priorDetails = previousCompactionDetails(branchEntries);
572
- const metrics = plan?.metrics ?? {
573
- contextWindow: ctx.getContextUsage()?.contextWindow ?? null,
574
- tokensBefore: event.preparation.tokensBefore,
575
- currentITokens: 0,
576
- summaryTokens: 0,
577
- keptSuffixTokens: 0,
578
- estimatedAfterTokens: null,
579
- targetRatio: 0.1,
580
- currentI: execution.currentI ?? null,
581
- firstKeptEntryId,
582
- targetMet: false,
583
- hardFloorReason: "I-aware budget unavailable for this legacy execution snapshot",
584
- };
585
- const details = mergeCompactionDetails(priorDetails, {
586
- kind: "pi-plans-execution-compaction",
587
- version: 1,
588
- currentI: execution.currentI ?? plan?.currentI ?? null,
589
- iSections: plan?.slices.map((slice) => ({ id: slice.id, entryIds: slice.entries.map((entry) => entry.id).filter((id): id is string => !!id) })) ?? [],
590
- readRecords,
591
- metrics: { ...metrics, firstKeptEntryId },
415
+ function buildExecutionVccResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): VccCompactionBuildResult | null {
416
+ if (!execution) return null;
417
+ const active = activeVccSettings(ctx, "execution");
418
+ if (!active) return null;
419
+ return buildPiPlansVccCompaction({
420
+ branchEntries: event.branchEntries as unknown as CompactionEntryLike[],
421
+ preparation: event.preparation,
422
+ customInstructions: event.customInstructions,
592
423
  reason: event.reason,
593
424
  willRetry: event.willRetry,
594
- finishedItems: execution.items.filter((item) => item.done).map((item) => item.id),
425
+ settings: active.settings,
426
+ phaseContext: executionVccContext(),
595
427
  });
596
- const parts: string[] = [];
597
- if (event.customInstructions?.trim()) {
598
- parts.push(`## Compact Instructions\n${compactText(event.customInstructions, 1000)}`);
599
- }
600
- parts.push(`## Plan Before This Run\n- Request: ${compactText(run?.request_text ?? execution.planPath, 280)}\n- Plan file: \`${execution.planPath}\``);
601
- if (event.preparation.previousSummary?.trim()) {
602
- parts.push(`## Previous Compact Summary\n${event.preparation.previousSummary.trim()}`);
603
- }
604
- parts.push(`## Implementation Items\n${plan?.slices.length ? (buildImplementationSections(plan).join("\\n\\n") || "- (no I transcript captured)") : "- Legacy snapshot: current I is inferred from the execution frontier."}`);
605
- parts.push(`## Finished VC Items\n${sections.length ? sections.join("\\n\\n") : "- (none yet)"}`);
606
- parts.push(`## Current I\n- \`${execution.currentI ?? plan?.currentI ?? "unknown"}\`\n- Recent legal suffix begins at \`${firstKeptEntryId}\`.`);
607
- parts.push(`## Read Records\n${readRecords.length ? readRecords.map((record) => formatReadRecord(record)).join("\\n") : "- (none)"}`);
608
- parts.push(`## Compaction Boundary\n- firstKeptEntryId: \`${firstKeptEntryId}\`\n- currentI: \`${execution.currentI ?? plan?.currentI ?? "unknown"}\`\n- tokensBefore: ${metrics.tokensBefore}\n- estimatedAfterTokens: ${metrics.estimatedAfterTokens ?? "unknown"}\n- targetRatio: ${metrics.targetRatio}\n- targetMet: ${metrics.targetMet}\n- hardFloorReason: ${metrics.hardFloorReason ?? "none"}`);
609
- parts.push(`## Current Work\n- Raw tail preserved from \`${firstKeptEntryId}\` onward.${event.preparation.isSplitTurn ? "\\n- Split-turn prefix remains in the kept tail." : ""}`);
610
- return { parts, details };
611
428
  }
612
429
 
613
430
  export function buildExecutionCompactionResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): CompactionResult | null {
614
- if (!execution) return null;
615
- const branchEntries = event.branchEntries as unknown as CompactionEntryLike[];
616
- const hasIState = (execution.implItems?.length ?? 0) > 0;
617
- const plan = hasIState ? buildExecutionIPlan(event, ctx) : null;
618
- const firstKeptEntryId = plan?.firstKeptEntryId
619
- ?? findExecutionCompactionCutEntryId(event.branchEntries as CompactBranchEntry[], event.preparation.firstKeptEntryId);
620
- const boundaryIndex = event.branchEntries.findIndex((entry) => entry.id === firstKeptEntryId);
621
- const { parts, details } = executionSummaryParts(event, ctx, firstKeptEntryId, boundaryIndex, plan);
622
- return {
623
- summary: parts.join("\\n\\n"),
624
- firstKeptEntryId,
625
- tokensBefore: event.preparation.tokensBefore,
626
- estimatedTokensAfter: plan?.metrics.estimatedAfterTokens ?? undefined,
627
- details,
628
- };
629
- }
630
-
631
- function assistantResponseText(response: { content?: Array<{ type?: string; text?: string }> }): string {
632
- return (response.content ?? [])
633
- .filter((part) => part.type === "text")
634
- .map((part) => part.text ?? "")
635
- .join("\\n")
636
- .trim();
637
- }
638
-
639
- function isUsableCompactionResponse(response: { content?: Array<{ type?: string; text?: string }>; stopReason?: string }, summary: string): boolean {
640
- if (!summary) return false;
641
- if (["length", "toolUse", "error", "aborted", "deferred"].includes(response.stopReason ?? "")) return false;
642
- return ["## Implementation Items", "## Current I", "## Read Records", "## Compaction Boundary"]
643
- .every((section) => summary.includes(section));
644
- }
645
-
646
- async function buildModelExecutionCompactionResult(
647
- event: SessionBeforeCompactEvent,
648
- ctx: ExtensionContext,
649
- fallback: CompactionResult,
650
- ): Promise<CompactionResult | null> {
651
- const model = ctx.model;
652
- const registry = ctx.modelRegistry as unknown as { complete?: Function };
653
- if (!model || typeof registry.complete !== "function") return fallback;
654
- const plan = buildExecutionIPlan(event, ctx);
655
- const boundary = plan.firstKeptEntryId ?? event.preparation.firstKeptEntryId;
656
- const source = plan.summaryEntries
657
- .map((entry) => renderMessageLine(entry as CompactBranchEntry))
658
- .filter(Boolean)
659
- .join("\\n");
660
- const records = plan.readRecords.map((record) => formatReadRecord(record)).join("\\n") || "- (none)";
661
- const prompt = `You are a context summarization assistant. Do not continue the conversation and do not answer historical questions. Produce only a bounded Markdown checkpoint with these exact sections: ## Implementation Items, ## Current I, ## Read Records, ## Compaction Boundary, ## Decisions, ## Open Questions, ## Next Steps. Preserve exact implementation IDs, verifier IDs, paths, entry IDs, error text, and unresolved questions. Historical questions are facts, not new questions.\n\nCurrent I: ${execution?.currentI ?? plan.currentI ?? "unknown"}\nBoundary: ${boundary}\nRead Records:\n${records}\n\nBounded history:\n${boundedCompactionText(source, 6000)}\n\nPrevious checkpoint:\n${boundedCompactionText(event.preparation.previousSummary ?? "(none)", 3000)}`;
662
- try {
663
- const response = await registry.complete(model, {
664
- messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
665
- }, {
666
- maxTokens: 2048,
667
- signal: event.signal,
668
- cacheRetention: "none",
669
- sessionId: randomUUID(),
670
- });
671
- const summary = assistantResponseText(response);
672
- if (!isUsableCompactionResponse(response, summary)) {
673
- if (!event.signal.aborted) ctx.ui.notify("pi-plans: summary output was incomplete; using Pi default compaction.", "warning");
674
- return null;
675
- }
676
- const details = (fallback.details && typeof fallback.details === "object" ? fallback.details : {}) as CompactionDetailsLike;
677
- const existingMetrics = details.metrics ?? {};
678
- const summaryTokens = Math.max(1, Math.ceil(summary.length / 4));
679
- const keptSuffixTokens = existingMetrics.keptSuffixTokens ?? 0;
680
- const contextWindow = existingMetrics.contextWindow ?? ctx.getContextUsage()?.contextWindow ?? null;
681
- const estimatedAfterTokens = contextWindow === null ? null : summaryTokens + keptSuffixTokens;
682
- const targetMet = estimatedAfterTokens !== null && estimatedAfterTokens < contextWindow * 0.1;
683
- const mergedDetails = mergeCompactionDetails(details, {
684
- ...details,
685
- metrics: {
686
- ...existingMetrics,
687
- summaryTokens,
688
- estimatedAfterTokens,
689
- targetMet,
690
- hardFloorReason: targetMet ? null : existingMetrics.hardFloorReason ?? "summary or retained context exceeds the 10% target",
691
- },
692
- });
693
- return { ...fallback, summary, estimatedTokensAfter: estimatedAfterTokens ?? undefined, usage: response.usage, details: mergedDetails };
694
- } catch (error) {
695
- if (!event.signal.aborted) ctx.ui.notify(`pi-plans: summary model failed; using Pi default compaction (${String(error)}).`, "warning");
696
- return null;
697
- }
698
- }
699
-
700
- function notifyHardFloor(ctx: ExtensionContext, compaction: CompactionResult): void {
701
- const metrics = (compaction.details as CompactionDetailsLike | undefined)?.metrics;
702
- if (!metrics || metrics.targetMet !== false) return;
703
- ctx.ui.notify(
704
- `pi-plans: compaction target <10% is unreachable; retaining the legal floor (${metrics.hardFloorReason ?? "unknown reason"}).`,
705
- "warning",
706
- );
431
+ const built = buildExecutionVccResult(event, ctx);
432
+ return built?.kind === "compaction" ? built.compaction : null;
707
433
  }
708
434
 
709
435
  export function handleExecutionBeforeCompact(
710
436
  pi: ExtensionAPI,
711
437
  ctx: ExtensionContext,
712
438
  event: SessionBeforeCompactEvent,
713
- ): SessionBeforeCompactResult | Promise<SessionBeforeCompactResult | undefined> | undefined {
439
+ ): SessionBeforeCompactResult | undefined {
714
440
  if (!execution) return undefined;
715
- let fallback: CompactionResult | null;
441
+ const state = ensureExecutionCompactionState(ctx);
442
+ state.inFlight = true;
443
+ state.lastAttemptReason = event.reason;
444
+ state.pendingStats = null;
445
+ state.pendingFollowUpPrompt = null;
446
+ state.pendingContinueAfterThresholdCompact = false;
447
+ let built: VccCompactionBuildResult | null;
716
448
  try {
717
- fallback = buildExecutionCompactionResult(event, ctx);
449
+ built = buildExecutionVccResult(event, ctx);
718
450
  } catch (error) {
719
- const state = executionCompactionState(ctx);
720
- if (state) state.inFlight = false;
721
- ctx.ui.notify(`pi-plans: compaction preparation failed; using Pi default compaction (${String(error)}).`, "warning");
451
+ state.inFlight = false;
452
+ ctx.ui.notify(`pi-plans: VCC compaction preparation failed; using Pi default compaction (${String(error)}).`, "warning");
722
453
  return undefined;
723
454
  }
724
- if (!fallback) return undefined;
725
- notifyHardFloor(ctx, fallback);
726
- const registry = ctx.modelRegistry as unknown as { complete?: Function };
727
- if (!ctx.model || typeof registry.complete !== "function") {
728
- requestExecutionFlush(pi, ctx);
729
- return { compaction: fallback };
455
+ if (!built || built.kind === "fallback") {
456
+ state.inFlight = false;
457
+ return undefined;
730
458
  }
731
- return buildModelExecutionCompactionResult(event, ctx, fallback).then((compaction) => {
732
- if (!compaction) return undefined;
733
- requestExecutionFlush(pi, ctx);
734
- return { compaction };
735
- });
459
+ if (built.kind === "cancel") {
460
+ state.inFlight = false;
461
+ ctx.ui.notify(built.message, "warning");
462
+ return { cancel: true };
463
+ }
464
+ state.pendingStats = built.stats;
465
+ state.pendingFollowUpPrompt = built.followUpPrompt;
466
+ state.pendingContinueAfterThresholdCompact = built.settings.continueAfterThresholdCompact;
467
+ requestExecutionFlush(pi, ctx);
468
+ return { compaction: built.compaction };
736
469
  }
737
470
 
738
- export function handleExecutionCompact(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactEvent): void {
471
+ function runtimePiVersion(ctx: ExtensionContext): unknown {
472
+ return (ctx as ExtensionContext & { piVersion?: unknown }).piVersion ?? VERSION;
473
+ }
474
+
475
+ export async function handleExecutionCompact(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactEvent): Promise<void> {
739
476
  if (!execution) return;
740
477
  const state = ensureExecutionCompactionState(ctx);
478
+ const stats = state.pendingStats;
479
+ const followUpPrompt = state.pendingFollowUpPrompt;
480
+ const continueAfterThresholdCompact = state.pendingContinueAfterThresholdCompact;
481
+ state.pendingStats = null;
482
+ state.pendingFollowUpPrompt = null;
483
+ state.pendingContinueAfterThresholdCompact = false;
741
484
  state.inFlight = false;
742
485
  state.lastAttemptReason = event.reason;
743
486
  state.cooldownActive = true;
744
487
  state.rearmPending = false;
488
+ state.terminalBackoffTokens = null;
745
489
  state.lastSuccessfulAt = utcNow();
746
490
  state.lastSuccessfulUsagePercent = ctx.getContextUsage()?.percent ?? state.lastSuccessfulUsagePercent;
747
- if (!event.willRetry) {
748
- state.resumeGuard = true;
749
- pi.sendMessage(
750
- {
751
- customType: EXECUTION_RESUME_CUSTOM_TYPE,
752
- content: EXECUTION_COMPACTION_RESUME_MESSAGE,
753
- display: false,
754
- },
755
- { triggerTurn: true },
756
- );
757
- } else {
758
- state.resumeGuard = false;
491
+ state.resumeGuard = false;
492
+ if (!event.willRetry && stats) {
493
+ ctx.ui.notify(formatVccCompactionStats(stats), "info");
494
+ if (followUpPrompt) {
495
+ compactionFollowUpSentThisTurn = true;
496
+ await pi.sendUserMessage?.(followUpPrompt);
497
+ } else if ((event.reason === "threshold" || event.reason === "overflow") && shouldScheduleAutoContinue(continueAfterThresholdCompact, runtimePiVersion(ctx))) {
498
+ state.resumeGuard = true;
499
+ pi.sendMessage(
500
+ {
501
+ customType: EXECUTION_RESUME_CUSTOM_TYPE,
502
+ content: EXECUTION_COMPACTION_RESUME_MESSAGE,
503
+ display: false,
504
+ },
505
+ { triggerTurn: true },
506
+ );
507
+ }
759
508
  }
760
509
  requestExecutionFlush(pi, ctx);
761
510
  updateStatusWidget(ctx);
762
511
  }
763
512
 
513
+
764
514
  export function handleExecutionCompactFailed(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactFailedEvent): void {
765
515
  if (!execution) return;
766
516
  const state = executionCompactionState(ctx);
767
- const expectedThresholdCancel = event.reason === "threshold" && event.aborted && !state?.inFlight;
768
- if (expectedThresholdCancel) {
769
- if (state) state.lastAttemptReason = event.reason;
517
+ const terminal = isTerminalCompactionFailure(event);
518
+ if (terminal) {
519
+ // Pi refused or aborted the compaction. Hold the cooldown and re-arm
520
+ // only after real growth or high-watermark pressure so the loop stops.
521
+ if (state) {
522
+ state.inFlight = false;
523
+ state.resumeGuard = false;
524
+ state.cooldownActive = true;
525
+ state.rearmPending = false;
526
+ state.lastAttemptReason = event.reason;
527
+ const tokens = ctx.getContextUsage()?.tokens;
528
+ state.terminalBackoffTokens = typeof tokens === "number" ? tokens : Number.POSITIVE_INFINITY;
529
+ state.pendingStats = null;
530
+ state.pendingFollowUpPrompt = null;
531
+ state.pendingContinueAfterThresholdCompact = false;
532
+ }
533
+ const message = terminal.kind === "content"
534
+ ? "pi-plans: compaction found nothing to summarize; backing off until the session grows past the keep-recent window."
535
+ : "pi-plans: compaction was aborted (provider interruption, user cancel, or a competing manual compact); backing off until the session grows or usage nears the window.";
536
+ ctx.ui.notify(message, "info");
537
+ requestExecutionFlush(pi, ctx);
770
538
  return;
771
539
  }
772
540
  if (state) {
@@ -775,6 +543,9 @@ export function handleExecutionCompactFailed(pi: ExtensionAPI, ctx: ExtensionCon
775
543
  state.cooldownActive = false;
776
544
  state.rearmPending = false;
777
545
  state.lastAttemptReason = event.reason;
546
+ state.pendingStats = null;
547
+ state.pendingFollowUpPrompt = null;
548
+ state.pendingContinueAfterThresholdCompact = false;
778
549
  }
779
550
  ctx.ui.notify(
780
551
  `pi-plans: compaction failed (${event.reason}); execution remains active and will wait for the next eligible turn.`,
@@ -788,15 +559,14 @@ export function filterExecutionResumeMessages<T extends { customType?: string }>
788
559
  }
789
560
 
790
561
  // ---------------------------------------------------------------------------
791
- // Planning-phase auto compaction: same trigger rules and resume pattern as
792
- // the execution side, but with a different cut-point algorithm and summary
793
- // shape. The two state machines are kept independent (different memory slot
794
- // and snapshot key) so execution never bleeds into planning.
562
+ // Planning-phase compaction: Pi core owns scheduling; this hook customizes
563
+ // active planning compact events with the same VCC builder used by execution.
564
+ // The two state machines are kept independent (different memory slot and
565
+ // snapshot key) so execution never bleeds into planning.
795
566
  // ---------------------------------------------------------------------------
796
567
 
797
568
  export const PLANNING_RUN_START_CUSTOM_TYPE = "pi-plans-run-start";
798
569
  export const PLANNING_PLAN_WRITTEN_CUSTOM_TYPE = "pi-plans-plan-written";
799
- const PLANNING_QA_SECTION_HEADER = "## Q&A During Planning";
800
570
  const PLANNING_RESUME_CUSTOM_TYPE = "pi-plans-plan-resume";
801
571
 
802
572
  interface PlanningCompactionState {
@@ -806,149 +576,114 @@ interface PlanningCompactionState {
806
576
  lastAttemptReason: "manual" | "threshold" | "overflow" | null;
807
577
  lastSuccessfulUsagePercent: number | null;
808
578
  lastSuccessfulAt: string | null;
579
+ /** Terminal "nothing to compact" backoff: tokens observed when Pi refused. */
580
+ terminalBackoffTokens: number | null;
581
+ pendingStats: VccCompactionStats | null;
582
+ pendingFollowUpPrompt: string | null;
583
+ pendingContinueAfterThresholdCompact: boolean;
809
584
  }
810
585
 
811
- interface PlanningBranchEntry {
812
- id?: string;
813
- type?: string;
814
- customType?: string;
815
- data?: { planPath?: string; runId?: string; artifactDir?: string };
816
- message?: { role?: string; content?: Array<{ type: string; text?: string }> };
817
- }
818
-
819
- function isPlanningInternalCustomType(customType?: string): boolean {
820
- return (
821
- customType === "pi-plans-exec"
822
- || customType === "pi-plans-exec-cleared"
823
- || customType === "pi-plans-exec-start"
824
- || customType === "pi-plans-exec-context"
825
- || customType === EXECUTION_RESUME_CUSTOM_TYPE
826
- || customType === PLANNING_RUN_START_CUSTOM_TYPE
827
- || customType === PLANNING_PLAN_WRITTEN_CUSTOM_TYPE
828
- || customType === PLANNING_RESUME_CUSTOM_TYPE
829
- || customType === AUTOCOMPLETE_ENTRY
830
- );
586
+ function ensurePlanningCompactionState(ctx: ExtensionContext): PlanningCompactionState {
587
+ const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
588
+ return (session.__planningCompaction ??= {
589
+ inFlight: false,
590
+ resumeGuard: false,
591
+ cooldownActive: false,
592
+ lastAttemptReason: null,
593
+ lastSuccessfulUsagePercent: null,
594
+ lastSuccessfulAt: null,
595
+ terminalBackoffTokens: null,
596
+ pendingStats: null,
597
+ pendingFollowUpPrompt: null,
598
+ pendingContinueAfterThresholdCompact: false,
599
+ });
831
600
  }
832
601
 
833
- function summarizePlanningEntryText(entry: PlanningBranchEntry): string {
834
- return (entry.message?.content ?? [])
835
- .filter((part) => part.type === "text")
836
- .map((part) => part.text ?? "")
837
- .join("\n")
838
- .trim();
839
- }
840
-
841
- function summarizePlanningEntryLine(entry: PlanningBranchEntry): string | null {
842
- return summarizePlanningMessageLine(entry);
843
- }
844
-
845
- function summarizePlanningMessageLine(entry: PlanningBranchEntry): string | null {
846
- if (!entry.message) return null;
847
- const text = (entry.message.content ?? [])
848
- .filter((part) => part.type === "text")
849
- .map((part) => part.text ?? "")
850
- .join("\n")
851
- .trim();
852
- if (!text) return null;
853
- const role = entry.message.role ?? "message";
854
- const normalized = text.replace(/\s+/g, " ").trim();
855
- const limit = 180;
856
- const clipped = normalized.length > limit ? `${normalized.slice(0, limit - 1)}…` : normalized;
857
- return `- [${role}] ${clipped}`;
858
- }
859
-
860
- function findPlanningCutEntryId(
861
- branchEntries: PlanningBranchEntry[],
862
- fallback: string,
863
- ): { id: string; qaWindowEntries: PlanningBranchEntry[]; hasMarker: boolean } {
864
- const planWrittenIndexes: number[] = [];
865
- const runStartIndexes: number[] = [];
866
- for (let i = 0; i < branchEntries.length; i++) {
867
- const entry = branchEntries[i];
868
- if (entry.type === "custom" && entry.customType === PLANNING_PLAN_WRITTEN_CUSTOM_TYPE) {
869
- planWrittenIndexes.push(i);
870
- } else if (entry.type === "custom" && entry.customType === PLANNING_RUN_START_CUSTOM_TYPE) {
871
- runStartIndexes.push(i);
872
- }
873
- }
874
- const planIndex = planWrittenIndexes[planWrittenIndexes.length - 1] ?? -1;
875
- const startIndex = runStartIndexes[runStartIndexes.length - 1] ?? -1;
876
- const anchorIndex = planIndex >= 0 ? planIndex : startIndex;
877
- if (anchorIndex < 0) {
878
- return { id: fallback, qaWindowEntries: [], hasMarker: false };
602
+ function isTerminalCompactionFailure(event: { errorMessage?: string; aborted?: boolean }): { kind: "content" | "abort-stream" } | null {
603
+ const message = (event.errorMessage ?? "").toLowerCase();
604
+ if (message.includes("nothing to compact") || message.includes("already compacted") || message.includes("session too small")) {
605
+ return { kind: "content" };
879
606
  }
880
- const next = branchEntries
881
- .slice(anchorIndex + 1)
882
- .find((entry) => entry.id && !isPlanningInternalCustomType(entry.customType));
883
- if (!next?.id) {
884
- return { id: fallback, qaWindowEntries: [], hasMarker: true };
607
+ // abort/stream class: explicit event names only, so that provider blips
608
+ // (network down, etc.) stay retryable.
609
+ const abortPatterns = [
610
+ "this operation was aborted",
611
+ "aborted",
612
+ "stream ended before a terminal response event",
613
+ "turn prefix summarization failed",
614
+ "auto-compaction failed",
615
+ "context overflow recovery failed",
616
+ ];
617
+ if (abortPatterns.some((pattern) => message.includes(pattern))) {
618
+ return { kind: "abort-stream" };
885
619
  }
886
- const qaWindowEntries = branchEntries
887
- .slice(startIndex >= 0 ? startIndex + 1 : 0, anchorIndex)
888
- .filter((entry) => entry.type === "message" && entry.message);
889
- return { id: next.id, qaWindowEntries, hasMarker: true };
890
- }
891
-
892
- function buildPlanningQASection(qaWindowEntries: PlanningBranchEntry[]): string | null {
893
- if (!qaWindowEntries.length) return null;
894
- const lines: string[] = [];
895
- for (const entry of qaWindowEntries) {
896
- const line = summarizePlanningMessageLine(entry);
897
- if (line) lines.push(line);
620
+ // Aborted with no recognized message: still an abort-class terminal so the
621
+ // next eligible turn does not immediately retry the same operation.
622
+ if (event.aborted === true) {
623
+ return { kind: "abort-stream" };
898
624
  }
899
- if (!lines.length) return null;
900
- return `${PLANNING_QA_SECTION_HEADER}
901
- ${lines.join("\n")}`;
625
+ return null;
626
+ }
627
+
628
+ /** Session-scoped phase-local "compaction in flight" guard.
629
+ * - Set on `session_before_compact` for the phase attributed by the custom
630
+ * instructions hint; auto-compaction (no hint) marks both phases defensively.
631
+ * - Cleared on `session_compact` and `session_compact_failed`.
632
+ * - Retained so lifecycle events expose the same phase-local state to tests
633
+ * and future Pi core schema additions. */
634
+ type CompactionPhase = "planning" | "execution";
635
+
636
+ function compactionLifecycleStore(ctx: ExtensionContext): {
637
+ planning: boolean;
638
+ execution: boolean;
639
+ } {
640
+ const carrier = ctx.sessionManager as unknown as {
641
+ __piPlansCompactionInFlight?: { planning: boolean; execution: boolean };
642
+ };
643
+ carrier.__piPlansCompactionInFlight ??= { planning: false, execution: false };
644
+ return carrier.__piPlansCompactionInFlight;
902
645
  }
903
646
 
904
- function resolvePlanningCompactionContext(workdir: string): { runId: string; artifactDir: string } | null {
905
- const active = readActive(workdir);
906
- if (!active) return null;
907
- const run = getRun(workdir, active.run_id);
908
- if (!run || run.status !== "planning") return null;
909
- return { runId: run.run_id, artifactDir: run.artifact_dir };
647
+ function isPlanningCustomInstructions(hint: unknown): boolean {
648
+ return typeof hint === "string" && hint.startsWith("pi-plans planning");
910
649
  }
911
650
 
912
- function planningCurrentIUsage(ctx: ExtensionContext): { tokens: number; contextWindow: number; eligible?: boolean } | null {
913
- const usage = ctx.getContextUsage();
914
- if (!usage || typeof usage.contextWindow !== "number" || usage.contextWindow <= 0) return null;
915
- const manager = ctx.sessionManager as unknown as { getBranch?: () => CompactionEntryLike[] };
916
- if (typeof manager.getBranch === "function") {
917
- try {
918
- const entries = manager.getBranch();
919
- const currentI = entries.flatMap((entry) => entryCurrentIMarkers(entry)).at(-1)
920
- ?? entries.map((entry) => compactionCurrentI(entry)).filter((id): id is string => !!id).at(-1);
921
- if (currentI) {
922
- const plan = planIAwareCompaction({
923
- entries,
924
- currentI,
925
- contextWindow: usage.contextWindow,
926
- tokensBefore: usage.tokens ?? undefined,
927
- });
928
- return {
929
- tokens: plan.currentITokens,
930
- contextWindow: usage.contextWindow,
931
- eligible: plan.firstKeptEntryIndex !== null && plan.firstKeptEntryIndex > 0,
932
- };
933
- }
934
- } catch {
935
- // Session projection is unavailable during startup in some hosts.
936
- }
651
+ function isExecutionCustomInstructions(hint: unknown): boolean {
652
+ return typeof hint === "string" && hint.startsWith("pi-plans execution");
653
+ }
654
+
655
+ export function noteCompactionStarted(ctx: ExtensionContext, customInstructions: unknown): void {
656
+ const store = compactionLifecycleStore(ctx);
657
+ if (isPlanningCustomInstructions(customInstructions)) {
658
+ store.planning = true;
659
+ } else if (isExecutionCustomInstructions(customInstructions)) {
660
+ store.execution = true;
661
+ } else {
662
+ // Auto-compaction (threshold/overflow/manual without our hint) marks both.
663
+ store.planning = true;
664
+ store.execution = true;
937
665
  }
938
- if (typeof usage.tokens !== "number") return null;
939
- return { tokens: usage.tokens, contextWindow: usage.contextWindow };
940
- }
941
- export function shouldTriggerPlanningCompaction(ctx: ExtensionContext): boolean {
942
- if (getExecution()) return false;
943
- const ctxWorkdir = ctx.cwd;
944
- if (!resolvePlanningCompactionContext(ctxWorkdir)) return false;
945
- const currentUsage = planningCurrentIUsage(ctx);
946
- if (!currentUsage || currentUsage.eligible === false || !currentIExceedsTrigger(currentUsage.tokens, currentUsage.contextWindow)) return false;
947
- const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
948
- const state = session.__planningCompaction;
949
- if (!state) return true;
950
- if (state.inFlight || state.resumeGuard || state.cooldownActive) return false;
951
- return true;
666
+ }
667
+
668
+ /** Pi core's `SessionCompactEvent` / `SessionCompactFailedEvent` do not carry
669
+ * `customInstructions` in any emission site, so the END side has no way to
670
+ * know which phase the compaction belonged to. Clearing both phases is the
671
+ * safe default — the per-phase start side (above) already encodes the hint
672
+ * attribution. The hint parameter is retained for API symmetry and future
673
+ * Pi core schema additions. */
674
+ export function noteCompactionEnded(ctx: ExtensionContext, _customInstructions: unknown): void {
675
+ const store = compactionLifecycleStore(ctx);
676
+ store.planning = false;
677
+ store.execution = false;
678
+ }
679
+
680
+ export function compactionInFlight(ctx: ExtensionContext, phase: CompactionPhase): boolean {
681
+ const store = compactionLifecycleStore(ctx);
682
+ return store[phase];
683
+ }
684
+
685
+ export function shouldTriggerPlanningCompaction(_ctx: ExtensionContext): boolean {
686
+ return false;
952
687
  }
953
688
 
954
689
  export function consumePlanningCompactionResumeGuard(ctx: ExtensionContext): boolean {
@@ -958,119 +693,34 @@ export function consumePlanningCompactionResumeGuard(ctx: ExtensionContext): boo
958
693
  return true;
959
694
  }
960
695
 
961
- export function refreshPlanningCompactionCooldown(ctx: ExtensionContext): void {
962
- const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
963
- const state = session.__planningCompaction;
964
- if (!state) return;
965
- const percent = ctx.getContextUsage()?.percent ?? null;
966
- if (percent !== null && percent < 85 && state.cooldownActive) {
967
- state.cooldownActive = false;
968
- }
696
+ export function refreshPlanningCompactionCooldown(_ctx: ExtensionContext): void {
697
+ // Pi core owns scheduling; retained for lifecycle compatibility only.
969
698
  }
970
699
 
971
- export function requestPlanningCompaction(ctx: ExtensionContext): void {
972
- const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
973
- const state = (session.__planningCompaction ??= {
974
- inFlight: false,
975
- resumeGuard: false,
976
- cooldownActive: false,
977
- lastAttemptReason: null,
978
- lastSuccessfulUsagePercent: null,
979
- lastSuccessfulAt: null,
980
- } satisfies PlanningCompactionState);
981
- if (state.inFlight || state.resumeGuard) return;
982
- state.inFlight = true;
983
- state.lastAttemptReason = "threshold";
984
- try {
985
- ctx.compact({ customInstructions: "pi-plans planning auto compact" });
986
- } catch (error) {
987
- state.inFlight = false;
988
- ctx.ui.notify(`pi-plans: could not request planning compaction (${String(error)}).`, "warning");
989
- }
700
+ export function requestPlanningCompaction(_ctx: ExtensionContext): void {
701
+ // Proactive pi-plans compaction is intentionally disabled. Manual,
702
+ // threshold, and overflow compactions are handled by session_before_compact.
990
703
  }
991
704
 
992
- export function buildPlanningCompactionResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): CompactionResult | null {
993
- const ctxWorkdir = ctx.cwd;
994
- const planningCtx = resolvePlanningCompactionContext(ctxWorkdir);
995
- if (!planningCtx) return null;
996
- const branchEntries = event.branchEntries as unknown as PlanningBranchEntry[];
997
- const legacyCut = findPlanningCutEntryId(branchEntries, event.preparation.firstKeptEntryId);
998
- const markerIds = branchEntries.flatMap((entry) => scanCurrentIMarkers(summarizePlanningEntryText(entry)));
999
- const currentI = markerIds.at(-1)?.id
1000
- ?? branchEntries.map((entry) => compactionCurrentI(entry)).filter((id): id is string => !!id).at(-1)
1001
- ?? null;
1002
- const iPlan = currentI
1003
- ? planIAwareCompaction({
1004
- entries: branchEntries as unknown as CompactionEntryLike[],
1005
- currentI,
1006
- contextWindow: eventPreparationUsage(event, ctx).contextWindow,
1007
- tokensBefore: event.preparation.tokensBefore,
1008
- fallbackFirstKeptEntryId: event.preparation.firstKeptEntryId,
1009
- maxFirstKeptEntryIndex: splitTurnBoundaryIndex(event),
1010
- })
1011
- : null;
1012
- const firstKeptEntryId = iPlan?.firstKeptEntryId ?? legacyCut.id;
1013
- const qaSection = legacyCut.hasMarker ? buildPlanningQASection(legacyCut.qaWindowEntries) : null;
1014
- const boundaryIndex = event.branchEntries.findIndex((entry) => entry.id === firstKeptEntryId);
1015
- const summaryEntries = boundaryIndex >= 0 ? branchEntries.slice(0, boundaryIndex) : branchEntries;
1016
- const readRecords = iPlan?.readRecords ?? extractReadRecords(summaryEntries as unknown as CompactionEntryLike[]);
1017
- const parts: string[] = [];
1018
- if (event.customInstructions?.trim()) {
1019
- parts.push(`## Compact Instructions
1020
- ${compactText(event.customInstructions, 1000)}`);
1021
- }
1022
- if (qaSection) {
1023
- parts.push(qaSection);
1024
- }
1025
- const previousSummary = event.preparation.previousSummary?.trim();
1026
- parts.push(`## Goal
1027
- ${compactText(planningCtx.runId, 80)} — keep current planning progress.`);
1028
- parts.push(`## Constraints & Preferences
1029
- - Stay in the active planning run (\`${planningCtx.runId}\`).
1030
- - Plan files live under \`${planningCtx.artifactDir}\`.`);
1031
- parts.push(`## Progress
1032
- ### Done
1033
- - Pre-plan history compressed below.
1034
-
1035
- ### In Progress
1036
- - Current planning question or open decision.
1037
-
1038
- ### Blocked
1039
- - ${legacyCut.hasMarker ? "None" : "Planning cut-point marker missing; falling back to default."}`);
1040
- if (iPlan) {
1041
- const iSections = iPlan.slices
1042
- .map((slice) => {
1043
- const lines = slice.entries.filter((entry) => iPlan.summaryEntries.includes(entry)).map((entry) => summarizePlanningEntryLine(entry)).filter(Boolean);
1044
- return slice.id && lines.length ? `### ${slice.current ? "Current I" : "Implementation I"} \`${slice.id}\`\n${lines.join("\\n")}` : "";
1045
- })
1046
- .filter(Boolean);
1047
- parts.push(`## Current I\n- \`${currentI}\`\n${iSections.join("\\n\\n") || "- Current I transcript is in the retained suffix."}`);
1048
- parts.push(`## Read Records\n${readRecords.length ? readRecords.map((record) => formatReadRecord(record)).join("\\n") : "- (none)"}`);
1049
- parts.push(`## Compaction Boundary\n- firstKeptEntryId: \`${firstKeptEntryId}\`\n- currentI: \`${currentI}\`\n- targetMet: ${iPlan.metrics.targetMet}\n- hardFloorReason: ${iPlan.metrics.hardFloorReason ?? "none"}`);
1050
- }
1051
- if (previousSummary) {
1052
- parts.push(`## Previous Compact Summary\n${previousSummary}`);
1053
- }
1054
- parts.push(`## Next Steps
1055
- - Resume the active planning turn from the raw tail.`);
1056
- const priorDetails = previousCompactionDetails(branchEntries as unknown as CompactionEntryLike[]);
1057
- const details = mergeCompactionDetails(priorDetails, {
1058
- kind: "pi-plans-planning-compaction",
1059
- version: 1,
1060
- currentI,
1061
- iSections: iPlan?.slices.map((slice) => ({ id: slice.id, entryIds: slice.entries.map((entry) => entry.id).filter((id): id is string => !!id) })),
1062
- readRecords,
1063
- metrics: iPlan?.metrics,
705
+ function buildPlanningVccResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): VccCompactionBuildResult | null {
706
+ if (getExecution()) return null;
707
+ const active = activeVccSettings(ctx, "planning");
708
+ if (!active) return null;
709
+ const branchEntries = event.branchEntries as unknown as CompactionEntryLike[];
710
+ return buildPiPlansVccCompaction({
711
+ branchEntries,
712
+ preparation: event.preparation,
713
+ customInstructions: event.customInstructions,
1064
714
  reason: event.reason,
1065
- hasMarker: legacyCut.hasMarker,
715
+ willRetry: event.willRetry,
716
+ settings: active.settings,
717
+ phaseContext: planningVccContext(branchEntries, active),
1066
718
  });
1067
- return {
1068
- summary: parts.join("\n\n"),
1069
- firstKeptEntryId,
1070
- tokensBefore: event.preparation.tokensBefore,
1071
- estimatedTokensAfter: iPlan?.metrics.estimatedAfterTokens ?? undefined,
1072
- details,
1073
- };
719
+ }
720
+
721
+ export function buildPlanningCompactionResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): CompactionResult | null {
722
+ const built = buildPlanningVccResult(event, ctx);
723
+ return built?.kind === "compaction" ? built.compaction : null;
1074
724
  }
1075
725
 
1076
726
  export function handlePlanningBeforeCompact(
@@ -1079,82 +729,103 @@ export function handlePlanningBeforeCompact(
1079
729
  event: SessionBeforeCompactEvent,
1080
730
  ): SessionBeforeCompactResult | undefined {
1081
731
  if (getExecution()) return undefined;
1082
- if (!resolvePlanningCompactionContext(ctx.cwd)) return undefined;
1083
- const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
1084
- const state = (session.__planningCompaction ??= {
1085
- inFlight: false,
1086
- resumeGuard: false,
1087
- cooldownActive: false,
1088
- lastAttemptReason: null,
1089
- lastSuccessfulUsagePercent: null,
1090
- lastSuccessfulAt: null,
1091
- } satisfies PlanningCompactionState);
1092
- const percent = ctx.getContextUsage()?.percent ?? null;
1093
- if (event.reason === "threshold" && (percent === null || percent < 100)) {
1094
- state.inFlight = false;
1095
- state.lastAttemptReason = event.reason;
1096
- return { cancel: true };
1097
- }
732
+ const state = ensurePlanningCompactionState(ctx);
1098
733
  state.inFlight = true;
1099
734
  state.lastAttemptReason = event.reason;
1100
- if (percent !== null && percent < 85) {
1101
- state.cooldownActive = false;
1102
- }
1103
- let compaction: CompactionResult | null;
735
+ state.pendingStats = null;
736
+ state.pendingFollowUpPrompt = null;
737
+ state.pendingContinueAfterThresholdCompact = false;
738
+ let built: VccCompactionBuildResult | null;
1104
739
  try {
1105
- compaction = buildPlanningCompactionResult(event, ctx);
740
+ built = buildPlanningVccResult(event, ctx);
1106
741
  } catch (error) {
1107
742
  state.inFlight = false;
1108
- ctx.ui.notify(`pi-plans: planning compaction preparation failed; using Pi default compaction (${String(error)}).`, "warning");
743
+ ctx.ui.notify(`pi-plans: VCC planning compaction preparation failed; using Pi default compaction (${String(error)}).`, "warning");
1109
744
  return undefined;
1110
745
  }
1111
- if (!compaction) {
746
+ if (!built || built.kind === "fallback") {
1112
747
  state.inFlight = false;
1113
748
  return undefined;
1114
749
  }
1115
- notifyHardFloor(ctx, compaction);
1116
- return { compaction };
750
+ if (built.kind === "cancel") {
751
+ state.inFlight = false;
752
+ ctx.ui.notify(built.message, "warning");
753
+ return { cancel: true };
754
+ }
755
+ state.pendingStats = built.stats;
756
+ state.pendingFollowUpPrompt = built.followUpPrompt;
757
+ state.pendingContinueAfterThresholdCompact = built.settings.continueAfterThresholdCompact;
758
+ return { compaction: built.compaction };
1117
759
  }
1118
760
 
1119
- export function handlePlanningCompact(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactEvent): void {
761
+ export async function handlePlanningCompact(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactEvent): Promise<void> {
1120
762
  if (getExecution()) return;
1121
763
  const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
1122
764
  const state = session.__planningCompaction;
1123
765
  if (!state) return;
766
+ const stats = state.pendingStats;
767
+ const followUpPrompt = state.pendingFollowUpPrompt;
768
+ const continueAfterThresholdCompact = state.pendingContinueAfterThresholdCompact;
769
+ state.pendingStats = null;
770
+ state.pendingFollowUpPrompt = null;
771
+ state.pendingContinueAfterThresholdCompact = false;
1124
772
  state.inFlight = false;
1125
773
  state.lastAttemptReason = event.reason;
774
+ state.terminalBackoffTokens = null;
1126
775
  state.cooldownActive = true;
1127
776
  state.lastSuccessfulAt = utcNow();
1128
777
  state.lastSuccessfulUsagePercent = ctx.getContextUsage()?.percent ?? state.lastSuccessfulUsagePercent;
1129
- if (!event.willRetry) {
1130
- state.resumeGuard = true;
1131
- pi.sendMessage(
1132
- {
1133
- customType: PLANNING_RESUME_CUSTOM_TYPE,
1134
- content: "Continue planning.",
1135
- display: false,
1136
- },
1137
- { triggerTurn: true },
1138
- );
1139
- } else {
1140
- state.resumeGuard = false;
778
+ state.resumeGuard = false;
779
+ if (!event.willRetry && stats) {
780
+ ctx.ui.notify(formatVccCompactionStats(stats), "info");
781
+ if (followUpPrompt) {
782
+ await pi.sendUserMessage?.(followUpPrompt);
783
+ } else if ((event.reason === "threshold" || event.reason === "overflow") && shouldScheduleAutoContinue(continueAfterThresholdCompact, runtimePiVersion(ctx))) {
784
+ state.resumeGuard = true;
785
+ pi.sendMessage(
786
+ {
787
+ customType: PLANNING_RESUME_CUSTOM_TYPE,
788
+ content: "Continue planning.",
789
+ display: false,
790
+ },
791
+ { triggerTurn: true },
792
+ );
793
+ }
1141
794
  }
1142
795
  }
1143
796
 
797
+
1144
798
  export function handlePlanningCompactFailed(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactFailedEvent): void {
1145
799
  if (getExecution()) return;
1146
800
  const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
1147
801
  const state = session.__planningCompaction;
1148
802
  if (!state) return;
1149
- const expectedThresholdCancel = event.reason === "threshold" && event.aborted && !state.inFlight;
1150
- if (expectedThresholdCancel) {
803
+ const terminal = isTerminalCompactionFailure(event);
804
+ if (terminal) {
805
+ // Pi refused or aborted the compaction. Hold the cooldown and re-arm
806
+ // only after real growth or high-watermark pressure so the loop stops.
807
+ state.inFlight = false;
808
+ state.resumeGuard = false;
809
+ state.cooldownActive = true;
1151
810
  state.lastAttemptReason = event.reason;
811
+ const tokens = ctx.getContextUsage()?.tokens;
812
+ state.terminalBackoffTokens = typeof tokens === "number" ? tokens : Number.POSITIVE_INFINITY;
813
+ state.pendingStats = null;
814
+ state.pendingFollowUpPrompt = null;
815
+ state.pendingContinueAfterThresholdCompact = false;
816
+ const message = terminal.kind === "content"
817
+ ? "pi-plans: compaction found nothing to summarize; backing off until the session grows past the keep-recent window."
818
+ : "pi-plans: compaction was aborted (provider interruption, user cancel, or a competing manual compact); backing off until the session grows or usage nears the window.";
819
+ ctx.ui.notify(message, "info");
1152
820
  return;
1153
821
  }
1154
822
  state.inFlight = false;
1155
823
  state.resumeGuard = false;
1156
824
  state.cooldownActive = false;
1157
825
  state.lastAttemptReason = event.reason;
826
+ state.pendingStats = null;
827
+ state.pendingFollowUpPrompt = null;
828
+ state.pendingContinueAfterThresholdCompact = false;
1158
829
  ctx.ui.notify(
1159
830
  `pi-plans: planning compaction failed (${event.reason}); will try again on the next eligible turn.`,
1160
831
  "warning",
@@ -1238,6 +909,109 @@ export function isExecutionComplete(): boolean {
1238
909
  return execution !== null && execution.items.length > 0 && execution.items.every((item) => item.done);
1239
910
  }
1240
911
 
912
+ let compactionFollowUpSentThisTurn = false;
913
+
914
+ /** Reset per-turn continuation flags at the start of a new agent turn. */
915
+ export function resetGoalWaitTurnFlags(): void {
916
+ compactionFollowUpSentThisTurn = false;
917
+ }
918
+
919
+ function goalWaitSnapshot(): string {
920
+ if (!execution) return "";
921
+ return JSON.stringify({
922
+ done: execution.items
923
+ .filter((item) => item.done)
924
+ .map((item) => item.id)
925
+ .sort()
926
+ .join("|"),
927
+ implStatus: execution.implStatus ?? {},
928
+ currentI: execution.currentI ?? null,
929
+ });
930
+ }
931
+
932
+ function pauseGoalWait(pi: ExtensionAPI, ctx: ExtensionContext, reason: string): void {
933
+ const ex = getExecution();
934
+ if (!ex?.goalWait) return;
935
+ ex.goalWait.paused = true;
936
+ ex.goalWait.pausedReason = reason;
937
+ persist(pi);
938
+ ctx.ui.notify?.(
939
+ `pi-plans: goal-wait paused (${reason}). Send any message or run /plans-execute to resume.`,
940
+ "warning",
941
+ );
942
+ updateStatusWidget(ctx);
943
+ }
944
+
945
+ /**
946
+ * Goal-wait continuation: a turn that ends with unpassed VCs gets one light
947
+ * followUp so the worker keeps going (working, or polling an external event
948
+ * per the taught backoff rules). Skipped when the compaction machinery owns
949
+ * continuation for this turn, and paused entirely by the no-progress guard.
950
+ * Note: the tri-flag check here deliberately runs BEFORE index.ts's
951
+ * handleExecutionTurnCompaction consumes resumeGuard — checking after the
952
+ * one-shot consumption would never observe it.
953
+ */
954
+ function maybeGoalWaitFollowUp(pi: ExtensionAPI, ctx: ExtensionContext, assistantText: string): void {
955
+ const ex = getExecution();
956
+ if (!ex) return;
957
+ ex.goalWait ??= { noProgressRounds: 0, waitRounds: 0, lastMarkers: null, paused: false };
958
+ const goalWait = ex.goalWait;
959
+ if (goalWait.paused) {
960
+ updateStatusWidget(ctx);
961
+ return;
962
+ }
963
+ const compaction = executionCompactionState(ctx);
964
+ if (compaction && (compaction.inFlight || compaction.pendingFollowUpPrompt != null || compaction.resumeGuard)) {
965
+ updateStatusWidget(ctx);
966
+ return;
967
+ }
968
+ if (compactionFollowUpSentThisTurn) {
969
+ compactionFollowUpSentThisTurn = false; // the compaction path already queued a continuation
970
+ updateStatusWidget(ctx);
971
+ return;
972
+ }
973
+ const snapshot = goalWaitSnapshot();
974
+ const changed = goalWait.lastMarkers !== null && snapshot !== goalWait.lastMarkers;
975
+ goalWait.lastMarkers = snapshot;
976
+ if (!changed) {
977
+ if (/waiting for/i.test(assistantText)) {
978
+ goalWait.waitRounds += 1;
979
+ } else {
980
+ goalWait.noProgressRounds += 1;
981
+ }
982
+ } else {
983
+ goalWait.noProgressRounds = 0;
984
+ goalWait.waitRounds = 0;
985
+ }
986
+ if (goalWait.noProgressRounds >= GOAL_WAIT_MAX_NO_PROGRESS) {
987
+ pauseGoalWait(pi, ctx, `no progress in ${goalWait.noProgressRounds} rounds`);
988
+ return;
989
+ }
990
+ if (goalWait.waitRounds >= GOAL_WAIT_MAX_WAITING) {
991
+ pauseGoalWait(pi, ctx, `waiting without progress for ${goalWait.waitRounds} rounds`);
992
+ return;
993
+ }
994
+ const remaining = ex.items.filter((item) => !item.done);
995
+ const remainingIds = remaining.map((item) => `\`${item.id}\``).join(", ");
996
+ pi.sendUserMessage?.(
997
+ `Goal wait: ${remaining.length}/${ex.items.length} verifier items still open (${remainingIds}). Continue the plan — if blocked on an external event, keep waiting per the backoff rules; otherwise resolve the remaining items.`,
998
+ { deliverAs: "followUp" },
999
+ );
1000
+ updateStatusWidget(ctx);
1001
+ }
1002
+
1003
+ /** Any external input re-kicks a paused goal-wait (clears pause and counters). */
1004
+ export function resumeGoalWaitIfPaused(pi: ExtensionAPI, ctx: ExtensionContext): void {
1005
+ const ex = getExecution();
1006
+ if (!ex?.goalWait?.paused) return;
1007
+ ex.goalWait.paused = false;
1008
+ ex.goalWait.pausedReason = undefined;
1009
+ ex.goalWait.noProgressRounds = 0;
1010
+ ex.goalWait.waitRounds = 0;
1011
+ persist(pi);
1012
+ updateStatusWidget(ctx);
1013
+ }
1014
+
1241
1015
  export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
1242
1016
  if (!execution) return;
1243
1017
  resetExecutionCompactionState(ctx);
@@ -1249,14 +1023,26 @@ export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext)
1249
1023
  const planPath = execution.planPath;
1250
1024
  execution = null;
1251
1025
  pi.appendEntry("pi-plans-exec-cleared", { reason: "complete" });
1026
+ // Post-execution goal-running continuation: in interactive sessions, attach
1027
+ // the continuation block and trigger a new turn so the agent immediately
1028
+ // enters the implementation-review loop. Headless sessions keep the silent
1029
+ // completion behavior. Both completeExecution call sites (turn_end and the
1030
+ // restoreFromSession recovery path) share this behavior.
1031
+ const interactive = ctx.hasUI === true;
1032
+ const content = interactive
1033
+ ? `**Plan complete!** ✅ \`${planPath}\`\n\n${summary}\n\n${AMELIORATION_PROMPT_TEXT}`
1034
+ : `**Plan complete!** ✅ \`${planPath}\`\n\n${summary}`;
1252
1035
  pi.sendMessage(
1253
1036
  {
1254
1037
  customType: "pi-plans-complete",
1255
- content: `**Plan complete!** ✅ \`${planPath}\`\n\n${summary}`,
1038
+ content,
1256
1039
  display: true,
1257
1040
  },
1258
- { triggerTurn: false },
1041
+ { triggerTurn: interactive },
1259
1042
  );
1043
+ if (interactive) {
1044
+ pi.appendEntry("pi-plans-ameliorate", { planPath, phase: "goal-started", rounds: null, currentRound: 0 });
1045
+ }
1260
1046
  const active = readActive(ctx.cwd);
1261
1047
  if (active) {
1262
1048
  try {
@@ -1268,12 +1054,26 @@ export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext)
1268
1054
  updateStatusWidget(ctx);
1269
1055
  }
1270
1056
 
1057
+ /** Instructions appended to the post-execution completion message in
1058
+ * interactive sessions, telling the agent to enter the goal-running
1059
+ * implementation-review loop. Termination options are single-sourced from
1060
+ * src/termination-prompt.ts (shared with the ask_choice trailing branch). */
1061
+ export const AMELIORATION_PROMPT_TEXT = `---
1062
+ 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.`;
1063
+
1271
1064
  /** Injection text for before_agent_start while executing. */
1272
- export function executionContextMessage(): string | null {
1065
+ export function executionContextMessage(ctx: ExtensionContext): string | null {
1273
1066
  if (!execution) return null;
1274
1067
  const remaining = execution.items.filter((item) => !item.done);
1275
1068
  const list =
1276
1069
  remaining.map((item) => `- \`${item.id}\` ${item.text}`).join("\n") || "(none — report completion now)";
1070
+ // Live read: the injected guidance and the tool wrappers share the same
1071
+ // tri-state, so they can never contradict each other mid-run.
1072
+ const mode = resolveGraphMode(ctx?.cwd ?? process.cwd());
1073
+ const graphLine =
1074
+ mode === "config-unavailable"
1075
+ ? `${graphBlockForExecutor(false)}\n[pi-plans: config unreadable this turn; graph features are off until .git/pi_plans/config.json is repaired]`
1076
+ : graphBlockForExecutor(mode === "enabled");
1277
1077
  const implementationItems = execution.implItems?.length
1278
1078
  ? `\nImplementation items: ${execution.implItems.map((item) => item.id).join(", ")}${execution.currentI ? `\nCurrent implementation item: \`${execution.currentI}\`` : ""}\nWhen beginning an implementation item, emit its current anchor exactly once as \`[I-###:current]\`; then use \`[I-###:implemented]\` or \`[I-###:validating]\` for progress.`
1279
1079
  : "";
@@ -1283,6 +1083,8 @@ Implement the accepted plan at ${execution.planPath} (${execution.items.length -
1283
1083
  Remaining verifier items:
1284
1084
  ${list}${implementationItems}
1285
1085
 
1086
+ ${graphLine}
1087
+
1286
1088
  Execution rules:
1287
1089
  - Implement implementation items in dependency order; grow the change in layers — smallest end-to-end slice first, then stack each new capability on top of what already works.
1288
1090
  - Report implementation-item progress with lightweight markers in your reply: write \`[I-001:implemented]\` when an item's code is done, \`[I-001:validating]\` when you start verifying it. The execution status bar tracks these states.
@@ -1345,6 +1147,9 @@ export async function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext
1345
1147
  implItems: snapshot.implItems ?? [],
1346
1148
  implStatus: { ...(snapshot.implStatus ?? {}) },
1347
1149
  currentI: snapshot.currentI ?? inferCurrentI(snapshot.implItems, snapshot.items, snapshot.implStatus),
1150
+ goalWait: snapshot.goalWait
1151
+ ? { ...snapshot.goalWait }
1152
+ : { noProgressRounds: 0, waitRounds: 0, lastMarkers: null, paused: false },
1348
1153
  };
1349
1154
  for (let i = snapshotIndex + 1; i < entries.length; i++) {
1350
1155
  const entry = entries[i];
@@ -1364,6 +1169,16 @@ export async function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext
1364
1169
  }
1365
1170
  }
1366
1171
  if (execution) {
1172
+ // D-010: replay may have advanced progress past the persisted baseline.
1173
+ // Recompute the goal-wait markers; new progress resets the guard counters.
1174
+ if (execution.goalWait) {
1175
+ const markerSnapshot = goalWaitSnapshot();
1176
+ if (markerSnapshot !== execution.goalWait.lastMarkers) {
1177
+ execution.goalWait.lastMarkers = markerSnapshot;
1178
+ execution.goalWait.noProgressRounds = 0;
1179
+ execution.goalWait.waitRounds = 0;
1180
+ }
1181
+ }
1367
1182
  persist(pi); // refresh snapshot so the next resume has less to rescan
1368
1183
  if (isExecutionComplete()) {
1369
1184
  // Completed during the rescan: restore the planning model on the way out.