@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.
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +203 -0
- package/index.ts +1 -0
- package/package.json +55 -0
- package/src/agent-dir.ts +48 -0
- package/src/agent-prompts.ts +115 -0
- package/src/agents.ts +262 -0
- package/src/approval-gate.ts +139 -0
- package/src/cli.ts +442 -0
- package/src/command-util.ts +33 -0
- package/src/config.ts +89 -0
- package/src/dependencies.ts +445 -0
- package/src/discover.ts +112 -0
- package/src/dispatch-gate.ts +257 -0
- package/src/final-gate.ts +158 -0
- package/src/flow-entries.ts +258 -0
- package/src/flow-recovery.ts +227 -0
- package/src/goal-base.ts +339 -0
- package/src/implement-command.ts +1151 -0
- package/src/index.ts +173 -0
- package/src/lifecycle.ts +156 -0
- package/src/mode.ts +285 -0
- package/src/objective.ts +246 -0
- package/src/plan-command.ts +285 -0
- package/src/plan-flow.ts +133 -0
- package/src/plan-gate.ts +279 -0
- package/src/plan-review.ts +452 -0
- package/src/progress-observer.ts +78 -0
- package/src/progress-projection.ts +118 -0
- package/src/report-tools.ts +317 -0
- package/src/review-scope.ts +172 -0
- package/src/rollback-command.ts +241 -0
- package/src/sandbox-profiles.ts +73 -0
- package/src/skills.ts +386 -0
- package/src/static-conflict.ts +89 -0
- package/src/task-sync.ts +226 -0
- package/src/tasks-md.ts +136 -0
- package/src/worker-dispatch.ts +152 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency state for pi-openspec-x (design D7; spec "可选依赖降级与受限模式
|
|
3
|
+
* fail-closed").
|
|
4
|
+
*
|
|
5
|
+
* `@xzzpig/pi-sandbox` is a peer dependency, but the extension must load and
|
|
6
|
+
* serve the official-track skills even when it cannot be imported. This module
|
|
7
|
+
* is the single place that touches it:
|
|
8
|
+
*
|
|
9
|
+
* - `probeSandboxDependency` performs the lazy dynamic import at extension
|
|
10
|
+
* init. Success records the module facet; failure records a reason and never
|
|
11
|
+
* throws past the probe, so `resources_discover` and everything else keep
|
|
12
|
+
* working (the official track is unaffected by a missing sandbox).
|
|
13
|
+
* - `requireRestrictedModeSupport` is the fail-closed gate for restricted
|
|
14
|
+
* modes (opsx planner / agent): it throws a typed
|
|
15
|
+
* {@link OpsxDependencyMissingError} when the sandbox is unavailable, so a
|
|
16
|
+
* restricted mode is refused instead of running unsandboxed.
|
|
17
|
+
* - `probeSubagentsDependency` / `requireSubagentsSupport` are the same
|
|
18
|
+
* pattern for `@xzzpig/pi-subagents` (the runtime subagent registry): a
|
|
19
|
+
* missing pi-subagents only costs the four opsx subagents — the official
|
|
20
|
+
* track and direct main-session implementation are unaffected.
|
|
21
|
+
*
|
|
22
|
+
* The module specifier is kept as a plain string and resolved only through
|
|
23
|
+
* `await import(...)`: a static value import would fail the whole extension
|
|
24
|
+
* load when an optional peer is missing. (Type-only imports are erased at
|
|
25
|
+
* runtime and stay.) Tests simulate a missing or broken module by injecting a
|
|
26
|
+
* loader — node_modules is never touched.
|
|
27
|
+
*/
|
|
28
|
+
import type { SandboxProfileDefinition } from "@xzzpig/pi-sandbox";
|
|
29
|
+
import type { registerAgentViaEvents } from "@xzzpig/pi-subagents/agents";
|
|
30
|
+
|
|
31
|
+
import type { GoalStorageContextLike } from "./goal-base.ts";
|
|
32
|
+
import type {
|
|
33
|
+
GoalChangeBaselineHandle,
|
|
34
|
+
ReviewWindowDelta,
|
|
35
|
+
} from "./review-scope.ts";
|
|
36
|
+
|
|
37
|
+
/** The optional peer this module probes and records state for. */
|
|
38
|
+
export const SANDBOX_DEPENDENCY = "@xzzpig/pi-sandbox";
|
|
39
|
+
|
|
40
|
+
/** The optional peer backing the four opsx runtime subagents. */
|
|
41
|
+
export const SUBAGENTS_DEPENDENCY = "@xzzpig/pi-subagents";
|
|
42
|
+
|
|
43
|
+
/** The pi-subagents public entry the runtime registration goes through. */
|
|
44
|
+
const SUBAGENTS_AGENTS_MODULE = "@xzzpig/pi-subagents/agents";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The slice of pi-sandbox this package uses after a successful load: runtime
|
|
48
|
+
* profile registration (D4) and the per-session service lookup.
|
|
49
|
+
*/
|
|
50
|
+
export interface SandboxModuleFacet {
|
|
51
|
+
registerSandboxProfiles(
|
|
52
|
+
profiles: Record<string, SandboxProfileDefinition>,
|
|
53
|
+
): void;
|
|
54
|
+
getSandboxService(sessionId?: string): SandboxServiceLike | null | undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Minimal structural view of pi-sandbox's per-session SandboxService. */
|
|
58
|
+
export interface SandboxServiceLike {
|
|
59
|
+
setProfile(
|
|
60
|
+
profileName: string | undefined,
|
|
61
|
+
): Promise<{ ok: boolean; message?: string }>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Injectable dynamic-import seam; tests reject it to simulate a missing module. */
|
|
65
|
+
export type SandboxModuleLoader = () => Promise<unknown>;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What exactly stops working without the dependency: the message must be
|
|
69
|
+
* self-explanatory about what is refused and what keeps working. The sandbox
|
|
70
|
+
* consequence is exported because the per-session service lookup (mode.ts)
|
|
71
|
+
* raises the same typed error through a second call site.
|
|
72
|
+
*/
|
|
73
|
+
export const SANDBOX_MISSING_CONSEQUENCE =
|
|
74
|
+
"Restricted modes (opsx planner / agent) are refused (fail-closed); direct main-session implementation and the official openspec skills are unaffected.";
|
|
75
|
+
const SUBAGENTS_MISSING_CONSEQUENCE =
|
|
76
|
+
"The four opsx subagents (opsx-gap-analysis, opsx-plan-review, opsx-worker, opsx-reviewer) cannot be registered or dispatched, so /opsx:plan review gates and agent implementation are unavailable (fail-closed); the official openspec skills and direct main-session implementation are unaffected.";
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A missing dependency must be a typed, self-explanatory failure: the
|
|
80
|
+
* message names what is absent, says what is refused, and states what keeps
|
|
81
|
+
* working without it.
|
|
82
|
+
*/
|
|
83
|
+
export class OpsxDependencyMissingError extends Error {
|
|
84
|
+
/** The dependency that is missing, e.g. "@xzzpig/pi-sandbox". */
|
|
85
|
+
readonly dependency: string;
|
|
86
|
+
/** Why it is considered missing (import failure, service not published, ...). */
|
|
87
|
+
readonly reason: string;
|
|
88
|
+
|
|
89
|
+
constructor(dependency: string, reason: string, consequence: string) {
|
|
90
|
+
super(
|
|
91
|
+
`${dependency} is not available in this session: ${reason}. ${consequence}`,
|
|
92
|
+
);
|
|
93
|
+
this.name = "OpsxDependencyMissingError";
|
|
94
|
+
this.dependency = dependency;
|
|
95
|
+
this.reason = reason;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function describeError(error: unknown): string {
|
|
100
|
+
return error instanceof Error ? error.message : String(error);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Recorded state. `undefined` module + `undefined` reason means "not probed
|
|
105
|
+
* yet", which is itself a refusal reason: restricted modes require a probe.
|
|
106
|
+
*/
|
|
107
|
+
let sandboxModule: SandboxModuleFacet | undefined;
|
|
108
|
+
let sandboxMissingReason: string | undefined;
|
|
109
|
+
|
|
110
|
+
export interface SandboxAvailability {
|
|
111
|
+
available: boolean;
|
|
112
|
+
/** Why the sandbox is unavailable; undefined when available or not probed. */
|
|
113
|
+
reason?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function sandboxAvailability(): SandboxAvailability {
|
|
117
|
+
if (sandboxModule) return { available: true };
|
|
118
|
+
return {
|
|
119
|
+
available: false,
|
|
120
|
+
reason:
|
|
121
|
+
sandboxMissingReason ??
|
|
122
|
+
"pi-sandbox has not been probed yet (extension not initialized)",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Fail-closed gate for restricted modes. Returns the recorded module facet so
|
|
128
|
+
* the caller can proceed without a second lookup; throws a typed
|
|
129
|
+
* {@link OpsxDependencyMissingError} otherwise.
|
|
130
|
+
*/
|
|
131
|
+
export function requireRestrictedModeSupport(): SandboxModuleFacet {
|
|
132
|
+
if (sandboxModule) return sandboxModule;
|
|
133
|
+
throw new OpsxDependencyMissingError(
|
|
134
|
+
SANDBOX_DEPENDENCY,
|
|
135
|
+
sandboxMissingReason ??
|
|
136
|
+
"pi-sandbox has not been probed yet (extension not initialized)",
|
|
137
|
+
SANDBOX_MISSING_CONSEQUENCE,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Resolve the sandbox service published for this session.
|
|
143
|
+
*
|
|
144
|
+
* Returns undefined — never throws — when the module was not loaded, the
|
|
145
|
+
* service lookup fails, or the published object does not expose `setProfile`.
|
|
146
|
+
* Callers must treat undefined as "sandbox controls unavailable" and refuse
|
|
147
|
+
* restricted modes instead of assuming success.
|
|
148
|
+
*/
|
|
149
|
+
export async function loadSessionSandboxService(
|
|
150
|
+
sessionId: string | undefined,
|
|
151
|
+
): Promise<SandboxServiceLike | undefined> {
|
|
152
|
+
if (!sandboxModule) return undefined;
|
|
153
|
+
let service: SandboxServiceLike | null | undefined;
|
|
154
|
+
try {
|
|
155
|
+
service = sandboxModule.getSandboxService(sessionId);
|
|
156
|
+
} catch {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
if (!service || typeof service.setProfile !== "function") return undefined;
|
|
160
|
+
return service;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Record a sandbox failure discovered outside the probe (e.g. profile
|
|
165
|
+
* registration threw at init). Downgrades the recorded state so the
|
|
166
|
+
* fail-closed gate keeps refusing restricted modes with the actual reason.
|
|
167
|
+
*/
|
|
168
|
+
export function noteSandboxUnavailable(reason: string): void {
|
|
169
|
+
sandboxModule = undefined;
|
|
170
|
+
sandboxMissingReason = reason;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface SandboxProbeResult {
|
|
174
|
+
available: boolean;
|
|
175
|
+
/** Present (with `module`) when available; the refusal reason otherwise. */
|
|
176
|
+
reason?: string;
|
|
177
|
+
module?: SandboxModuleFacet;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function defaultLoader(): Promise<unknown> {
|
|
181
|
+
return import(SANDBOX_DEPENDENCY);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Probe pi-sandbox through an injectable dynamic import and record the result.
|
|
186
|
+
* A missing or shape-incompatible module is recorded as dependency state and
|
|
187
|
+
* returned as a plain result — this function never throws past the loader.
|
|
188
|
+
*/
|
|
189
|
+
export async function probeSandboxDependency(
|
|
190
|
+
loader: SandboxModuleLoader = defaultLoader,
|
|
191
|
+
): Promise<SandboxProbeResult> {
|
|
192
|
+
let loaded: unknown;
|
|
193
|
+
try {
|
|
194
|
+
loaded = await loader();
|
|
195
|
+
} catch (error) {
|
|
196
|
+
noteSandboxUnavailable(`dynamic import failed: ${describeError(error)}`);
|
|
197
|
+
return { available: false, reason: sandboxMissingReason };
|
|
198
|
+
}
|
|
199
|
+
const facet = loaded as Partial<SandboxModuleFacet> | null | undefined;
|
|
200
|
+
if (
|
|
201
|
+
typeof facet?.registerSandboxProfiles !== "function" ||
|
|
202
|
+
typeof facet.getSandboxService !== "function"
|
|
203
|
+
) {
|
|
204
|
+
noteSandboxUnavailable(
|
|
205
|
+
"module loaded but the expected exports (registerSandboxProfiles, getSandboxService) are missing; the installed pi-sandbox is incompatible",
|
|
206
|
+
);
|
|
207
|
+
return { available: false, reason: sandboxMissingReason };
|
|
208
|
+
}
|
|
209
|
+
sandboxModule = {
|
|
210
|
+
registerSandboxProfiles: facet.registerSandboxProfiles,
|
|
211
|
+
getSandboxService: facet.getSandboxService,
|
|
212
|
+
};
|
|
213
|
+
sandboxMissingReason = undefined;
|
|
214
|
+
return { available: true, module: sandboxModule };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Reset the recorded dependency state. Test-only. */
|
|
218
|
+
export function resetDependencyStateForTests(): void {
|
|
219
|
+
sandboxModule = undefined;
|
|
220
|
+
sandboxMissingReason = undefined;
|
|
221
|
+
subagentsModule = undefined;
|
|
222
|
+
subagentsMissingReason = undefined;
|
|
223
|
+
goalXModule = undefined;
|
|
224
|
+
goalXMissingReason = undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The slice of pi-subagents this package uses after a successful load:
|
|
229
|
+
* runtime agent registration through the installed owner's event bus.
|
|
230
|
+
*/
|
|
231
|
+
export interface SubagentsModuleFacet {
|
|
232
|
+
registerAgentViaEvents: typeof registerAgentViaEvents;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Recorded subagents state, mirroring {@link SandboxAvailability}. */
|
|
236
|
+
export interface SubagentsAvailability {
|
|
237
|
+
available: boolean;
|
|
238
|
+
/** Why pi-subagents is unavailable; undefined when available or not probed. */
|
|
239
|
+
reason?: string;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let subagentsModule: SubagentsModuleFacet | undefined;
|
|
243
|
+
let subagentsMissingReason: string | undefined;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Fail-closed gate for everything that needs the runtime subagent registry
|
|
247
|
+
* (agent registration now, delegation later). Returns the recorded module
|
|
248
|
+
* facet; throws a typed {@link OpsxDependencyMissingError} otherwise.
|
|
249
|
+
*/
|
|
250
|
+
export function requireSubagentsSupport(): SubagentsModuleFacet {
|
|
251
|
+
if (subagentsModule) return subagentsModule;
|
|
252
|
+
throw new OpsxDependencyMissingError(
|
|
253
|
+
SUBAGENTS_DEPENDENCY,
|
|
254
|
+
subagentsMissingReason ??
|
|
255
|
+
"pi-subagents has not been probed yet (extension not initialized)",
|
|
256
|
+
SUBAGENTS_MISSING_CONSEQUENCE,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function defaultSubagentsLoader(): Promise<unknown> {
|
|
261
|
+
return import(SUBAGENTS_AGENTS_MODULE);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Probe pi-subagents through an injectable dynamic import and record the
|
|
266
|
+
* result. Same contract as {@link probeSandboxDependency}: never throws past
|
|
267
|
+
* the loader; a missing or shape-incompatible module is recorded as
|
|
268
|
+
* dependency state. A missing pi-subagents costs only the four opsx
|
|
269
|
+
* subagents — the official track keeps working (spec "可选依赖降级").
|
|
270
|
+
*/
|
|
271
|
+
export async function probeSubagentsDependency(
|
|
272
|
+
loader: SandboxModuleLoader = defaultSubagentsLoader,
|
|
273
|
+
): Promise<
|
|
274
|
+
| { available: true; module: SubagentsModuleFacet }
|
|
275
|
+
| { available: false; reason: string }
|
|
276
|
+
> {
|
|
277
|
+
let loaded: unknown;
|
|
278
|
+
try {
|
|
279
|
+
loaded = await loader();
|
|
280
|
+
} catch (error) {
|
|
281
|
+
subagentsMissingReason = `dynamic import failed: ${describeError(error)}`;
|
|
282
|
+
return { available: false, reason: subagentsMissingReason };
|
|
283
|
+
}
|
|
284
|
+
const facet = loaded as Partial<SubagentsModuleFacet> | null | undefined;
|
|
285
|
+
if (typeof facet?.registerAgentViaEvents !== "function") {
|
|
286
|
+
subagentsMissingReason =
|
|
287
|
+
"module loaded but the expected export (registerAgentViaEvents) is missing; the installed pi-subagents is incompatible";
|
|
288
|
+
return { available: false, reason: subagentsMissingReason };
|
|
289
|
+
}
|
|
290
|
+
subagentsModule = { registerAgentViaEvents: facet.registerAgentViaEvents };
|
|
291
|
+
subagentsMissingReason = undefined;
|
|
292
|
+
return { available: true, module: subagentsModule };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The optional peer `/opsx:implement` runs on. Its absence must refuse the
|
|
297
|
+
* implementation flow (fail-closed) while the official track and `/opsx:plan`
|
|
298
|
+
* keep working.
|
|
299
|
+
*/
|
|
300
|
+
export const GOAL_X_DEPENDENCY = "@xzzpig/pi-goal-x";
|
|
301
|
+
|
|
302
|
+
const GOAL_X_MISSING_CONSEQUENCE =
|
|
303
|
+
"/opsx:implement is refused (fail-closed); the official openspec skills and /opsx:plan are unaffected.";
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* The slice of goal-x the implementation flow consumes: the per-goal auditor
|
|
307
|
+
* override and cross-extension resolver (the S1/S2 fork seams) plus the
|
|
308
|
+
* change-window delta used for the final review scope.
|
|
309
|
+
*/
|
|
310
|
+
export interface GoalXModuleFacet {
|
|
311
|
+
setGoalAuditorOverride(
|
|
312
|
+
goalId: string,
|
|
313
|
+
override: Record<string, unknown>,
|
|
314
|
+
): void;
|
|
315
|
+
clearGoalAuditorOverride(goalId: string): void;
|
|
316
|
+
registerAuditorAgentResolver(
|
|
317
|
+
resolver: (agentName: string) => object | undefined,
|
|
318
|
+
): () => void;
|
|
319
|
+
readChangeBaseline(
|
|
320
|
+
ctx: GoalStorageContextLike,
|
|
321
|
+
goalId: string,
|
|
322
|
+
): GoalChangeBaselineHandle | undefined;
|
|
323
|
+
computeChangeDelta(
|
|
324
|
+
baseline: GoalChangeBaselineHandle,
|
|
325
|
+
options?: { timeoutMs?: number },
|
|
326
|
+
): Promise<ReviewWindowDelta>;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Injectable dynamic-import seam; tests reject it to simulate a missing module. */
|
|
330
|
+
export type GoalXModuleLoader = () => Promise<Partial<GoalXModuleFacet>>;
|
|
331
|
+
|
|
332
|
+
const GOAL_X_OVERRIDE_MODULE: string =
|
|
333
|
+
"@xzzpig/pi-goal-x/extensions/goal-auditor-override.ts";
|
|
334
|
+
const GOAL_X_RESOLVER_MODULE: string =
|
|
335
|
+
"@xzzpig/pi-goal-x/extensions/goal-auditor-agent-resolver.ts";
|
|
336
|
+
const GOAL_X_BASELINE_MODULE: string =
|
|
337
|
+
"@xzzpig/pi-goal-x/extensions/goal-change-baseline.ts";
|
|
338
|
+
const GOAL_X_DELTA_MODULE: string =
|
|
339
|
+
"@xzzpig/pi-goal-x/extensions/goal-change-delta.ts";
|
|
340
|
+
|
|
341
|
+
async function defaultGoalXLoader(): Promise<Partial<GoalXModuleFacet>> {
|
|
342
|
+
const overrideModule = (await import(
|
|
343
|
+
GOAL_X_OVERRIDE_MODULE
|
|
344
|
+
)) as Partial<GoalXModuleFacet>;
|
|
345
|
+
const resolverModule = (await import(
|
|
346
|
+
GOAL_X_RESOLVER_MODULE
|
|
347
|
+
)) as Partial<GoalXModuleFacet>;
|
|
348
|
+
const baselineModule = (await import(
|
|
349
|
+
GOAL_X_BASELINE_MODULE
|
|
350
|
+
)) as Partial<GoalXModuleFacet>;
|
|
351
|
+
const deltaModule = (await import(
|
|
352
|
+
GOAL_X_DELTA_MODULE
|
|
353
|
+
)) as Partial<GoalXModuleFacet>;
|
|
354
|
+
return {
|
|
355
|
+
setGoalAuditorOverride: overrideModule.setGoalAuditorOverride,
|
|
356
|
+
clearGoalAuditorOverride: overrideModule.clearGoalAuditorOverride,
|
|
357
|
+
registerAuditorAgentResolver: resolverModule.registerAuditorAgentResolver,
|
|
358
|
+
readChangeBaseline: baselineModule.readChangeBaseline,
|
|
359
|
+
computeChangeDelta: deltaModule.computeChangeDelta,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
let goalXModule: GoalXModuleFacet | undefined;
|
|
364
|
+
let goalXMissingReason: string | undefined;
|
|
365
|
+
|
|
366
|
+
export interface GoalXAvailability {
|
|
367
|
+
available: boolean;
|
|
368
|
+
reason?: string;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function goalXAvailability(): GoalXAvailability {
|
|
372
|
+
if (goalXModule) return { available: true };
|
|
373
|
+
return {
|
|
374
|
+
available: false,
|
|
375
|
+
reason:
|
|
376
|
+
goalXMissingReason ??
|
|
377
|
+
"pi-goal-x has not been probed yet (extension not initialized)",
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Fail-closed gate for `/opsx:implement`. Returns the recorded goal-x facet;
|
|
383
|
+
* throws a typed {@link OpsxDependencyMissingError} otherwise. The official
|
|
384
|
+
* track and `/opsx:plan` never call this.
|
|
385
|
+
*/
|
|
386
|
+
export function requireGoalXSupport(): GoalXModuleFacet {
|
|
387
|
+
if (goalXModule) return goalXModule;
|
|
388
|
+
throw new OpsxDependencyMissingError(
|
|
389
|
+
GOAL_X_DEPENDENCY,
|
|
390
|
+
goalXMissingReason ??
|
|
391
|
+
"pi-goal-x has not been probed yet (extension not initialized)",
|
|
392
|
+
GOAL_X_MISSING_CONSEQUENCE,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const GOAL_X_REQUIRED_EXPORTS = [
|
|
397
|
+
"setGoalAuditorOverride",
|
|
398
|
+
"clearGoalAuditorOverride",
|
|
399
|
+
"registerAuditorAgentResolver",
|
|
400
|
+
"readChangeBaseline",
|
|
401
|
+
"computeChangeDelta",
|
|
402
|
+
] as const;
|
|
403
|
+
|
|
404
|
+
/** Narrow a partially-loaded goal-x facet to the full API this plugin needs. */
|
|
405
|
+
function isGoalXModuleFacet(
|
|
406
|
+
value: Partial<GoalXModuleFacet>,
|
|
407
|
+
): value is GoalXModuleFacet {
|
|
408
|
+
return GOAL_X_REQUIRED_EXPORTS.every(
|
|
409
|
+
(name) => typeof value[name] === "function",
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Probe pi-goal-x through an injectable dynamic import and record the result.
|
|
415
|
+
* Never throws past the loader. A module that loads but lacks the fork APIs
|
|
416
|
+
* the implementation flow requires (the S1 override, the S2 resolver, the
|
|
417
|
+
* window delta) is recorded as incompatible — the same fail-closed outcome as
|
|
418
|
+
* a missing module.
|
|
419
|
+
*/
|
|
420
|
+
export async function probeGoalXDependency(
|
|
421
|
+
loader: GoalXModuleLoader = defaultGoalXLoader,
|
|
422
|
+
): Promise<
|
|
423
|
+
| { available: true; module: GoalXModuleFacet }
|
|
424
|
+
| { available: false; reason: string }
|
|
425
|
+
> {
|
|
426
|
+
let facet: Partial<GoalXModuleFacet>;
|
|
427
|
+
try {
|
|
428
|
+
facet = await loader();
|
|
429
|
+
} catch (error) {
|
|
430
|
+
goalXMissingReason = `dynamic import failed: ${describeError(error)}`;
|
|
431
|
+
return { available: false, reason: goalXMissingReason };
|
|
432
|
+
}
|
|
433
|
+
if (!isGoalXModuleFacet(facet)) {
|
|
434
|
+
const missing = GOAL_X_REQUIRED_EXPORTS.filter(
|
|
435
|
+
(name) => typeof facet[name] !== "function",
|
|
436
|
+
);
|
|
437
|
+
goalXMissingReason = `pi-goal-x loaded but required export(s) are missing: ${missing.join(
|
|
438
|
+
", ",
|
|
439
|
+
)}; the installed pi-goal-x is incompatible with this plugin`;
|
|
440
|
+
return { available: false, reason: goalXMissingReason };
|
|
441
|
+
}
|
|
442
|
+
goalXModule = facet;
|
|
443
|
+
goalXMissingReason = undefined;
|
|
444
|
+
return { available: true, module: facet };
|
|
445
|
+
}
|
package/src/discover.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* resources_discover wiring for pi-openspec-x.
|
|
3
|
+
*
|
|
4
|
+
* The handler returned by `createResourcesDiscoverHandler`:
|
|
5
|
+
* - ensures the versioned official-track skills are generated under the agent
|
|
6
|
+
* dir cache and returns that directory as a `skillPaths` entry;
|
|
7
|
+
* - warns once per cwd when static `<cwd>/.pi/skills/openspec-*` directories
|
|
8
|
+
* collide with the generated skills;
|
|
9
|
+
* - never throws: when the openspec CLI is missing (or generation fails for
|
|
10
|
+
* any other reason) it returns an empty result and sends a one-time notice.
|
|
11
|
+
*/
|
|
12
|
+
import type {
|
|
13
|
+
ExtensionAPI,
|
|
14
|
+
ExtensionContext,
|
|
15
|
+
ExtensionHandler,
|
|
16
|
+
ResourcesDiscoverEvent,
|
|
17
|
+
ResourcesDiscoverResult,
|
|
18
|
+
} from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { isOpenspecCliMissingError } from "./cli.ts";
|
|
20
|
+
import {
|
|
21
|
+
ensureOpenspecSkills,
|
|
22
|
+
type EnsureOpenspecSkillsOptions,
|
|
23
|
+
} from "./skills.ts";
|
|
24
|
+
import {
|
|
25
|
+
STATIC_CONFLICT_CUSTOM_TYPE,
|
|
26
|
+
warnStaticSkillConflicts,
|
|
27
|
+
} from "./static-conflict.ts";
|
|
28
|
+
|
|
29
|
+
/** customType of the one-time CLI-missing notice. */
|
|
30
|
+
export const CLI_MISSING_NOTICE_CUSTOM_TYPE =
|
|
31
|
+
"pi-openspec-x/cli-missing-notice";
|
|
32
|
+
|
|
33
|
+
export interface ResourcesDiscoverDeps
|
|
34
|
+
extends Partial<EnsureOpenspecSkillsOptions> {
|
|
35
|
+
/**
|
|
36
|
+
* Working directory for the static-conflict check, defaulting to the
|
|
37
|
+
* event's cwd. Kept separate from the CLI child-process cwd, which stays
|
|
38
|
+
* unset (schemas/templates/version do not depend on it).
|
|
39
|
+
*/
|
|
40
|
+
conflictCwd?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// One-time flags live at module level so they survive extension reloads
|
|
44
|
+
// within the same pi process. Reset via resetResourcesDiscoverStateForTests.
|
|
45
|
+
let cliMissingNoticeSent = false;
|
|
46
|
+
const staticConflictWarned = new Set<string>();
|
|
47
|
+
|
|
48
|
+
/** Reset the one-time notice state. Test-only. */
|
|
49
|
+
export function resetResourcesDiscoverStateForTests(): void {
|
|
50
|
+
cliMissingNoticeSent = false;
|
|
51
|
+
staticConflictWarned.clear();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sendCliMissingNotice(
|
|
55
|
+
pi: Pick<ExtensionAPI, "sendMessage">,
|
|
56
|
+
error: unknown,
|
|
57
|
+
): void {
|
|
58
|
+
const detail = isOpenspecCliMissingError(error)
|
|
59
|
+
? error.message
|
|
60
|
+
: `pi-openspec-x could not generate the official OpenSpec skills: ${error instanceof Error ? error.message : String(error)}`;
|
|
61
|
+
try {
|
|
62
|
+
pi.sendMessage(
|
|
63
|
+
{
|
|
64
|
+
customType: CLI_MISSING_NOTICE_CUSTOM_TYPE,
|
|
65
|
+
content: `${detail}\n\nThe extension will register no OpenSpec skills for this session. Install the openspec CLI (or fix the error above) and reload to enable the official workflow.`,
|
|
66
|
+
display: true,
|
|
67
|
+
},
|
|
68
|
+
{ deliverAs: "followUp" },
|
|
69
|
+
);
|
|
70
|
+
} catch {
|
|
71
|
+
// Notification is best-effort; never break discovery.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build the resources_discover handler. `pi` is used only for one-time
|
|
77
|
+
* notices; skill generation goes through `ensureOpenspecSkills` with the
|
|
78
|
+
* given deps (env/homeDir/agentDir/spawnImpl are injectable for tests).
|
|
79
|
+
*/
|
|
80
|
+
export function createResourcesDiscoverHandler(
|
|
81
|
+
pi: Pick<ExtensionAPI, "sendMessage">,
|
|
82
|
+
deps: ResourcesDiscoverDeps = {},
|
|
83
|
+
): ExtensionHandler<ResourcesDiscoverEvent, ResourcesDiscoverResult> {
|
|
84
|
+
const ensureOptions: EnsureOpenspecSkillsOptions = {
|
|
85
|
+
env: deps.env,
|
|
86
|
+
homeDir: deps.homeDir,
|
|
87
|
+
agentDir: deps.agentDir,
|
|
88
|
+
spawnImpl: deps.spawnImpl,
|
|
89
|
+
};
|
|
90
|
+
return (
|
|
91
|
+
event: ResourcesDiscoverEvent,
|
|
92
|
+
_ctx: ExtensionContext,
|
|
93
|
+
): ResourcesDiscoverResult => {
|
|
94
|
+
const cwd = deps.conflictCwd ?? event.cwd;
|
|
95
|
+
warnStaticSkillConflicts(pi, cwd, {
|
|
96
|
+
oncePerCwd: true,
|
|
97
|
+
warned: staticConflictWarned,
|
|
98
|
+
});
|
|
99
|
+
try {
|
|
100
|
+
const result = ensureOpenspecSkills(ensureOptions);
|
|
101
|
+
return { skillPaths: [result.skillsDir] };
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (!cliMissingNoticeSent) {
|
|
104
|
+
cliMissingNoticeSent = true;
|
|
105
|
+
sendCliMissingNotice(pi, error);
|
|
106
|
+
}
|
|
107
|
+
return { skillPaths: [] };
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export { STATIC_CONFLICT_CUSTOM_TYPE };
|