pi-goal-x 0.26.3 → 0.27.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
@@ -183,6 +183,12 @@ Run read-only storage/runtime health checks:
183
183
  /goal-status health
184
184
  ```
185
185
 
186
+ Re-read goal storage caches (pool, ledger, settings) from disk and report what changed — picks up external edits to `.pi` files without file watchers:
187
+
188
+ ```text
189
+ /goal-refresh
190
+ ```
191
+
186
192
  Select an open goal for the current session:
187
193
 
188
194
  ```text
@@ -348,6 +354,8 @@ Each session can focus on one goal while the project keeps other goals open.
348
354
  /goal-status Show the focused goal (unified dashboard)
349
355
  /goal-status verbose Show the focused goal with full diagnostic detail
350
356
  /goal-status health Check goal storage/runtime health (read-only)
357
+ /goal-refresh Re-read storage caches and report external changes
358
+ /goal-recovery Read-only recovery report; `/goal-recovery repair` fixes stale locks + snapshot after confirmation
351
359
  /goal-focus Select an open goal
352
360
  /goal-unfocus Remove the session’s focus
353
361
  /goal-tweak <change> Revise the focused goal
@@ -217,6 +217,7 @@ function makeAuditorResourceLoader(): ResourceLoader {
217
217
  };
218
218
  }
219
219
 
