@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,317 @@
1
+ /**
2
+ * Structured reporting tools the opsx subagents submit their results through
3
+ * (design D3; spec "结构化裁决上报").
4
+ *
5
+ * Three child-facing tools are registered into the running pi process at
6
+ * extension init, one per reporting subagent role:
7
+ *
8
+ * - `report_gap_analysis` (opsx-gap-analysis): pre-planning gap analysis;
9
+ * - `report_plan_review` (opsx-plan-review): work-plan verdict OKAY/ITERATE/REJECT;
10
+ * - `report_work` (opsx-worker): per-task completion report with evidence.
11
+ *
12
+ * Gates read the structured arguments, never the child's free text: a payload
13
+ * that does not satisfy the schema is rejected by the host's tool-argument
14
+ * validation before `execute` runs, so an invalid verdict can never reach a
15
+ * gate. The opsx-reviewer has deliberately NO plugin-side verdict tool — its
16
+ * final ruling goes through the execution base's structured_output channel
17
+ * (design D3/D10); it only reports progress through goal-x's
18
+ * `report_auditor_progress`.
19
+ *
20
+ * Tool results stay short: they are appended to the subagent's context, so a
21
+ * one-line confirmation plus the structured payload in `details` is all a
22
+ * call returns. There is no progress bar here — a tool may be called any
23
+ * number of times; projection of the calls is a later concern.
24
+ */
25
+ import { Type } from "@earendil-works/pi-ai";
26
+ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
27
+
28
+ export const REPORT_GAP_ANALYSIS_TOOL_NAME = "report_gap_analysis";
29
+ export const REPORT_PLAN_REVIEW_TOOL_NAME = "report_plan_review";
30
+ export const REPORT_WORK_TOOL_NAME = "report_work";
31
+
32
+ /**
33
+ * One located finding. `location` is free-form ("src/cli.ts:42",
34
+ * "tasks.md#Step 3") because artifact sections do not always map to lines.
35
+ */
36
+ const findingItem = Type.Object(
37
+ {
38
+ description: Type.String({
39
+ description:
40
+ "The finding itself, specific enough to act on without re-deriving it.",
41
+ }),
42
+ location: Type.Optional(
43
+ Type.String({
44
+ description:
45
+ "Where the finding applies: file path, and line or section when known.",
46
+ }),
47
+ ),
48
+ },
49
+ { additionalProperties: false },
50
+ );
51
+
52
+ /**
53
+ * Structured progress (spec: 子 agent 的过程进度 MUST 经结构化上报 —— 阶段标签 +
54
+ * 百分比,或等效字段). Optional: a report that carries no progress simply leaves
55
+ * the parent's board unchanged.
56
+ */
57
+ const progressField = Type.Optional(
58
+ Type.Object(
59
+ {
60
+ phase: Type.String({
61
+ description:
62
+ "The stage this update belongs to, e.g. reading, analyzing, editing, verifying.",
63
+ }),
64
+ percentage: Type.Number({
65
+ description: "How far through this stage the agent is, 0 to 100.",
66
+ minimum: 0,
67
+ maximum: 100,
68
+ }),
69
+ label: Type.Optional(
70
+ Type.String({
71
+ description:
72
+ "Short human-readable detail for the board; defaults to the phase name.",
73
+ }),
74
+ ),
75
+ },
76
+ { additionalProperties: false },
77
+ ),
78
+ );
79
+
80
+ const gapAnalysisParams = Type.Object(
81
+ {
82
+ progress: progressField,
83
+ intentType: Type.String({
84
+ description:
85
+ "Classified user intent, e.g. refactor, build-from-scratch, mid-sized, collaborative, architecture, or research.",
86
+ }),
87
+ confidence: Type.Number({
88
+ description: "Confidence in the intent classification, from 0 to 1.",
89
+ minimum: 0,
90
+ maximum: 1,
91
+ }),
92
+ contradictions: Type.Array(findingItem, {
93
+ description: "Requirements or statements that conflict with each other.",
94
+ }),
95
+ missingConstraints: Type.Array(findingItem, {
96
+ description:
97
+ "Constraints the request implies but never states (platform, compatibility, performance, security, ...).",
98
+ }),
99
+ scopeRisks: Type.Array(findingItem, {
100
+ description:
101
+ "Scope-inflation or gold-plating risks, including premature abstraction and over-validation.",
102
+ }),
103
+ unvalidatedAssumptions: Type.Array(findingItem, {
104
+ description:
105
+ "Assumptions the plan relies on without evidence (environment, behavior, availability).",
106
+ }),
107
+ missingAcceptanceCriteria: Type.Array(findingItem, {
108
+ description:
109
+ "Outcomes that lack an agent-executable acceptance criterion (no 'user manually tests' criteria).",
110
+ }),
111
+ summary: Type.String({
112
+ description: "One-paragraph synthesis of the analysis.",
113
+ }),
114
+ },
115
+ { additionalProperties: false },
116
+ );
117
+
118
+ const planReviewParams = Type.Object(
119
+ {
120
+ progress: progressField,
121
+ verdict: Type.Union(
122
+ [Type.Literal("OKAY"), Type.Literal("ITERATE"), Type.Literal("REJECT")],
123
+ {
124
+ description:
125
+ "OKAY: executable as written. ITERATE: workable after targeted, non-blocking revisions. REJECT: not executable as written.",
126
+ },
127
+ ),
128
+ issues: Type.Array(
129
+ Type.Object(
130
+ {
131
+ severity: Type.Union(
132
+ [
133
+ Type.Literal("blocker"),
134
+ Type.Literal("major"),
135
+ Type.Literal("minor"),
136
+ ],
137
+ { description: "How badly the issue threatens execution." },
138
+ ),
139
+ file: Type.Optional(
140
+ Type.String({
141
+ description: "Plan file the issue applies to, when located.",
142
+ }),
143
+ ),
144
+ line: Type.Optional(
145
+ Type.Number({
146
+ description: "Line number in that file, when known.",
147
+ }),
148
+ ),
149
+ description: Type.String({
150
+ description:
151
+ "The issue, concrete and actionable (what to change, not a taste judgment).",
152
+ }),
153
+ blocking: Type.Boolean({
154
+ description:
155
+ "True only for REJECT blockers; ITERATE/OKAY issues are never blocking.",
156
+ }),
157
+ },
158
+ { additionalProperties: false },
159
+ ),
160
+ { description: "Found issues; may be empty for a clean OKAY." },
161
+ ),
162
+ summary: Type.String({
163
+ description:
164
+ "One-paragraph verdict rationale; for REJECT it must reference which earlier blockers (if any) are now resolved.",
165
+ }),
166
+ },
167
+ { additionalProperties: false },
168
+ );
169
+
170
+ const workReportParams = Type.Object(
171
+ {
172
+ progress: progressField,
173
+ taskId: Type.String({
174
+ description: "The dispatched task identifier (e.g. the tasks.md item).",
175
+ }),
176
+ changedFiles: Type.Array(Type.String(), {
177
+ description:
178
+ "Every file this task created or modified, with paths relative to the project root.",
179
+ }),
180
+ claims: Type.String({
181
+ description:
182
+ "What was done and what the result is. Only claims backed by evidence below.",
183
+ }),
184
+ selfVerification: Type.Object(
185
+ {
186
+ command: Type.Optional(
187
+ Type.String({
188
+ description:
189
+ "The verification command actually run (e.g. build/test).",
190
+ }),
191
+ ),
192
+ result: Type.Optional(
193
+ Type.String({
194
+ description:
195
+ "The real command output summary (exit status, failing/passing counts). Never invented.",
196
+ }),
197
+ ),
198
+ },
199
+ { additionalProperties: false },
200
+ ),
201
+ verificationPassed: Type.Optional(
202
+ Type.Boolean({
203
+ description:
204
+ "Structured verdict: whether the verification actually passed. When provided the gate trusts it and does not parse the result text; when absent the result text is scanned heuristically.",
205
+ }),
206
+ ),
207
+ },
208
+ { additionalProperties: false },
209
+ );
210
+
211
+ /** Count of non-empty finding lists, for the one-line confirmation text. */
212
+ function countFindings(params: {
213
+ contradictions: unknown[];
214
+ missingConstraints: unknown[];
215
+ scopeRisks: unknown[];
216
+ unvalidatedAssumptions: unknown[];
217
+ missingAcceptanceCriteria: unknown[];
218
+ }): number {
219
+ return (
220
+ params.contradictions.length +
221
+ params.missingConstraints.length +
222
+ params.scopeRisks.length +
223
+ params.unvalidatedAssumptions.length +
224
+ params.missingAcceptanceCriteria.length
225
+ );
226
+ }
227
+
228
+ /** The gap-analysis tool (opsx-gap-analysis's reporting channel). */
229
+ export const reportGapAnalysisTool = defineTool({
230
+ name: REPORT_GAP_ANALYSIS_TOOL_NAME,
231
+ label: "Report Gap Analysis",
232
+ description:
233
+ "Submit the pre-planning gap analysis (intent classification, contradictions, missing constraints, scope risks, unvalidated assumptions, missing acceptance criteria) to the parent orchestrator.",
234
+ promptSnippet: "Submit the structured pre-planning gap analysis.",
235
+ promptGuidelines: [
236
+ "Submit your complete analysis through this tool; the parent only reads the structured fields.",
237
+ "Call it again whenever the analysis changes — later submissions supersede earlier ones.",
238
+ ],
239
+ parameters: gapAnalysisParams,
240
+ executionMode: "sequential",
241
+ async execute(_toolCallId, params) {
242
+ return {
243
+ content: [
244
+ {
245
+ type: "text",
246
+ text: `Gap analysis recorded: intent ${params.intentType} (confidence ${params.confidence}), ${countFindings(params)} findings.`,
247
+ },
248
+ ],
249
+ details: params,
250
+ };
251
+ },
252
+ });
253
+
254
+ /** The plan-review tool (opsx-plan-review's verdict channel). */
255
+ export const reportPlanReviewTool = defineTool({
256
+ name: REPORT_PLAN_REVIEW_TOOL_NAME,
257
+ label: "Report Plan Review",
258
+ description:
259
+ "Submit the work-plan review verdict (OKAY/ITERATE/REJECT) with the issue list to the parent orchestrator.",
260
+ promptSnippet: "Submit the structured plan review verdict.",
261
+ promptGuidelines: [
262
+ "The parent gates on the verdict field; free text is never parsed.",
263
+ "REJECT carries at most 3 blocking issues, each new relative to your previous review rounds.",
264
+ ],
265
+ parameters: planReviewParams,
266
+ executionMode: "sequential",
267
+ async execute(_toolCallId, params) {
268
+ const blocking = params.issues.filter((issue) => issue.blocking).length;
269
+ return {
270
+ content: [
271
+ {
272
+ type: "text",
273
+ text: `Plan review recorded: verdict ${params.verdict} (${params.issues.length} issues, ${blocking} blocking).`,
274
+ },
275
+ ],
276
+ details: params,
277
+ };
278
+ },
279
+ });
280
+
281
+ /** The work-report tool (opsx-worker's completion channel). */
282
+ export const reportWorkTool = defineTool({
283
+ name: REPORT_WORK_TOOL_NAME,
284
+ label: "Report Work",
285
+ description:
286
+ "Submit the completed-task report (task id, changed files, completion claims, self-verification evidence) to the parent orchestrator.",
287
+ promptSnippet: "Submit the structured completion report for a task.",
288
+ promptGuidelines: [
289
+ "Report every file you changed; the parent reviews the diff against these claims.",
290
+ "Self-verification must carry real command output, never an invented result.",
291
+ ],
292
+ parameters: workReportParams,
293
+ executionMode: "sequential",
294
+ async execute(_toolCallId, params) {
295
+ return {
296
+ content: [
297
+ {
298
+ type: "text",
299
+ text: `Work report recorded for task ${params.taskId} (${params.changedFiles.length} changed files).`,
300
+ },
301
+ ],
302
+ details: params,
303
+ };
304
+ },
305
+ });
306
+
307
+ /** The three reporting tools, in registration order. */
308
+ export function createReportTools() {
309
+ return [reportGapAnalysisTool, reportPlanReviewTool, reportWorkTool];
310
+ }
311
+
312
+ /** Register the three reporting tools into the running pi process. */
313
+ export function registerReportTools(pi: ExtensionAPI): void {
314
+ for (const tool of createReportTools()) {
315
+ pi.registerTool(tool);
316
+ }
317
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Review-window scope for the final audit (design D10; openspec change
3
+ * add-pi-openspec-x, task 6.5).
4
+ *
5
+ * The completion audit reviews the execution WINDOW's delta, not the whole
6
+ * repository: a file that was already dirty before the window started was not
7
+ * produced by this flow and must not be reported as a finding or rolled back.
8
+ * The baseline and the delta belong to goal-x (`captureChangeBaseline` /
9
+ * `computeChangeDelta`), so this module only reads them — dynamically, because
10
+ * pi-goal-x is an optional peer — and renders the scope block injected into
11
+ * the opsx-reviewer dispatch context.
12
+ *
13
+ * Non-git degradation: when there is no window delta (not a repository, or no
14
+ * baseline was captured), the reviewer is told to read the code as needed and
15
+ * to rely on the worker's `report_work` changed-file declarations instead.
16
+ */
17
+ import type { GoalStorageContextLike } from "./goal-base.ts";
18
+
19
+ /** One changed path in the window, structurally goal-x's RepoChangeEntry. */
20
+ export interface ReviewScopeEntry {
21
+ path: string;
22
+ status: string;
23
+ code: string;
24
+ origPath?: string;
25
+ }
26
+
27
+ /** One repository's delta, structurally goal-x's RepoDelta. */
28
+ interface ReviewScopeRepo {
29
+ root: string;
30
+ entries: ReviewScopeEntry[];
31
+ /**
32
+ * Baseline reference the diff was taken against (the window's stash commit,
33
+ * else HEAD). `null` for an unborn HEAD. Rollback restores from it.
34
+ */
35
+ base?: string | null;
36
+ /** Which baseline the diff used; `status-only` has nothing to restore from. */
37
+ baseKind?: "stash" | "head" | "status-only";
38
+ error?: string;
39
+ }
40
+
41
+ /** The window delta, structurally goal-x's ChangeDelta. */
42
+ export interface ReviewWindowDelta {
43
+ goalId: string;
44
+ repos: ReviewScopeRepo[];
45
+ empty: boolean;
46
+ truncated: boolean;
47
+ diagnostics: string[];
48
+ }
49
+
50
+ export interface ReviewScope {
51
+ /** True when a git window delta was available. */
52
+ fromWindow: boolean;
53
+ /** Repo-relative changed paths, sorted and de-duplicated. */
54
+ paths: string[];
55
+ /** Present on the non-git degradation path. */
56
+ degraded?: string;
57
+ }
58
+
59
+ const DEGRADED_NOTE =
60
+ "the goal base has no git window delta (not a git repository, or no baseline was captured)";
61
+
62
+ /**
63
+ * Build the review scope from a window delta. `undefined` is the explicit
64
+ * non-git / no-baseline degradation; an empty window simply yields no paths.
65
+ *
66
+ * The scope block that once rendered here has no plugin-side injection point:
67
+ * goal-x natively renders the change manifest into the opsx-reviewer's audit
68
+ * context, and `OPSX_REVIEW_INSTRUCTIONS` points at that. What the plugin
69
+ * needs the scope FOR is the tick gate's file reconciliation, which reads
70
+ * `.paths` (deliberately uncapped).
71
+ */
72
+ export function buildReviewScope(
73
+ delta: ReviewWindowDelta | undefined,
74
+ ): ReviewScope {
75
+ if (!delta) {
76
+ return { fromWindow: false, paths: [], degraded: DEGRADED_NOTE };
77
+ }
78
+ const paths = [
79
+ ...new Set(
80
+ delta.repos.flatMap((repo) =>
81
+ repo.entries.map((entry) =>
82
+ delta.repos.length > 1 ? `${repo.root}/${entry.path}` : entry.path,
83
+ ),
84
+ ),
85
+ ),
86
+ ].sort((a, b) => a.localeCompare(b));
87
+ return { fromWindow: true, paths };
88
+ }
89
+
90
+ /**
91
+ * Opaque handle for goal-x's ChangeBaseline. This adapter only passes it from
92
+ * `readChangeBaseline` to `computeChangeDelta`; it never inspects the shape.
93
+ */
94
+ export interface GoalChangeBaselineHandle {
95
+ readonly __goalChangeBaseline?: never;
96
+ }
97
+
98
+ /** The slice of goal-x's change-delta module this adapter needs. */
99
+ export interface GoalXWindowFacet {
100
+ readChangeBaseline(
101
+ ctx: GoalStorageContextLike,
102
+ goalId: string,
103
+ ): GoalChangeBaselineHandle | undefined;
104
+ computeChangeDelta(
105
+ baseline: GoalChangeBaselineHandle,
106
+ options?: { timeoutMs?: number },
107
+ ): Promise<ReviewWindowDelta>;
108
+ }
109
+
110
+ /** Injectable dynamic-import seam; tests supply a fake window facet. */
111
+ export type GoalXWindowLoader = () => Promise<GoalXWindowFacet>;
112
+
113
+ const GOAL_BASELINE_MODULE: string =
114
+ "@xzzpig/pi-goal-x/extensions/goal-change-baseline.ts";
115
+ const GOAL_DELTA_MODULE: string =
116
+ "@xzzpig/pi-goal-x/extensions/goal-change-delta.ts";
117
+
118
+ async function defaultGoalXWindowLoader(): Promise<GoalXWindowFacet> {
119
+ const baselineModule = (await import(GOAL_BASELINE_MODULE)) as {
120
+ readChangeBaseline: GoalXWindowFacet["readChangeBaseline"];
121
+ };
122
+ const deltaModule = (await import(GOAL_DELTA_MODULE)) as {
123
+ computeChangeDelta: GoalXWindowFacet["computeChangeDelta"];
124
+ };
125
+ return {
126
+ readChangeBaseline: baselineModule.readChangeBaseline,
127
+ computeChangeDelta: deltaModule.computeChangeDelta,
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Read the goal's window delta from goal-x. Returns `undefined` when goal-x is
133
+ * missing, no baseline was captured, or the delta cannot be computed — the
134
+ * caller then degrades to full read + report_work.
135
+ */
136
+ export async function readGoalWindowDelta(
137
+ ctx: GoalStorageContextLike,
138
+ goalId: string,
139
+ loader: GoalXWindowLoader = defaultGoalXWindowLoader,
140
+ ): Promise<ReviewWindowDelta | undefined> {
141
+ let facet: GoalXWindowFacet;
142
+ try {
143
+ facet = await loader();
144
+ } catch {
145
+ return undefined;
146
+ }
147
+ let baseline: GoalChangeBaselineHandle | undefined;
148
+ try {
149
+ baseline = facet.readChangeBaseline(ctx, goalId);
150
+ } catch {
151
+ return undefined;
152
+ }
153
+ if (!baseline) return undefined;
154
+ try {
155
+ return await facet.computeChangeDelta(baseline);
156
+ } catch {
157
+ return undefined;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Compute the review scope for one goal: the window delta when available, the
163
+ * full-read degradation otherwise. Never throws.
164
+ */
165
+ export async function computeReviewScope(
166
+ ctx: GoalStorageContextLike,
167
+ goalId: string,
168
+ options: { loader?: GoalXWindowLoader } = {},
169
+ ): Promise<ReviewScope> {
170
+ const delta = await readGoalWindowDelta(ctx, goalId, options.loader);
171
+ return buildReviewScope(delta);
172
+ }