@quantmind-br/pi-goal-x 0.0.0-stage → 0.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -2
  3. package/assets/badge-dark.svg +18 -0
  4. package/assets/badge-light.svg +18 -0
  5. package/docs/advanced-usage.md +106 -0
  6. package/docs/agent-flow-design.md +306 -0
  7. package/docs/agentic-runtime-prd.md +773 -0
  8. package/docs/architecture.md +368 -0
  9. package/docs/controls.md +140 -0
  10. package/docs/pi-autoresearch-survey.md +50 -0
  11. package/docs/pi-goal-x-unified-dashboard-plan.md +1667 -0
  12. package/docs/prompt-caching.md +31 -0
  13. package/docs/unified-dashboard.md +286 -0
  14. package/extensions/auditor-selector.ts +74 -0
  15. package/extensions/goal-accounting.ts +107 -0
  16. package/extensions/goal-activity.ts +202 -0
  17. package/extensions/goal-auditor.ts +480 -0
  18. package/extensions/goal-commands.ts +866 -0
  19. package/extensions/goal-compaction.ts +197 -0
  20. package/extensions/goal-completion.ts +173 -0
  21. package/extensions/goal-context.ts +12 -0
  22. package/extensions/goal-contract.ts +58 -0
  23. package/extensions/goal-controls.ts +202 -0
  24. package/extensions/goal-core-tools.ts +549 -0
  25. package/extensions/goal-core.ts +78 -0
  26. package/extensions/goal-detail.ts +65 -0
  27. package/extensions/goal-dialog-controls.ts +31 -0
  28. package/extensions/goal-draft.ts +244 -0
  29. package/extensions/goal-drafting.ts +535 -0
  30. package/extensions/goal-events.ts +692 -0
  31. package/extensions/goal-format.ts +376 -0
  32. package/extensions/goal-interruption.ts +53 -0
  33. package/extensions/goal-ledger-index.ts +68 -0
  34. package/extensions/goal-ledger.ts +1007 -0
  35. package/extensions/goal-live-retention.ts +239 -0
  36. package/extensions/goal-oracle.ts +346 -0
  37. package/extensions/goal-policy.ts +370 -0
  38. package/extensions/goal-pool.ts +103 -0
  39. package/extensions/goal-prompt-cache.ts +80 -0
  40. package/extensions/goal-questionnaire.ts +1073 -0
  41. package/extensions/goal-record.ts +388 -0
  42. package/extensions/goal-recovery.ts +256 -0
  43. package/extensions/goal-runtime.ts +277 -0
  44. package/extensions/goal-scheduler-state.ts +100 -0
  45. package/extensions/goal-scheduler.ts +436 -0
  46. package/extensions/goal-service.ts +843 -0
  47. package/extensions/goal-session-health.ts +130 -0
  48. package/extensions/goal-session-safety.ts +65 -0
  49. package/extensions/goal-settings.ts +1370 -0
  50. package/extensions/goal-state.ts +1069 -0
  51. package/extensions/goal-status.ts +305 -0
  52. package/extensions/goal-task-confirmation.ts +174 -0
  53. package/extensions/goal-task-count.ts +59 -0
  54. package/extensions/goal-task-derive.ts +46 -0
  55. package/extensions/goal-task-index.ts +57 -0
  56. package/extensions/goal-task-tools.ts +595 -0
  57. package/extensions/goal-tool-inputs.ts +102 -0
  58. package/extensions/goal-tool-names.ts +73 -0
  59. package/extensions/goal-tools.ts +16 -0
  60. package/extensions/goal-widget.ts +455 -0
  61. package/extensions/goal.ts +42 -0
  62. package/extensions/network-error-backoff.ts +55 -0
  63. package/extensions/prompts/goal-prompts.ts +330 -0
  64. package/extensions/storage/atomic-rename.ts +52 -0
  65. package/extensions/storage/goal-files.ts +695 -0
  66. package/extensions/storage/goal-lock.ts +131 -0
  67. package/extensions/storage/goal-root.ts +95 -0
  68. package/extensions/widgets/auditor-dashboard-model.ts +174 -0
  69. package/extensions/widgets/dialog-scaffold.ts +26 -0
  70. package/extensions/widgets/goal-controls-dialog.ts +329 -0
  71. package/extensions/widgets/goal-dashboard-model.ts +530 -0
  72. package/extensions/widgets/goal-dashboard-renderer.ts +674 -0
  73. package/extensions/widgets/goal-escape-dialog.ts +148 -0
  74. package/extensions/widgets/goal-notifications.ts +9 -0
  75. package/extensions/widgets/goal-widget.ts +723 -0
  76. package/extensions/widgets/task-list-overlay.ts +409 -0
  77. package/extensions/widgets/text-cache.ts +30 -0
  78. package/package.json +117 -4
  79. package/scripts/recover-session-checkpoints.mjs +289 -0
