pi-goal-x 0.27.4 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -343,6 +343,38 @@ Completed and cleared goals are stored in:
343
343
 
344
344
  Each session can focus on one goal while the project keeps other goals open.
345
345
 
346
+ ## Session checkpoint recovery
347
+
348
+ Auto-continue checkpoints are tiny structured markers (≤160 chars) that
349
+ trigger the next turn; the full goal state is injected once per turn by the
350
+ extension and is never persisted into checkpoints. Sessions created by older
351
+ versions may still contain large "legacy" full-prompt checkpoints.
352
+
353
+ Check `/goal-recovery` or `/goal-status health` for a read-only report:
354
+
355
+ ```text
356
+ Session checkpoints:
357
+ total: 851
358
+ legacy full checkpoints: 851
359
+ checkpoint content: 5.4 MB
360
+ projected content after recovery: 92 KB
361
+ ```
362
+
363
+ To repair an affected session file, close Pi first (the tool refuses to run
364
+ while a live session could be open), then:
365
+
366
+ ```bash
367
+ # Report only — writes nothing:
368
+ pi-goal-x-recover --session <session.jsonl>
369
+
370
+ # Repair (creates a timestamped backup, rewrites only checkpoint entries):
371
+ pi-goal-x-recover --session <session.jsonl> --apply --confirm-pi-closed
372
+ ```
373
+
374
+ Recovery preserves every entry id and parent link, keeps all non-goal lines
375
+ byte-identical (including malformed lines), and is idempotent. Rollback is
376
+ the timestamped `.backup-*` file created next to the session.
377
+
346
378
  ## Commands
347
379
 
348
380
  ```text
@@ -368,12 +400,21 @@ Each session can focus on one goal while the project keeps other goals open.
368
400
 
369
401
  ## Configuration
370
402
 
371
- Settings are stored in:
403
+ Settings resolve per setting in this order (highest wins):
404
+
405
+ ```text
406
+ environment > project layer > global layer > defaults
407
+ ```
408
+
409
+ Files:
372
410
 
373
411
  ```text
