@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/src/agents.ts ADDED
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Runtime registration of the four opsx subagents (design D3).
3
+ *
4
+ * opsx-gap-analysis / opsx-plan-review / opsx-worker / opsx-reviewer are registered with
5
+ * the installed pi-subagents owner through `registerAgentViaEvents` (the
6
+ * pi-goal-x goal-auditor pattern): no agent markdown files, definitions owned
7
+ * by this package, tool allowlists enforced by pi-subagents.
8
+ *
9
+ * Registration goes through the recorded pi-subagents facet
10
+ * (dependencies.ts): when pi-subagents is missing the extension still loads,
11
+ * the official-track skills keep working, and later dispatch attempts fail
12
+ * closed with a typed OpsxDependencyMissingError. The event bus carries the
13
+ * registration to pi-subagents' owner listener — that part works regardless
14
+ * of extension load order — but pi-subagents may simply not be loaded yet
15
+ * when this extension initializes, so a failed attempt (and only a failed
16
+ * attempt) is retried on the next `session_start`, mirroring the goal-auditor
17
+ * precedent of registering when the host is fully up.
18
+ *
19
+ * Re-registration is dispose-first idempotent: every previous registration is
20
+ * disposed before new ones are created, so a reload or a session_start retry
21
+ * never collides with itself.
22
+ */
23
+ import { createRequire } from "node:module";
24
+ import type {
25
+ ExtensionAPI,
26
+ ExtensionHandler,
27
+ SessionStartEvent,
28
+ } from "@earendil-works/pi-coding-agent";
29
+ import type { RuntimeAgentDefinition } from "@xzzpig/pi-subagents/agents";
30
+
31
+ import {
32
+ GAP_ANALYSIS_SYSTEM_PROMPT,
33
+ PLAN_REVIEW_SYSTEM_PROMPT,
34
+ OPSX_REVIEWER_SYSTEM_PROMPT,
35
+ WORKER_SYSTEM_PROMPT,
36
+ } from "./agent-prompts.ts";
37
+ import { describeError, requireSubagentsSupport } from "./dependencies.ts";
38
+ import type { SubagentsModuleFacet } from "./dependencies.ts";
39
+
40
+ import {
41
+ REPORT_GAP_ANALYSIS_TOOL_NAME,
42
+ REPORT_PLAN_REVIEW_TOOL_NAME,
43
+ REPORT_WORK_TOOL_NAME,
44
+ } from "./report-tools.ts";
45
+
46
+ /**
47
+ * Protocol tool goal-x's completion audit requires its auditor to carry
48
+ * (preflight fails without it). The tool itself is registered by goal-x's
49
+ * child-only progress extension; the reviewer's definition loads that
50
+ * extension via `subagentOnlyExtensions` below, exactly like the default
51
+ * goal-auditor does.
52
+ */
53
+ export const REVIEWER_PROGRESS_TOOL_NAME = "report_auditor_progress";
54
+
55
+ /** The four agent names, in registration order. */
56
+ export const OPSX_AGENT_NAMES = [
57
+ "opsx-gap-analysis",
58
+ "opsx-plan-review",
59
+ "opsx-worker",
60
+ "opsx-reviewer",
61
+ ] as const;
62
+
63
+ type OpsxAgentName = (typeof OPSX_AGENT_NAMES)[number];
64
+
65
+ /**
66
+ * Absolute path of goal-x's child-only progress extension, resolved the same
67
+ * way the default goal-auditor resolves its own copy: an absolute path on
68
+ * purpose, because child pi processes run in the audited workspace, not this
69
+ * package's directory. Resolved lazily-tolerantly: goal-x is an optional
70
+ * peer, and an unresolvable path only defers the (already specified)
71
+ * delegation preflight failure — it must not break extension load.
72
+ */
73
+ const GOAL_AUDITOR_PROGRESS_MODULE =
74
+ "@xzzpig/pi-goal-x/extensions/goal-auditor-progress.ts";
75
+
76
+ function resolveGoalAuditorProgressPath(): string | undefined {
77
+ try {
78
+ return createRequire(import.meta.url).resolve(GOAL_AUDITOR_PROGRESS_MODULE);
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ }
83
+
84
+ /** Isolation defaults shared by all four agents (goal-auditor precedent). */
85
+ const SHARED_ISOLATION = {
86
+ systemPromptMode: "replace",
87
+ inheritProjectContext: true,
88
+ inheritSkills: false,
89
+ defaultContext: "fresh",
90
+ } as const;
91
+
92
+ const reviewerProgressExtensionPath = resolveGoalAuditorProgressPath();
93
+
94
+ /** The four canonical runtime definitions, keyed by agent name. */
95
+ export const OPSX_AGENT_DEFINITIONS: Record<
96
+ OpsxAgentName,
97
+ RuntimeAgentDefinition
98
+ > = {
99
+ "opsx-gap-analysis": {
100
+ description:
101
+ "Read-only pre-planning gap analyst for the opsx planning flow",
102
+ systemPrompt: GAP_ANALYSIS_SYSTEM_PROMPT,
103
+ tools: [
104
+ "read",
105
+ "grep",
106
+ "find",
107
+ "ls",
108
+ "bash",
109
+ REPORT_GAP_ANALYSIS_TOOL_NAME,
110
+ ],
111
+ ...SHARED_ISOLATION,
112
+ acceptanceRole: "read-only",
113
+ thinking: "high",
114
+ },
115
+ "opsx-plan-review": {
116
+ description: "Read-only work-plan reviewer for the opsx planning flow",
117
+ systemPrompt: PLAN_REVIEW_SYSTEM_PROMPT,
118
+ tools: ["read", "grep", "find", "ls", REPORT_PLAN_REVIEW_TOOL_NAME],
119
+ ...SHARED_ISOLATION,
120
+ acceptanceRole: "read-only",
121
+ thinking: "high",
122
+ },
123
+ "opsx-worker": {
124
+ description:
125
+ "Full-write single-task executor for the opsx implementation flow",
126
+ systemPrompt: WORKER_SYSTEM_PROMPT,
127
+ tools: [
128
+ "read",
129
+ "edit",
130
+ "write",
131
+ "bash",
132
+ "grep",
133
+ "find",
134
+ "ls",
135
+ REPORT_WORK_TOOL_NAME,
136
+ ],
137
+ ...SHARED_ISOLATION,
138
+ acceptanceRole: "writer",
139
+ },
140
+ "opsx-reviewer": {
141
+ description:
142
+ "Read-only final holistic reviewer for the opsx implementation flow",
143
+ systemPrompt: OPSX_REVIEWER_SYSTEM_PROMPT,
144
+ tools: ["read", "grep", "find", "ls", "bash", REVIEWER_PROGRESS_TOOL_NAME],
145
+ ...(reviewerProgressExtensionPath
146
+ ? { subagentOnlyExtensions: [reviewerProgressExtensionPath] }
147
+ : {}),
148
+ ...SHARED_ISOLATION,
149
+ acceptanceRole: "read-only",
150
+ thinking: "high",
151
+ },
152
+ };
153
+
154
+ /** Outcome of one registration attempt. */
155
+ export interface OpsxAgentsRegistrationResult {
156
+ /** True when all four agents were registered in this attempt. */
157
+ registered: boolean;
158
+ /** Names registered before the attempt concluded (all four on success). */
159
+ agents: OpsxAgentName[];
160
+ /** Why the attempt failed; undefined on success. */
161
+ reason?: string;
162
+ }
163
+
164
+ let activeDisposers: Array<() => void> = [];
165
+ let lastResult: OpsxAgentsRegistrationResult | undefined;
166
+
167
+ /** The last registration attempt's outcome, for the session_start retry gate. */
168
+ export function lastOpsxAgentsRegistration():
169
+ | OpsxAgentsRegistrationResult
170
+ | undefined {
171
+ return lastResult;
172
+ }
173
+
174
+ function disposeActiveRegistrations(): void {
175
+ for (const dispose of activeDisposers) {
176
+ try {
177
+ dispose();
178
+ } catch {
179
+ // A failed dispose must not block re-registration; the next
180
+ // registration would collide only if dispose silently no-opped, which
181
+ // pi-subagents' registry does not do.
182
+ }
183
+ }
184
+ activeDisposers = [];
185
+ }
186
+
187
+ /** Reset registration state and drop active registrations. Test-only. */
188
+ export function resetAgentsRegistrationForTests(): void {
189
+ disposeActiveRegistrations();
190
+ lastResult = undefined;
191
+ }
192
+
193
+ /**
194
+ * Register all four opsx agents with the installed pi-subagents owner. On any
195
+ * failure the partial registration is rolled back and the typed reason
196
+ * returned — this function never throws.
197
+ */
198
+ export function registerOpsxAgents(
199
+ pi: ExtensionAPI,
200
+ ): OpsxAgentsRegistrationResult {
201
+ disposeActiveRegistrations();
202
+ lastResult = undefined;
203
+
204
+ let registerAgentViaEvents: SubagentsModuleFacet["registerAgentViaEvents"];
205
+ try {
206
+ ({ registerAgentViaEvents } = requireSubagentsSupport());
207
+ } catch (error) {
208
+ lastResult = {
209
+ registered: false,
210
+ agents: [],
211
+ reason: describeError(error),
212
+ };
213
+ return lastResult;
214
+ }
215
+
216
+ const agents: OpsxAgentName[] = [];
217
+ const disposers: Array<() => void> = [];
218
+ try {
219
+ for (const name of OPSX_AGENT_NAMES) {
220
+ const registration = registerAgentViaEvents({
221
+ pi,
222
+ name,
223
+ definition: OPSX_AGENT_DEFINITIONS[name],
224
+ });
225
+ disposers.push(() => registration.dispose());
226
+ agents.push(name);
227
+ }
228
+ } catch (error) {
229
+ for (const dispose of disposers) {
230
+ try {
231
+ dispose();
232
+ } catch {
233
+ // Rollback is best-effort; the reported reason is what matters.
234
+ }
235
+ }
236
+ lastResult = {
237
+ registered: false,
238
+ agents,
239
+ reason: describeError(error),
240
+ };
241
+ return lastResult;
242
+ }
243
+
244
+ activeDisposers = disposers;
245
+ lastResult = { registered: true, agents };
246
+ return lastResult;
247
+ }
248
+
249
+ /**
250
+ * Build the `session_start` handler that retries a failed registration once
251
+ * the host is fully up (pi-subagents may load after this extension, so the
252
+ * init-time attempt can find no listener yet). A no-op when the init attempt
253
+ * already succeeded. Register alongside the other session handlers.
254
+ */
255
+ export function createOpsxAgentsSessionStartHandler(
256
+ pi: ExtensionAPI,
257
+ ): ExtensionHandler<SessionStartEvent> {
258
+ return (_event: SessionStartEvent, _ctx: unknown): void => {
259
+ if (lastResult?.registered) return;
260
+ registerOpsxAgents(pi);
261
+ };
262
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Plan approval gate (design D6/D7; openspec change add-pi-openspec-x, task
3
+ * 7.3).
4
+ *
5
+ * After the plan review returns OKAY the plan is final, but implementation is
6
+ * still not authorized: the user must approve the PLAN explicitly. Approval
7
+ * covers the plan only — it never authorizes implementation by itself; the
8
+ * `/opsx:implement` flow is a separate, explicit step.
9
+ *
10
+ * The gate is fail-closed: `canEnterImplementation` is true only for an
11
+ * `approved` decision recorded against the same change. A missing, pending,
12
+ * revised, or rejected approval keeps implementation locked.
13
+ *
14
+ * The structured question uses the host's blocking selector when a UI exists
15
+ * (pi-ask present or not — the selector is the plugin's own deterministic
16
+ * dialog). Without a UI the plugin degrades to a plain-text request and leaves
17
+ * the decision pending, so the gate stays closed until a structured approval
18
+ * is recorded.
19
+ */
20
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
21
+
22
+ import {
23
+ emitSilentUiSpan,
24
+ publishLifecycle,
25
+ publishNotify,
26
+ type EventEmitterLike,
27
+ } from "./lifecycle.ts";
28
+
29
+ /** Minimal host view for capability probes; the real ExtensionAPI satisfies it. */
30
+ export interface ToolNameSource {
31
+ getAllTools(): ReadonlyArray<{ name: string }>;
32
+ }
33
+
34
+ /** How the approval was requested. */
35
+ export type ApprovalVia = "pi-ask" | "select" | "text";
36
+
37
+ /** The user's plan decision. */
38
+ export type ApprovalDecision = "approved" | "revise" | "rejected" | "pending";
39
+
40
+ export interface PlanApproval {
41
+ changeId: string;
42
+ decision: ApprovalDecision;
43
+ /** ISO timestamp. */
44
+ at: string;
45
+ via: ApprovalVia;
46
+ }
47
+
48
+ const APPROVAL_OPTIONS = [
49
+ { value: "approved", label: "Approve the plan" },
50
+ { value: "revise", label: "Request changes" },
51
+ { value: "rejected", label: "Reject the plan" },
52
+ ] as const;
53
+
54
+ /** The pi-ask / host tool whose presence marks the structured-ask path. */
55
+ export const PI_ASK_TOOL_NAME = "ask_user";
56
+
57
+ /** True when pi-ask's `ask_user` tool is registered in this process. */
58
+ export function detectPiAsk(pi: ToolNameSource): boolean {
59
+ try {
60
+ return pi.getAllTools().some((tool) => tool.name === PI_ASK_TOOL_NAME);
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Fail-closed gate: only an `approved` decision for this exact change unlocks
68
+ * implementation. Everything else (missing, pending, revise, rejected, or a
69
+ * different change) keeps it locked.
70
+ *
71
+ * The decision is only ever read from the structured selector answer (or the
72
+ * recorded ledger entry) — free text is never interpreted (README: the plugin
73
+ * does not listen to free-text approval).
74
+ */
75
+ export function canEnterImplementation(
76
+ approval: PlanApproval | undefined,
77
+ changeId: string,
78
+ ): boolean {
79
+ return Boolean(
80
+ approval &&
81
+ approval.changeId === changeId &&
82
+ approval.decision === "approved",
83
+ );
84
+ }
85
+
86
+ function approvalPrompt(changeId: string): string {
87
+ return `Approve the plan for change "${changeId}"? Approval covers the finalized plan only; implementation remains a separate /opsx:implement step.`;
88
+ }
89
+
90
+ /**
91
+ * Request the user's plan approval. Opens the host's blocking selector when a
92
+ * UI exists (recording `via: "pi-ask"` when pi-ask is installed, so observers
93
+ * know the structured path was available); without a UI the decision stays
94
+ * pending and the caller asks in plain text.
95
+ */
96
+ export async function requestPlanApproval(
97
+ pi: ToolNameSource,
98
+ ctx: ExtensionContext,
99
+ changeId: string,
100
+ events?: EventEmitterLike,
101
+ ): Promise<PlanApproval> {
102
+ const at = new Date().toISOString();
103
+ if (events) {
104
+ // Blocking approval: claim the UI span as silent, then announce the wait.
105
+ emitSilentUiSpan(events, "plan approval dialog");
106
+ publishLifecycle(events, {
107
+ type: "approval_wait",
108
+ change: changeId,
109
+ mode: "plan",
110
+ });
111
+ publishNotify(events, {
112
+ eventId: "input-required",
113
+ label: `Approve the plan for ${changeId}`,
114
+ });
115
+ }
116
+ if (!ctx.hasUI) {
117
+ return { changeId, decision: "pending", at, via: "text" };
118
+ }
119
+ const via: ApprovalVia = detectPiAsk(pi) ? "pi-ask" : "select";
120
+ let choice: string | undefined;
121
+ try {
122
+ choice = await ctx.ui.select(
123
+ approvalPrompt(changeId),
124
+ APPROVAL_OPTIONS.map((option) => option.label),
125
+ );
126
+ } catch {
127
+ return { changeId, decision: "pending", at, via };
128
+ }
129
+ const option = APPROVAL_OPTIONS.find((entry) => entry.label === choice);
130
+ const decision = option ? option.value : "pending";
131
+ if (events && decision === "approved") {
132
+ publishLifecycle(events, {
133
+ type: "plan_approved",
134
+ change: changeId,
135
+ mode: "plan",
136
+ });
137
+ }
138
+ return { changeId, decision, at, via };
139
+ }