@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,279 @@
1
+ /**
2
+ * Parent-side plan gate tools (openspec change add-pi-openspec-x, tasks 7.2 and
3
+ * 7.3).
4
+ *
5
+ * The child reporting tools (`report_gap_analysis` / `report_plan_review`) run
6
+ * in the subagent's own session, so their results reach the main agent but not
7
+ * this extension's process state. The plan flow therefore owns two
8
+ * parent-facing tools the main agent calls with the child's structured result:
9
+ *
10
+ * - `opsx_plan_gap_analysis` persists gap-analysis findings into the change's
11
+ * append-only plan ledger (advisory: it never gates);
12
+ * - `opsx_plan_verdict` persists the plan-review verdict, derives the round and
13
+ * the escalation policy from the ledger, and — only on OKAY — opens the
14
+ * blocking user approval dialog and records the decision.
15
+ *
16
+ * The ledger, not in-memory state, is the source of truth: `/opsx:implement`
17
+ * reads the recorded approval from disk, so approval survives a restart and a
18
+ * fresh session cannot bypass the gate.
19
+ */
20
+ import { Type } from "@earendil-works/pi-ai";
21
+ import {
22
+ defineTool,
23
+ type ExtensionAPI,
24
+ type ExtensionContext,
25
+ } from "@earendil-works/pi-coding-agent";
26
+
27
+ import { requestPlanApproval, type PlanApproval } from "./approval-gate.ts";
28
+ import { changeDirPath } from "./implement-command.ts";
29
+ import { appendFlowEntry, OPSX_ENTRY_TYPES } from "./flow-entries.ts";
30
+ import { publishLifecycle } from "./lifecycle.ts";
31
+ import { activePlanSession } from "./plan-command.ts";
32
+ import { sessionKey } from "./command-util.ts";
33
+ import {
34
+ readPlanApproval,
35
+ recordEscalation,
36
+ recordGapAnalysis,
37
+ recordPlanReviewVerdict,
38
+ recordPlanApproval,
39
+ type DelegationUsage,
40
+ type PlanVerdict,
41
+ } from "./plan-review.ts";
42
+
43
+ export const OPSX_PLAN_GAP_ANALYSIS_TOOL_NAME = "opsx_plan_gap_analysis";
44
+ export const OPSX_PLAN_VERDICT_TOOL_NAME = "opsx_plan_verdict";
45
+
46
+ const usageSchema = Type.Optional(
47
+ Type.Object(
48
+ {
49
+ input: Type.Number(),
50
+ output: Type.Number(),
51
+ cacheRead: Type.Number(),
52
+ cacheWrite: Type.Number(),
53
+ turns: Type.Number(),
54
+ },
55
+ { additionalProperties: false },
56
+ ),
57
+ );
58
+
59
+ const gapAnalysisParams = Type.Object(
60
+ {
61
+ findings: Type.Array(
62
+ Type.String({
63
+ description: "One concrete gap, specific enough to act on.",
64
+ }),
65
+ { description: "Gap-analysis findings; may be empty." },
66
+ ),
67
+ summary: Type.Optional(Type.String()),
68
+ changeId: Type.Optional(
69
+ Type.String({
70
+ description:
71
+ "OpenSpec change id; defaults to this session's active plan flow.",
72
+ }),
73
+ ),
74
+ },
75
+ { additionalProperties: false },
76
+ );
77
+
78
+ const planReviewParams = Type.Object(
79
+ {
80
+ verdict: Type.Union([
81
+ Type.Literal("OKAY"),
82
+ Type.Literal("ITERATE"),
83
+ Type.Literal("REJECT"),
84
+ ]),
85
+ blockers: Type.Optional(
86
+ Type.Array(Type.String(), {
87
+ description:
88
+ "Blocking issues; at most three, each new relative to earlier rounds.",
89
+ }),
90
+ ),
91
+ summary: Type.Optional(Type.String()),
92
+ usage: usageSchema,
93
+ changeId: Type.Optional(
94
+ Type.String({
95
+ description:
96
+ "OpenSpec change id; defaults to this session's active plan flow.",
97
+ }),
98
+ ),
99
+ },
100
+ { additionalProperties: false },
101
+ );
102
+
103
+ /**
104
+ * Resolve the change this call belongs to: an explicit argument wins, else the
105
+ * session's active plan flow. Returns undefined when neither is available, so
106
+ * the tool can answer with a corrective message instead of writing to a guess.
107
+ */
108
+ function resolveChangeId(
109
+ ctx: ExtensionContext,
110
+ explicit: string | undefined,
111
+ ): string | undefined {
112
+ const trimmed = explicit?.trim();
113
+ if (trimmed) return trimmed;
114
+ return activePlanSession(sessionKey(ctx))?.changeId;
115
+ }
116
+
117
+ function requireChangeId(
118
+ ctx: ExtensionContext,
119
+ explicit: string | undefined,
120
+ ): { changeId: string } | { error: string } {
121
+ const changeId = resolveChangeId(ctx, explicit);
122
+ if (!changeId) {
123
+ return {
124
+ error:
125
+ "No plan flow is active for this session and no changeId was supplied; start /opsx:plan <change-id> first.",
126
+ };
127
+ }
128
+ return { changeId };
129
+ }
130
+
131
+ /** A one-line tool result (no structured details to expose). */
132
+ function toolText(text: string) {
133
+ return { content: [{ type: "text" as const, text }], details: undefined };
134
+ }
135
+
136
+ /** Register the two parent-facing gate tools. */
137
+ export function registerPlanGateTools(pi: ExtensionAPI): void {
138
+ pi.registerTool(
139
+ defineTool({
140
+ name: OPSX_PLAN_GAP_ANALYSIS_TOOL_NAME,
141
+ label: "Record Gap Analysis",
142
+ description:
143
+ "Record the opsx-gap-analysis gap analysis for the active plan change into the append-only plan ledger. Advisory: it never gates the flow.",
144
+ promptSnippet:
145
+ "Record the opsx-gap-analysis gap analysis after that dispatch returns.",
146
+ promptGuidelines: [
147
+ "Call this once per gap-analysis dispatch with the findings the child reported.",
148
+ "Gap analysis is advisory; its findings must still be absorbed into the plan artifacts.",
149
+ ],
150
+ parameters: gapAnalysisParams,
151
+ executionMode: "sequential",
152
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
153
+ const resolved = requireChangeId(ctx, params.changeId);
154
+ if ("error" in resolved) {
155
+ return toolText(resolved.error);
156
+ }
157
+ const changeDir = changeDirPath(ctx.cwd, resolved.changeId);
158
+ recordGapAnalysis(changeDir, {
159
+ findings: params.findings,
160
+ ...(params.summary ? { summary: params.summary } : {}),
161
+ });
162
+ return toolText(
163
+ `Gap analysis recorded (${params.findings.length} findings). Absorb them into the plan artifacts, then continue.`,
164
+ );
165
+ },
166
+ }),
167
+ );
168
+
169
+ pi.registerTool(
170
+ defineTool({
171
+ name: OPSX_PLAN_VERDICT_TOOL_NAME,
172
+ label: "Record Plan Review",
173
+ description:
174
+ "Record the opsx-plan-review plan verdict into the append-only plan ledger, apply the review-loop policy, and (on OKAY) open the blocking user approval dialog.",
175
+ promptSnippet:
176
+ "Record the opsx-plan-review verdict after that dispatch returns.",
177
+ promptGuidelines: [
178
+ "Call this once per plan-review dispatch with the verdict and blockers the child reported.",
179
+ "Follow the returned instruction: revise and re-review, hand over to the user, or proceed only after approval.",
180
+ "Never treat an OKAY verdict as approval; the user's decision is returned by this tool.",
181
+ ],
182
+ parameters: planReviewParams,
183
+ executionMode: "sequential",
184
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
185
+ const resolved = requireChangeId(ctx, params.changeId);
186
+ if ("error" in resolved) {
187
+ return toolText(resolved.error);
188
+ }
189
+ const { changeId } = resolved;
190
+ const changeDir = changeDirPath(ctx.cwd, changeId);
191
+ const verdict = params.verdict as PlanVerdict;
192
+ const blockers = params.blockers ?? [];
193
+ const usage = params.usage as DelegationUsage | undefined;
194
+
195
+ const { action } = recordPlanReviewVerdict(changeDir, {
196
+ verdict,
197
+ blockers,
198
+ ...(params.summary ? { summary: params.summary } : {}),
199
+ ...(usage ? { usage } : {}),
200
+ });
201
+ publishLifecycle(pi.events, {
202
+ type: "review_verdict",
203
+ change: changeId,
204
+ mode: "plan",
205
+ verdict,
206
+ });
207
+ // The lifecycle event above is the bus counterpart of this entry.
208
+ appendFlowEntry(pi, OPSX_ENTRY_TYPES.verdict, {
209
+ changeId,
210
+ verdict,
211
+ ...(action.kind === "dispatch" ? {} : { round: action.round }),
212
+ });
213
+
214
+ if (action.kind === "revise") {
215
+ return toolText(
216
+ `Verdict ${verdict} recorded for round ${action.round}. ${action.reason}`,
217
+ );
218
+ }
219
+ if (action.kind === "escalate") {
220
+ recordEscalation(changeDir, {
221
+ reason: action.reason,
222
+ round: action.round,
223
+ });
224
+ return toolText(
225
+ `Review loop escalated after round ${action.round}: ${action.reason} Stop the automated loop and ask the user how to proceed.`,
226
+ );
227
+ }
228
+ if (action.kind === "dispatch") {
229
+ return toolText(action.reason);
230
+ }
231
+
232
+ // OKAY: the plan is final. Request the user's explicit approval and
233
+ // record the decision before telling the agent anything. A second
234
+ // OKAY round for an already-approved plan must not re-open the dialog
235
+ // or append another approval entry: the ledger is the source of truth.
236
+ const recorded = readPlanApproval(changeDir);
237
+ if (recorded && recorded.decision === "approved") {
238
+ const prior: PlanApproval = { changeId, ...recorded };
239
+ return toolText(approvalInstruction(changeId, prior));
240
+ }
241
+ const approval: PlanApproval = await requestPlanApproval(
242
+ pi,
243
+ ctx,
244
+ changeId,
245
+ pi.events,
246
+ );
247
+ recordPlanApproval(changeDir, {
248
+ changeId,
249
+ decision: approval.decision,
250
+ via: approval.via,
251
+ at: approval.at,
252
+ });
253
+ appendFlowEntry(pi, OPSX_ENTRY_TYPES.approval, {
254
+ changeId,
255
+ decision: approval.decision,
256
+ via: approval.via,
257
+ });
258
+ return toolText(approvalInstruction(changeId, approval));
259
+ },
260
+ }),
261
+ );
262
+ }
263
+
264
+ /** The instruction returned to the agent after the approval dialog closes. */
265
+ export function approvalInstruction(
266
+ changeId: string,
267
+ approval: PlanApproval,
268
+ ): string {
269
+ if (approval.decision === "approved") {
270
+ return `The user approved the plan for "${changeId}". Plan mode is done: stop here. Implementation is a separate step the user starts with /opsx:implement --agent|--direct ${changeId}.`;
271
+ }
272
+ if (approval.decision === "revise") {
273
+ return `The user requested changes to the plan for "${changeId}". Revise the artifacts, then re-run the opsx-plan-review review before asking again.`;
274
+ }
275
+ if (approval.decision === "rejected") {
276
+ return `The user rejected the plan for "${changeId}". Stop the flow and ask how to proceed; do not start implementation.`;
277
+ }
278
+ return `The user's decision for "${changeId}" is still pending (no structured answer was recorded). Do not start implementation; ask the user to approve or revise the plan.`;
279
+ }