@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
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
|
+
}
|