pi-plans 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +74 -21
  2. package/index.ts +115 -9
  3. package/package.json +7 -1
  4. package/references/pi-planning-workflow.md +18 -3
  5. package/references/state-and-config.md +34 -2
  6. package/scripts/validate.ts +4 -0
  7. package/src/code-graph/commands.ts +437 -0
  8. package/src/code-graph/discovery.ts +118 -0
  9. package/src/code-graph/git.ts +108 -0
  10. package/src/code-graph/identity.ts +59 -0
  11. package/src/code-graph/indexer.ts +281 -0
  12. package/src/code-graph/materialize.ts +166 -0
  13. package/src/code-graph/mode.ts +28 -0
  14. package/src/code-graph/mutations.ts +160 -0
  15. package/src/code-graph/parser.ts +51 -0
  16. package/src/code-graph/parsers/javascript.ts +35 -0
  17. package/src/code-graph/parsers/python.ts +160 -0
  18. package/src/code-graph/parsers/tree-sitter.ts +316 -0
  19. package/src/code-graph/paths.ts +85 -0
  20. package/src/code-graph/prompts.ts +18 -0
  21. package/src/code-graph/resolver.ts +69 -0
  22. package/src/code-graph/runtime.ts +158 -0
  23. package/src/code-graph/schema.ts +135 -0
  24. package/src/code-graph/screening.ts +82 -0
  25. package/src/code-graph/store.ts +278 -0
  26. package/src/code-graph/summary.ts +435 -0
  27. package/src/code-graph/types.ts +163 -0
  28. package/src/compaction.ts +1125 -371
  29. package/src/config-command.ts +326 -0
  30. package/src/exec.ts +356 -686
  31. package/src/refine-prompts.ts +50 -0
  32. package/src/refine-ui-helpers.ts +71 -18
  33. package/src/refine-ui-state.ts +87 -21
  34. package/src/refine-ui.ts +210 -102
  35. package/src/state.ts +19 -6
  36. package/src/subagent.ts +163 -61
  37. package/tests/ask-choice.test.ts +263 -0
  38. package/tests/autocomplete.test.ts +6 -1
  39. package/tests/code-graph-apply.test.ts +185 -0
  40. package/tests/code-graph-commands.test.ts +211 -0
  41. package/tests/code-graph-db.test.ts +166 -0
  42. package/tests/code-graph-discovery.test.ts +38 -0
  43. package/tests/code-graph-git.test.ts +94 -0
  44. package/tests/code-graph-index.test.ts +175 -0
  45. package/tests/code-graph-loop.e2e.test.ts +159 -0
  46. package/tests/code-graph-mutations.test.ts +117 -0
  47. package/tests/code-graph-parser.test.ts +85 -0
  48. package/tests/code-graph-rollback.test.ts +100 -0
  49. package/tests/code-graph-summary-batching.test.ts +518 -0
  50. package/tests/code-graph-summary.test.ts +148 -0
  51. package/tests/compaction.test.ts +371 -57
  52. package/tests/config-command.test.ts +255 -0
  53. package/tests/exec.test.ts +665 -241
  54. package/tests/fixtures/code-graph/sample.js +36 -0
  55. package/tests/fixtures/code-graph/sample.py +20 -0
  56. package/tests/fixtures/code-graph/sample.ts +15 -0
  57. package/tests/graph-aware-file-tools.test.ts +411 -0
  58. package/tests/refine-prompts.test.ts +67 -2
  59. package/tests/refine-ui.test.ts +337 -72
  60. package/tests/subagent.test.ts +26 -20
  61. package/tools/ask-choice.ts +158 -11
  62. package/tools/code-graph.ts +254 -0
  63. package/tools/graph-aware-file-tools.ts +392 -0
  64. package/tools/plans.ts +84 -1
  65. 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,25 @@ 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";
