@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,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/opsx:rollback <change-id>` — the REJECT rollback entry point (spec:
|
|
3
|
+
* REJECT 回滚带备份).
|
|
4
|
+
*
|
|
5
|
+
* The actual rollback is goal-x's `goal-change-rollback` module (the same
|
|
6
|
+
* implementation `/goal-clear` uses): its safe model is plan -> backup ->
|
|
7
|
+
* execute, the backup is written into the goal archive directory before the
|
|
8
|
+
* worktree is touched (fail-closed when the backup cannot be written), and it
|
|
9
|
+
* reports head-moved and unrecoverable paths explicitly. This command is the
|
|
10
|
+
* thin plugin-side entry point: it resolves the change's goal, reads the
|
|
11
|
+
* execution-window delta, and delegates all three steps, so pi-openspec-x and
|
|
12
|
+
* goal-x can never drift on the rollback logic.
|
|
13
|
+
*
|
|
14
|
+
* When the goal has no baseline commit (non-git project, unborn HEAD, no
|
|
15
|
+
* baseline captured) the command degrades: it reports that rollback is
|
|
16
|
+
* unavailable and changes nothing.
|
|
17
|
+
*/
|
|
18
|
+
import type {
|
|
19
|
+
ExtensionAPI,
|
|
20
|
+
ExtensionCommandContext,
|
|
21
|
+
ExtensionContext,
|
|
22
|
+
} from "@earendil-works/pi-coding-agent";
|
|
23
|
+
|
|
24
|
+
import { describeError } from "./dependencies.ts";
|
|
25
|
+
import { appendFlowEntry, OPSX_ENTRY_TYPES } from "./flow-entries.ts";
|
|
26
|
+
import { findOpsxGoals, type GoalBaseSummary } from "./flow-recovery.ts";
|
|
27
|
+
import { sendNotice } from "./command-util.ts";
|
|
28
|
+
import {
|
|
29
|
+
readGoalBaseSummaries,
|
|
30
|
+
type GoalStorageContextLike,
|
|
31
|
+
} from "./goal-base.ts";
|
|
32
|
+
import { readGoalWindowDelta, type ReviewWindowDelta } from "./review-scope.ts";
|
|
33
|
+
|
|
34
|
+
export const ROLLBACK_NOTICE_CUSTOM_TYPE = "pi-openspec-x/rollback-notice";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The slice of goal-x's `goal-change-rollback` this command uses. The real
|
|
38
|
+
* shapes are goal-x's; the seam keeps them opaque and injectable, and the
|
|
39
|
+
* dynamic import is a plain string so an absent peer never breaks the build.
|
|
40
|
+
*/
|
|
41
|
+
export interface GoalXRollbackFacet {
|
|
42
|
+
planRollback(delta: unknown): {
|
|
43
|
+
goalId: string;
|
|
44
|
+
repos: Array<{ base: string | null; baseKind: string }>;
|
|
45
|
+
restoreCount: number;
|
|
46
|
+
deleteCount: number;
|
|
47
|
+
unrecoverableCount: number;
|
|
48
|
+
};
|
|
49
|
+
writeRollbackBackup(
|
|
50
|
+
ctx: GoalStorageContextLike,
|
|
51
|
+
plan: unknown,
|
|
52
|
+
options?: { now?: Date; maxBytes?: number },
|
|
53
|
+
): Promise<{
|
|
54
|
+
ok: boolean;
|
|
55
|
+
dir: string | null;
|
|
56
|
+
reason?: string;
|
|
57
|
+
bytes: number;
|
|
58
|
+
}>;
|
|
59
|
+
executeRollback(plan: unknown): Promise<
|
|
60
|
+
Array<{
|
|
61
|
+
restored: number;
|
|
62
|
+
deleted: number;
|
|
63
|
+
failures: Array<{ path: string; reason: string }>;
|
|
64
|
+
}>
|
|
65
|
+
>;
|
|
66
|
+
formatRollbackReport(
|
|
67
|
+
plan: unknown,
|
|
68
|
+
results: unknown,
|
|
69
|
+
backupDir: string | null,
|
|
70
|
+
): string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const GOAL_ROLLBACK_MODULE: string =
|
|
74
|
+
"@xzzpig/pi-goal-x/extensions/goal-change-rollback.ts";
|
|
75
|
+
|
|
76
|
+
async function defaultGoalXRollbackLoader(): Promise<GoalXRollbackFacet> {
|
|
77
|
+
return (await import(GOAL_ROLLBACK_MODULE)) as GoalXRollbackFacet;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface RollbackFlowDeps {
|
|
81
|
+
/** Injectable goal-base reader; defaults to goal-x's active goal files. */
|
|
82
|
+
readGoals?: (ctx: ExtensionContext) => Promise<GoalBaseSummary[]>;
|
|
83
|
+
/** Injectable window-delta reader; defaults to goal-x's baseline + delta. */
|
|
84
|
+
readDelta?: (
|
|
85
|
+
ctx: ExtensionContext,
|
|
86
|
+
goalId: string,
|
|
87
|
+
) => Promise<ReviewWindowDelta | undefined>;
|
|
88
|
+
/** Injectable goal-x rollback module; defaults to the dynamic import. */
|
|
89
|
+
rollbackModule?: () => Promise<GoalXRollbackFacet>;
|
|
90
|
+
/** Injectable clock for the backup directory stamp. */
|
|
91
|
+
now?: () => Date;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Register `/opsx:rollback`. Flow: resolve the change's goal, read the window
|
|
96
|
+
* delta, then delegate plan -> backup -> execute to goal-x's rollback module.
|
|
97
|
+
*/
|
|
98
|
+
export function registerRollbackFlow(
|
|
99
|
+
pi: ExtensionAPI,
|
|
100
|
+
deps: RollbackFlowDeps = {},
|
|
101
|
+
): void {
|
|
102
|
+
const readGoals =
|
|
103
|
+
deps.readGoals ??
|
|
104
|
+
((ctx: ExtensionContext) =>
|
|
105
|
+
readGoalBaseSummaries({
|
|
106
|
+
cwd: ctx.cwd,
|
|
107
|
+
sessionManager: ctx.sessionManager,
|
|
108
|
+
}));
|
|
109
|
+
const readDelta =
|
|
110
|
+
deps.readDelta ??
|
|
111
|
+
((ctx: ExtensionContext, goalId: string) =>
|
|
112
|
+
readGoalWindowDelta(
|
|
113
|
+
{ cwd: ctx.cwd, sessionManager: ctx.sessionManager },
|
|
114
|
+
goalId,
|
|
115
|
+
));
|
|
116
|
+
const loadRollback = deps.rollbackModule ?? defaultGoalXRollbackLoader;
|
|
117
|
+
const now = deps.now ?? (() => new Date());
|
|
118
|
+
|
|
119
|
+
pi.registerCommand("opsx:rollback", {
|
|
120
|
+
description:
|
|
121
|
+
"Roll back the execution window of a REJECTed opsx goal: goal-x plans, backs every discarded file up, then restores the worktree from the goal baseline.",
|
|
122
|
+
async handler(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
123
|
+
const changeId = args.trim();
|
|
124
|
+
if (!changeId) {
|
|
125
|
+
sendNotice(
|
|
126
|
+
pi,
|
|
127
|
+
ROLLBACK_NOTICE_CUSTOM_TYPE,
|
|
128
|
+
"Usage: /opsx:rollback <change-id>",
|
|
129
|
+
);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let goals: GoalBaseSummary[];
|
|
134
|
+
try {
|
|
135
|
+
goals = await readGoals(ctx);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
sendNotice(
|
|
138
|
+
pi,
|
|
139
|
+
ROLLBACK_NOTICE_CUSTOM_TYPE,
|
|
140
|
+
`Cannot read the goal base, so there is nothing to roll back: ${describeError(error)}`,
|
|
141
|
+
);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const goal = findOpsxGoals(goals).find(
|
|
145
|
+
(candidate) => candidate.changeId === changeId,
|
|
146
|
+
);
|
|
147
|
+
if (!goal) {
|
|
148
|
+
sendNotice(
|
|
149
|
+
pi,
|
|
150
|
+
ROLLBACK_NOTICE_CUSTOM_TYPE,
|
|
151
|
+
`No opsx goal in the active base owns change "${changeId}". Rollback only applies to the execution window of a goal this plugin created; nothing was changed.`,
|
|
152
|
+
);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let delta: ReviewWindowDelta | undefined;
|
|
157
|
+
try {
|
|
158
|
+
delta = await readDelta(ctx, goal.goalId);
|
|
159
|
+
} catch {
|
|
160
|
+
delta = undefined;
|
|
161
|
+
}
|
|
162
|
+
if (!delta) {
|
|
163
|
+
sendNotice(
|
|
164
|
+
pi,
|
|
165
|
+
ROLLBACK_NOTICE_CUSTOM_TYPE,
|
|
166
|
+
`Rollback is unavailable for change "${changeId}": the goal base has no baseline commit to roll back to (not a git repository, an unborn HEAD, or no baseline was captured). Nothing was changed.`,
|
|
167
|
+
);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let facet: GoalXRollbackFacet;
|
|
172
|
+
try {
|
|
173
|
+
facet = await loadRollback();
|
|
174
|
+
} catch (error) {
|
|
175
|
+
sendNotice(
|
|
176
|
+
pi,
|
|
177
|
+
ROLLBACK_NOTICE_CUSTOM_TYPE,
|
|
178
|
+
`Rollback is unavailable for change "${changeId}": the goal-x rollback module could not be loaded (${describeError(error)}). Nothing was changed.`,
|
|
179
|
+
);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Step 1 — plan (goal-x, pure; touches nothing).
|
|
184
|
+
let plan: ReturnType<GoalXRollbackFacet["planRollback"]>;
|
|
185
|
+
try {
|
|
186
|
+
plan = facet.planRollback(delta as never);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
sendNotice(
|
|
189
|
+
pi,
|
|
190
|
+
ROLLBACK_NOTICE_CUSTOM_TYPE,
|
|
191
|
+
`Rollback aborted: could not plan against the window delta (${describeError(error)}). Nothing was changed.`,
|
|
192
|
+
);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const restorable =
|
|
196
|
+
plan.repos.length > 0 &&
|
|
197
|
+
plan.repos.some(
|
|
198
|
+
(repo) => repo.baseKind !== "status-only" && repo.base !== null,
|
|
199
|
+
);
|
|
200
|
+
if (!restorable) {
|
|
201
|
+
sendNotice(
|
|
202
|
+
pi,
|
|
203
|
+
ROLLBACK_NOTICE_CUSTOM_TYPE,
|
|
204
|
+
`Rollback is unavailable for change "${changeId}": no repository in the window has a baseline commit to restore from. Nothing was changed.`,
|
|
205
|
+
);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Step 2 — back up. Goal-x writes the backup into the goal archive
|
|
210
|
+
// directory and returns ok:false on any failure; the worktree must not
|
|
211
|
+
// be touched until the whole backup is on disk.
|
|
212
|
+
const backup = await facet.writeRollbackBackup(
|
|
213
|
+
{ cwd: ctx.cwd, sessionManager: ctx.sessionManager },
|
|
214
|
+
plan,
|
|
215
|
+
{ now: now() },
|
|
216
|
+
);
|
|
217
|
+
if (!backup.ok) {
|
|
218
|
+
sendNotice(
|
|
219
|
+
pi,
|
|
220
|
+
ROLLBACK_NOTICE_CUSTOM_TYPE,
|
|
221
|
+
`Rollback aborted before touching the worktree: the backup could not be written (${backup.reason ?? "unknown reason"}). Nothing was changed.`,
|
|
222
|
+
);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Step 3 — execute (goal-x; failures are collected, never thrown).
|
|
227
|
+
const results = await facet.executeRollback(plan);
|
|
228
|
+
appendFlowEntry(pi, OPSX_ENTRY_TYPES.rollback, {
|
|
229
|
+
changeId,
|
|
230
|
+
goalId: goal.goalId,
|
|
231
|
+
restored: results.reduce((sum, result) => sum + result.restored, 0),
|
|
232
|
+
deleted: results.reduce((sum, result) => sum + result.deleted, 0),
|
|
233
|
+
});
|
|
234
|
+
sendNotice(
|
|
235
|
+
pi,
|
|
236
|
+
ROLLBACK_NOTICE_CUSTOM_TYPE,
|
|
237
|
+
facet.formatRollbackReport(plan, results, backup.dir),
|
|
238
|
+
);
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three sandbox profiles the opsx workflow runs its subagents under
|
|
3
|
+
* (design D4, openspec change add-pi-openspec-x).
|
|
4
|
+
*
|
|
5
|
+
* The definitions are code constants registered into pi-sandbox's runtime
|
|
6
|
+
* profile registry at extension init (see index.ts); registration is
|
|
7
|
+
* in-memory for the session lifetime and never writes a user's sandbox.json.
|
|
8
|
+
* Resolution order is user configuration > runtime registration, so an
|
|
9
|
+
* operator can pin or refine any of these names by defining the same profile
|
|
10
|
+
* in sandbox.json.
|
|
11
|
+
*
|
|
12
|
+
* allowRead is the whole project ("."), and every other field keeps inheriting
|
|
13
|
+
* the operator's baseline (inheritGlobalConfig defaults to true, so the
|
|
14
|
+
* configured deny lists stay in force): only the write boundary differs
|
|
15
|
+
* per role.
|
|
16
|
+
*/
|
|
17
|
+
import type { SandboxProfileDefinition } from "@xzzpig/pi-sandbox";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
DEFAULT_AGENT_ALLOW_WRITE,
|
|
21
|
+
DEFAULT_PLANNER_ALLOW_WRITE,
|
|
22
|
+
DEFAULT_REVIEWER_ALLOW_WRITE,
|
|
23
|
+
type OpsxConfig,
|
|
24
|
+
} from "./config.ts";
|
|
25
|
+
|
|
26
|
+
/** Project-wide read access, shared by all three profiles. */
|
|
27
|
+
const PROJECT_WIDE_READ = ["."];
|
|
28
|
+
|
|
29
|
+
/** The configured write list, or the role default; always a fresh array. */
|
|
30
|
+
function writeAllowList(
|
|
31
|
+
configured: string[] | undefined,
|
|
32
|
+
fallback: string[],
|
|
33
|
+
): string[] {
|
|
34
|
+
return [...(configured ?? fallback)];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build the three opsx profile definitions from the configuration surface.
|
|
39
|
+
* Each OpsxConfig allow-write list overrides its role's default when present.
|
|
40
|
+
*/
|
|
41
|
+
export function buildSandboxProfiles(
|
|
42
|
+
config: OpsxConfig = {},
|
|
43
|
+
): Record<string, SandboxProfileDefinition> {
|
|
44
|
+
return {
|
|
45
|
+
"opsx-planner": {
|
|
46
|
+
filesystem: {
|
|
47
|
+
allowRead: [...PROJECT_WIDE_READ],
|
|
48
|
+
allowWrite: writeAllowList(
|
|
49
|
+
config.plannerAllowWrite,
|
|
50
|
+
DEFAULT_PLANNER_ALLOW_WRITE,
|
|
51
|
+
),
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
"opsx-agent": {
|
|
55
|
+
filesystem: {
|
|
56
|
+
allowRead: [...PROJECT_WIDE_READ],
|
|
57
|
+
allowWrite: writeAllowList(
|
|
58
|
+
config.agentAllowWrite,
|
|
59
|
+
DEFAULT_AGENT_ALLOW_WRITE,
|
|
60
|
+
),
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
"opsx-reviewer": {
|
|
64
|
+
filesystem: {
|
|
65
|
+
allowRead: [...PROJECT_WIDE_READ],
|
|
66
|
+
allowWrite: writeAllowList(
|
|
67
|
+
config.reviewerAllowWrite,
|
|
68
|
+
DEFAULT_REVIEWER_ALLOW_WRITE,
|
|
69
|
+
),
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|