@quantmind-br/pi-goal-x 0.0.0-stage → 0.35.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/LICENSE +21 -0
- package/README.md +131 -2
- package/assets/badge-dark.svg +18 -0
- package/assets/badge-light.svg +18 -0
- package/docs/advanced-usage.md +106 -0
- package/docs/agent-flow-design.md +306 -0
- package/docs/agentic-runtime-prd.md +773 -0
- package/docs/architecture.md +368 -0
- package/docs/controls.md +140 -0
- package/docs/pi-autoresearch-survey.md +50 -0
- package/docs/pi-goal-x-unified-dashboard-plan.md +1667 -0
- package/docs/prompt-caching.md +31 -0
- package/docs/unified-dashboard.md +286 -0
- package/extensions/auditor-selector.ts +74 -0
- package/extensions/goal-accounting.ts +107 -0
- package/extensions/goal-activity.ts +202 -0
- package/extensions/goal-auditor.ts +480 -0
- package/extensions/goal-commands.ts +866 -0
- package/extensions/goal-compaction.ts +197 -0
- package/extensions/goal-completion.ts +173 -0
- package/extensions/goal-context.ts +12 -0
- package/extensions/goal-contract.ts +58 -0
- package/extensions/goal-controls.ts +202 -0
- package/extensions/goal-core-tools.ts +549 -0
- package/extensions/goal-core.ts +78 -0
- package/extensions/goal-detail.ts +65 -0
- package/extensions/goal-dialog-controls.ts +31 -0
- package/extensions/goal-draft.ts +244 -0
- package/extensions/goal-drafting.ts +535 -0
- package/extensions/goal-events.ts +692 -0
- package/extensions/goal-format.ts +376 -0
- package/extensions/goal-interruption.ts +53 -0
- package/extensions/goal-ledger-index.ts +68 -0
- package/extensions/goal-ledger.ts +1007 -0
- package/extensions/goal-live-retention.ts +219 -0
- package/extensions/goal-oracle.ts +346 -0
- package/extensions/goal-policy.ts +370 -0
- package/extensions/goal-pool.ts +103 -0
- package/extensions/goal-prompt-cache.ts +80 -0
- package/extensions/goal-questionnaire.ts +1073 -0
- package/extensions/goal-record.ts +388 -0
- package/extensions/goal-recovery.ts +256 -0
- package/extensions/goal-runtime.ts +277 -0
- package/extensions/goal-scheduler-state.ts +100 -0
- package/extensions/goal-scheduler.ts +436 -0
- package/extensions/goal-service.ts +843 -0
- package/extensions/goal-session-health.ts +130 -0
- package/extensions/goal-session-safety.ts +65 -0
- package/extensions/goal-settings.ts +1370 -0
- package/extensions/goal-state.ts +1069 -0
- package/extensions/goal-status.ts +305 -0
- package/extensions/goal-task-confirmation.ts +174 -0
- package/extensions/goal-task-count.ts +59 -0
- package/extensions/goal-task-derive.ts +46 -0
- package/extensions/goal-task-index.ts +57 -0
- package/extensions/goal-task-tools.ts +595 -0
- package/extensions/goal-tool-inputs.ts +102 -0
- package/extensions/goal-tool-names.ts +73 -0
- package/extensions/goal-tools.ts +16 -0
- package/extensions/goal-widget.ts +455 -0
- package/extensions/goal.ts +42 -0
- package/extensions/network-error-backoff.ts +55 -0
- package/extensions/prompts/goal-prompts.ts +321 -0
- package/extensions/storage/atomic-rename.ts +52 -0
- package/extensions/storage/goal-files.ts +695 -0
- package/extensions/storage/goal-lock.ts +131 -0
- package/extensions/storage/goal-root.ts +95 -0
- package/extensions/widgets/auditor-dashboard-model.ts +174 -0
- package/extensions/widgets/dialog-scaffold.ts +26 -0
- package/extensions/widgets/goal-controls-dialog.ts +329 -0
- package/extensions/widgets/goal-dashboard-model.ts +530 -0
- package/extensions/widgets/goal-dashboard-renderer.ts +674 -0
- package/extensions/widgets/goal-escape-dialog.ts +148 -0
- package/extensions/widgets/goal-notifications.ts +9 -0
- package/extensions/widgets/goal-widget.ts +723 -0
- package/extensions/widgets/task-list-overlay.ts +409 -0
- package/extensions/widgets/text-cache.ts +30 -0
- package/package.json +117 -4
- 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
|
+
}
|