35
39
  import {
36
40
  extractCoverage,
37
41
  latestPlanVersion,
@@ -86,9 +90,6 @@ export function getExecution(): ExecState | null {
86
90
  return execution;
87
91
  }
88
92
 
89
- const EXECUTION_COMPACTION_TRIGGER_PERCENT = 20;
90
- const EXECUTION_COMPACTION_REARM_PERCENT = 80;
91
- const EXECUTION_COMPACTION_REARM_HIGH_PERCENT = 95;
92
93
  const EXECUTION_COMPACTION_RESUME_MESSAGE = "Continue execution.";
93
94
 
94
95
  interface ExecutionCompactionState {
@@ -99,6 +100,11 @@ interface ExecutionCompactionState {
99
100
  lastSuccessfulUsagePercent: number | null;
100
101
  lastSuccessfulAt: string | null;
101
102
  rearmPending: boolean;
103
+ /** Terminal failure metadata is retained for diagnostics, not proactive retry. */
104
+ terminalBackoffTokens: number | null;
105
+ pendingStats: VccCompactionStats | null;
106
+ pendingFollowUpPrompt: string | null;
107
+ pendingContinueAfterThresholdCompact: boolean;
102
108
  }
103
109
 
104
110
  type ExecutionCompactionSession = { __executionCompaction?: ExecutionCompactionState };
@@ -126,6 +132,10 @@ function ensureExecutionCompactionState(ctx: ExtensionContext): ExecutionCompact
126
132
  lastSuccessfulUsagePercent: null,
127
133
  lastSuccessfulAt: null,
128
134
  rearmPending: false,
135
+ terminalBackoffTokens: null,
136
+ pendingStats: null,
137
+ pendingFollowUpPrompt: null,
138
+ pendingContinueAfterThresholdCompact: false,
129
139
  });
130
140
  }
131
141
 
@@ -142,79 +152,12 @@ function consumeExecutionCompactionResumeGuard(ctx: ExtensionContext): boolean {
142
152
  return true;
143
153
  }
144
154
 
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
- }
155
+ export function shouldTriggerExecutionCompaction(_ctx: ExtensionContext): boolean {
156
+ return false;
210
157
  }
211
158
 
212
159
  export function handleExecutionTurnCompaction(ctx: ExtensionContext): void {
213
- refreshExecutionCompactionCooldown(ctx);
214
- if (consumeExecutionCompactionResumeGuard(ctx)) return;
215
- if (shouldTriggerExecutionCompaction(ctx)) {
216
- requestExecutionCompaction(ctx);
217
- }
160
+ consumeExecutionCompactionResumeGuard(ctx);
218
161
  }
219
162
 
220
163
  export function computeExecutionProgress(execution: ExecState): { done: number; total: number } {
@@ -240,11 +183,6 @@ export function computeExecutionProgress(execution: ExecState): { done: number;
240
183
  };
241
184
  }
242
185
 
243
- export function executionProgress(): { done: number; total: number } | null {
244
- if (!execution) return null;
245
- return computeExecutionProgress(execution);
246
- }
247
-
248
186
  function formatElapsed(startedAt: string): string {
249
187
  const total = Math.max(0, Math.floor((Date.now() - Date.parse(startedAt)) / 1000));
250
188
  const h = String(Math.floor(total / 3600)).padStart(2, "0");
@@ -405,368 +343,170 @@ export function registerExecutionTurnHandlers(
405
343
 
406
344
  const EXECUTION_RESUME_CUSTOM_TYPE = "pi-plans-exec-resume";
407
345
 
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)}`;
346
+ function activeVccSettings(ctx: ExtensionContext, phase: PiPlansCompactionPhase): { settings: PiPlansVccSettings; runId: string; artifactDir: string } | null {
347
+ const stateRoot = resolveStateRootOrNull(ctx.cwd);
348
+ if (!stateRoot) return null;
349
+ const active = readActive(ctx.cwd);
350
+ if (!active) return null;
351
+ const run = getRun(ctx.cwd, active.run_id);
352
+ if (!run) return null;
353
+ if (phase === "planning" && run.status !== "planning") return null;
354
+ if (phase === "execution" && run.status !== "executing") return null;
355
+ scaffoldVccSettings(stateRoot);
356
+ return { settings: loadVccSettings(stateRoot), runId: run.run_id, artifactDir: run.artifact_dir };
453
357
  }
454
358
 
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;
359
+ function executionVccContext(): PiPlansVccPhaseContext {
360
+ return {
361
+ phase: "execution",
362
+ planPath: execution?.planPath ?? null,
363
+ currentI: execution?.currentI ?? null,
364
+ remainingVerifierIds: execution?.items.filter((item) => !item.done).map((item) => item.id) ?? [],
365
+ implementationIds: execution?.implItems?.map((item) => item.id) ?? [],
366
+ };
476
367
  }
477
368
 
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;
369
+ function planningVccContext(branchEntries: CompactionEntryLike[], fallback: { runId?: string; artifactDir?: string }): PiPlansVccPhaseContext {
370
+ let runId: string | null = fallback.runId ?? null;
371
+ let artifactDir: string | null = fallback.artifactDir ?? null;
372
+ let planPath: string | null = null;
373
+ let currentI: string | null = null;
374
+ for (const entry of branchEntries) {
375
+ if (entry.type === "custom" && entry.customType === PLANNING_RUN_START_CUSTOM_TYPE) {
376
+ runId = typeof entry.data?.runId === "string" ? entry.data.runId : runId;
377
+ artifactDir = typeof entry.data?.artifactDir === "string" ? entry.data.artifactDir : artifactDir;
496
378
  }
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")}`);
379
+ if (entry.type === "custom" && entry.customType === PLANNING_PLAN_WRITTEN_CUSTOM_TYPE) {
380
+ planPath = typeof entry.data?.planPath === "string" ? entry.data.planPath : planPath;
550
381
  }