220
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- pi SDK types Model<any> idiomatically (pi-coding-agent sdk.d.ts)
220
221
  export function resolveAuditorModel(ctx: ExtensionContext, config: GoalSettings): { model: Model<any> | undefined; error?: string } {
221
222
  if (!config.model && !config.provider) return { model: ctx.model };
222
223
  if (config.provider && config.model) {
@@ -268,6 +269,7 @@ export function resolveAuditorSessionModelOptions(ctx: ExtensionContext): {
268
269
  return { modelRegistry: ctx.modelRegistry };
269
270
  }
270
271
 
272
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- pi SDK types Model<any> idiomatically (pi-coding-agent sdk.d.ts)
271
273
  function modelLabel(model: Model<any> | undefined): string | undefined {
272
274
  return model ? `${model.provider}/${model.id}` : undefined;
273
275
  }
@@ -379,7 +381,7 @@ export async function runGoalCompletionAuditor(args: {
379
381
  }
380
382
  if (event.type === "message_update") {
381
383
  // Check for thinking events from the assistant stream
382
- const streamEvent = (event as any).assistantMessageEvent;
384
+ const streamEvent = (event as { assistantMessageEvent?: { type?: string } }).assistantMessageEvent;
383
385
  if (streamEvent?.type === "thinking_start") {
384
386
  progress.phase = "thinking";
385
387
  if (!progress.label) progress.label = "Analyzing goal...";
@@ -393,7 +395,7 @@ export async function runGoalCompletionAuditor(args: {
393
395
  }
394
396
  // For text content, show producing_report phase
395
397
  progress.phase = "producing_report";
396
- const message = event.message as any;
398
+ const message = event.message as { role?: string; content?: Array<{ type?: string; text?: string }> };
397
399
  if (message?.role === "assistant") {
398
400
  for (const part of message.content ?? []) {
399
401
  if (part.type === "text" && typeof part.text === "string" && part.text.trim()) {
@@ -407,7 +409,7 @@ export async function runGoalCompletionAuditor(args: {
407
409
  return;
408
410
  }
409
411
  if (event.type !== "message_end") return;
410
- const message = event.message as any;
412
+ const message = event.message as { role?: string; content?: Array<{ type?: string; text?: string }> };
411
413
  if (message.role !== "assistant") return;
412
414
  for (const part of message.content ?? []) {
413
415
  if (part.type === "text" && typeof part.text === "string") outputParts.push(part.text);
@@ -17,12 +17,45 @@ import {
17
17
  otherOpenGoalCount,
18
18
  } from "./goal-pool.ts";
19
19
  import { clearGoalCommandMessage, validateResumeGoal } from "./goal-policy.ts";
20
- import { readGoalLedger } from "./goal-ledger.ts";
20
+ import { invalidateGoalLedgerCache, readGoalLedger } from "./goal-ledger.ts";
21
21
  import { buildGoalStatusText } from "./goal-status.ts";
22
- import { effectiveSettingsReport, envOverrideFor } from "./goal-settings.ts";
23
- import { mergeGoalPromptFromDisk } from "./storage/goal-files.ts";
22
+ import { effectiveSettingsReport, envOverrideFor, invalidateGoalSettingsCache } from "./goal-settings.ts";
23
+ import { invalidateGoalPoolCache, mergeGoalPromptFromDisk, readActiveGoalPool } from "./storage/goal-files.ts";
24
24
  import { nowIso, type GoalMode, type GoalRecord } from "./goal-record.ts";
25
25
  import { clearGoalDrafting, hasActiveDraft, startGoalDrafting } from "./goal-drafting.ts";
26
+ import { formatRecoveryReport, runRecoveryReport, runRecoveryRepair } from "./goal-recovery.ts";
27
+
28
+ export interface GoalRefreshState {
29
+ poolIds: Iterable<string>;
30
+ ledgerEvents: number;
31
+ ledgerMalformed: number;
32
+ /** Stable fingerprint of the effective settings (JSON of the parsed file). */
33
+ settings: string;
34
+ }
35
+
36
+ /**
37
+ * Pure diff of a goal-refresh cycle (before invalidation vs after re-read).
38
+ * Unit-testable without the command harness; the command renders the changes.
39
+ */
40
+ export function diffGoalRefreshState(before: GoalRefreshState, after: GoalRefreshState): string[] {
41
+ const changes: string[] = [];
42
+ const beforeIds = new Set(before.poolIds);
43
+ const afterIds = new Set(after.poolIds);
44
+ const added = [...afterIds].filter((id) => !beforeIds.has(id)).sort();
45
+ const removed = [...beforeIds].filter((id) => !afterIds.has(id)).sort();
46
+ if (added.length > 0) changes.push(`pool: ${added.length} goal(s) added — ${added.join(", ")}`);
47
+ if (removed.length > 0) changes.push(`pool: ${removed.length} goal(s) removed — ${removed.join(", ")}`);
48
+ if (after.ledgerEvents !== before.ledgerEvents) {
49
+ changes.push(`ledger: ${before.ledgerEvents} -> ${after.ledgerEvents} events`);
50
+ }
51
+ if (after.ledgerMalformed !== before.ledgerMalformed) {
52
+ changes.push(`ledger: malformed entries ${before.ledgerMalformed} -> ${after.ledgerMalformed}`);
53
+ }
54
+ if (before.settings !== after.settings) {
55
+ changes.push("settings: effective settings changed (external edit)");
56
+ }
57
+ return changes;
58
+ }
26
59
  import {
27
60
  AUDITOR_THINKING_LEVELS,
28
61
  buildAuditorModelChoices,
@@ -157,6 +190,68 @@ export function registerGoalCommands(core: GoalCore): void {
157
190
  core.replaceGoal({ objective, autoContinue: true, sisyphus: mode === "sisyphus" }, ctx, true, verificationContract);
158
191
  }
159
192
 
193
+ async function runGoalRecovery(rawArgs: string, ctx: ExtensionContext): Promise<void> {
194
+ const report = runRecoveryReport({ cwd: ctx.cwd });
195
+ if (/^repair$/i.test(rawArgs)) {
196
+ const result = await runRecoveryRepair({ cwd: ctx.cwd }, report, async () => {
197
+ const confirmed = await ctx.ui.confirm(`Remove ${report.staleLocks.length} stale lock(s) and refresh the pool snapshot?`, `Files are backed up to .pi/goals/.recovery-backup first.`);
198
+ return confirmed === true;
199
+ });
200
+ if (result.confirmed) {
201
+ ctx.ui.notify(result.applied.length > 0
202
+ ? `goal-recovery repair: ${result.applied.length} operation(s) applied.\n${result.applied.map((a) => ` - ${a}`).join("\n")}\nBackup: ${result.backupDir}`
203
+ : "goal-recovery repair: nothing to repair.", "info");
204
+ } else {
205
+ ctx.ui.notify("goal-recovery repair: cancelled — nothing changed.", "info");
206
+ }
207
+ return;
208
+ }
209
+ ctx.ui.notify(formatRecoveryReport(report), report.healthy ? "info" : "warning");
210
+ }
211
+
212
+ async function runGoalRefresh(ctx: ExtensionContext): Promise<void> {
213
+ // Pre-state snapshots are cache-served (0 fs ops); the re-reads after
214
+ // invalidation go cold. This is the explicit user-owned path for picking
215
+ // up external edits — no watchers, no per-turn polling.
216
+ const beforePool = readActiveGoalPool(ctx);
217
+ const beforeLedger = readGoalLedger(ctx);
218
+ // Settings: the before fingerprint is cache-served; the after one is a
219
+ // cold re-read after invalidation — so an external settings edit shows
220
+ // up as a fingerprint difference.
221
+ const beforeSettings = settingsFingerprint(ctx);
222
+
223
+ invalidateGoalPoolCache();
224
+ invalidateGoalLedgerCache();
225
+ invalidateGoalSettingsCache();
226
+
227
+ const afterPool = readActiveGoalPool(ctx);
228
+ const afterLedger = readGoalLedger(ctx);
229
+ const afterSettings = settingsFingerprint(ctx);
230
+
231
+ const changes = diffGoalRefreshState({
232
+ poolIds: beforePool.keys(),
233
+ ledgerEvents: beforeLedger.events.length,
234
+ ledgerMalformed: beforeLedger.malformed,
235
+ settings: beforeSettings,
236
+ }, {
237
+ poolIds: afterPool.keys(),
238
+ ledgerEvents: afterLedger.events.length,
239
+ ledgerMalformed: afterLedger.malformed,
240
+ settings: afterSettings,
241
+ });
242
+
243
+ const text = changes.length > 0
244
+ ? `goal-refresh: re-read caches from disk — ${changes.length} change(s):\n${changes.map((c) => ` - ${c}`).join("\n")}`
245
+ : "goal-refresh: no changes detected — caches were already current.";
246
+ ctx.ui.notify(text, "info");
247
+ core.updateUI(ctx);
248
+ }
249
+
250
+ /** Stable fingerprint of the effective settings (cache-served before invalidation). */
251
+ function settingsFingerprint(ctx: ExtensionContext): string {
252
+ return JSON.stringify(loadGoalSettingsFileConfig(ctx.cwd));
253
+ }
254
+
160
255
  async function showGoalStatus(rawArgs: string, ctx: ExtensionContext): Promise<void> {
161
256
  core.reconcileFocusedGoalFromDisk(ctx);
162
257
  if (core.state.goal) core.syncGoalPromptFromDisk(ctx);
@@ -547,6 +642,18 @@ export function registerGoalCommands(core: GoalCore): void {
547
642
  await showGoalStatus(rawArgs ?? "", ctx);
548
643
  },
549
644
  });
645
+ pi.registerCommand("goal-refresh", {
646
+ description: "Re-read goal storage caches (pool, ledger, settings) from disk and report what changed. Picks up external edits to .pi files — no file watchers needed.",
647
+ handler: async (_rawArgs, ctx) => {
648
+ await runGoalRefresh(ctx);
649
+ },
650
+ });
651
+ pi.registerCommand("goal-recovery", {
652
+ description: "Read-only storage/recovery report (malformed goal files, malformed ledger lines, stale locks, orphaned snapshot data). Append \"repair\" to remove stale locks and refresh the pool snapshot after confirmation (with backup).",
653
+ handler: async (rawArgs, ctx) => {
654
+ await runGoalRecovery((rawArgs ?? "").trim(), ctx);
655
+ },
656
+ });
550
657
  pi.registerCommand("goal-focus", {
551
658
  description: "Choose which open goal this session focuses on.",
552
659
  handler: async (_rawArgs, ctx) => {
@@ -9,10 +9,15 @@ import {
9
9
  latestEventsForGoal,
10
10
  reconstructGoalLedger,
11
11
  type GoalLedgerEvent,
12
+ type LedgerStateReadResult,
12
13
  } from "./goal-ledger.ts";
13
14
  import { type GoalRecord } from "./goal-record.ts";
14
15
 
15
- export function buildGoalCompactSummary(goal: GoalRecord, events: GoalLedgerEvent[]): string {
16
+ export function buildGoalCompactSummary(
17
+ goal: GoalRecord,
18
+ events: GoalLedgerEvent[],
19
+ auditorOverride?: { verdict: "approved" | "disapproved" | "error"; report: string; at: string },
20
+ ): string {
16
21
  const lines: string[] = [];
17
22
  lines.push(`Goal ${goal.id} — ${statusLabel(goal)}`);
18
23
  lines.push(` Objective: ${truncateText(goal.objective, 200)}`);
@@ -70,7 +75,7 @@ export function buildGoalCompactSummary(goal: GoalRecord, events: GoalLedgerEven
70
75
  }
71
76
  }
72
77
 
73
- const auditor = latestAuditorResultForGoal(events, goal.id);
78
+ const auditor = auditorOverride ?? latestAuditorResultForGoal(events, goal.id);
74
79
  if (auditor && auditor.verdict === "disapproved") {
75
80
  lines.push(` Auditor rejection (latest): ${truncateText(auditor.report, 120)}`);
76
81
  }
@@ -88,20 +93,29 @@ export function buildGoalCompactSummary(goal: GoalRecord, events: GoalLedgerEven
88
93
  export function buildCompactionSummary(args: {
89
94
  goalsById: Map<string, GoalRecord>;
90
95
  focusedGoalId: string | null;
91
- ledgerEvents: GoalLedgerEvent[];
96
+ ledgerEvents?: GoalLedgerEvent[];
97
+ /** Checkpoint-fed view: uses reconstructed state + per-goal recent tails instead of a full event list. */
98
+ ledgerState?: LedgerStateReadResult;
92
99
  capOpenGoals?: number;
93
100
  capEventsPerGoal?: number;
94
101
  }): string {
95
- const { goalsById, focusedGoalId, ledgerEvents, capOpenGoals = 20, capEventsPerGoal = 5 } = args;
102
+ const { goalsById, focusedGoalId, ledgerEvents = [], ledgerState, capOpenGoals = 20, capEventsPerGoal = 5 } = args;
96
103
 
97
104
  const lines: string[] = [];
98
105
  const openGoals = Array.from(goalsById.values()).filter((g) => g.status !== "complete");
99
- const reconstructed = reconstructGoalLedger(ledgerEvents);
106
+ const reconstructed = ledgerState?.state ?? reconstructGoalLedger(ledgerEvents);
107
+ const recentEventsFor = (goalId: string, cap: number): GoalLedgerEvent[] => {
108
+ if (ledgerState) return (ledgerState.recentEventsByGoal.get(goalId) ?? []).slice(0, cap);
109
+ return latestEventsForGoal(ledgerEvents, goalId, cap);
110
+ };
100
111
 
101
112
  if (focusedGoalId && goalsById.has(focusedGoalId)) {
102
113
  const focused = goalsById.get(focusedGoalId)!;
114
+ const auditor = ledgerState
115
+ ? reconstructed.goals.get(focusedGoalId)?.latestAuditorResult ?? reconstructed.terminalGoals.get(focusedGoalId)?.latestAuditorResult
116
+ : undefined;
103
117
  lines.push(`[FOCUSED GOAL]`);
104
- lines.push(buildGoalCompactSummary(focused, latestEventsForGoal(ledgerEvents, focusedGoalId, capEventsPerGoal)));
118
+ lines.push(buildGoalCompactSummary(focused, recentEventsFor(focusedGoalId, capEventsPerGoal), auditor));
105
119
  lines.push("");
106
120
  }
107
121
 
@@ -27,7 +27,7 @@ export function extractVerificationContract(objective: string): { objective: str
27
27
  const trimmed = line.trim();
28
28
  const m = VERIFICATION_CONTRACT_RE.exec(trimmed);
29
29
  if (m) {
30
- contract = m[1].trim();
30
+ contract = m[1]!.trim();
31
31
  // Skip this line — don't add it to the cleaned objective
32
32
  } else {
33
33
  filtered.push(line);
@@ -71,7 +71,7 @@ export function statusLabel(goal: Pick<GoalDisplayRecordLike, "sisyphus" | "stat
71
71
  export function footerStatus(goal: GoalDisplayRecordLike): string {
72
72
  const usageBits: string[] = [];
73
73
  if (goal.usage.activeSeconds > 0) usageBits.push(formatDuration(goal.usage.activeSeconds));
74
- if (goal.usage.tokensUsed > 0) usageBits.push(formatTokenValue(goal.usage.tokensUsed).split(" ")[0]);
74
+ if (goal.usage.tokensUsed > 0) usageBits.push(formatTokenValue(goal.usage.tokensUsed).split(" ")[0]!);
75
75
  const usage = usageBits.length > 0 ? ` [${usageBits.join(" ")}]` : "";
76
76
  const prefix = goal.sisyphus ? "goal✊" : "goal";
77
77
  return `${prefix}: ${statusLabel(goal)}${usage} - ${truncateText(goal.objective, 60)}`;
@@ -276,7 +276,7 @@ export function registerDraftingTools(core: GoalCore): void {
276
276
  }, { additionalProperties: false }),
277
277
  async execute(_id, params, _signal, _update, ctx) {
278
278
  if (!activeDraft(core)) return { content: [{ type: "text", text: "No guided goal draft is active." }], details: goalDetails(core.state.goal) };
279
- const questions: GoalQuestionnaireQuestion[] = params.questions.map((q: any) => ({ ...q, allowCustom: q.allow_custom }));
279
+ const questions: GoalQuestionnaireQuestion[] = params.questions.map((q: GoalQuestionnaireQuestion & { allow_custom?: boolean }) => ({ ...q, allowCustom: q.allow_custom }));
280
280
  core.enterGoalModal();
281
281
  try {
282
282
  const result = await runGoalQuestionnaire(ctx, questions);
@@ -12,7 +12,7 @@ import {
12
12
  isToolUseAssistantMessage,
13
13
  } from "./goal-format.ts";
14
14
  import { buildCompactionSummary } from "./goal-compaction.ts";
15
- import { latestAuditorResultForGoal, readGoalLedger, invalidateGoalLedgerCache } from "./goal-ledger.ts";
15
+ import { latestAuditorResultForGoal, loadLedgerState, readGoalLedger, invalidateGoalLedgerCache } from "./goal-ledger.ts";
16
16
  import { shouldArmPostCompactReminder, shouldInjectPostCompactReminder } from "./goal-policy.ts";
17
17
  import { formatTokenValue } from "./goal-core.ts";
18
18
  import { loadGoalSettings, invalidateGoalSettingsCache } from "./goal-settings.ts";
@@ -413,7 +413,7 @@ export function registerGoalEvents(core: GoalCore): void {
413
413
  // Use deterministic compaction summary instead of generic reminder
414
414
  try {
415
415
  const ledger = getPromptLedger();
416
- const compaction = buildCompactionSummary({ goalsById: core.goalsById, focusedGoalId: core.focusedGoalId, ledgerEvents: ledger.events });
416
+ const compaction = buildCompactionSummary({ goalsById: core.goalsById, focusedGoalId: core.focusedGoalId, ledgerState: loadLedgerState(ctx) });
417
417
  prompt = `${prompt}\n\n[POST-COMPACTION RESYNC goalId=${activeGoal.id}]\n${compaction}`;
418
418
  } catch {
419
419
  prompt = `${prompt}\n\n[POST-COMPACTION RESYNC goalId=${core.state.goal.id}]\nThe conversation was just compacted. Re-read the objective and continue from the actual artifacts/state; do not rely on memory of the prior chat.`;
@@ -181,7 +181,7 @@ export function extractGoalIdFromInjectedMessage(text: string): string | null {
181
181
  // Borrowed from pi-codex-goal. More robust than bare bracket text because
182
182
  // the angle brackets + attributes are nearly impossible for users to type
183
183
  // by accident, and the structure is grep-able / parse-able by external tooling.
184
- const xmlMatch = text.match(/^<pi_goal_continuation\s+goal_id=\"([^\"]+)\"/);
184
+ const xmlMatch = text.match(/^<pi_goal_continuation\s+goal_id="([^"]+)"/);
185
185
  if (xmlMatch) return xmlMatch[1] ?? null;
186
186
  const match = text.match(/^\[(?:GOAL CHECKPOINT|GOAL CONTINUATION|GOAL STALE) goalId=([^\]\s]+)\]/);
187
187
  return match?.[1] ?? null;