@xzzpig/pi-openspec-x 0.1.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.
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Flow recovery and base-divergence detection (design D10; openspec change
3
+ * add-pi-openspec-x, task 6.4).
4
+ *
5
+ * `/opsx:implement` runs on a persistent pi-goal-x goal, so the flow's
6
+ * durability is goal-x's durability: after a crash or a compaction, the goal
7
+ * (objective, task tree, ledger) is still on disk and goal-x's own
8
+ * `/goal-resume` reattaches execution. This module is the plugin's read-only
9
+ * half of that contract:
10
+ *
11
+ * - it recognizes the goals the plugin itself created, by the objective marker
12
+ * the assembler writes (`Implement the OpenSpec change "<id>".`), so a
13
+ * resumed session can offer the recovery entry for exactly those goals and
14
+ * never for a user's ordinary `/goal`;
15
+ * - it detects when the session's active opsx flow and the goal base have
16
+ * diverged (the user cleared, paused, or completed the base goal), and
17
+ * reports the fork with explicit options instead of silently continuing;
18
+ * - diagnosis is read-only. Every repair (resume, re-mirror, terminate) is an
19
+ * action the user confirms; nothing here mutates a goal.
20
+ *
21
+ * The module is pure: callers feed it goal summaries (see ./goal-base.ts for
22
+ * the disk reader) and get decisions back.
23
+ */
24
+
25
+ /** The minimal goal state this module reasons about. */
26
+ export interface GoalBaseSummary {
27
+ id: string;
28
+ /** goal-x GoalStatus: active | paused | blocked | budget_limited | complete. */
29
+ status: string;
30
+ objective: string;
31
+ }
32
+
33
+ /** A goal this plugin created for an openspec change. */
34
+ export interface OpsxGoalRef {
35
+ goalId: string;
36
+ changeId: string;
37
+ status: string;
38
+ }
39
+
40
+ /** The objective marker the assembler writes; also the ownership test. */
41
+ const OPSX_OBJECTIVE_RE = /^Implement the OpenSpec change "([^"]+)"/m;
42
+
43
+ /** Extract the change id from an objective, when it is an opsx objective. */
44
+ export function opsxChangeIdFromObjective(
45
+ objective: string,
46
+ ): string | undefined {
47
+ const match = OPSX_OBJECTIVE_RE.exec(objective);
48
+ return match ? match[1] : undefined;
49
+ }
50
+
51
+ /** goal-x considers every status but `complete` unfinished. */
52
+ export function isUnfinishedGoalStatus(status: string): boolean {
53
+ return status !== "complete";
54
+ }
55
+
56
+ /** Every opsx-created goal in the base, in the order given. */
57
+ export function findOpsxGoals(
58
+ goals: readonly GoalBaseSummary[],
59
+ ): OpsxGoalRef[] {
60
+ const refs: OpsxGoalRef[] = [];
61
+ for (const goal of goals) {
62
+ const changeId = opsxChangeIdFromObjective(goal.objective);
63
+ if (changeId) {
64
+ refs.push({ goalId: goal.id, changeId, status: goal.status });
65
+ }
66
+ }
67
+ return refs;
68
+ }
69
+
70
+ export interface RecoveryEntry {
71
+ goal: OpsxGoalRef;
72
+ /** User-facing prompt; recovery itself stays goal-x's `/goal-resume`. */
73
+ message: string;
74
+ }
75
+
76
+ export type RecoveryResolution =
77
+ | { kind: "none" }
78
+ | { kind: "resume"; entry: RecoveryEntry }
79
+ | { kind: "ambiguous"; candidates: OpsxGoalRef[]; message: string };
80
+
81
+ /**
82
+ * Decide the recovery entry for a session start. With `changeId` the search is
83
+ * scoped to that change; without it, a single unfinished opsx goal is offered
84
+ * and several are reported as ambiguous rather than guessed.
85
+ */
86
+ export function resolveRecoveryEntry(
87
+ goals: readonly GoalBaseSummary[],
88
+ options: { changeId?: string } = {},
89
+ ): RecoveryResolution {
90
+ const unfinished = findOpsxGoals(goals).filter((ref) =>
91
+ isUnfinishedGoalStatus(ref.status),
92
+ );
93
+ const candidates = options.changeId
94
+ ? unfinished.filter((ref) => ref.changeId === options.changeId)
95
+ : unfinished;
96
+
97
+ if (candidates.length === 0) return { kind: "none" };
98
+ if (candidates.length === 1) {
99
+ const goal = candidates[0]!;
100
+ return {
101
+ kind: "resume",
102
+ entry: {
103
+ goal,
104
+ message: `An unfinished opsx implementation goal for change "${goal.changeId}" is in the goal base (${goal.goalId}, ${goal.status}). Run /goal-resume to reattach execution, or /goal-clear to abandon it.`,
105
+ },
106
+ };
107
+ }
108
+ return {
109
+ kind: "ambiguous",
110
+ candidates,
111
+ message: `Several unfinished opsx implementation goals exist (${candidates
112
+ .map((ref) => `${ref.goalId} → ${ref.changeId}`)
113
+ .join(
114
+ ", ",
115
+ )}). Pick the change explicitly (or focus a goal with /goal-focus) before resuming.`,
116
+ };
117
+ }
118
+
119
+ /** The session's active opsx flow, as the plugin tracks it in memory. */
120
+ export interface ActiveFlowRef {
121
+ goalId: string;
122
+ changeId: string;
123
+ }
124
+
125
+ export type FlowDivergenceKind =
126
+ | "goal_cleared"
127
+ | "goal_paused"
128
+ | "goal_completed";
129
+
130
+ export interface FlowDivergence {
131
+ kind: FlowDivergenceKind;
132
+ goalId: string;
133
+ changeId: string;
134
+ message: string;
135
+ /** The user's choices; no repair runs without confirmation. */
136
+ options: string[];
137
+ }
138
+
139
+ /**
140
+ * Detect that the session's active opsx flow no longer matches the goal base:
141
+ * the goal was cleared (gone), paused/blocked, or completed out from under the
142
+ * flow. `undefined` means the flow and the base still agree.
143
+ */
144
+ export function detectFlowDivergence(
145
+ active: ActiveFlowRef | undefined,
146
+ goals: readonly GoalBaseSummary[],
147
+ ): FlowDivergence | undefined {
148
+ if (!active) return undefined;
149
+ const goal = goals.find((candidate) => candidate.id === active.goalId);
150
+ if (!goal) {
151
+ return {
152
+ kind: "goal_cleared",
153
+ goalId: active.goalId,
154
+ changeId: active.changeId,
155
+ message: `The opsx implementation flow for change "${active.changeId}" was running on goal ${active.goalId}, but that goal is no longer in the base (cleared or archived).`,
156
+ options: [
157
+ "Recreate the goal from tasks.md and resume the flow.",
158
+ "Terminate the flow and leave the change as it is.",
159
+ ],
160
+ };
161
+ }
162
+ if (goal.status === "paused" || goal.status === "blocked") {
163
+ return {
164
+ kind: "goal_paused",
165
+ goalId: active.goalId,
166
+ changeId: active.changeId,
167
+ message: `The base goal ${active.goalId} for change "${active.changeId}" is ${goal.status}; the flow cannot advance until it is resumed.`,
168
+ options: [
169
+ "Run /goal-resume to continue the existing goal.",
170
+ "Terminate the flow and leave the change as it is.",
171
+ ],
172
+ };
173
+ }
174
+ if (goal.status === "complete") {
175
+ return {
176
+ kind: "goal_completed",
177
+ goalId: active.goalId,
178
+ changeId: active.changeId,
179
+ message: `The base goal ${active.goalId} for change "${active.changeId}" is already complete while the flow still considers itself active.`,
180
+ options: [
181
+ "Accept the completed goal and move to the archive step.",
182
+ "Start a fresh goal for the remaining work.",
183
+ ],
184
+ };
185
+ }
186
+ return undefined;
187
+ }
188
+
189
+ export interface FlowDiagnosis {
190
+ /** False when a divergence was found. */
191
+ ok: boolean;
192
+ /** Read-only observations, safe to show without any mutation. */
193
+ notices: string[];
194
+ divergence?: FlowDivergence;
195
+ recovery?: RecoveryEntry;
196
+ /** Every suggested repair is an explicit user-confirmed action. */
197
+ repairRequiresConfirmation: true;
198
+ }
199
+
200
+ /**
201
+ * Read-only diagnosis for a session: what the base holds for this change, and
202
+ * whether the active flow diverged from it. Never mutates; the returned
203
+ * notices/options are for the caller to surface.
204
+ */
205
+ export function diagnoseFlow(input: {
206
+ active?: ActiveFlowRef;
207
+ goals: readonly GoalBaseSummary[];
208
+ changeId?: string;
209
+ }): FlowDiagnosis {
210
+ const notices: string[] = [];
211
+ const divergence = detectFlowDivergence(input.active, input.goals);
212
+ if (divergence) notices.push(divergence.message);
213
+
214
+ const recovery = resolveRecoveryEntry(input.goals, {
215
+ changeId: input.changeId,
216
+ });
217
+ if (recovery.kind === "resume") notices.push(recovery.entry.message);
218
+ else if (recovery.kind === "ambiguous") notices.push(recovery.message);
219
+
220
+ return {
221
+ ok: !divergence,
222
+ notices,
223
+ ...(divergence ? { divergence } : {}),
224
+ ...(recovery.kind === "resume" ? { recovery: recovery.entry } : {}),
225
+ repairRequiresConfirmation: true,
226
+ };
227
+ }
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Read-only goal-base adapter (design D10; openspec change add-pi-openspec-x,
3
+ * task 6.4).
4
+ *
5
+ * The plugin never owns the goal state — goal-x does. This module is the one
6
+ * place that reads it: it resolves goal-x's storage root and parses its active
7
+ * goal files through goal-x's own exported helpers, so the plugin can never
8
+ * drift from the on-disk format. The import is dynamic because pi-goal-x is an
9
+ * optional peer (task 6.6): a missing goal-x yields an empty base, never a
10
+ * load-time crash.
11
+ *
12
+ * Nothing here writes, mutates, or locks a goal. Recovery and repair stay
13
+ * goal-x's own `/goal-resume` and `/goal-clear`, surfaced by the session-start
14
+ * notice below.
15
+ */
16
+ import * as fs from "node:fs";
17
+ import * as path from "node:path";
18
+ import type {
19
+ ExtensionAPI,
20
+ ExtensionContext,
21
+ ExtensionHandler,
22
+ SessionStartEvent,
23
+ } from "@earendil-works/pi-coding-agent";
24
+
25
+ import type { GoalBaseSummary } from "./flow-recovery.ts";
26
+ import {
27
+ diagnoseFlow,
28
+ findOpsxGoals,
29
+ isUnfinishedGoalStatus,
30
+ } from "./flow-recovery.ts";
31
+ import type { GoalTaskLike } from "./task-sync.ts";
32
+
33
+ /** The goal-x context shape `goalStorageRoot` accepts. */
34
+ export interface GoalStorageContextLike {
35
+ cwd: string;
36
+ sessionManager?: { getSessionId(): string | undefined };
37
+ }
38
+
39
+ /**
40
+ * The slice of goal-x's storage the reader needs. `parseGoalFile` actually
41
+ * returns goal-x's full GoalRecord; the extra fields are optional here so the
42
+ * fake facets in tests stay minimal while the task-tree reader can still see
43
+ * `taskList` (structural typing — no runtime import of the optional peer).
44
+ */
45
+ export interface GoalXStorageFacet {
46
+ goalStorageRoot(ctx: GoalStorageContextLike): string;
47
+ parseGoalFile(filePath: string): {
48
+ id: string;
49
+ status: string;
50
+ objective: string;
51
+ taskList?: { tasks?: GoalTaskLike[] } | undefined;
52
+ } | null;
53
+ }
54
+
55
+ /** Injectable dynamic-import seam; tests supply a fake storage facet. */
56
+ export type GoalXStorageLoader = () => Promise<GoalXStorageFacet>;
57
+
58
+ // Typed as `string` so TypeScript treats the dynamic import as unresolved at
59
+ // compile time (the peer may be absent at runtime).
60
+ const GOAL_ROOT_MODULE: string =
61
+ "@xzzpig/pi-goal-x/extensions/storage/goal-root.ts";
62
+ const GOAL_FILES_MODULE: string =
63
+ "@xzzpig/pi-goal-x/extensions/storage/goal-files.ts";
64
+
65
+ async function defaultGoalXStorageLoader(): Promise<GoalXStorageFacet> {
66
+ const rootModule = (await import(GOAL_ROOT_MODULE)) as {
67
+ goalStorageRoot: GoalXStorageFacet["goalStorageRoot"];
68
+ };
69
+ const filesModule = (await import(GOAL_FILES_MODULE)) as {
70
+ parseGoalFile: GoalXStorageFacet["parseGoalFile"];
71
+ };
72
+ return {
73
+ goalStorageRoot: rootModule.goalStorageRoot,
74
+ parseGoalFile: filesModule.parseGoalFile,
75
+ };
76
+ }
77
+
78
+ /** The active-goal file prefix goal-x writes (`active_goal_<ts>_<id>.md`). */
79
+ const ACTIVE_GOAL_FILE_PREFIX = "active_goal_";
80
+ const GOAL_FILE_SUFFIX = ".md";
81
+ /** goal-x's archived subdirectory under the goal storage root. */
82
+ const ARCHIVED_GOALS_DIR_NAME = "archived";
83
+
84
+ /**
85
+ * Read the active goal base as plain summaries. Every failure path (goal-x
86
+ * missing, unreadable root, malformed file) degrades to an empty or partial
87
+ * list — a diagnosis must never take the session down.
88
+ */
89
+ export async function readGoalBaseSummaries(
90
+ ctx: GoalStorageContextLike,
91
+ loader: GoalXStorageLoader = defaultGoalXStorageLoader,
92
+ ): Promise<GoalBaseSummary[]> {
93
+ let facet: GoalXStorageFacet;
94
+ try {
95
+ facet = await loader();
96
+ } catch {
97
+ return [];
98
+ }
99
+
100
+ let root: string;
101
+ try {
102
+ root = facet.goalStorageRoot(ctx);
103
+ } catch {
104
+ return [];
105
+ }
106
+
107
+ let entries: fs.Dirent[];
108
+ try {
109
+ entries = fs.readdirSync(root, { withFileTypes: true });
110
+ } catch {
111
+ return [];
112
+ }
113
+
114
+ const goals: GoalBaseSummary[] = [];
115
+ for (const entry of entries) {
116
+ if (
117
+ !entry.isFile() ||
118
+ !entry.name.startsWith(ACTIVE_GOAL_FILE_PREFIX) ||
119
+ !entry.name.endsWith(GOAL_FILE_SUFFIX)
120
+ ) {
121
+ continue;
122
+ }
123
+ let parsed: ReturnType<GoalXStorageFacet["parseGoalFile"]>;
124
+ try {
125
+ parsed = facet.parseGoalFile(path.join(root, entry.name));
126
+ } catch {
127
+ continue;
128
+ }
129
+ if (parsed) {
130
+ goals.push({
131
+ id: parsed.id,
132
+ status: parsed.status,
133
+ objective: parsed.objective,
134
+ });
135
+ }
136
+ }
137
+ return goals;
138
+ }
139
+
140
+ /** customType of the session-start recovery notice. */
141
+ export const RECOVERY_NOTICE_CUSTOM_TYPE = "pi-openspec-x/recovery-notice";
142
+
143
+ /** One archived goal: its id plus the status goal-x archived it under. */
144
+ export interface ArchivedGoalRef {
145
+ id: string;
146
+ /**
147
+ * goal-x archives a completed goal as `complete` and a cleared goal under
148
+ * its live status (e.g. `paused`), so the status tells "this goal finished"
149
+ * apart from "the user ran /goal-clear mid-flow".
150
+ */
151
+ status: string;
152
+ }
153
+
154
+ /**
155
+ * The goals goal-x has archived, with the status each was archived under.
156
+ * Used to tell "this goal finished" (status `complete`) apart from "the user
157
+ * cleared it" (any other status) before the implement flow ends itself.
158
+ */
159
+ export async function readArchivedGoals(
160
+ ctx: GoalStorageContextLike,
161
+ loader: GoalXStorageLoader = defaultGoalXStorageLoader,
162
+ ): Promise<ArchivedGoalRef[]> {
163
+ let facet: GoalXStorageFacet;
164
+ try {
165
+ facet = await loader();
166
+ } catch {
167
+ return [];
168
+ }
169
+ let root: string;
170
+ try {
171
+ root = facet.goalStorageRoot(ctx);
172
+ } catch {
173
+ return [];
174
+ }
175
+ let entries: fs.Dirent[];
176
+ try {
177
+ entries = fs.readdirSync(path.join(root, ARCHIVED_GOALS_DIR_NAME), {
178
+ withFileTypes: true,
179
+ });
180
+ } catch {
181
+ return [];
182
+ }
183
+ const archived: ArchivedGoalRef[] = [];
184
+ for (const entry of entries) {
185
+ if (!entry.isFile() || !entry.name.endsWith(GOAL_FILE_SUFFIX)) continue;
186
+ try {
187
+ const parsed = facet.parseGoalFile(
188
+ path.join(root, ARCHIVED_GOALS_DIR_NAME, entry.name),
189
+ );
190
+ if (parsed) archived.push({ id: parsed.id, status: parsed.status });
191
+ } catch {
192
+ continue;
193
+ }
194
+ }
195
+ return archived;
196
+ }
197
+
198
+ /**
199
+ * The goal task tree of one active goal, for the tasks.md reconciliation
200
+ * (design D10: tasks.md is authoritative; the goal tree is base state).
201
+ * `undefined` means the goal could not be found or read — callers must skip
202
+ * the reconciliation instead of misreading it as "no tasks".
203
+ */
204
+ export async function readGoalTaskTree(
205
+ ctx: GoalStorageContextLike,
206
+ goalId: string,
207
+ loader: GoalXStorageLoader = defaultGoalXStorageLoader,
208
+ ): Promise<GoalTaskLike[] | undefined> {
209
+ let facet: GoalXStorageFacet;
210
+ try {
211
+ facet = await loader();
212
+ } catch {
213
+ return undefined;
214
+ }
215
+ let root: string;
216
+ try {
217
+ root = facet.goalStorageRoot(ctx);
218
+ } catch {
219
+ return undefined;
220
+ }
221
+ let entries: fs.Dirent[];
222
+ try {
223
+ entries = fs.readdirSync(root, { withFileTypes: true });
224
+ } catch {
225
+ return undefined;
226
+ }
227
+ for (const entry of entries) {
228
+ if (
229
+ !entry.isFile() ||
230
+ !entry.name.startsWith(ACTIVE_GOAL_FILE_PREFIX) ||
231
+ !entry.name.endsWith(GOAL_FILE_SUFFIX)
232
+ ) {
233
+ continue;
234
+ }
235
+ let parsed: ReturnType<GoalXStorageFacet["parseGoalFile"]>;
236
+ try {
237
+ parsed = facet.parseGoalFile(path.join(root, entry.name));
238
+ } catch {
239
+ continue;
240
+ }
241
+ if (parsed?.id !== goalId) continue;
242
+ return parsed.taskList?.tasks ?? [];
243
+ }
244
+ return undefined;
245
+ }
246
+
247
+ export interface RecoveryHandlerDeps {
248
+ /** Injectable storage loader (tests); defaults to the real goal-x storage. */
249
+ loadGoalXStorage?: GoalXStorageLoader;
250
+ /** Scope the recovery search to one change id. */
251
+ changeId?: string;
252
+ /**
253
+ * Re-install the per-goal auditor override (S1) for an unfinished opsx goal.
254
+ * The override lives in memory, so a crash/restart loses it and the restored
255
+ * goal would otherwise silently fall back to the default goal-auditor for its
256
+ * completion audit instead of opsx-reviewer.
257
+ */
258
+ installAuditor?: (goalId: string) => void;
259
+ }
260
+
261
+ // One notice per (session, change) pair; module-level so an extension reload
262
+ // in the same process does not repeat it.
263
+ const recoveryNoticeSent = new Set<string>();
264
+
265
+ /** Reset the one-time notice state. Test-only. */
266
+ export function resetRecoveryStateForTests(): void {
267
+ recoveryNoticeSent.clear();
268
+ }
269
+
270
+ /**
271
+ * Build the `session_start` handler that offers the recovery entry when the
272
+ * goal base holds an unfinished opsx goal. Read-only: it only sends a notice
273
+ * telling the user to run goal-x's `/goal-resume` (or `/goal-clear`), and it
274
+ * never resumes or mutates anything itself.
275
+ */
276
+ export function createOpsxRecoverySessionStartHandler(
277
+ pi: Pick<ExtensionAPI, "sendMessage">,
278
+ deps: RecoveryHandlerDeps = {},
279
+ ): ExtensionHandler<SessionStartEvent> {
280
+ return async (
281
+ _event: SessionStartEvent,
282
+ ctx: ExtensionContext,
283
+ ): Promise<void> => {
284
+ let goals: GoalBaseSummary[];
285
+ try {
286
+ goals = await readGoalBaseSummaries(
287
+ { cwd: ctx.cwd, sessionManager: ctx.sessionManager },
288
+ deps.loadGoalXStorage,
289
+ );
290
+ } catch {
291
+ return;
292
+ }
293
+ // Read-only diagnosis (task 6.4): what the base holds for this change and
294
+ // whether the flow diverged from it (paused/blocked/cleared). Nothing here
295
+ // mutates; the repair options it lists all need the user's explicit action.
296
+ const opsxGoals = findOpsxGoals(goals);
297
+ const only = opsxGoals.length === 1 ? opsxGoals[0] : undefined;
298
+ const active =
299
+ only && isUnfinishedGoalStatus(only.status)
300
+ ? { goalId: only.goalId, changeId: only.changeId }
301
+ : undefined;
302
+ const diagnosis = diagnoseFlow({
303
+ active,
304
+ goals,
305
+ changeId: deps.changeId,
306
+ });
307
+ if (diagnosis.notices.length === 0) return;
308
+
309
+ // Best-effort: never break session start.
310
+ if (deps.installAuditor) {
311
+ for (const goal of opsxGoals) {
312
+ if (!isUnfinishedGoalStatus(goal.status)) continue;
313
+ try {
314
+ deps.installAuditor(goal.goalId);
315
+ } catch {
316
+ // Best-effort.
317
+ }
318
+ }
319
+ }
320
+
321
+ const key = `${ctx.sessionManager?.getSessionId() ?? "unknown"}:${deps.changeId ?? "*"}`;
322
+ if (recoveryNoticeSent.has(key)) return;
323
+ recoveryNoticeSent.add(key);
324
+
325
+ const content = diagnosis.notices.join("\n");
326
+ try {
327
+ pi.sendMessage(
328
+ {
329
+ customType: RECOVERY_NOTICE_CUSTOM_TYPE,
330
+ content,
331
+ display: true,
332
+ },
333
+ { deliverAs: "followUp" },
334
+ );
335
+ } catch {
336
+ // Notification is best-effort; never break session start.
337
+ }
338
+ };
339
+ }