382
+ for (const id of entryCurrentIMarkers(entry)) currentI = id;
383
+ currentI = compactionCurrentI(entry) ?? currentI;
551
384
  }
552
- return sections;
385
+ return { phase: "planning", runId, artifactDir, planPath, currentI };
553
386
  }
554
387
 
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 },
388
+ function buildExecutionVccResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): VccCompactionBuildResult | null {
389
+ if (!execution) return null;
390
+ const active = activeVccSettings(ctx, "execution");
391
+ if (!active) return null;
392
+ return buildPiPlansVccCompaction({
393
+ branchEntries: event.branchEntries as unknown as CompactionEntryLike[],
394
+ preparation: event.preparation,
395
+ customInstructions: event.customInstructions,
592
396
  reason: event.reason,
593
397
  willRetry: event.willRetry,
594
- finishedItems: execution.items.filter((item) => item.done).map((item) => item.id),
398
+ settings: active.settings,
399
+ phaseContext: executionVccContext(),
595
400
  });
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
401
  }
612
402
 
613
403
  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
- );
404
+ const built = buildExecutionVccResult(event, ctx);
405
+ return built?.kind === "compaction" ? built.compaction : null;
707
406
  }
708
407
 
709
408
  export function handleExecutionBeforeCompact(
710
409
  pi: ExtensionAPI,
711
410
  ctx: ExtensionContext,
712
411
  event: SessionBeforeCompactEvent,
713
- ): SessionBeforeCompactResult | Promise<SessionBeforeCompactResult | undefined> | undefined {
412
+ ): SessionBeforeCompactResult | undefined {
714
413
  if (!execution) return undefined;
715
- let fallback: CompactionResult | null;
414
+ const state = ensureExecutionCompactionState(ctx);
415
+ state.inFlight = true;
416
+ state.lastAttemptReason = event.reason;
417
+ state.pendingStats = null;
418
+ state.pendingFollowUpPrompt = null;
419
+ state.pendingContinueAfterThresholdCompact = false;
420
+ let built: VccCompactionBuildResult | null;
716
421
  try {
717
- fallback = buildExecutionCompactionResult(event, ctx);
422
+ built = buildExecutionVccResult(event, ctx);
718
423
  } 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");
424
+ state.inFlight = false;
425
+ ctx.ui.notify(`pi-plans: VCC compaction preparation failed; using Pi default compaction (${String(error)}).`, "warning");
722
426
  return undefined;
723
427
  }
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 };
428
+ if (!built || built.kind === "fallback") {
429
+ state.inFlight = false;
430
+ return undefined;
730
431
  }
731
- return buildModelExecutionCompactionResult(event, ctx, fallback).then((compaction) => {
732
- if (!compaction) return undefined;
733
- requestExecutionFlush(pi, ctx);
734
- return { compaction };
735
- });
432
+ if (built.kind === "cancel") {
433
+ state.inFlight = false;
434
+ ctx.ui.notify(built.message, "warning");
435
+ return { cancel: true };
436
+ }
437
+ state.pendingStats = built.stats;
438
+ state.pendingFollowUpPrompt = built.followUpPrompt;
439
+ state.pendingContinueAfterThresholdCompact = built.settings.continueAfterThresholdCompact;
440
+ requestExecutionFlush(pi, ctx);
441
+ return { compaction: built.compaction };
736
442
  }
737
443
 