374
- .pi/pi-goal-x-settings.json
412
+ global: ~/.pi/pi-goal-x-settings.json (or $PI_CODING_AGENT_DIR, or $PI_GOAL_GLOBAL_SETTINGS_FILE)
413
+ project: <cwd>/.pi/pi-goal-x-settings.json (or $PI_GOAL_SETTINGS_FILE)
375
414
  ```
376
415
 
416
+ Define shared configuration once in the global file and override per project. Explicit `false`/`0` values in a lower layer override inherited values; nested `keybindings` inherit per key. `/goal-settings` shows each row's effective value and source, can switch the edited scope, and can remove a local override to return to inheritance.
417
+
377
418
  Use `/goal-settings` to configure task lists, verification contracts, subtask depth, automatic goal selection, and completion auditing. Goal objectives have no hard length limit by default; set `objectiveMaxChars` (or `PI_GOAL_OBJECTIVE_MAX_CHARS`, `0` = no limit) to cap objective length across `create_goal`, `propose_goal_draft`, and `/goal-tweak`.
378
419
 
379
420
  Configure the task shortcuts in the same file when the terminal captures the defaults:
@@ -5,10 +5,15 @@ import { extractVerificationContract, sisyphusObjectiveSufficient } from "./goal
5
5
  import { detailedSummary, oneLineSummary } from "./goal-format.ts";
6
6
  import {
7
7
  goalSettingsPath,
8
+ goalGlobalSettingsPath,
8
9
  loadGoalSettings,
9
- loadGoalSettingsFileConfig,
10
- saveGoalSettingsFileConfig,
10
+ loadSettingsSnapshot,
11
+ mutateSettingsLayer,
12
+ readSettingsLayer,
13
+ envOverrideFor,
11
14
  type GoalSettings,
15
+ type SettingsMutation,
16
+ type SettingsScope,
12
17
  } from "./goal-settings.ts";
13
18
  import {
14
19
  buildGoalListText,
@@ -19,11 +24,12 @@ import {
19
24
  import { clearGoalCommandMessage, validateResumeGoal } from "./goal-policy.ts";
20
25
  import { invalidateGoalLedgerCache, readGoalLedger } from "./goal-ledger.ts";
21
26
  import { buildGoalStatusText } from "./goal-status.ts";
22
- import { effectiveSettingsReport, envOverrideFor, invalidateGoalSettingsCache } from "./goal-settings.ts";
27
+ import { effectiveSettingsReport, invalidateGoalSettingsCache, loadGoalSettingsFileConfig } from "./goal-settings.ts";
23
28
  import { invalidateGoalPoolCache, mergeGoalPromptFromDisk, readActiveGoalPool } from "./storage/goal-files.ts";
24
29
  import { nowIso, type GoalMode, type GoalRecord } from "./goal-record.ts";
25
30
  import { clearGoalDrafting, hasActiveDraft, startGoalDrafting } from "./goal-drafting.ts";
26
31
  import { formatRecoveryReport, runRecoveryReport, runRecoveryRepair } from "./goal-recovery.ts";
32
+ import { formatCheckpointHealthReport, readSessionCheckpointHealth } from "./goal-session-health.ts";
27
33
 
28
34
  export interface GoalRefreshState {
29
35
  poolIds: Iterable<string>;
@@ -206,7 +212,18 @@ export function registerGoalCommands(core: GoalCore): void {
206
212
  }
207
213
  return;
208
214
  }
209
- ctx.ui.notify(formatRecoveryReport(report), report.healthy ? "info" : "warning");
215
+ // Issue #30: surface persisted-checkpoint health for this session file
216
+ // (read-only). Legacy full checkpoints are repairable offline with the
217
+ // shipped pi-goal-x-recover CLI; this command never writes.
218
+ const sessionFile = (ctx.sessionManager as { getSessionFile?: () => string | undefined } | undefined)?.getSessionFile?.();
219
+ const checkpointHealth = sessionFile ? readSessionCheckpointHealth(sessionFile) : null;
220
+ const checkpointSection = checkpointHealth && checkpointHealth.total > 0
221
+ ? `\n\n${formatCheckpointHealthReport(checkpointHealth, sessionFile)}`
222
+ : "";
223
+ ctx.ui.notify(
224
+ formatRecoveryReport(report) + checkpointSection,
225
+ report.healthy && (checkpointHealth?.legacyFull ?? 0) === 0 ? "info" : "warning",
226
+ );
210
227
  }
211
228
 
212
229
  async function runGoalRefresh(ctx: ExtensionContext): Promise<void> {
@@ -271,6 +288,13 @@ export function registerGoalCommands(core: GoalCore): void {
271
288
  activeFilePresent: health && view?.activePath
272
289
  ? existsSync(path.resolve(ctx.cwd, view.activePath))
273
290
  : undefined,
291
+ // Issue #30: checkpoint growth report (health view only, read-only).
292
+ checkpointHealth: health
293
+ ? readSessionCheckpointHealth(
294
+ (ctx.sessionManager as { getSessionFile?: () => string | undefined } | undefined)?.getSessionFile?.() ?? "",
295
+ )
296
+ : null,
297
+ checkpointSessionFile: (ctx.sessionManager as { getSessionFile?: () => string | undefined } | undefined)?.getSessionFile?.(),
274
298
  // §13.2: effective settings with provenance appear only in verbose mode;
275
299
  // the standard mode stays free of settings noise (§13.1).
276
300
  settingsReport: verbose ? effectiveSettingsReport(ctx.cwd) : [],
@@ -364,6 +388,7 @@ export function registerGoalCommands(core: GoalCore): void {
364
388
 
365
389
  const SETTING_ROWS: readonly SettingRow[] = [
366
390
  { key: "autoSelectSingleGoal", label: "autoSelectSingleGoal", section: "Goal behavior", kind: "boolean" },
391
+ { key: "hideUnfocusedBanner", label: "hideUnfocusedBanner", section: "Goal behavior", kind: "boolean" },
367
392
  { key: "disableContracts", label: "disableContracts", section: "Goal behavior", kind: "boolean" },
368
393
  { key: "stallTimeoutMinutes", label: "stall timeout (minutes)", section: "Goal behavior", kind: "positiveInteger" },
369
394
  { key: "objectiveMaxChars", label: "max objective length (0 = none)", section: "Goal behavior", kind: "positiveInteger" },
@@ -376,7 +401,7 @@ export function registerGoalCommands(core: GoalCore): void {
376
401
  ];
377
402
 
378
403
  function settingsValue(config: GoalSettings, key: keyof GoalSettings): string {
379
- if (key === "disabled" || key === "disableTasks" || key === "disableContracts" || key === "autoSelectSingleGoal" || key === "auditorProjectResources") {
404
+ if (key === "disabled" || key === "disableTasks" || key === "disableContracts" || key === "autoSelectSingleGoal" || key === "auditorProjectResources" || key === "hideUnfocusedBanner") {
380
405
  return config[key] === true ? "true" : "false";
381
406
  }
382
407
  if (key === "subtaskDepth") return config.subtaskDepth !== undefined ? String(config.subtaskDepth) : "1";
@@ -393,29 +418,52 @@ export function registerGoalCommands(core: GoalCore): void {
393
418
 
394
419
  async function handleSettingsMenu(ctx: ExtensionContext): Promise<void> {
395
420
  if (!ctx.hasUI) {
396
- ctx.ui.notify(`Settings file: ${goalSettingsPath(ctx.cwd)}`, "info");
421
+ // Headless: read-only report of both layer paths.
422
+ ctx.ui.notify(
423
+ `Settings — project file: ${goalSettingsPath(ctx.cwd)}\nGlobal file: ${goalGlobalSettingsPath()}`,
424
+ "info",
425
+ );
397
426
  return;
398
427
  }
428
+
399
429
  /**
400
- * Persist a settings edit, then reinstall the fixed three/five profile
401
- * when the effective disableTasks value changed since the last install.
402
- * core.tasksEnabled tracks the last profile actually installed (updated
403
- * by installGoalToolProfile), so toggling task availability off, on, and
404
- * off within one menu session reinstalls on every real change instead of
405
- * comparing against the value captured when the menu opened.
430
+ * After any mutation: reinstall the fixed three/five tool profile when
431
+ * the effective disableTasks value changed since the last install, and
432
+ * refresh the UI. This is the central settings side-effect hook — no
433
+ * ad hoc per-row refresh code.
406
434
  */
407
- const saveSettings = (next: GoalSettings): void => {
408
- saveGoalSettingsFileConfig(ctx.cwd, next);
435
+ const applyEffectiveSettings = (): void => {
409
436
  const tasksEnabledNow = !loadGoalSettings(ctx.cwd).disableTasks;
410
437
  if (tasksEnabledNow !== core.tasksEnabled) {
411
438
  core.installGoalToolProfile(tasksEnabledNow);
412
439
  }
440
+ // PR #29: settings changes must be visible immediately — the banner
441
+ // hide/restore happens through this coalesced UI refresh.
442
+ core.updateUI(ctx);
413
443
  };
444
+
445
+ const applyMutation = (scope: SettingsScope, mutation: SettingsMutation): boolean => {
446
+ try {
447
+ mutateSettingsLayer({ scope, cwd: ctx.cwd, mutation });
448
+ applyEffectiveSettings();
449
+ return true;
450
+ } catch (err) {
451
+ ctx.ui.notify(`Settings change failed: ${err instanceof Error ? err.message : String(err)}`, "warning");
452
+ return false;
453
+ }
454
+ };
455
+
456
+ const scopePath = (scope: SettingsScope): string =>
457
+ scope === "global" ? goalGlobalSettingsPath() : goalSettingsPath(ctx.cwd);
458
+
414
459
  core.enterGoalModal();
415
460
  try {
461
+ let scope: SettingsScope = "project";
416
462
  while (true) {
417
- const config = loadGoalSettingsFileConfig(ctx.cwd);
463
+ const snapshot = loadSettingsSnapshot(ctx.cwd);
464
+ const localLayer = snapshot[scope].layer as Record<string, unknown>;
418
465
  const options: string[] = [];
466
+ options.push(`─── Editing: ${scope} (${scopePath(scope)}) ───`);
419
467
  let lastSection: string | null = null;
420
468
  for (const row of SETTING_ROWS) {
421
469
  if (row.section !== lastSection) {
@@ -423,109 +471,147 @@ export function registerGoalCommands(core: GoalCore): void {
423
471
  lastSection = row.section;
424
472
  }
425
473
  const envVar = envOverrideFor(row.key);
426
- options.push(envVar ? ` ${row.label}: ${settingsValue(config, row.key)} (env: ${envVar} — read-only)` : ` ${row.label}: ${settingsValue(config, row.key)}`);
474
+ if (envVar) {
475
+ options.push(` ${row.label}: ${settingsValue(snapshot.value, row.key)} (environment; read-only)`);
476
+ continue;
477
+ }
478
+ const provenance = snapshot.provenance.get(String(row.key));
479
+ let source: string;
480
+ if (provenance?.source === "global") source = scope === "project" ? "(inherited from global)" : "(global override)";
481
+ else if (provenance?.source === "project") source = scope === "project" ? "(project override)" : "(project; edit via project scope)";
482
+ else source = "(default)";
483
+ const hasLocalOverride = localLayer[row.key] !== undefined;
484
+ if (hasLocalOverride && provenance?.source === scope) source = `(${scope} override)`;
485
+ options.push(` ${row.label}: ${settingsValue(snapshot.value, row.key)} ${source}`);
427
486
  }
428
487
  options.push("Done");
429
488
  const selected = await ctx.ui.select("Goal settings", options);
430
489
  if (!selected || selected === "Done") break;
431
- if (selected.startsWith("───")) continue; // section headers are not rows
432
- // Strip leading spaces and resolve the row from the display label.
433
- const selectedTrimmed = selected.trim();
434
- const colon = selectedTrimmed.indexOf(":");
490
+ if (selected.startsWith("───")) continue; // headers are not rows
491
+ const trimmed = selected.trim();
492
+ if (trimmed.startsWith("Scope:")) {
493
+ scope = scope === "project" ? "global" : "project";
494
+ continue;
495
+ }
496
+ const colon = trimmed.indexOf(":");
435
497
  if (colon === -1) continue;
436
- const label = selectedTrimmed.slice(0, colon).trim();
437
- const row = SETTING_ROWS.find((r) => r.label === label);
438
- if (!row) continue;
439
- const key = row.key;
440
- if (envOverrideFor(key)) {
441
- ctx.ui.notify(`${row.label} is read-only: overridden by the ${envOverrideFor(key)} env var.`, "warning");
442
- continue;
443
- }
444
- if (row.kind === "boolean") {
445
- const next = { ...config, [key]: config[key] !== true };
446
- saveSettings(next);
447
- ctx.ui.notify(`Settings saved:\n${settingsLines(loadGoalSettingsFileConfig(ctx.cwd)).join("\n")}`, "info");
448
- continue;
449
- }
450
- if (row.kind === "positiveInteger") {
451
- const input = await ctx.ui.input(`Set ${row.label}`, settingsValue(config, key));
452
- if (input === undefined) continue;
453
- // Row-driven lower bound: subtaskDepth is a nesting depth (min 1);
454
- // stallTimeoutMinutes and objectiveMaxChars default to 0 meaning
455
- // "off / no limit".
456
- const min = (row.key === "stallTimeoutMinutes" || row.key === "objectiveMaxChars") ? 0 : 1;
457
- // Full-string decimal validation: no partial parseInt. Rejects
458
- // 1.5, 1x, negatives, infinity, and unsafe integers alike.
459
- const trimmed = input.trim();
460
- if (!/^[0-9]+$/.test(trimmed) || !Number.isSafeInteger(Number(trimmed)) || Number(trimmed) < min) {
461
- ctx.ui.notify(`${row.label} must be an integer >= ${min} (e.g. ${min}, ${min + 1}, ${min + 2})`, "warning");
498
+ const label = trimmed.slice(0, colon).trim();
499
+ const row = SETTING_ROWS.find((r) => r.label === label);
500
+ if (!row) continue;
501
+ if (envOverrideFor(row.key)) {
502
+ ctx.ui.notify(`${row.label} is read-only: overridden by the ${envOverrideFor(row.key)} env var.`, "warning");
462
503
  continue;
463
504
  }
464
- const next = { ...config, [key]: Number(trimmed) };
465
- saveSettings(next);
466
- ctx.ui.notify(`Settings saved:\n${settingsLines(loadGoalSettingsFileConfig(ctx.cwd)).join("\n")}`, "info");
467
- continue;
468
- }
469
- if (row.kind === "thinking") {
470
- const currentValue = settingsValue(config, key);
471
- const levels = thinkingLevelChoices(currentValue === "(default)" ? undefined : currentValue);
472
- const picked = await ctx.ui.select(`Set ${row.label}`, levels);
473
- if (!picked) continue;
474
- const trimmed = picked.trim().replace(/^\u2713\s+/, "");
475
- const next: GoalSettings = { ...config };
476
- if (trimmed === "(default)") {
477
- delete next.thinkingLevel;
478
- } else if ((AUDITOR_THINKING_LEVELS as readonly string[]).includes(trimmed)) {
479
- next.thinkingLevel = trimmed as GoalSettings["thinkingLevel"];
480
- } else {
481
- ctx.ui.notify(`thinking_level must be one of: ${AUDITOR_THINKING_LEVELS.join(", ")} (or "(default)")`, "warning");
505
+
506
+ const inheritLabel = scope === "project" ? "Use inherited value" : "Use default (delete global override)";
507
+ const hasLocalOverride = localLayer[row.key] !== undefined;
508
+
509
+ if (row.kind === "boolean") {
510
+ const effective = snapshot.provenance.get(String(row.key))?.value === true;
511
+ const actions = [
512
+ `Set ${scope} override to true`,
513
+ `Set ${scope} override to false`,
514
+ ];
515
+ if (hasLocalOverride) actions.push(inheritLabel);
516
+ actions.push("Cancel");
517
+ const action = await ctx.ui.select(`${row.label} (${scope})`, actions);
518
+ if (!action || action === "Cancel") continue;
519
+ if (action === inheritLabel) {
520
+ applyMutation(scope, { op: "unset", path: [row.key] });
521
+ continue;
522
+ }
523
+ const value = action.endsWith("true");
524
+ if (value !== effective || !hasLocalOverride) {
525
+ applyMutation(scope, { op: "set", path: [row.key], value });
526
+ }
482
527
  continue;
483
528
  }
484
- saveSettings(next);
485
- ctx.ui.notify(`Settings saved:\n${settingsLines(loadGoalSettingsFileConfig(ctx.cwd)).join("\n")}`, "info");
486
- continue;
487
- }
488
- // modelSelector rows (provider, model): searchable auditor model
489
- // picker with current-session/default, authenticated models (\u2713
490
- // marker on the exact current selection), and a manual
491
- // provider/model entry (ll01 pattern). Either row applies the same
492
- // provider+model pair.
493
- const configured = configuredAuditorModelKey(config);
494
- const session = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
495
- const choices = buildAuditorModelChoices(ctx.modelRegistry.getAvailable(), configured, session);
496
- const filter = await ctx.ui.input("Filter auditor models (provider/id/name; blank = all)", "");
497
- if (filter === undefined) continue;
498
- const filtered = filterAuditorModelChoices(choices, filter);
499
- const picked = await ctx.ui.select("Select auditor model", filtered.map((choice) => choice.label));
500
- if (!picked) continue;
501
- const choice = filtered.find((candidate: AuditorChoice) => candidate.label === picked);
502
- if (!choice) continue;
503
- const next: GoalSettings = { ...config };
504
- if (choice.kind === "default") {
505
- delete next.provider;
506
- delete next.model;
507
- } else if (choice.kind === "manual") {
508
- const input = await ctx.ui.input("Set auditor provider/model", configured ?? "provider/model");
509
- if (input === undefined) continue;
510
- const parsed = parseManualAuditorModel(input);
511
- if ("error" in parsed) {
512
- ctx.ui.notify(parsed.error, "warning");
529
+
530
+ if (row.kind === "positiveInteger") {
531
+ const min = (row.key === "stallTimeoutMinutes" || row.key === "objectiveMaxChars") ? 0 : 1;
532
+ const actions = [`Set ${scope} override...`];
533
+ if (hasLocalOverride) actions.push(inheritLabel);
534
+ actions.push("Cancel");
535
+ const action = await ctx.ui.select(`${row.label} (${scope})`, actions);
536
+ if (!action || action === "Cancel") continue;
537
+ if (action === inheritLabel) {
538
+ applyMutation(scope, { op: "unset", path: [row.key] });
539
+ continue;
540
+ }
541
+ const input = await ctx.ui.input(`Set ${row.label}`, String(snapshot.provenance.get(String(row.key))?.value ?? min));
542
+ if (input === undefined) continue;
543
+ // Full-string decimal validation: rejects 1.5, 1x, negatives,
544
+ // infinity, and unsafe integers alike.
545
+ const value = input.trim();
546
+ if (!/^[0-9]+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < min) {
547
+ ctx.ui.notify(`${row.label} must be an integer >= ${min} (e.g. ${min}, ${min + 1}, ${min + 2})`, "warning");
548
+ continue;
549
+ }
550
+ applyMutation(scope, { op: "set", path: [row.key], value: Number(value) });
513
551
  continue;
514
552
  }
515
- next.provider = parsed.provider;
516
- next.model = parsed.model;
517
- } else {
518
- next.provider = choice.provider;
519
- next.model = choice.model;
520
- }
521
- saveSettings(next);
522
- ctx.ui.notify(`Settings saved:\n${settingsLines(loadGoalSettingsFileConfig(ctx.cwd)).join("\n")}`, "info");
553
+
554
+ if (row.kind === "thinking") {
555
+ const currentValue = settingsValue(snapshot.value, row.key);
556
+ const levels = thinkingLevelChoices(currentValue === "(default)" ? undefined : currentValue);
557
+ const picked = await ctx.ui.select(`Set ${row.label} (${scope})`, levels);
558
+ if (!picked) continue;
559
+ const choice = picked.trim().replace(/^\u2713\s+/, "");
560
+ if (choice === "(default)") {
561
+ if (hasLocalOverride) applyMutation(scope, { op: "unset", path: ["thinkingLevel"] });
562
+ continue;
563
+ }
564
+ if (!(AUDITOR_THINKING_LEVELS as readonly string[]).includes(choice)) {
565
+ ctx.ui.notify(`thinking_level must be one of: ${AUDITOR_THINKING_LEVELS.join(", ")} (or "(default)")`, "warning");
566
+ continue;
567
+ }
568
+ applyMutation(scope, { op: "set", path: ["thinkingLevel"], value: choice });
569
+ continue;
570
+ }
571
+
572
+ // modelSelector rows (provider, model): searchable auditor model
573
+ // picker with explicit inherit/default handling. Either row applies
574
+ // the same provider+model pair.
575
+ const configured = configuredAuditorModelKey(snapshot.value as GoalSettings);
576
+ const session = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
577
+ const choices = buildAuditorModelChoices(ctx.modelRegistry.getAvailable(), configured, session);
578
+ const filter = await ctx.ui.input("Filter auditor models (provider/id/name; blank = all)", "");
579
+ if (filter === undefined) continue;
580
+ const filtered = filterAuditorModelChoices(choices, filter);
581
+ const picked = await ctx.ui.select("Select auditor model", filtered.map((choice: AuditorChoice) => choice.label));
582
+ if (!picked) continue;
583
+ const choice = filtered.find((candidate: AuditorChoice) => candidate.label === picked);
584
+ if (!choice) continue;
585
+ if (choice.kind === "default") {
586
+ for (const key of ["provider", "model"] as const) {
587
+ if (localLayer[key] !== undefined) applyMutation(scope, { op: "unset", path: [key] });
588
+ }
589
+ continue;
590
+ }
591
+ let providerValue: string;
592
+ let modelValue: string;
593
+ if (choice.kind === "manual") {
594
+ const input = await ctx.ui.input("Set auditor provider/model", configured ?? "provider/model");
595
+ if (input === undefined) continue;
596
+ const parsed = parseManualAuditorModel(input);
597
+ if ("error" in parsed) {
598
+ ctx.ui.notify(parsed.error, "warning");
599
+ continue;
600
+ }
601
+ providerValue = parsed.provider;
602
+ modelValue = parsed.model;
603
+ } else {
604
+ providerValue = choice.provider;
605
+ modelValue = choice.model;
606
+ }
607
+ if (applyMutation(scope, { op: "set", path: ["provider"], value: providerValue })) {
608
+ applyMutation(scope, { op: "set", path: ["model"], value: modelValue });
609
+ }
523
610
  }
524
611
  } finally {
525
612
  core.exitGoalModal();
526
613
  }
527
614
  }
528
-
529
615
  async function handleGoalClear(ctx: ExtensionContext): Promise<void> {
530
616
  core.reconcileFocusedGoalFromDisk(ctx);
531
617
  if (!core.state.goal && core.openGoals().length > 0) {
@@ -17,9 +17,10 @@ import { shouldArmPostCompactReminder, shouldInjectPostCompactReminder } from ".
17
17
  import { formatTokenValue } from "./goal-core.ts";
18
18
  import { loadGoalSettings, invalidateGoalSettingsCache } from "./goal-settings.ts";
19
19
  import { budgetLine, budgetRemaining } from "./goal-accounting.ts";
20
- import { asRecord, nowIso, type AssistantMessageLike } from "./goal-record.ts";
20
+ import { asRecord, nowIso, type AssistantMessageLike, type GoalRecord } from "./goal-record.ts";
21
21
  import { goalSelectorLabel } from "./goal-pool.ts";
22
- import { invalidateGoalPoolCache } from "./storage/goal-files.ts";import {
22
+ import { invalidateGoalPoolCache } from "./storage/goal-files.ts";
23
+ import { checkpointTriggerPrompt } from "./prompts/goal-prompts.ts";import {
23
24
  goalPrompt,
24
25
  staleContinuationPrompt,
25
26
  unfocusedOpenGoalsPrompt,
@@ -30,6 +31,56 @@ import { syncTerminalInputPause } from "./goal-widget.ts";
30
31
  import type { GoalCore } from "./goal-state.ts";
31
32
  import type { GoalMutationOutcome } from "./goal-service.ts";
32
33
 
34
+ /**
35
+ * Issue #30: provider-context checkpoint compaction (pure helper).
36
+ *
37
+ * Every historical checkpoint message is redundant: its authoritative state is
38
+ * reconstructed from goal storage and injected once per turn by
39
+ * before_agent_start. Normal provider requests therefore retain at most ONE
40
+ * checkpoint marker — the latest — rewritten to the tiny bounded v2 trigger
41
+ * content. Keeping one user-role turn-start marker avoids provider edge cases
42
+ * where removing it would leave the request ending on an assistant or tool
43
+ * result. Audit events, user messages, assistant messages, and tool results
44
+ * pass through untouched.
45
+ */
46
+ export function compactGoalCheckpointContext(
47
+ messages: readonly unknown[],
48
+ currentGoal: GoalRecord | null,
49
+ ): unknown[] | null {
50
+ let lastCheckpointIndex = -1;
51
+ for (let i = 0; i < messages.length; i += 1) {
52
+ if (goalEventMessageId(messages[i] as { customType?: string; details?: unknown; content?: unknown }) !== null) {
53
+ lastCheckpointIndex = i;
54
+ }
55
+ }
56
+ if (lastCheckpointIndex < 0) return null;
57
+
58
+ const output: unknown[] = [];
59
+ for (let i = 0; i < messages.length; i += 1) {
60
+ const message = messages[i] as { customType?: string; details?: unknown; content?: unknown };
61
+ const checkpointGoalId = goalEventMessageId(message);
62
+ if (checkpointGoalId === null) {
63
+ output.push(messages[i]);
64
+ continue;
65
+ }
66
+ // Every historical checkpoint is dropped entirely.
67
+ if (i !== lastCheckpointIndex) continue;
68
+ output.push({
69
+ ...(message as Record<string, unknown>),
70
+ content: checkpointTriggerPrompt(checkpointGoalId),
71
+ display: false,
72
+ details: {
73
+ version: 2,
74
+ kind: currentGoal?.id === checkpointGoalId && currentGoal?.status === "active" ? "checkpoint" : "stale",
75
+ goalId: checkpointGoalId,
76
+ currentGoalId: currentGoal?.id ?? null,
77
+ currentStatus: currentGoal?.status ?? null,
78
+ },
79
+ });
80
+ }
81
+ return output;
82
+ }
83
+
33
84
  /**
34
85
  * The goal extension's lifecycle event handlers (context, turn_start,
35
86
  * tool_call, tool_execution_end, turn_end, message_end, session_start,
@@ -41,40 +92,10 @@ export function registerGoalEvents(core: GoalCore): void {
41
92
  const { pi } = core;
42
93
  let continuationAfterSettleFor: string | null = null;
43
94
 
44
- pi.on("context", async (event): Promise<{ messages: typeof event.messages } | undefined> => {
45
- let changed = false;
46
- const latestGoalEventIndex = new Map<string, number>();
47
- event.messages.forEach((message, index) => {
48
- const queuedGoalId = goalEventMessageId(message as { customType?: string; details?: unknown; content?: unknown });
49
- if (queuedGoalId) latestGoalEventIndex.set(queuedGoalId, index);
50
- });
51
-
52
- const messages = event.messages.map((message, index) => {
53
- const candidate = message as { customType?: string; details?: unknown; content?: unknown };
54
- const queuedGoalId = goalEventMessageId(candidate);
55
- if (!queuedGoalId) return message;
56
- if (
57
- core.state.goal?.id === queuedGoalId
58
- && (core.state.goal.status === "active")
59
- && core.state.goal.autoContinue
60
- && latestGoalEventIndex.get(queuedGoalId) === index
61
- ) return message;
62
- changed = true;
63
- const details = asRecord(candidate.details) ?? {};
64
- return {
65
- ...message,
66
- content: staleContinuationPrompt(queuedGoalId, core.state.goal),
67
- display: false,
68
- details: {
69
- ...details,
70
- kind: "stale",
71
- goalId: queuedGoalId,
72
- currentGoalId: core.state.goal?.id ?? null,
73
- currentStatus: core.state.goal?.status ?? null,
74
- },
75
- } as typeof message;
76
- });
77
- return changed ? { messages } : undefined;
95
+ pi.on("context", async (event) => {
96
+ const messages = compactGoalCheckpointContext(event.messages, core.state.goal);
97
+ // Reference equality means no goal-event messages existed at all.
98
+ return messages === null ? undefined : { messages: messages as typeof event.messages };
78
99
  });
79
100
 
80
101
  pi.on("turn_start", async (_event, ctx) => {
@@ -176,17 +176,38 @@ export function renderGoalAuditEvent(message: { content?: unknown; details?: Goa
176
176
  );
177
177
  }
178
178
 
179
- export function extractGoalIdFromInjectedMessage(text: string): string | null {
179
+ /**
180
+ * Issue #30 v2 markers are self-closing and carry an explicit version:
181
+ * `<pi_goal_continuation goal_id="..." kind="checkpoint" v="2"/>`.
182
+ */
183
+ function matchV2SelfClosingContinuation(text: string): string | null {
184
+ const match = text.match(/^<pi_goal_continuation\s+goal_id="([^"]+)"[^>]*v="2"\s*\/?>/);
185
+ return match?.[1] ?? null;
186
+ }
187
+
188
+ function matchLegacyContinuation(text: string): string | null {
180
189
  // Phase 5 C1: structured outer marker `<pi_goal_continuation goal_id="..." kind="...">`.
181
190
  // Borrowed from pi-codex-goal. More robust than bare bracket text because
182
191
  // the angle brackets + attributes are nearly impossible for users to type
183
192
  // by accident, and the structure is grep-able / parse-able by external tooling.
184
193
  const xmlMatch = text.match(/^<pi_goal_continuation\s+goal_id="([^"]+)"/);
185
194
  if (xmlMatch) return xmlMatch[1] ?? null;
195
+ return null;
196
+ }
197
+
198
+ function matchLegacyBracketCheckpoint(text: string): string | null {
186
199
  const match = text.match(/^\[(?:GOAL CHECKPOINT|GOAL CONTINUATION|GOAL STALE) goalId=([^\]\s]+)\]/);
187
200
  return match?.[1] ?? null;
188
201
  }
189
202
 
203
+ export function extractGoalIdFromInjectedMessage(text: string): string | null {
204
+ return (
205
+ matchV2SelfClosingContinuation(text)
206
+ ?? matchLegacyContinuation(text)
207
+ ?? matchLegacyBracketCheckpoint(text)
208
+ );
209
+ }
210
+
190
211
  export function goalEventMessageId(message: { customType?: string; details?: unknown; content?: unknown }): string | null {
191
212
  if (message.customType !== GOAL_EVENT_ENTRY) return null;
192
213
  const details = asRecord(message.details);
@@ -81,7 +81,24 @@ export interface GoalFocusEntry {
81
81
  reason: GoalFocusReason;
82
82
  }
83
83
 
84
- export interface GoalEventDetails {
84
+ /**
85
+ * Issue #30 v2 checkpoint details: a tiny structured trigger record. Never
86
+ * contains objective text, task text, verification contracts, budget strings,
87
+ * or policy text — the authoritative state is injected once per turn by
88
+ * before_agent_start from goal storage.
89
+ */
90
+ export interface GoalCheckpointDetailsV2 {
91
+ version: 2;
92
+ kind: "checkpoint";
93
+ goalId: string;
94
+ status: "active";
95
+ revision: number;
96
+ checkpointSeq: number;
97
+ timestamp: number;
98
+ }
99
+
100
+ /** Pre-v2 event details (full objective persisted). Read-only legacy shape. */
101
+ export interface LegacyGoalEventDetails {
85
102
  kind: GoalEventKind;
86
103
  goalId: string;
87
104
  status?: GoalStatus;
@@ -93,6 +110,23 @@ export interface GoalEventDetails {
93
110
  focus?: GoalMode;
94
111
  }
95
112
 
113
+ /** Historical "stale" rewrite of a checkpoint that no longer matches the goal. */
114
+ export interface GoalStaleDetails {
115
+ kind: "stale";
116
+ goalId: string;
117
+ currentGoalId?: string | null;
118
+ currentStatus?: GoalStatus | null;
119
+ }
120
+
121
+ /** Everything that can appear in a persisted pi-goal-event entry's details. */
122
+ export type GoalEventEntryDetails = GoalCheckpointDetailsV2 | LegacyGoalEventDetails | GoalStaleDetails;
123
+
124
+ /**
125
+ * Legacy normalized renderer shape. Kept as the stable contract for
126
+ * renderGoalEvent / registerMessageRenderer; v2 entries normalize into it.
127
+ */
128
+ export type GoalEventDetails = LegacyGoalEventDetails;
129
+
96
130
  export interface GoalCreationConfig {
97
131
  objective: string;
98
132
  autoContinue: boolean;