@@ -0,0 +1,388 @@
1
+ import { normalizeGoalScheduler, type GoalSchedulerState } from "./goal-scheduler-state.ts";
2
+ export type GoalStatus = "active" | "paused" | "blocked" | "budget_limited" | "complete";
3
+ export type StopReason = "user" | "agent";
4
+ export type GoalEventKind = "checkpoint" | "stale";
5
+ /** Goal creation mode used by the /goal and /sisyphus commands. */
6
+ export type GoalMode = "goal" | "sisyphus";
7
+ export type GoalFocusReason = "created" | "selected" | "unfocused" | "resumed" | "completed" | "cleared" | "aborted" | "migrated";
8
+
9
+ export type TaskStatus = "pending" | "complete" | "skipped";
10
+
11
+ export interface GoalTask {
12
+ id: string;
13
+ title: string;
14
+ status: TaskStatus;
15
+ completedAt?: string;
16
+ skippedAt?: string;
17
+ evidence?: string;
18
+ skipReason?: string;
19
+ verificationContract?: string;
20
+ lightweightSubtasks?: boolean;
21
+ subtasks?: GoalTask[];
22
+ }
23
+
24
+ export interface GoalTaskList {
25
+ tasks: GoalTask[];
26
+ blockCompletion: boolean;
27
+ proposedAt: string;
28
+ }
29
+
30
+ export interface GoalUsage {
31
+ tokensUsed: number;
32
+ activeSeconds: number;
33
+ }
34
+
35
+ export interface GoalRecord {
36
+ id: string;
37
+ objective: string;
38
+ status: GoalStatus;
39
+ autoContinue: boolean;
40
+ usage: GoalUsage;
41
+ sisyphus: boolean;
42
+ createdAt: string;
43
+ updatedAt: string;
44
+ activePath?: string;
45
+ archivedPath?: string;
46
+ stopReason?: StopReason;
47
+ // Set when the model reports the goal blocked. Cleared when the goal becomes active again.
48
+ pauseReason?: string;
49
+ pauseSuggestedAction?: string;
50
+ skipAuditor?: boolean;
51
+ /**
52
+ * Persisted monotonic mutation counter (follow-up Stage 4). Missing
53
+ * historical values normalize to zero. Cross-process writers compare the
54
+ * revision captured at reconciliation with the disk value under the
55
+ * per-goal lock; a mismatch is a typed conflict instead of a blind write.
56
+ */
57
+ revision?: number;
58
+ /** Optional token budget (whole tokens). When accounted usage reaches it, the runtime marks the goal budget_limited. */
59
+ tokenBudget?: number;
60
+ scheduler?: GoalSchedulerState;
61
+ /**
62
+ * Execution focus: the id of the task (or subtask) the agent is working on.
63
+ * Optional for backward compatibility; normalized at load (only an existing
64
+ * pending task id is accepted; cleared on complete/skip/removal). Describes
65
+ * execution focus, not completion state — TaskStatus is unchanged.
66
+ */
67
+ currentTaskId?: string;
68
+ taskList?: GoalTaskList;
69
+ /** Plain-text description of what verification evidence is required before completing this goal. */
70
+ verificationContract?: string;
71
+ }
72
+
73
+ export interface GoalStateEntry {
74
+ version: 3;
75
+ goal: GoalRecord | null;
76
+ /** E7: expandable tool-result detail line (e.g. full pause reason). */
77
+ resultDetail?: string;
78
+ }
79
+
80
+ export interface GoalFocusEntry {
81
+ version: 1;
82
+ storageRoot?: string;
83
+ focusedGoalId: string | null;
84
+ reason: GoalFocusReason;
85
+ }
86
+
87
+ /**
88
+ * Issue #30 v2 checkpoint details: a tiny structured trigger record. Never
89
+ * contains objective text, task text, verification contracts, budget strings,
90
+ * or policy text — the authoritative state is injected once per turn by
91
+ * before_agent_start from goal storage.
92
+ */
93
+ export interface GoalCheckpointDetailsV2 {
94
+ version: 2;
95
+ kind: "checkpoint";
96
+ goalId: string;
97
+ status: "active";
98
+ revision: number;
99
+ checkpointSeq: number;
100
+ timestamp: number;
101
+ }
102
+
103
+ /** Pre-v2 event details (full objective persisted). Read-only legacy shape. */
104
+ export interface LegacyGoalEventDetails {
105
+ kind: GoalEventKind;
106
+ goalId: string;
107
+ status?: GoalStatus;
108
+ objective?: string;
109
+ timestamp?: number;
110
+ currentGoalId?: string | null;
111
+ currentStatus?: GoalStatus | null;
112
+ /** Legacy-read-only creation mode on historical event entries; no writer emits it today. */
113
+ focus?: GoalMode;
114
+ }
115
+
116
+ /** Historical "stale" rewrite of a checkpoint that no longer matches the goal. */
117
+ export interface GoalStaleDetails {
118
+ kind: "stale";
119
+ goalId: string;
120
+ currentGoalId?: string | null;
121
+ currentStatus?: GoalStatus | null;
122
+ }
123
+
124
+ /** Everything that can appear in a persisted pi-goal-event entry's details. */
125
+ export interface GoalCheckpointDetailsV3 extends Omit<GoalCheckpointDetailsV2, "version"> {
126
+ version: 3;
127
+ generation: string;
128
+ dispatchId: string;
129
+ }
130
+
131
+ export type GoalEventEntryDetails = GoalCheckpointDetailsV3 | GoalCheckpointDetailsV2 | LegacyGoalEventDetails | GoalStaleDetails;
132
+
133
+ /**
134
+ * Legacy normalized renderer shape. Kept as the stable contract for
135
+ * renderGoalEvent / registerMessageRenderer; v2 entries normalize into it.
136
+ */
137
+ export type GoalEventDetails = LegacyGoalEventDetails;
138
+
139
+ export interface GoalCreationConfig {
140
+ objective: string;
141
+ autoContinue: boolean;
142
+ sisyphus: boolean;
143
+ taskList?: GoalTaskList;
144
+ /** User-chosen per-draft auditor bypass, persisted on the created goal. */
145
+ skipAuditor?: boolean;
146
+ }
147
+
148
+ export interface AssistantUsage {
149
+ input?: number;
150
+ output?: number;
151
+ }
152
+
153
+ export interface AssistantMessageLike {
154
+ role?: string;
155
+ stopReason?: string;
156
+ usage?: AssistantUsage;
157
+ }
158
+
159
+ export function nowIso(now = Date.now()): string {
160
+ return new Date(now).toISOString();
161
+ }
162
+
163
+ export function safeIdPart(value: string): string {
164
+ return value.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 80) || "goal";
165
+ }
166
+
167
+ export function newGoalId(): string {
168
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
169
+ }
170
+
171
+ export function normalizeRelPath(relPath: string): string {
172
+ return relPath.split(/[\\/]+/).join("/");
173
+ }
174
+
175
+ export function asRecord(value: unknown): Record<string, unknown> | null {
176
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
177
+ }
178
+
179
+ export function emptyUsage(): GoalUsage {
180
+ return { tokensUsed: 0, activeSeconds: 0 };
181
+ }
182
+
183
+ function cloneGoalTask(task: GoalTask): GoalTask {
184
+ return {
185
+ ...task,
186
+ subtasks: task.subtasks ? task.subtasks.map(cloneGoalTask) : undefined,
187
+ };
188
+ }
189
+
190
+ export function cloneGoal(goal: GoalRecord): GoalRecord {
191
+ return {
192
+ ...goal,
193
+ ...(goal.scheduler ? { scheduler: structuredClone(goal.scheduler) } : {}),
194
+ usage: { ...goal.usage },
195
+ taskList: goal.taskList
196
+ ? { ...goal.taskList, tasks: goal.taskList.tasks.map(cloneGoalTask) }
197
+ : undefined,
198
+ };
199
+ }
200
+
201
+ export function goalFocusDetails(focusedGoalId: string | null, reason: GoalFocusReason): GoalFocusEntry {
202
+ return {
203
+ version: 1,
204
+ focusedGoalId: focusedGoalId ? safeIdPart(focusedGoalId) : null,
205
+ reason,
206
+ };
207
+ }
208
+
209
+ export function normalizeGoalFocusEntry(value: unknown): GoalFocusEntry | null {
210
+ const raw = asRecord(value);
211
+ if (!raw || raw.version !== 1) return null;
212
+ const focusedGoalId = typeof raw.focusedGoalId === "string" && raw.focusedGoalId.trim()
213
+ ? safeIdPart(raw.focusedGoalId)
214
+ : null;
215
+ const reason: GoalFocusReason =
216
+ raw.reason === "created" || raw.reason === "selected" || raw.reason === "unfocused" || raw.reason === "resumed" || raw.reason === "completed" || raw.reason === "cleared" || raw.reason === "aborted" || raw.reason === "migrated"
217
+ ? raw.reason
218
+ : "selected";
219
+ return { version: 1, focusedGoalId, reason, ...(typeof raw.storageRoot === "string" ? { storageRoot: raw.storageRoot } : {}) };
220
+ }
221
+
222
+ export function createGoal(config: GoalCreationConfig, now = Date.now()): GoalRecord {
223
+ const timestamp = nowIso(now);
224
+ return {
225
+ id: newGoalId(),
226
+ objective: config.objective,
227
+ status: "active",
228
+ autoContinue: config.autoContinue,
229
+ usage: emptyUsage(),
230
+ sisyphus: config.sisyphus,
231
+ skipAuditor: config.skipAuditor === true ? true : undefined,
232
+ revision: 0,
233
+ createdAt: timestamp,
234
+ updatedAt: timestamp,
235
+ };
236
+ }
237
+
238
+ export function normalizeUsage(value: unknown): GoalUsage {
239
+ const raw = asRecord(value);
240
+ if (!raw) return emptyUsage();
241
+ const tokensUsed = typeof raw.tokensUsed === "number" && Number.isFinite(raw.tokensUsed) ? Math.max(0, Math.floor(raw.tokensUsed)) : 0;
242
+ const activeSeconds = typeof raw.activeSeconds === "number" && Number.isFinite(raw.activeSeconds) ? Math.max(0, Math.floor(raw.activeSeconds)) : 0;
243
+ return { tokensUsed, activeSeconds };
244
+ }
245
+
246
+ export function normalizeTaskItem(raw: Record<string, unknown>): GoalTask | undefined {
247
+ const id = typeof raw.id === "string" && raw.id.trim() ? raw.id.trim() : "";
248
+ const title = typeof raw.title === "string" ? raw.title.trim() : "";
249
+ if (!id || !title) return undefined;
250
+ const status: TaskStatus = raw.status === "complete" ? "complete" : raw.status === "skipped" ? "skipped" : "pending";
251
+ const subtasksRaw = raw.subtasks;
252
+ let subtasks: GoalTask[] | undefined;
253
+ if (Array.isArray(subtasksRaw)) {
254
+ subtasks = subtasksRaw
255
+ .map((item) => (item && typeof item === "object" ? normalizeTaskItem(item as Record<string, unknown>) : undefined))
256
+ .filter((t): t is GoalTask => !!t);
257
+ if (subtasks.length === 0) subtasks = undefined;
258
+ }
259
+ return {
260
+ id,
261
+ title,
262
+ status,
263
+ completedAt: typeof raw.completedAt === "string" ? raw.completedAt : undefined,
264
+ skippedAt: typeof raw.skippedAt === "string" ? raw.skippedAt : undefined,
265
+ evidence: typeof raw.evidence === "string" ? raw.evidence : undefined,
266
+ skipReason: typeof raw.skipReason === "string" ? raw.skipReason : undefined,
267
+ verificationContract: typeof raw.verificationContract === "string" ? raw.verificationContract : undefined,
268
+ lightweightSubtasks: raw.lightweightSubtasks === true ? true : undefined,
269
+ subtasks,
270
+ };
271
+ }
272
+
273
+ export function normalizeTaskList(value: unknown): GoalTaskList | undefined {
274
+ const raw = asRecord(value);
275
+ if (!raw) return undefined;
276
+ const tasksRaw = raw.tasks;
277
+ if (!Array.isArray(tasksRaw)) return undefined;
278
+ const tasks: GoalTask[] = tasksRaw
279
+ .map((item) => (item && typeof item !== "object" || Array.isArray(item) ? undefined : normalizeTaskItem(item as Record<string, unknown>)))
280
+ .filter((t): t is GoalTask => !!t);
281
+ if (tasks.length === 0) return undefined;
282
+ return {
283
+ tasks,
284
+ blockCompletion: raw.blockCompletion === true,
285
+ proposedAt: typeof raw.proposedAt === "string" ? raw.proposedAt : nowIso(),
286
+ };
287
+ }
288
+
289
+ /**
290
+ * Shared positive-safe-integer normalization for persisted numeric values such
291
+ * as tokenBudget. Non-finite, fractional, zero, negative, and unsafe numbers
292
+ * normalize to absent rather than silently changing meaning. Live tool input
293
+ * is validated separately (rejected with a user-facing message); this handles
294
+ * persisted legacy values.
295
+ */
296
+ /**
297
+ * Live-input validation for token_budget (shared by slash-command parsing, tool
298
+ * execution, and record creation). Tool callers are untrusted, so the schema
299
+ * Type.Integer bound is double-checked at runtime with Number.isSafeInteger.
300
+ */
301
+ export function validateTokenBudgetInput(value: unknown): { ok: true; value: number } | { ok: false; message: string } {
302
+ if (typeof value !== "number" || !Number.isFinite(value)) {
303
+ return { ok: false, message: "token_budget must be a number." };
304
+ }
305
+ if (!Number.isSafeInteger(value)) {
306
+ return { ok: false, message: "token_budget must be a whole safe integer (fractional values are not accepted)." };
307
+ }
308
+ if (value < 1) {
309
+ return { ok: false, message: "token_budget must be at least 1." };
310
+ }
311
+ return { ok: true, value };
312
+ }
313
+
314
+ export function normalizePositiveSafeInteger(value: unknown): number | undefined {
315
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 1 ? value : undefined;
316
+ }
317
+
318
+ /**
319
+ * §7.4: a persisted currentTaskId is accepted only when it references an
320
+ * existing PENDING task (top-level or nested). It is cleared when the task is
321
+ * complete, skipped, or no longer exists; absent for historical files. The
322
+ * caller decides whether to persist the normalized value — normalization
323
+ * itself never rewrites old files.
324
+ */
325
+ export function currentTaskIdIsPending(tasks: readonly GoalTask[] | undefined, id: string | undefined): boolean {
326
+ if (!id || !tasks) return false;
327
+ for (const t of tasks) {
328
+ if (t.id === id) return t.status === "pending";
329
+ if (t.subtasks && currentTaskIdIsPending(t.subtasks, id)) return true;
330
+ }
331
+ return false;
332
+ }
333
+
334
+ export function normalizeGoalRecord(value: unknown): GoalRecord | null {
335
+ const raw = asRecord(value);
336
+ if (!raw) return null;
337
+ const objective = typeof raw.objective === "string" ? raw.objective.trim() : "";
338
+ if (!objective) return null;
339
+
340
+ const timestamp = nowIso();
341
+ // Persisted lifecycle status is authoritative. autoContinue is an execution
342
+ // preference only: it must never rewrite status during reads or migration.
343
+ const rawStatus = raw.status;
344
+ const status: GoalStatus = rawStatus === "complete"
345
+ ? "complete"
346
+ : rawStatus === "paused"
347
+ ? "paused"
348
+ : rawStatus === "budget_limited"
349
+ ? "budget_limited"
350
+ : rawStatus === "blocked"
351
+ ? "blocked"
352
+ : "active";
353
+ // autoContinue normalizes independently of status.
354
+ const autoContinue = typeof raw.autoContinue === "boolean" ? raw.autoContinue : true;
355
+ const usage = normalizeUsage(raw.usage);
356
+ const sisyphus = raw.sisyphus === true;
357
+ const taskList = normalizeTaskList(raw.taskList);
358
+ // §7.4: accept a persisted currentTaskId only when it references an existing
359
+ // pending task; otherwise leave it absent. Historical files stay absent and
360
+ // are never rewritten just because the field is missing.
361
+ const currentTaskId =
362
+ typeof raw.currentTaskId === "string" && currentTaskIdIsPending(taskList?.tasks, raw.currentTaskId.trim())
363
+ ? raw.currentTaskId.trim()
364
+ : undefined;
365
+
366
+ return {
367
+ id: typeof raw.id === "string" && raw.id ? safeIdPart(raw.id) : newGoalId(),
368
+ objective,
369
+ status,
370
+ autoContinue,
371
+ usage,
372
+ sisyphus,
373
+ createdAt: typeof raw.createdAt === "string" ? raw.createdAt : timestamp,
374
+ updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : timestamp,
375
+ activePath: typeof raw.activePath === "string" ? raw.activePath : undefined,
376
+ archivedPath: typeof raw.archivedPath === "string" ? raw.archivedPath : undefined,
377
+ stopReason: raw.stopReason === "agent" || raw.stopReason === "user" ? raw.stopReason : undefined,
378
+ pauseReason: typeof raw.pauseReason === "string" && raw.pauseReason.trim() ? raw.pauseReason : undefined,
379
+ pauseSuggestedAction: typeof raw.pauseSuggestedAction === "string" && raw.pauseSuggestedAction.trim() ? raw.pauseSuggestedAction : undefined,
380
+ skipAuditor: raw.skipAuditor === true ? true : undefined,
381
+ revision: Number.isSafeInteger(raw.revision) && (raw.revision as number) >= 0 ? (raw.revision as number) : 0,
382
+ tokenBudget: normalizePositiveSafeInteger(raw.tokenBudget),
383
+ ...(raw.scheduler !== undefined ? { scheduler: normalizeGoalScheduler(raw.scheduler) } : {}),
384
+ taskList,
385
+ currentTaskId,
386
+ verificationContract: typeof raw.verificationContract === "string" ? raw.verificationContract : undefined,
387
+ };
388
+ }
@@ -0,0 +1,256 @@
1
+ import { goalStoragePath, goalPoolSnapshotPath, ensureGoalStorageDirectory } from "./storage/goal-root.ts";
2
+ import { commandPaths, type GoalCommands } from "./goal-controls.ts";
3
+ /**
4
+ * /goal-recovery — read-only storage/recovery report + guarded repair.
5
+ *
6
+ * Reliability campaign 2026-08-09. The report scans four failure classes:
7
+ * - malformed goal files (active_goal_*.md that do not parse);
8
+ * - malformed ledger lines (counted by the ledger reader);
9
+ * - stale locks (.pi/goals/.locks/*.lock whose pid is dead or whose age
10
+ * exceeds the TTL — left behind by crashed sessions);
11
+ * - orphaned snapshot data (pool-snapshot goals with no matching file).
12
+ *
13
+ * Everything is read-only by default. Repair operations (stale-lock removal,
14
+ * snapshot refresh) require an explicit confirmation AND copy the affected
15
+ * files into a timestamped backup directory first. Malformed goal files and
16
+ * malformed ledger lines are reported but never rewritten automatically —
17
+ * rewriting user-owned data is deliberately out of scope (and automatic
18
+ * ledger rewriting is a documented non-goal of the reliability campaign).
19
+ */
20
+
21
+ import * as fs from "node:fs";
22
+ import * as path from "node:path";
23
+ import { readGoalLedger } from "./goal-ledger.ts";
24
+ import { GOAL_LOCK_DIR } from "./storage/goal-lock.ts";
25
+ import { invalidateGoalPoolCache, parseGoalFile, readActiveGoalPool, type GoalFileContext } from "./storage/goal-files.ts";
26
+
27
+ export const GOALS_DIR = ".pi/goals";
28
+ export const RECOVERY_BACKUP_DIR = ".pi/goals/.recovery-backup";
29
+
30
+ export interface MalformedGoalFileEntry {
31
+ relPath: string;
32
+ error: string;
33
+ }
34
+
35
+ export interface StaleLockEntry {
36
+ fileName: string;
37
+ pid: number;
38
+ startedAt: string;
39
+ ageMs: number;
40
+ }
41
+
42
+ export interface OrphanedSnapshotEntry {
43
+ goalId: string;
44
+ activePath: string;
45
+ }
46
+
47
+ export interface RecoveryReport {
48
+ scannedAt: string;
49
+ malformedGoalFiles: MalformedGoalFileEntry[];
50
+ malformedLedgerLines: number;
51
+ staleLocks: StaleLockEntry[];
52
+ orphanedSnapshotGoals: OrphanedSnapshotEntry[];
53
+ healthy: boolean;
54
+ }
55
+
56
+ export interface RecoveryRepairResult {
57
+ applied: string[];
58
+ backupDir: string | null;
59
+ confirmed: boolean;
60
+ }
61
+
62
+ const LOCK_STALE_TTL_MS = 30_000;
63
+
64
+ function safeLockName(goalId: string): string {
65
+ return goalId.replace(/[^A-Za-z0-9._-]/g, "_");
66
+ }
67
+
68
+ function pidAlive(pid: number): boolean {
69
+ if (!Number.isInteger(pid) || pid <= 0) return false;
70
+ try {
71
+ process.kill(pid, 0);
72
+ return true;
73
+ } catch (err) {
74
+ return (err as NodeJS.ErrnoException).code === "EPERM";
75
+ }
76
+ }
77
+
78
+ function goalsDir(ctx: GoalFileContext): string {
79
+ return goalStoragePath(ctx, GOALS_DIR);
80
+ }
81
+
82
+ function locksDir(ctx: GoalFileContext): string {
83
+ return goalStoragePath(ctx, GOAL_LOCK_DIR);
84
+ }
85
+
86
+ /** Scan the goals dir for active_goal files that fail to parse. */
87
+ function scanMalformedGoalFiles(ctx: GoalFileContext): MalformedGoalFileEntry[] {
88
+ const root = goalsDir(ctx);
89
+ const out: MalformedGoalFileEntry[] = [];
90
+ let names: string[];
91
+ try {
92
+ names = fs.readdirSync(root);
93
+ } catch {
94
+ return out;
95
+ }
96
+ for (const name of names) {
97
+ if (!/^active_goal_.*\.md$/.test(name)) continue;
98
+ const relPath = path.posix.join(GOALS_DIR, name);
99
+ const parsed = parseGoalFile(path.join(root, name));
100
+ if (!parsed) {
101
+ out.push({ relPath, error: "file does not parse as a goal record" });
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /** Scan the lock dir for lock files whose pid is dead or whose age exceeds the TTL. */
108
+ function scanStaleLocks(ctx: GoalFileContext): StaleLockEntry[] {
109
+ const dir = locksDir(ctx);
110
+ const out: StaleLockEntry[] = [];
111
+ let names: string[];
112
+ try {
113
+ names = fs.readdirSync(dir);
114
+ } catch {
115
+ return out;
116
+ }
117
+ const now = Date.now();
118
+ for (const name of names) {
119
+ if (!name.endsWith(".lock")) continue;
120
+ try {
121
+ const raw = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8")) as { pid?: unknown; startedAt?: unknown };
122
+ const pid = typeof raw.pid === "number" ? raw.pid : 0;
123
+ const startedAt = typeof raw.startedAt === "string" ? raw.startedAt : "";
124
+ const startedMs = new Date(startedAt).getTime();
125
+ const ageMs = Number.isFinite(startedMs) ? Math.max(0, now - startedMs) : now;
126
+ if (!pidAlive(pid) || ageMs > LOCK_STALE_TTL_MS) {
127
+ out.push({ fileName: name, pid, startedAt, ageMs });
128
+ }
129
+ } catch {
130
+ // Unreadable lock body: report as stale (pid unknown).
131
+ out.push({ fileName: name, pid: 0, startedAt: "", ageMs: now });
132
+ }
133
+ }
134
+ return out;
135
+ }
136
+
137
+ /** Snapshot goals whose active file no longer exists in the goals dir. */
138
+ function scanOrphanedSnapshotGoals(ctx: GoalFileContext): OrphanedSnapshotEntry[] {
139
+ const root = goalsDir(ctx);
140
+ const out: OrphanedSnapshotEntry[] = [];
141
+ let present: Set<string>;
142
+ try {
143
+ present = new Set(fs.readdirSync(root));
144
+ } catch {
145
+ return out;
146
+ }
147
+ try {
148
+ const snapshotPath = goalPoolSnapshotPath(ctx);
149
+ const snapshot = JSON.parse(fs.readFileSync(snapshotPath, "utf8")) as { goals?: Array<{ goalId?: unknown; activePath?: unknown }> };
150
+ for (const goal of snapshot.goals ?? []) {
151
+ const activePath = typeof goal.activePath === "string" ? goal.activePath : "";
152
+ const base = path.posix.basename(activePath);
153
+ if (base && !present.has(base)) {
154
+ out.push({ goalId: typeof goal.goalId === "string" ? goal.goalId : base, activePath });
155
+ }
156
+ }
157
+ } catch {
158
+ // Missing/corrupt snapshot: no orphan data to report.
159
+ }
160
+ return out;
161
+ }
162
+
163
+ /** Read-only recovery report. Never mutates goal storage. */
164
+ export function runRecoveryReport(ctx: GoalFileContext): RecoveryReport {
165
+ const ledger = readGoalLedger(ctx);
166
+ const malformedGoalFiles = scanMalformedGoalFiles(ctx);
167
+ const staleLocks = scanStaleLocks(ctx);
168
+ const orphanedSnapshotGoals = scanOrphanedSnapshotGoals(ctx);
169
+ return {
170
+ scannedAt: new Date().toISOString(),
171
+ malformedGoalFiles,
172
+ malformedLedgerLines: ledger.malformed,
173
+ staleLocks,
174
+ orphanedSnapshotGoals,
175
+ healthy: malformedGoalFiles.length === 0 && ledger.malformed === 0 && staleLocks.length === 0 && orphanedSnapshotGoals.length === 0,
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Apply the safe repair operations with confirmation + backup.
181
+ *
182
+ * Repairs: stale-lock removal (backed up then unlinked) and pool-snapshot
183
+ * refresh (backed up then rewritten from a fresh scan). Returns what was
184
+ * applied. When `confirm` rejects, nothing is touched.
185
+ */
186
+ export async function runRecoveryRepair(
187
+ ctx: GoalFileContext,
188
+ report: RecoveryReport,
189
+ confirm: () => Promise<boolean>,
190
+ ): Promise<RecoveryRepairResult> {
191
+ if (report.staleLocks.length === 0 && report.orphanedSnapshotGoals.length === 0) {
192
+ return { applied: [], backupDir: null, confirmed: false };
193
+ }
194
+ const confirmed = await confirm();
195
+ if (!confirmed) return { applied: [], backupDir: null, confirmed: false };
196
+
197
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
198
+ const backupDir = goalStoragePath(ctx, `${RECOVERY_BACKUP_DIR}/${stamp}`);
199
+ ensureGoalStorageDirectory(ctx, `${RECOVERY_BACKUP_DIR}/${stamp}`);
200
+ const applied: string[] = [];
201
+
202
+ for (const lock of report.staleLocks) {
203
+ const source = path.join(locksDir(ctx), lock.fileName);
204
+ try {
205
+ fs.copyFileSync(source, path.join(backupDir, `lock-${safeLockName(lock.fileName)}`));
206
+ fs.unlinkSync(source);
207
+ applied.push(`removed stale lock ${lock.fileName}`);
208
+ } catch {
209
+ // best-effort per item
210
+ }
211
+ }
212
+
213
+ if (report.orphanedSnapshotGoals.length > 0) {
214
+ const snapshotPath = goalPoolSnapshotPath(ctx);
215
+ try {
216
+ if (fs.existsSync(snapshotPath)) {
217
+ fs.copyFileSync(snapshotPath, path.join(backupDir, "pool-snapshot.json"));
218
+ }
219
+ // Refresh the pool snapshot from a fresh scan (invalidate first so
220
+ // the re-read goes cold and rewrites the snapshot).
221
+ invalidateGoalPoolCache();
222
+ readActiveGoalPool(ctx);
223
+ applied.push(`refreshed pool snapshot (${report.orphanedSnapshotGoals.length} orphaned entr${report.orphanedSnapshotGoals.length === 1 ? "y" : "ies"} dropped)`);
224
+ } catch {
225
+ // best-effort
226
+ }
227
+ }
228
+
229
+ return { applied, backupDir, confirmed: true };
230
+ }
231
+
232
+ export function formatRecoveryReport(report: RecoveryReport, commands?: GoalCommands): string {
233
+ const lines: string[] = [];
234
+ lines.push(report.healthy ? "Recovery report: OK — no issues found." : "Recovery report: issues found.");
235
+ if (report.malformedGoalFiles.length > 0) {
236
+ lines.push(` - ${report.malformedGoalFiles.length} malformed goal file(s):`);
237
+ for (const f of report.malformedGoalFiles) lines.push(` ${f.relPath} — ${f.error}`);
238
+ }
239
+ if (report.malformedLedgerLines > 0) {
240
+ lines.push(` - ${report.malformedLedgerLines} malformed ledger line(s) (read-only; manual review advised)`);
241
+ }
242
+ if (report.staleLocks.length > 0) {
243
+ lines.push(` - ${report.staleLocks.length} stale lock(s):`);
244
+ for (const l of report.staleLocks) lines.push(` ${l.fileName} (pid ${l.pid}, ${Math.round(l.ageMs / 1000)}s old)`);
245
+ }
246
+ if (report.orphanedSnapshotGoals.length > 0) {
247
+ lines.push(` - ${report.orphanedSnapshotGoals.length} orphaned snapshot entr${report.orphanedSnapshotGoals.length === 1 ? "y" : "ies"}:`);
248
+ for (const o of report.orphanedSnapshotGoals) lines.push(` ${o.goalId} (${o.activePath})`);
249
+ }
250
+ if (report.staleLocks.length > 0 || report.orphanedSnapshotGoals.length > 0) {
251
+ const route = commandPaths(commands, "goal-recovery")[0];
252
+ lines.push(route ? `Run \`/${route} repair\` to remove stale locks and refresh the pool snapshot (confirmation + backup required).`
253
+ : "Enable a recovery command in /goal-settings controls to repair these files (confirmation + backup required).");
254
+ }
255
+ return lines.join("\n");
256
+ }