738
- export function handleExecutionCompact(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactEvent): void {
444
+ function runtimePiVersion(ctx: ExtensionContext): unknown {
445
+ return (ctx as ExtensionContext & { piVersion?: unknown }).piVersion ?? VERSION;
446
+ }
447
+
448
+ export async function handleExecutionCompact(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactEvent): Promise<void> {
739
449
  if (!execution) return;
740
450
  const state = ensureExecutionCompactionState(ctx);
451
+ const stats = state.pendingStats;
452
+ const followUpPrompt = state.pendingFollowUpPrompt;
453
+ const continueAfterThresholdCompact = state.pendingContinueAfterThresholdCompact;
454
+ state.pendingStats = null;
455
+ state.pendingFollowUpPrompt = null;
456
+ state.pendingContinueAfterThresholdCompact = false;
741
457
  state.inFlight = false;
742
458
  state.lastAttemptReason = event.reason;
743
459
  state.cooldownActive = true;
744
460
  state.rearmPending = false;
461
+ state.terminalBackoffTokens = null;
745
462
  state.lastSuccessfulAt = utcNow();
746
463
  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;
464
+ state.resumeGuard = false;
465
+ if (!event.willRetry && stats) {
466
+ ctx.ui.notify(formatVccCompactionStats(stats), "info");
467
+ if (followUpPrompt) {
468
+ await pi.sendUserMessage?.(followUpPrompt);
469
+ } else if ((event.reason === "threshold" || event.reason === "overflow") && shouldScheduleAutoContinue(continueAfterThresholdCompact, runtimePiVersion(ctx))) {
470
+ state.resumeGuard = true;
471
+ pi.sendMessage(
472
+ {
473
+ customType: EXECUTION_RESUME_CUSTOM_TYPE,
474
+ content: EXECUTION_COMPACTION_RESUME_MESSAGE,
475
+ display: false,
476
+ },
477
+ { triggerTurn: true },
478
+ );
479
+ }
759
480
  }
760
481
  requestExecutionFlush(pi, ctx);
761
482
  updateStatusWidget(ctx);
762
483
  }
763
484
 
485
+
764
486
  export function handleExecutionCompactFailed(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactFailedEvent): void {
765
487
  if (!execution) return;
766
488
  const state = executionCompactionState(ctx);
767
- const expectedThresholdCancel = event.reason === "threshold" && event.aborted && !state?.inFlight;
768
- if (expectedThresholdCancel) {
769
- if (state) state.lastAttemptReason = event.reason;
489
+ const terminal = isTerminalCompactionFailure(event);
490
+ if (terminal) {
491
+ // Pi refused or aborted the compaction. Hold the cooldown and re-arm
492
+ // only after real growth or high-watermark pressure so the loop stops.
493
+ if (state) {
494
+ state.inFlight = false;
495
+ state.resumeGuard = false;
496
+ state.cooldownActive = true;
497
+ state.rearmPending = false;
498
+ state.lastAttemptReason = event.reason;
499
+ const tokens = ctx.getContextUsage()?.tokens;
500
+ state.terminalBackoffTokens = typeof tokens === "number" ? tokens : Number.POSITIVE_INFINITY;
501
+ state.pendingStats = null;
502
+ state.pendingFollowUpPrompt = null;
503
+ state.pendingContinueAfterThresholdCompact = false;
504
+ }
505
+ const message = terminal.kind === "content"
506
+ ? "pi-plans: compaction found nothing to summarize; backing off until the session grows past the keep-recent window."
507
+ : "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.";
508
+ ctx.ui.notify(message, "info");
509
+ requestExecutionFlush(pi, ctx);
770
510
  return;
771
511
  }
772
512
  if (state) {
@@ -775,6 +515,9 @@ export function handleExecutionCompactFailed(pi: ExtensionAPI, ctx: ExtensionCon
775
515
  state.cooldownActive = false;
776
516
  state.rearmPending = false;
777
517
  state.lastAttemptReason = event.reason;
518
+ state.pendingStats = null;
519
+ state.pendingFollowUpPrompt = null;
520
+ state.pendingContinueAfterThresholdCompact = false;
778
521
  }
779
522
  ctx.ui.notify(
780
523
  `pi-plans: compaction failed (${event.reason}); execution remains active and will wait for the next eligible turn.`,
@@ -788,15 +531,14 @@ export function filterExecutionResumeMessages<T extends { customType?: string }>
788
531
  }
789
532
 
790
533
  // ---------------------------------------------------------------------------
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.
534
+ // Planning-phase compaction: Pi core owns scheduling; this hook customizes
535
+ // active planning compact events with the same VCC builder used by execution.
536
+ // The two state machines are kept independent (different memory slot and
537
+ // snapshot key) so execution never bleeds into planning.
795
538
  // ---------------------------------------------------------------------------
796
539
 
797
540
  export const PLANNING_RUN_START_CUSTOM_TYPE = "pi-plans-run-start";
798
541
  export const PLANNING_PLAN_WRITTEN_CUSTOM_TYPE = "pi-plans-plan-written";
799
- const PLANNING_QA_SECTION_HEADER = "## Q&A During Planning";
800
542
  const PLANNING_RESUME_CUSTOM_TYPE = "pi-plans-plan-resume";
801
543
 
802
544
  interface PlanningCompactionState {
@@ -806,149 +548,114 @@ interface PlanningCompactionState {
806
548
  lastAttemptReason: "manual" | "threshold" | "overflow" | null;
807
549
  lastSuccessfulUsagePercent: number | null;
808
550
  lastSuccessfulAt: string | null;
551
+ /** Terminal "nothing to compact" backoff: tokens observed when Pi refused. */
552
+ terminalBackoffTokens: number | null;
553
+ pendingStats: VccCompactionStats | null;
554
+ pendingFollowUpPrompt: string | null;
555
+ pendingContinueAfterThresholdCompact: boolean;
809
556
  }
810
557
 
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
- );
558
+ function ensurePlanningCompactionState(ctx: ExtensionContext): PlanningCompactionState {
559
+ const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
560
+ return (session.__planningCompaction ??= {
561
+ inFlight: false,
562
+ resumeGuard: false,
563
+ cooldownActive: false,
564
+ lastAttemptReason: null,
565
+ lastSuccessfulUsagePercent: null,
566
+ lastSuccessfulAt: null,
567
+ terminalBackoffTokens: null,
568
+ pendingStats: null,
569
+ pendingFollowUpPrompt: null,
570
+ pendingContinueAfterThresholdCompact: false,
571
+ });
831
572
  }
832
573
 
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();
574
+ function isTerminalCompactionFailure(event: { errorMessage?: string; aborted?: boolean }): { kind: "content" | "abort-stream" } | null {
575
+ const message = (event.errorMessage ?? "").toLowerCase();
576
+ if (message.includes("nothing to compact") || message.includes("already compacted") || message.includes("session too small")) {
577
+ return { kind: "content" };
578
+ }
579
+ // abort/stream class: explicit event names only, so that provider blips
580
+ // (network down, etc.) stay retryable.
581
+ const abortPatterns = [
582
+ "this operation was aborted",
583
+ "aborted",
584
+ "stream ended before a terminal response event",
585
+ "turn prefix summarization failed",
586
+ "auto-compaction failed",
587
+ "context overflow recovery failed",
588
+ ];
589
+ if (abortPatterns.some((pattern) => message.includes(pattern))) {
590
+ return { kind: "abort-stream" };
591
+ }
592
+ // Aborted with no recognized message: still an abort-class terminal so the
593
+ // next eligible turn does not immediately retry the same operation.
594
+ if (event.aborted === true) {
595
+ return { kind: "abort-stream" };
596
+ }
597
+ return null;
598
+ }
599
+
600
+ /** Session-scoped phase-local "compaction in flight" guard.
601
+ * - Set on `session_before_compact` for the phase attributed by the custom
602
+ * instructions hint; auto-compaction (no hint) marks both phases defensively.
603
+ * - Cleared on `session_compact` and `session_compact_failed`.
604
+ * - Retained so lifecycle events expose the same phase-local state to tests
605
+ * and future Pi core schema additions. */
606
+ type CompactionPhase = "planning" | "execution";
607
+
608
+ function compactionLifecycleStore(ctx: ExtensionContext): {
609
+ planning: boolean;
610
+ execution: boolean;
611
+ } {
612
+ const carrier = ctx.sessionManager as unknown as {
613
+ __piPlansCompactionInFlight?: { planning: boolean; execution: boolean };
614
+ };
615
+ carrier.__piPlansCompactionInFlight ??= { planning: false, execution: false };
616
+ return carrier.__piPlansCompactionInFlight;
839
617
  }
840
618
 
841
- function summarizePlanningEntryLine(entry: PlanningBranchEntry): string | null {
842
- return summarizePlanningMessageLine(entry);
619
+ function isPlanningCustomInstructions(hint: unknown): boolean {
620
+ return typeof hint === "string" && hint.startsWith("pi-plans planning");
843
621
  }
844
622
 
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}`;
623
+ function isExecutionCustomInstructions(hint: unknown): boolean {
624
+ return typeof hint === "string" && hint.startsWith("pi-plans execution");
858
625
  }
859
626
 
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 };
879
- }
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 };
627
+ export function noteCompactionStarted(ctx: ExtensionContext, customInstructions: unknown): void {
628
+ const store = compactionLifecycleStore(ctx);
629
+ if (isPlanningCustomInstructions(customInstructions)) {
630
+ store.planning = true;
631
+ } else if (isExecutionCustomInstructions(customInstructions)) {
632
+ store.execution = true;
633
+ } else {
634
+ // Auto-compaction (threshold/overflow/manual without our hint) marks both.
635
+ store.planning = true;
636
+ store.execution = true;
885
637
  }
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
638
  }
891
639
 
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);
898
- }
899
- if (!lines.length) return null;
900
- return `${PLANNING_QA_SECTION_HEADER}
901
- ${lines.join("\n")}`;
640
+ /** Pi core's `SessionCompactEvent` / `SessionCompactFailedEvent` do not carry
641
+ * `customInstructions` in any emission site, so the END side has no way to
642
+ * know which phase the compaction belonged to. Clearing both phases is the
643
+ * safe default the per-phase start side (above) already encodes the hint
644
+ * attribution. The hint parameter is retained for API symmetry and future
645
+ * Pi core schema additions. */
646
+ export function noteCompactionEnded(ctx: ExtensionContext, _customInstructions: unknown): void {
647
+ const store = compactionLifecycleStore(ctx);
648
+ store.planning = false;
649
+ store.execution = false;
902
650
  }
903
651
 
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 };
652
+ export function compactionInFlight(ctx: ExtensionContext, phase: CompactionPhase): boolean {
653
+ const store = compactionLifecycleStore(ctx);
654
+ return store[phase];
910
655
  }
911
656
 
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
- }
937
- }
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;
657
+ export function shouldTriggerPlanningCompaction(_ctx: ExtensionContext): boolean {
658
+ return false;
952
659
  }
953
660
 
954
661
  export function consumePlanningCompactionResumeGuard(ctx: ExtensionContext): boolean {
@@ -958,119 +665,34 @@ export function consumePlanningCompactionResumeGuard(ctx: ExtensionContext): boo
958
665
  return true;
959
666
  }
960
667
 
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
- }
668
+ export function refreshPlanningCompactionCooldown(_ctx: ExtensionContext): void {
669
+ // Pi core owns scheduling; retained for lifecycle compatibility only.
969
670
  }
970
671
 
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
- }
672
+ export function requestPlanningCompaction(_ctx: ExtensionContext): void {
673
+ // Proactive pi-plans compaction is intentionally disabled. Manual,
674
+ // threshold, and overflow compactions are handled by session_before_compact.
990
675
  }
991
676
 
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,
677
+ function buildPlanningVccResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): VccCompactionBuildResult | null {
678
+ if (getExecution()) return null;
679
+ const active = activeVccSettings(ctx, "planning");
680
+ if (!active) return null;
681
+ const branchEntries = event.branchEntries as unknown as CompactionEntryLike[];
682
+ return buildPiPlansVccCompaction({
683
+ branchEntries,
684
+ preparation: event.preparation,
685
+ customInstructions: event.customInstructions,
1064
686
  reason: event.reason,
1065
- hasMarker: legacyCut.hasMarker,
687
+ willRetry: event.willRetry,
688
+ settings: active.settings,
689
+ phaseContext: planningVccContext(branchEntries, active),
1066
690
  });
1067
- return {
1068
- summary: parts.join("\n\n"),
1069
- firstKeptEntryId,
1070
- tokensBefore: event.preparation.tokensBefore,
1071
- estimatedTokensAfter: iPlan?.metrics.estimatedAfterTokens ?? undefined,
1072
- details,
1073
- };
691
+ }
692
+
693
+ export function buildPlanningCompactionResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): CompactionResult | null {
694
+ const built = buildPlanningVccResult(event, ctx);
695
+ return built?.kind === "compaction" ? built.compaction : null;
1074
696
  }
1075
697
 
1076
698
  export function handlePlanningBeforeCompact(
@@ -1079,82 +701,103 @@ export function handlePlanningBeforeCompact(
1079
701
  event: SessionBeforeCompactEvent,
1080
702
  ): SessionBeforeCompactResult | undefined {
1081
703
  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
- }
704
+ const state = ensurePlanningCompactionState(ctx);
1098
705
  state.inFlight = true;
1099
706
  state.lastAttemptReason = event.reason;
1100
- if (percent !== null && percent < 85) {
1101
- state.cooldownActive = false;
1102
- }
1103
- let compaction: CompactionResult | null;
707
+ state.pendingStats = null;
708
+ state.pendingFollowUpPrompt = null;
709
+ state.pendingContinueAfterThresholdCompact = false;
710
+ let built: VccCompactionBuildResult | null;
1104
711
  try {
1105
- compaction = buildPlanningCompactionResult(event, ctx);
712
+ built = buildPlanningVccResult(event, ctx);
1106
713
  } catch (error) {
1107
714
  state.inFlight = false;
1108
- ctx.ui.notify(`pi-plans: planning compaction preparation failed; using Pi default compaction (${String(error)}).`, "warning");
715
+ ctx.ui.notify(`pi-plans: VCC planning compaction preparation failed; using Pi default compaction (${String(error)}).`, "warning");
1109
716
  return undefined;
1110
717
  }
1111
- if (!compaction) {
718
+ if (!built || built.kind === "fallback") {
1112
719
  state.inFlight = false;
1113
720
  return undefined;
1114
721
  }
1115
- notifyHardFloor(ctx, compaction);
1116
- return { compaction };
722
+ if (built.kind === "cancel") {
723
+ state.inFlight = false;
724
+ ctx.ui.notify(built.message, "warning");
725
+ return { cancel: true };
726
+ }
727
+ state.pendingStats = built.stats;
728
+ state.pendingFollowUpPrompt = built.followUpPrompt;
729
+ state.pendingContinueAfterThresholdCompact = built.settings.continueAfterThresholdCompact;
730
+ return { compaction: built.compaction };
1117
731
  }
1118
732
 
1119
- export function handlePlanningCompact(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactEvent): void {
733
+ export async function handlePlanningCompact(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactEvent): Promise<void> {
1120
734
  if (getExecution()) return;
1121
735
  const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
1122
736
  const state = session.__planningCompaction;
1123
737
  if (!state) return;
738
+ const stats = state.pendingStats;
739
+ const followUpPrompt = state.pendingFollowUpPrompt;
740
+ const continueAfterThresholdCompact = state.pendingContinueAfterThresholdCompact;
741
+ state.pendingStats = null;
742
+ state.pendingFollowUpPrompt = null;
743
+ state.pendingContinueAfterThresholdCompact = false;
1124
744
  state.inFlight = false;
1125
745
  state.lastAttemptReason = event.reason;
746
+ state.terminalBackoffTokens = null;
1126
747
  state.cooldownActive = true;
1127
748
  state.lastSuccessfulAt = utcNow();
1128
749
  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;
750
+ state.resumeGuard = false;
751
+ if (!event.willRetry && stats) {
752
+ ctx.ui.notify(formatVccCompactionStats(stats), "info");
753
+ if (followUpPrompt) {
754
+ await pi.sendUserMessage?.(followUpPrompt);
755
+ } else if ((event.reason === "threshold" || event.reason === "overflow") && shouldScheduleAutoContinue(continueAfterThresholdCompact, runtimePiVersion(ctx))) {
756
+ state.resumeGuard = true;
757
+ pi.sendMessage(
758
+ {
759
+ customType: PLANNING_RESUME_CUSTOM_TYPE,
760
+ content: "Continue planning.",
761
+ display: false,
762
+ },
763
+ { triggerTurn: true },
764
+ );
765
+ }
1141
766
  }
1142
767
  }
1143
768
 
769
+
1144
770
  export function handlePlanningCompactFailed(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactFailedEvent): void {
1145
771
  if (getExecution()) return;
1146
772
  const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
1147
773
  const state = session.__planningCompaction;
1148
774
  if (!state) return;
1149
- const expectedThresholdCancel = event.reason === "threshold" && event.aborted && !state.inFlight;
1150
- if (expectedThresholdCancel) {
775
+ const terminal = isTerminalCompactionFailure(event);
776
+ if (terminal) {
777
+ // Pi refused or aborted the compaction. Hold the cooldown and re-arm
778
+ // only after real growth or high-watermark pressure so the loop stops.
779
+ state.inFlight = false;
780
+ state.resumeGuard = false;
781
+ state.cooldownActive = true;
1151
782
  state.lastAttemptReason = event.reason;
783
+ const tokens = ctx.getContextUsage()?.tokens;
784
+ state.terminalBackoffTokens = typeof tokens === "number" ? tokens : Number.POSITIVE_INFINITY;
785
+ state.pendingStats = null;
786
+ state.pendingFollowUpPrompt = null;
787
+ state.pendingContinueAfterThresholdCompact = false;
788
+ const message = terminal.kind === "content"
789
+ ? "pi-plans: compaction found nothing to summarize; backing off until the session grows past the keep-recent window."
790
+ : "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.";
791
+ ctx.ui.notify(message, "info");
1152
792
  return;
1153
793
  }
1154
794
  state.inFlight = false;
1155
795
  state.resumeGuard = false;
1156
796
  state.cooldownActive = false;
1157
797
  state.lastAttemptReason = event.reason;
798
+ state.pendingStats = null;
799
+ state.pendingFollowUpPrompt = null;
800
+ state.pendingContinueAfterThresholdCompact = false;
1158
801
  ctx.ui.notify(
1159
802
  `pi-plans: planning compaction failed (${event.reason}); will try again on the next eligible turn.`,
1160
803
  "warning",
@@ -1249,14 +892,26 @@ export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext)
1249
892
  const planPath = execution.planPath;
1250
893
  execution = null;
1251
894
  pi.appendEntry("pi-plans-exec-cleared", { reason: "complete" });
895
+ // Post-execution goal-running continuation: in interactive sessions, attach
896
+ // the continuation block and trigger a new turn so the agent immediately
897
+ // enters the implementation-review loop. Headless sessions keep the silent
898
+ // completion behavior. Both completeExecution call sites (turn_end and the
899
+ // restoreFromSession recovery path) share this behavior.
900
+ const interactive = ctx.hasUI === true;
901
+ const content = interactive
902
+ ? `**Plan complete!** ✅ \`${planPath}\`\n\n${summary}\n\n${AMELIORATION_PROMPT_TEXT}`
903
+ : `**Plan complete!** ✅ \`${planPath}\`\n\n${summary}`;
1252
904
  pi.sendMessage(
1253
905
  {
1254
906
  customType: "pi-plans-complete",
1255
- content: `**Plan complete!** ✅ \`${planPath}\`\n\n${summary}`,
907
+ content,
1256
908
  display: true,
1257
909
  },
1258
- { triggerTurn: false },
910
+ { triggerTurn: interactive },
1259
911
  );
912
+ if (interactive) {
913
+ pi.appendEntry("pi-plans-ameliorate", { planPath, phase: "goal-started", rounds: null, currentRound: 0 });
914
+ }
1260
915
  const active = readActive(ctx.cwd);
1261
916
  if (active) {
1262
917
  try {
@@ -1268,12 +923,25 @@ export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext)
1268
923
  updateStatusWidget(ctx);
1269
924
  }
1270
925
 
926
+ /** Instructions appended to the post-execution completion message in
927
+ * interactive sessions, telling the agent to enter the goal-running
928
+ * implementation-review loop. */
929
+ export const AMELIORATION_PROMPT_TEXT = `---
930
+ Goal-running continuation: immediately ask the user now via ask_choice (autoComplete: false, in the session language) for the termination condition of the implementation-review loop: until no high-severity finding (hard cap 5 rounds, recommended) / 1 round / 2 rounds / 3 rounds. Then keep running the loop without asking whether to continue: each round calls refine (role: "reviewer", target: "implementation"), accepts findings on evidence, applies fixes, re-runs relevant tests, and repeats until the chosen termination condition or the 5-round cap.`;
931
+
1271
932
  /** Injection text for before_agent_start while executing. */
1272
- export function executionContextMessage(): string | null {
933
+ export function executionContextMessage(ctx: ExtensionContext): string | null {
1273
934
  if (!execution) return null;
1274
935
  const remaining = execution.items.filter((item) => !item.done);
1275
936
  const list =
1276
937
  remaining.map((item) => `- \`${item.id}\` ${item.text}`).join("\n") || "(none — report completion now)";
938
+ // Live read: the injected guidance and the tool wrappers share the same
939
+ // tri-state, so they can never contradict each other mid-run.
940
+ const mode = resolveGraphMode(ctx?.cwd ?? process.cwd());
941
+ const graphLine =
942
+ mode === "config-unavailable"
943
+ ? `${graphBlockForExecutor(false)}\n[pi-plans: config unreadable this turn; graph features are off until .git/pi_plans/config.json is repaired]`
944
+ : graphBlockForExecutor(mode === "enabled");
1277
945
  const implementationItems = execution.implItems?.length
1278
946
  ? `\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
947
  : "";
@@ -1283,6 +951,8 @@ Implement the accepted plan at ${execution.planPath} (${execution.items.length -
1283
951
  Remaining verifier items:
1284
952
  ${list}${implementationItems}
1285
953
 
954
+ ${graphLine}
955
+
1286
956
  Execution rules:
1287
957
  - 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
958
  - 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.