@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,1151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/opsx:implement` mode selection and flow start (design D6/D10; openspec
|
|
3
|
+
* change add-pi-openspec-x, task 8.1).
|
|
4
|
+
*
|
|
5
|
+
* The command picks one of two modes — agent implementation (opsx-agent +
|
|
6
|
+
* opsx-worker dispatches) or direct main-session implementation — from the
|
|
7
|
+
* command arguments or a structured question. Nothing starts until a mode is
|
|
8
|
+
* chosen, and a second flow in the same session is refused with a pointer to
|
|
9
|
+
* resume or end the existing one.
|
|
10
|
+
*
|
|
11
|
+
* Both modes run on a persistent goal-x goal: the assembled objective is handed
|
|
12
|
+
* to `create_goal` (sisyphus in agent mode, regular autoContinue in direct
|
|
13
|
+
* mode) and the task tree to `set_goal_tasks`. Direct mode never switches the
|
|
14
|
+
* role or tightens permissions; agent mode enters the restricted `opsx-agent`
|
|
15
|
+
* sandbox profile first (fail-closed). The final gate is the goal's completion
|
|
16
|
+
* audit (task 8.3), so both paths end at the same review.
|
|
17
|
+
*
|
|
18
|
+
* The plugin cannot call goal tools itself — the main agent owns them — so the
|
|
19
|
+
* command appends the exact `create_goal` / `set_goal_tasks` payloads as an
|
|
20
|
+
* instruction message. Everything else (mode, guard, objective) is computed
|
|
21
|
+
* here.
|
|
22
|
+
*/
|
|
23
|
+
import * as fs from "node:fs";
|
|
24
|
+
import * as path from "node:path";
|
|
25
|
+
import type {
|
|
26
|
+
ExtensionAPI,
|
|
27
|
+
ExtensionCommandContext,
|
|
28
|
+
ExtensionContext,
|
|
29
|
+
ExtensionHandler,
|
|
30
|
+
ToolCallEvent,
|
|
31
|
+
ToolCallEventResult,
|
|
32
|
+
TurnEndEvent,
|
|
33
|
+
TurnEndEventResult,
|
|
34
|
+
ToolResultEvent,
|
|
35
|
+
ToolResultEventResult,
|
|
36
|
+
} from "@earendil-works/pi-coding-agent";
|
|
37
|
+
|
|
38
|
+
import { canEnterImplementation, type PlanApproval } from "./approval-gate.ts";
|
|
39
|
+
import { DEFAULT_AGENT_ALLOW_WRITE } from "./config.ts";
|
|
40
|
+
import {
|
|
41
|
+
describeError,
|
|
42
|
+
OpsxDependencyMissingError,
|
|
43
|
+
requireGoalXSupport,
|
|
44
|
+
} from "./dependencies.ts";
|
|
45
|
+
import { sendNotice, sessionKey } from "./command-util.ts";
|
|
46
|
+
import { DispatchReviewTracker, evaluateTickGate } from "./dispatch-gate.ts";
|
|
47
|
+
import {
|
|
48
|
+
buildOpsxAuditorOverride,
|
|
49
|
+
renderArchiveGuidance,
|
|
50
|
+
} from "./final-gate.ts";
|
|
51
|
+
import {
|
|
52
|
+
appendFlowEntry,
|
|
53
|
+
appendStatusSnapshot,
|
|
54
|
+
OPSX_ENTRY_TYPES,
|
|
55
|
+
} from "./flow-entries.ts";
|
|
56
|
+
import {
|
|
57
|
+
detectFlowDivergence,
|
|
58
|
+
findOpsxGoals,
|
|
59
|
+
type GoalBaseSummary,
|
|
60
|
+
} from "./flow-recovery.ts";
|
|
61
|
+
import {
|
|
62
|
+
readArchivedGoals as readArchivedGoalsFromBase,
|
|
63
|
+
readGoalBaseSummaries,
|
|
64
|
+
readGoalTaskTree,
|
|
65
|
+
type ArchivedGoalRef,
|
|
66
|
+
} from "./goal-base.ts";
|
|
67
|
+
import {
|
|
68
|
+
emitSilentUiSpan,
|
|
69
|
+
publishLifecycle,
|
|
70
|
+
publishNotify,
|
|
71
|
+
type EventEmitterLike,
|
|
72
|
+
} from "./lifecycle.ts";
|
|
73
|
+
import { enterOpsxMode } from "./mode.ts";
|
|
74
|
+
import { endPlanFlow } from "./plan-command.ts";
|
|
75
|
+
import { computeReviewScope } from "./review-scope.ts";
|
|
76
|
+
import {
|
|
77
|
+
planTaskSync,
|
|
78
|
+
type GoalTaskLike,
|
|
79
|
+
type TaskSyncPlan,
|
|
80
|
+
} from "./task-sync.ts";
|
|
81
|
+
import type { WorkReportLike } from "./worker-dispatch.ts";
|
|
82
|
+
import { readPlanApproval } from "./plan-review.ts";
|
|
83
|
+
import {
|
|
84
|
+
assembleOpsxObjective,
|
|
85
|
+
type ObjectiveAssembly,
|
|
86
|
+
type OpsxBoundaries,
|
|
87
|
+
} from "./objective.ts";
|
|
88
|
+
|
|
89
|
+
/** customType of the `/opsx:implement` start instruction message. */
|
|
90
|
+
export const IMPLEMENT_START_CUSTOM_TYPE = "pi-openspec-x/implement-start";
|
|
91
|
+
/** customType of an `/opsx:implement` notice. */
|
|
92
|
+
export const IMPLEMENT_NOTICE_CUSTOM_TYPE = "pi-openspec-x/implement-notice";
|
|
93
|
+
|
|
94
|
+
export type ImplementationMode = "agent" | "direct";
|
|
95
|
+
|
|
96
|
+
export interface ParsedImplementArgs {
|
|
97
|
+
changeId?: string;
|
|
98
|
+
mode?: ImplementationMode;
|
|
99
|
+
error?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Parse `/opsx:implement [--agent|--direct] <change-id>`. Bare `agent`/`direct`
|
|
104
|
+
* words are accepted too, so the command reads naturally.
|
|
105
|
+
*/
|
|
106
|
+
export function parseImplementArgs(args: string): ParsedImplementArgs {
|
|
107
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
108
|
+
let mode: ImplementationMode | undefined;
|
|
109
|
+
const rest: string[] = [];
|
|
110
|
+
for (const token of tokens) {
|
|
111
|
+
if (token === "--agent" || token === "agent") mode = "agent";
|
|
112
|
+
else if (token === "--direct" || token === "direct") mode = "direct";
|
|
113
|
+
else rest.push(token);
|
|
114
|
+
}
|
|
115
|
+
if (rest.length > 1) {
|
|
116
|
+
return { error: `expected a single change id, got: ${rest.join(" ")}` };
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
...(rest[0] ? { changeId: rest[0] } : {}),
|
|
120
|
+
...(mode ? { mode } : {}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface ImplementFlow {
|
|
125
|
+
changeId: string;
|
|
126
|
+
mode: ImplementationMode;
|
|
127
|
+
/** goal-x goal id once the main agent creates it. */
|
|
128
|
+
goalId?: string;
|
|
129
|
+
phase: "starting" | "implementing" | "reviewing";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const flows = new Map<string, ImplementFlow>();
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Divergences already reported, so a repeated turn does not spam the user.
|
|
136
|
+
* Keyed by session id and divergence kind.
|
|
137
|
+
*/
|
|
138
|
+
const reportedDivergences = new Set<string>();
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Sessions whose active flow already had its one tasks.md⇄goal-tree
|
|
142
|
+
* reconciliation warning (task 6.3). At most one warning per flow keeps the
|
|
143
|
+
* check from nagging on every tick; cleared when the flow ends.
|
|
144
|
+
*/
|
|
145
|
+
const taskSyncWarned = new Set<string>();
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Consecutive polls in which the flow's goal was missing from the active base.
|
|
149
|
+
* A single miss is treated as a degraded read, not a cleared goal.
|
|
150
|
+
*/
|
|
151
|
+
const missingGoalPolls = new Map<string, number>();
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Window-delta cache for the tick gate. The gate runs on the `tool_call` hot
|
|
155
|
+
* path and every evaluation would otherwise pay a dynamic import plus several
|
|
156
|
+
* git subprocesses; a short TTL bounds that while still refreshing within a
|
|
157
|
+
* task. Cleared whenever a flow ends or the test state resets.
|
|
158
|
+
*/
|
|
159
|
+
const WINDOW_DELTA_TTL_MS = 2_000;
|
|
160
|
+
const windowDeltaCache = new Map<
|
|
161
|
+
string,
|
|
162
|
+
{ at: number; paths: string[] | undefined }
|
|
163
|
+
>();
|
|
164
|
+
|
|
165
|
+
async function cachedWindowDelta(
|
|
166
|
+
key: string,
|
|
167
|
+
load: () => Promise<string[] | undefined>,
|
|
168
|
+
): Promise<string[] | undefined> {
|
|
169
|
+
const hit = windowDeltaCache.get(key);
|
|
170
|
+
if (hit && Date.now() - hit.at < WINDOW_DELTA_TTL_MS) return hit.paths;
|
|
171
|
+
const paths = await load();
|
|
172
|
+
windowDeltaCache.set(key, { at: Date.now(), paths });
|
|
173
|
+
return paths;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Per-session dispatch bookkeeping for the enforced tick gate (task 8.2).
|
|
178
|
+
*/
|
|
179
|
+
const trackers = new Map<string, DispatchReviewTracker>();
|
|
180
|
+
|
|
181
|
+
function trackerFor(sessionId: string): DispatchReviewTracker {
|
|
182
|
+
let tracker = trackers.get(sessionId);
|
|
183
|
+
if (!tracker) {
|
|
184
|
+
tracker = new DispatchReviewTracker();
|
|
185
|
+
trackers.set(sessionId, tracker);
|
|
186
|
+
}
|
|
187
|
+
return tracker;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Reset the per-session flow registry. Test-only. */
|
|
191
|
+
export function resetImplementFlowsForTests(): void {
|
|
192
|
+
flows.clear();
|
|
193
|
+
reportedDivergences.clear();
|
|
194
|
+
missingGoalPolls.clear();
|
|
195
|
+
trackers.clear();
|
|
196
|
+
windowDeltaCache.clear();
|
|
197
|
+
taskSyncWarned.clear();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function activeImplementFlow(
|
|
201
|
+
sessionId: string,
|
|
202
|
+
): ImplementFlow | undefined {
|
|
203
|
+
return flows.get(sessionId);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Register a new flow. Refuses when one is already active for the session:
|
|
208
|
+
* two concurrent implementation flows would fight over tasks.md and the goal
|
|
209
|
+
* tree, so the caller must resume or end the existing flow first.
|
|
210
|
+
*/
|
|
211
|
+
export function startImplementFlow(
|
|
212
|
+
sessionId: string,
|
|
213
|
+
flow: ImplementFlow,
|
|
214
|
+
): { ok: true } | { ok: false; reason: string } {
|
|
215
|
+
const existing = flows.get(sessionId);
|
|
216
|
+
if (existing) {
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
reason: `An opsx implementation flow for change "${existing.changeId}" (${existing.mode}) is already active in this session. Resume it or end it before starting another.`,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
flows.set(sessionId, flow);
|
|
223
|
+
return { ok: true };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function updateImplementFlow(
|
|
227
|
+
sessionId: string,
|
|
228
|
+
patch: Partial<ImplementFlow>,
|
|
229
|
+
): void {
|
|
230
|
+
const existing = flows.get(sessionId);
|
|
231
|
+
if (existing) flows.set(sessionId, { ...existing, ...patch });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function endImplementFlow(sessionId: string): void {
|
|
235
|
+
flows.delete(sessionId);
|
|
236
|
+
missingGoalPolls.delete(sessionId);
|
|
237
|
+
trackers.delete(sessionId);
|
|
238
|
+
for (const key of [...windowDeltaCache.keys()]) {
|
|
239
|
+
if (key.startsWith(`${sessionId}:`)) windowDeltaCache.delete(key);
|
|
240
|
+
}
|
|
241
|
+
for (const key of [...reportedDivergences]) {
|
|
242
|
+
if (key.startsWith(`${sessionId}:`)) reportedDivergences.delete(key);
|
|
243
|
+
}
|
|
244
|
+
for (const key of [...taskSyncWarned]) {
|
|
245
|
+
if (key.startsWith(`${sessionId}:`)) taskSyncWarned.delete(key);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Absolute path of a change's tasks.md. */
|
|
250
|
+
function tasksMdPath(cwd: string, changeId: string): string {
|
|
251
|
+
return path.join(cwd, "openspec", "changes", changeId, "tasks.md");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Absolute path of a change directory. */
|
|
255
|
+
export function changeDirPath(cwd: string, changeId: string): string {
|
|
256
|
+
return path.join(cwd, "openspec", "changes", changeId);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function readTasksMarkdown(cwd: string, changeId: string): string | undefined {
|
|
260
|
+
try {
|
|
261
|
+
return fs.readFileSync(tasksMdPath(cwd, changeId), "utf-8");
|
|
262
|
+
} catch {
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const MODE_OPTIONS: Array<{ value: ImplementationMode; label: string }> = [
|
|
268
|
+
{
|
|
269
|
+
value: "agent",
|
|
270
|
+
label: "Agent implementation (opsx-agent role, opsx-worker dispatches)",
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
value: "direct",
|
|
274
|
+
label: "Direct main-session implementation (no role switch)",
|
|
275
|
+
},
|
|
276
|
+
];
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Ask for the mode when the command did not carry one. Without a UI nothing is
|
|
280
|
+
* selected (undefined) and the caller must not start anything.
|
|
281
|
+
*/
|
|
282
|
+
export async function selectImplementationMode(
|
|
283
|
+
ctx: ExtensionContext,
|
|
284
|
+
events?: EventEmitterLike,
|
|
285
|
+
): Promise<ImplementationMode | undefined> {
|
|
286
|
+
if (!ctx.hasUI) return undefined;
|
|
287
|
+
// pi-notify (task 9.4): the mode question is a blocking UI that is not an
|
|
288
|
+
// agent-waiting prompt, so claim the next UI span as silent first. Done
|
|
289
|
+
// before `hasUI`-gated dialogs so the span is claimed for this dialog.
|
|
290
|
+
if (events) {
|
|
291
|
+
emitSilentUiSpan(events, "implementation mode dialog");
|
|
292
|
+
}
|
|
293
|
+
let choice: string | undefined;
|
|
294
|
+
try {
|
|
295
|
+
choice = await ctx.ui.select(
|
|
296
|
+
"How should /opsx:implement run this change?",
|
|
297
|
+
MODE_OPTIONS.map((option) => option.label),
|
|
298
|
+
);
|
|
299
|
+
} catch {
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
return MODE_OPTIONS.find((option) => option.label === choice)?.value;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** The boundaries the objective encodes for each mode. */
|
|
306
|
+
export function boundariesForMode(
|
|
307
|
+
mode: ImplementationMode,
|
|
308
|
+
agentAllowWrite: readonly string[],
|
|
309
|
+
): OpsxBoundaries {
|
|
310
|
+
return { mode, writable: [...agentAllowWrite] };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Render the instruction message the main agent follows to start the goal:
|
|
315
|
+
* the exact `create_goal` payload, the `set_goal_tasks` mirror, and the mode's
|
|
316
|
+
* dispatch discipline.
|
|
317
|
+
*/
|
|
318
|
+
export function renderImplementStart(
|
|
319
|
+
assembly: Extract<ObjectiveAssembly, { ok: true }>,
|
|
320
|
+
mode: ImplementationMode,
|
|
321
|
+
): string {
|
|
322
|
+
const lines = [
|
|
323
|
+
`[OPSX IMPLEMENT START change=${assembly.goalStart.objective.match(/change "([^"]+)"/)?.[1] ?? "?"} mode=${mode}]`,
|
|
324
|
+
"Start the implementation goal now:",
|
|
325
|
+
"",
|
|
326
|
+
"1. Call `create_goal` with:",
|
|
327
|
+
"```json",
|
|
328
|
+
JSON.stringify(assembly.goalStart, null, 2),
|
|
329
|
+
"```",
|
|
330
|
+
"2. Call `set_goal_tasks` with:",
|
|
331
|
+
"```json",
|
|
332
|
+
JSON.stringify({ tasks: assembly.goalTasks }, null, 2),
|
|
333
|
+
"```",
|
|
334
|
+
"",
|
|
335
|
+
];
|
|
336
|
+
if (mode === "agent") {
|
|
337
|
+
lines.push(
|
|
338
|
+
"Agent mode: you are sandboxed to openspec/ and the build-output allowlist; the source tree is read-only. Dispatch opsx-worker for every source change and review its full diff plus verification before ticking a task in tasks.md. Keep the goal task tree in sync with tasks.md.",
|
|
339
|
+
);
|
|
340
|
+
} else {
|
|
341
|
+
lines.push(
|
|
342
|
+
"Direct mode: implement tasks.md yourself in order, verify each task, and tick tasks.md only after its verification passes. Keep the goal task tree in sync with tasks.md.",
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
lines.push(
|
|
346
|
+
"",
|
|
347
|
+
"Completion is the goal's completion audit: ticking every task is not completion. When all tasks are done, request completion; a disapproved audit keeps the goal active and you fix and re-review.",
|
|
348
|
+
);
|
|
349
|
+
return lines.join("\n");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export interface ImplementFlowDeps {
|
|
353
|
+
/** Write allowlist for the agent objective (defaults to the D4 list). */
|
|
354
|
+
agentAllowWrite?: string[];
|
|
355
|
+
/** Injectable restricted-mode entry; defaults to the agent sandbox mode. */
|
|
356
|
+
enterMode?: (pi: ExtensionAPI, ctx: ExtensionCommandContext) => Promise<void>;
|
|
357
|
+
/** Injectable goal-base reader; defaults to goal-x's active goal files. */
|
|
358
|
+
readGoals?: (ctx: ExtensionContext) => Promise<GoalBaseSummary[]>;
|
|
359
|
+
/** Injectable archived-goal reader; defaults to goal-x's archived files. */
|
|
360
|
+
readArchivedGoals?: (ctx: ExtensionContext) => Promise<ArchivedGoalRef[]>;
|
|
361
|
+
/**
|
|
362
|
+
* Injectable goal task-tree reader for the tasks.md reconciliation
|
|
363
|
+
* (task 6.3); defaults to goal-x's active goal files. `undefined` in the
|
|
364
|
+
* returned promise means the tree could not be read and the check is
|
|
365
|
+
* skipped.
|
|
366
|
+
*/
|
|
367
|
+
readGoalTasks?: (
|
|
368
|
+
ctx: ExtensionContext,
|
|
369
|
+
goalId: string,
|
|
370
|
+
) => Promise<GoalTaskLike[] | undefined>;
|
|
371
|
+
/** Injectable final-audit installer; defaults to the S1 override. */
|
|
372
|
+
installAuditor?: (goalId: string) => void;
|
|
373
|
+
/** Injectable plan-approval reader; defaults to the change's plan ledger. */
|
|
374
|
+
readApproval?: (cwd: string, changeId: string) => PlanApproval | undefined;
|
|
375
|
+
/** Lifecycle bus; defaults to `pi.events` at call time. */
|
|
376
|
+
events?: EventEmitterLike;
|
|
377
|
+
/**
|
|
378
|
+
* Injectable window-delta reader for the tick gate; defaults to goal-x's
|
|
379
|
+
* baseline plus change delta. Undefined means the file-set reconciliation
|
|
380
|
+
* is skipped and only the dispatch/review structure is enforced.
|
|
381
|
+
*/
|
|
382
|
+
readWindowDelta?: (
|
|
383
|
+
ctx: ExtensionContext,
|
|
384
|
+
goalId: string,
|
|
385
|
+
) => Promise<string[] | undefined>;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* goal-x's goal-creation tools. The goal id only exists after one of these has
|
|
390
|
+
* run, and the agent may request completion in the same turn, so the S1
|
|
391
|
+
* override must be installed as soon as either result arrives.
|
|
392
|
+
*/
|
|
393
|
+
const CREATE_GOAL_TOOL_NAME = "create_goal";
|
|
394
|
+
const SET_GOAL_TASKS_TOOL_NAME = "set_goal_tasks";
|
|
395
|
+
|
|
396
|
+
type OpsxGoalRefLike = ReturnType<typeof findOpsxGoals>[number];
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Install the S1 per-goal auditor override and move the flow to the reviewing
|
|
400
|
+
* phase. Shared by the `turn_end` poll and the `create_goal`/`set_goal_tasks`
|
|
401
|
+
* tool-result hook: installing only at turn end would let a goal that is
|
|
402
|
+
* created and completed inside one turn run its audit with the default
|
|
403
|
+
* goal-auditor instead of opsx-reviewer.
|
|
404
|
+
*/
|
|
405
|
+
async function ensureAuditorInstalled(
|
|
406
|
+
pi: Pick<ExtensionAPI, "sendMessage" | "appendEntry" | "events">,
|
|
407
|
+
sessionId: string,
|
|
408
|
+
goal: OpsxGoalRefLike,
|
|
409
|
+
flow: ImplementFlow,
|
|
410
|
+
deps: Required<Pick<ImplementFlowDeps, "installAuditor">>,
|
|
411
|
+
): Promise<void> {
|
|
412
|
+
try {
|
|
413
|
+
deps.installAuditor(goal.goalId);
|
|
414
|
+
} catch (error) {
|
|
415
|
+
sendNotice(
|
|
416
|
+
pi,
|
|
417
|
+
IMPLEMENT_NOTICE_CUSTOM_TYPE,
|
|
418
|
+
`Could not point the completion audit at opsx-reviewer for goal ${goal.goalId}: ${describeError(error)}`,
|
|
419
|
+
);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
updateImplementFlow(sessionId, { goalId: goal.goalId, phase: "reviewing" });
|
|
423
|
+
appendFlowEntry(pi, OPSX_ENTRY_TYPES.phaseChanged, {
|
|
424
|
+
changeId: flow.changeId,
|
|
425
|
+
phase: "reviewing",
|
|
426
|
+
});
|
|
427
|
+
publishLifecycle(pi.events, {
|
|
428
|
+
type: "phase_changed",
|
|
429
|
+
change: flow.changeId,
|
|
430
|
+
mode: flow.mode,
|
|
431
|
+
phase: "reviewing",
|
|
432
|
+
});
|
|
433
|
+
sendNotice(
|
|
434
|
+
pi,
|
|
435
|
+
IMPLEMENT_NOTICE_CUSTOM_TYPE,
|
|
436
|
+
`Goal ${goal.goalId} created; its completion audit now runs the read-only opsx-reviewer (plan conformance, code quality, verification evidence, scope fidelity). Complete the goal when every task is ticked; a disapproved audit keeps it active for a fix round.`,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Build the `tool_result` handler that installs the S1 override the moment
|
|
442
|
+
* goal-x reports a goal creation, closing the same-turn completion race.
|
|
443
|
+
*/
|
|
444
|
+
function createImplementGoalToolResultHandler(
|
|
445
|
+
pi: Pick<ExtensionAPI, "sendMessage" | "appendEntry" | "events">,
|
|
446
|
+
deps: Required<Pick<ImplementFlowDeps, "readGoals" | "installAuditor">>,
|
|
447
|
+
): ExtensionHandler<ToolResultEvent, ToolResultEventResult> {
|
|
448
|
+
return async (
|
|
449
|
+
event: ToolResultEvent,
|
|
450
|
+
ctx: ExtensionContext,
|
|
451
|
+
): Promise<void> => {
|
|
452
|
+
if (
|
|
453
|
+
event.toolName !== CREATE_GOAL_TOOL_NAME &&
|
|
454
|
+
event.toolName !== SET_GOAL_TASKS_TOOL_NAME
|
|
455
|
+
) {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const sessionId = sessionKey(ctx);
|
|
459
|
+
const flow = flows.get(sessionId);
|
|
460
|
+
if (!flow || flow.goalId) return;
|
|
461
|
+
let goals: GoalBaseSummary[];
|
|
462
|
+
try {
|
|
463
|
+
goals = await deps.readGoals(ctx);
|
|
464
|
+
} catch {
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const goal = findOpsxGoals(goals).find(
|
|
468
|
+
(candidate) => candidate.changeId === flow.changeId,
|
|
469
|
+
);
|
|
470
|
+
if (!goal) return;
|
|
471
|
+
await ensureAuditorInstalled(pi, sessionId, goal, flow, deps);
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Tool traffic that carries flow facts the plugin cannot observe from inside
|
|
477
|
+
* goal-x: a worker dispatch, a task tick, and the goal's final audit verdict.
|
|
478
|
+
*/
|
|
479
|
+
const SUBAGENT_TOOL_NAME = "subagent";
|
|
480
|
+
const UPDATE_GOAL_TOOL_NAME = "update_goal";
|
|
481
|
+
const UPDATE_GOAL_TASK_TOOL_NAME = "update_goal_task";
|
|
482
|
+
const OPSX_WORKER_AGENT = "opsx-worker";
|
|
483
|
+
const REPORT_WORK_TOOL_NAME = "report_work";
|
|
484
|
+
const WORKER_DISPATCH_TASK_RE = /\[OPSX WORKER DISPATCH task=([^\]]+)\]/;
|
|
485
|
+
const DISPATCH_TASK_RE = /\btask\s*[=:]?\s*(\d+(?:\.\d+)*)\b/i;
|
|
486
|
+
|
|
487
|
+
/** Collect every string inside a tool payload, bounded by depth. */
|
|
488
|
+
function collectStrings(value: unknown, out: string[], depth = 0): void {
|
|
489
|
+
if (depth > 6) return;
|
|
490
|
+
if (typeof value === "string") {
|
|
491
|
+
out.push(value);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (Array.isArray(value)) {
|
|
495
|
+
for (const item of value) collectStrings(item, out, depth + 1);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (typeof value === "object" && value !== null) {
|
|
499
|
+
for (const item of Object.values(value))
|
|
500
|
+
collectStrings(item, out, depth + 1);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Read the task id out of a worker dispatch. The `subagent` tool accepts a
|
|
506
|
+
* free-form `prompt` or structured `args`/`task` sections, so the id is looked
|
|
507
|
+
* for in every string of the payload. `undefined` means the payload never
|
|
508
|
+
* named the task; the gate still counts the dispatch.
|
|
509
|
+
*/
|
|
510
|
+
function extractDispatchTaskId(
|
|
511
|
+
input: Record<string, unknown>,
|
|
512
|
+
): string | undefined {
|
|
513
|
+
const strings: string[] = [];
|
|
514
|
+
collectStrings(input, strings);
|
|
515
|
+
for (const text of strings) {
|
|
516
|
+
const marked = WORKER_DISPATCH_TASK_RE.exec(text)?.[1]?.trim();
|
|
517
|
+
if (marked) return marked;
|
|
518
|
+
}
|
|
519
|
+
for (const text of strings) {
|
|
520
|
+
const plain = DISPATCH_TASK_RE.exec(text)?.[1];
|
|
521
|
+
if (plain) return plain;
|
|
522
|
+
}
|
|
523
|
+
return undefined;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function stringField(
|
|
527
|
+
input: Record<string, unknown>,
|
|
528
|
+
key: string,
|
|
529
|
+
): string | undefined {
|
|
530
|
+
const value = input[key];
|
|
531
|
+
return typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Render the re-mirror instruction for a tasks.md⇄goal-tree divergence
|
|
536
|
+
* (design D10: tasks.md is authoritative). The plugin cannot call goal tools
|
|
537
|
+
* itself, so the message carries the exact `set_goal_tasks` mirror payload and
|
|
538
|
+
* the ordered `update_goal_task` follow-ups, like the start instruction does.
|
|
539
|
+
*/
|
|
540
|
+
function renderTaskSyncNotice(changeId: string, plan: TaskSyncPlan): string {
|
|
541
|
+
const lines = [
|
|
542
|
+
`[OPSX TASK SYNC change=${changeId}] tasks.md and the goal task tree diverged; tasks.md is authoritative, so re-mirror the goal tree from tasks.md now:`,
|
|
543
|
+
"",
|
|
544
|
+
"1. Call `set_goal_tasks` with:",
|
|
545
|
+
"```json",
|
|
546
|
+
JSON.stringify({ tasks: plan.mirror }, null, 2),
|
|
547
|
+
"```",
|
|
548
|
+
];
|
|
549
|
+
if (plan.updates.length > 0) {
|
|
550
|
+
lines.push(
|
|
551
|
+
"2. Follow with `update_goal_task` for each status realignment:",
|
|
552
|
+
);
|
|
553
|
+
for (const update of plan.updates) {
|
|
554
|
+
lines.push(
|
|
555
|
+
` - task ${update.taskId} → ${update.status}${update.requiresEvidence ? " (attach verification evidence)" : ""}`,
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
for (const warning of plan.warnings) lines.push(`- ${warning}`);
|
|
560
|
+
return lines.join("\n");
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* The lightweight tasks.md⇄goal-tree reconciliation (task 6.3, design D10),
|
|
565
|
+
* run after a successful tick is written back. Consistent trees do nothing;
|
|
566
|
+
* a divergence re-mirrors from tasks.md (the exact payloads go to the main
|
|
567
|
+
* agent, which owns the goal tools) and appends one warning entry. At most
|
|
568
|
+
* one warning per flow keeps this from nagging on every tick, and an
|
|
569
|
+
* unreadable goal tree is skipped rather than misread as "no tasks".
|
|
570
|
+
*/
|
|
571
|
+
async function runTaskSyncCheck(
|
|
572
|
+
pi: Pick<ExtensionAPI, "sendMessage" | "appendEntry" | "events">,
|
|
573
|
+
sessionId: string,
|
|
574
|
+
flow: ImplementFlow,
|
|
575
|
+
ctx: ExtensionContext,
|
|
576
|
+
deps: { readGoalTasks?: ImplementFlowDeps["readGoalTasks"] },
|
|
577
|
+
): Promise<void> {
|
|
578
|
+
const goalId = flow.goalId;
|
|
579
|
+
const readGoalTasks = deps.readGoalTasks;
|
|
580
|
+
if (!goalId || !readGoalTasks) return;
|
|
581
|
+
const key = `${sessionId}:${flow.changeId}`;
|
|
582
|
+
if (taskSyncWarned.has(key)) return;
|
|
583
|
+
let goalTasks: GoalTaskLike[] | undefined;
|
|
584
|
+
try {
|
|
585
|
+
goalTasks = await readGoalTasks(ctx, goalId);
|
|
586
|
+
} catch {
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
if (goalTasks === undefined) return;
|
|
590
|
+
const plan = planTaskSync({
|
|
591
|
+
tasksMarkdown: readTasksMarkdown(ctx.cwd, flow.changeId),
|
|
592
|
+
goalTasks,
|
|
593
|
+
});
|
|
594
|
+
if (plan.consistent) return;
|
|
595
|
+
taskSyncWarned.add(key);
|
|
596
|
+
appendFlowEntry(pi, OPSX_ENTRY_TYPES.progress, {
|
|
597
|
+
changeId: flow.changeId,
|
|
598
|
+
note: `task sync: re-mirroring from tasks.md (${plan.warnings.join(" ")})`,
|
|
599
|
+
});
|
|
600
|
+
sendNotice(
|
|
601
|
+
pi,
|
|
602
|
+
IMPLEMENT_NOTICE_CUSTOM_TYPE,
|
|
603
|
+
renderTaskSyncNotice(flow.changeId, plan),
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Publish the lifecycle events whose source is the main agent's own tool
|
|
609
|
+
* traffic (task 9.3): `task_dispatched` for an opsx-worker dispatch,
|
|
610
|
+
* `task_completed` for a completed goal task, and `final_verdict` for the
|
|
611
|
+
* goal's completion audit result. All three are best-effort observations; a
|
|
612
|
+
* payload without a task id still publishes the event.
|
|
613
|
+
*/
|
|
614
|
+
function createImplementLifecycleToolHandler(
|
|
615
|
+
pi: Pick<ExtensionAPI, "sendMessage" | "appendEntry" | "events">,
|
|
616
|
+
deps: {
|
|
617
|
+
events?: EventEmitterLike;
|
|
618
|
+
readGoalTasks?: ImplementFlowDeps["readGoalTasks"];
|
|
619
|
+
} = {},
|
|
620
|
+
): ExtensionHandler<ToolResultEvent, ToolResultEventResult> {
|
|
621
|
+
return async (event, ctx) => {
|
|
622
|
+
const sessionId = sessionKey(ctx);
|
|
623
|
+
const flow = flows.get(sessionId);
|
|
624
|
+
if (!flow) return;
|
|
625
|
+
const events = deps.events ?? pi.events;
|
|
626
|
+
|
|
627
|
+
if (event.toolName === SUBAGENT_TOOL_NAME) {
|
|
628
|
+
if (stringField(event.input, "agent") !== OPSX_WORKER_AGENT) return;
|
|
629
|
+
const taskId = extractDispatchTaskId(event.input);
|
|
630
|
+
// The tick gate needs to know a dispatch happened before it can demand
|
|
631
|
+
// a review for it (task 8.2).
|
|
632
|
+
trackerFor(sessionId).noteDispatch(taskId);
|
|
633
|
+
publishLifecycle(events, {
|
|
634
|
+
type: "task_dispatched",
|
|
635
|
+
change: flow.changeId,
|
|
636
|
+
mode: flow.mode,
|
|
637
|
+
...(taskId ? { taskId } : {}),
|
|
638
|
+
});
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (event.toolName === REPORT_WORK_TOOL_NAME) {
|
|
643
|
+
const report = workReportFrom(event.input);
|
|
644
|
+
if (report) trackerFor(sessionId).rememberReport(report);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (event.toolName === UPDATE_GOAL_TASK_TOOL_NAME) {
|
|
649
|
+
if (event.isError) return;
|
|
650
|
+
if (stringField(event.input, "status") !== "complete") return;
|
|
651
|
+
const taskId = stringField(event.input, "task_id");
|
|
652
|
+
if (taskId) trackerFor(sessionId).noteTick(taskId);
|
|
653
|
+
appendFlowEntry(pi, OPSX_ENTRY_TYPES.taskTicked, {
|
|
654
|
+
changeId: flow.changeId,
|
|
655
|
+
...(taskId ? { taskId } : {}),
|
|
656
|
+
});
|
|
657
|
+
publishLifecycle(events, {
|
|
658
|
+
type: "task_completed",
|
|
659
|
+
change: flow.changeId,
|
|
660
|
+
mode: flow.mode,
|
|
661
|
+
...(taskId ? { taskId } : {}),
|
|
662
|
+
});
|
|
663
|
+
// The tick was written back; reconcile the goal tree against tasks.md
|
|
664
|
+
// once per flow (task 6.3: tasks.md wins, divergence warns).
|
|
665
|
+
await runTaskSyncCheck(pi, sessionId, flow, ctx, deps);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (event.toolName === UPDATE_GOAL_TOOL_NAME) {
|
|
670
|
+
if (stringField(event.input, "status") !== "complete") return;
|
|
671
|
+
publishLifecycle(events, {
|
|
672
|
+
type: "final_verdict",
|
|
673
|
+
change: flow.changeId,
|
|
674
|
+
mode: flow.mode,
|
|
675
|
+
verdict: event.isError ? "disapproved" : "approved",
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** Structural read of a `report_work` payload; undefined when malformed. */
|
|
682
|
+
function workReportFrom(
|
|
683
|
+
input: Record<string, unknown>,
|
|
684
|
+
): WorkReportLike | undefined {
|
|
685
|
+
const taskId = stringField(input, "taskId");
|
|
686
|
+
const changedFiles = input.changedFiles;
|
|
687
|
+
if (!taskId || !Array.isArray(changedFiles)) return undefined;
|
|
688
|
+
const files = changedFiles.filter(
|
|
689
|
+
(entry): entry is string => typeof entry === "string",
|
|
690
|
+
);
|
|
691
|
+
const verification = input.selfVerification;
|
|
692
|
+
const selfVerification =
|
|
693
|
+
typeof verification === "object" && verification !== null
|
|
694
|
+
? (verification as { command?: string; result?: string })
|
|
695
|
+
: {};
|
|
696
|
+
return {
|
|
697
|
+
taskId,
|
|
698
|
+
changedFiles: files,
|
|
699
|
+
claims: stringField(input, "claims") ?? "",
|
|
700
|
+
selfVerification,
|
|
701
|
+
...(typeof input.verificationPassed === "boolean"
|
|
702
|
+
? { verificationPassed: input.verificationPassed }
|
|
703
|
+
: {}),
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* The enforced tick gate (task 8.2; spec MUST "未审查不得勾选"). Blocks an
|
|
709
|
+
* `update_goal_task` completion in agent mode until the task's worker dispatch
|
|
710
|
+
* has been reviewed with `report_work` and the review reconciles with the
|
|
711
|
+
* window delta. Direct mode is ungated: it has no dispatch to review.
|
|
712
|
+
*/
|
|
713
|
+
function createImplementTickGateHandler(
|
|
714
|
+
pi: Pick<ExtensionAPI, "sendMessage" | "appendEntry" | "events">,
|
|
715
|
+
deps: {
|
|
716
|
+
readWindowDelta?: (
|
|
717
|
+
ctx: ExtensionContext,
|
|
718
|
+
goalId: string,
|
|
719
|
+
) => Promise<string[] | undefined>;
|
|
720
|
+
} = {},
|
|
721
|
+
): ExtensionHandler<ToolCallEvent, ToolCallEventResult> {
|
|
722
|
+
return async (event, ctx) => {
|
|
723
|
+
if (event.toolName !== UPDATE_GOAL_TASK_TOOL_NAME) return {};
|
|
724
|
+
if (stringField(event.input, "status") !== "complete") return {};
|
|
725
|
+
const sessionId = sessionKey(ctx);
|
|
726
|
+
const flow = flows.get(sessionId);
|
|
727
|
+
if (!flow || flow.mode !== "agent") return {};
|
|
728
|
+
const taskId = stringField(event.input, "task_id");
|
|
729
|
+
if (!taskId) return {};
|
|
730
|
+
|
|
731
|
+
const goalId = flow.goalId;
|
|
732
|
+
const readDelta = deps.readWindowDelta;
|
|
733
|
+
const observedChangedFiles =
|
|
734
|
+
goalId && readDelta
|
|
735
|
+
? await cachedWindowDelta(`${sessionId}:${goalId}`, () =>
|
|
736
|
+
readDelta(ctx, goalId),
|
|
737
|
+
)
|
|
738
|
+
: undefined;
|
|
739
|
+
const decision = evaluateTickGate(trackerFor(sessionId), {
|
|
740
|
+
taskId,
|
|
741
|
+
...(observedChangedFiles ? { observedChangedFiles } : {}),
|
|
742
|
+
});
|
|
743
|
+
if (decision.allow) {
|
|
744
|
+
for (const warning of decision.warnings) {
|
|
745
|
+
sendNotice(
|
|
746
|
+
pi,
|
|
747
|
+
IMPLEMENT_NOTICE_CUSTOM_TYPE,
|
|
748
|
+
`[OPSX TICK GATE] ${warning}`,
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
return {};
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
sendNotice(
|
|
755
|
+
pi,
|
|
756
|
+
IMPLEMENT_NOTICE_CUSTOM_TYPE,
|
|
757
|
+
`[OPSX TICK GATE] ${decision.reason}`,
|
|
758
|
+
);
|
|
759
|
+
return { block: true, reason: decision.reason };
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Build the turn_end handler that attaches the final-audit configuration once
|
|
765
|
+
* the main agent has created the goal. The goal id is only known after the
|
|
766
|
+
* agent calls `create_goal`, so this polls the goal base each turn until the
|
|
767
|
+
* opsx goal for the active change appears, then installs the per-goal override
|
|
768
|
+
* (the S2 resolver was registered at extension init).
|
|
769
|
+
*/
|
|
770
|
+
export function createImplementTurnEndHandler(
|
|
771
|
+
pi: Pick<ExtensionAPI, "sendMessage" | "appendEntry" | "events">,
|
|
772
|
+
deps: Required<
|
|
773
|
+
Pick<
|
|
774
|
+
ImplementFlowDeps,
|
|
775
|
+
"readGoals" | "installAuditor" | "readArchivedGoals"
|
|
776
|
+
>
|
|
777
|
+
>,
|
|
778
|
+
): ExtensionHandler<TurnEndEvent, TurnEndEventResult> {
|
|
779
|
+
return async (_event: TurnEndEvent, ctx: ExtensionContext): Promise<void> => {
|
|
780
|
+
const sessionId = sessionKey(ctx);
|
|
781
|
+
const flow = flows.get(sessionId);
|
|
782
|
+
if (!flow) return;
|
|
783
|
+
|
|
784
|
+
let goals: GoalBaseSummary[];
|
|
785
|
+
try {
|
|
786
|
+
goals = await deps.readGoals(ctx);
|
|
787
|
+
} catch {
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
const goal = findOpsxGoals(goals).find(
|
|
791
|
+
(candidate) => candidate.changeId === flow.changeId,
|
|
792
|
+
);
|
|
793
|
+
|
|
794
|
+
if (flow.goalId) {
|
|
795
|
+
// The goal left the active base. Only end the flow when goal-x archived
|
|
796
|
+
// it (completed or cleared): a transient active-read failure returns an
|
|
797
|
+
// empty list too, and must not tear down a live flow.
|
|
798
|
+
if (!goal) {
|
|
799
|
+
const archived = await deps.readArchivedGoals(ctx);
|
|
800
|
+
const archivedEntry = archived.find(
|
|
801
|
+
(entry) => entry.id === flow.goalId,
|
|
802
|
+
);
|
|
803
|
+
if (archivedEntry) {
|
|
804
|
+
endImplementFlow(sessionId);
|
|
805
|
+
// Mark the persisted snapshot as ended so a restarted process does
|
|
806
|
+
// not render a finished flow as if it were still running.
|
|
807
|
+
appendStatusSnapshot(pi, { changeId: flow.changeId, ended: true });
|
|
808
|
+
appendFlowEntry(pi, OPSX_ENTRY_TYPES.modeExited, {
|
|
809
|
+
changeId: flow.changeId,
|
|
810
|
+
mode: flow.mode,
|
|
811
|
+
});
|
|
812
|
+
publishLifecycle(pi.events, {
|
|
813
|
+
type: "mode_exited",
|
|
814
|
+
change: flow.changeId,
|
|
815
|
+
mode: flow.mode,
|
|
816
|
+
});
|
|
817
|
+
if (archivedEntry.status === "complete") {
|
|
818
|
+
// The goal went through its completion audit and finished: report
|
|
819
|
+
// completion and guide the user to the openspec archive step
|
|
820
|
+
// (spec: the plugin never archives for them).
|
|
821
|
+
publishLifecycle(pi.events, {
|
|
822
|
+
type: "flow_completed",
|
|
823
|
+
change: flow.changeId,
|
|
824
|
+
mode: flow.mode,
|
|
825
|
+
});
|
|
826
|
+
publishNotify(pi.events, {
|
|
827
|
+
eventId: "task-completed",
|
|
828
|
+
label: `Opsx implementation of ${flow.changeId} completed`,
|
|
829
|
+
});
|
|
830
|
+
sendNotice(
|
|
831
|
+
pi,
|
|
832
|
+
IMPLEMENT_NOTICE_CUSTOM_TYPE,
|
|
833
|
+
renderArchiveGuidance(flow.changeId),
|
|
834
|
+
);
|
|
835
|
+
} else {
|
|
836
|
+
// A manual /goal-clear archives a non-complete goal under its live
|
|
837
|
+
// status (e.g. paused), so this is not a completion: the flow and
|
|
838
|
+
// the base have diverged. Report it with the recovery options
|
|
839
|
+
// (flow-recovery divergence semantics, spec R14) instead of
|
|
840
|
+
// claiming the flow completed.
|
|
841
|
+
const divergence = detectFlowDivergence(
|
|
842
|
+
{ goalId: flow.goalId, changeId: flow.changeId },
|
|
843
|
+
[],
|
|
844
|
+
);
|
|
845
|
+
if (divergence) {
|
|
846
|
+
sendNotice(
|
|
847
|
+
pi,
|
|
848
|
+
IMPLEMENT_NOTICE_CUSTOM_TYPE,
|
|
849
|
+
`[OPSX FLOW DIVERGENCE: ${divergence.kind}] ${divergence.message} Options: ${divergence.options.join(" / ")}`,
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
// Lifecycle divergence (task 6.4): the base goal was cleared, paused or
|
|
856
|
+
// blocked, or completed out from under a still-active flow. Reported
|
|
857
|
+
// once per session and kind; the flow is never repaired automatically.
|
|
858
|
+
// An absent goal is only reported after it stays absent for two
|
|
859
|
+
// consecutive polls, because a degraded goal-base read also returns an
|
|
860
|
+
// empty list and must not raise a false divergence.
|
|
861
|
+
let divergenceRef: { goalId: string; changeId: string } | undefined;
|
|
862
|
+
if (goal) {
|
|
863
|
+
missingGoalPolls.delete(sessionId);
|
|
864
|
+
divergenceRef = { goalId: flow.goalId, changeId: flow.changeId };
|
|
865
|
+
} else {
|
|
866
|
+
const polls = (missingGoalPolls.get(sessionId) ?? 0) + 1;
|
|
867
|
+
missingGoalPolls.set(sessionId, polls);
|
|
868
|
+
if (polls >= 2) {
|
|
869
|
+
divergenceRef = { goalId: flow.goalId, changeId: flow.changeId };
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
const divergence = divergenceRef
|
|
873
|
+
? detectFlowDivergence(
|
|
874
|
+
divergenceRef,
|
|
875
|
+
goals.filter((candidate) => candidate.id === flow.goalId),
|
|
876
|
+
)
|
|
877
|
+
: undefined;
|
|
878
|
+
if (divergence) {
|
|
879
|
+
const key = `${sessionId}:${divergence.kind}`;
|
|
880
|
+
if (!reportedDivergences.has(key)) {
|
|
881
|
+
reportedDivergences.add(key);
|
|
882
|
+
sendNotice(
|
|
883
|
+
pi,
|
|
884
|
+
IMPLEMENT_NOTICE_CUSTOM_TYPE,
|
|
885
|
+
`[OPSX FLOW DIVERGENCE: ${divergence.kind}] ${divergence.message} Options: ${divergence.options.join(" / ")}`,
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
// Stall detection (task 8.2 "不空转"): a task dispatched repeatedly with
|
|
890
|
+
// no reported work and no tick is spinning, not progressing.
|
|
891
|
+
const stalled = trackerFor(sessionId).stalled();
|
|
892
|
+
if (stalled.length > 0) {
|
|
893
|
+
const stallKey = `${sessionId}:stalled:${stalled.join(",")}`;
|
|
894
|
+
if (!reportedDivergences.has(stallKey)) {
|
|
895
|
+
reportedDivergences.add(stallKey);
|
|
896
|
+
sendNotice(
|
|
897
|
+
pi,
|
|
898
|
+
IMPLEMENT_NOTICE_CUSTOM_TYPE,
|
|
899
|
+
`[OPSX STALLED] Task(s) ${stalled.join(", ")} were dispatched repeatedly without a reviewed report or a tick. Re-dispatch with a tighter scope, or report the blocker instead of retrying.`,
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
if (!goal) return;
|
|
907
|
+
await ensureAuditorInstalled(pi, sessionId, goal, flow, deps);
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Register the `/opsx:implement` command. Mode selection, the duplicate-flow
|
|
913
|
+
* guard, objective assembly, and the restricted-mode entry all happen here;
|
|
914
|
+
* the actual goal/task tool calls are the main agent's.
|
|
915
|
+
*/
|
|
916
|
+
export function registerImplementFlow(
|
|
917
|
+
pi: ExtensionAPI,
|
|
918
|
+
deps: ImplementFlowDeps = {},
|
|
919
|
+
): void {
|
|
920
|
+
// One source of truth for the agent write boundary: config.ts. The same
|
|
921
|
+
// list feeds the sandbox profile's allowWrite and the objective's
|
|
922
|
+
// "Writable:" line, so the agent can never be told it may write somewhere
|
|
923
|
+
// the sandbox denies.
|
|
924
|
+
const agentAllowWrite = deps.agentAllowWrite ?? DEFAULT_AGENT_ALLOW_WRITE;
|
|
925
|
+
const enterMode =
|
|
926
|
+
deps.enterMode ??
|
|
927
|
+
((target: ExtensionAPI, ctx: ExtensionCommandContext) =>
|
|
928
|
+
enterOpsxMode(target, "agent", {
|
|
929
|
+
sessionId: () => ctx.sessionManager.getSessionId(),
|
|
930
|
+
}).then(() => undefined));
|
|
931
|
+
const readGoals =
|
|
932
|
+
deps.readGoals ??
|
|
933
|
+
((ctx: ExtensionContext) =>
|
|
934
|
+
readGoalBaseSummaries({
|
|
935
|
+
cwd: ctx.cwd,
|
|
936
|
+
sessionManager: ctx.sessionManager,
|
|
937
|
+
}));
|
|
938
|
+
const readArchivedGoals =
|
|
939
|
+
deps.readArchivedGoals ??
|
|
940
|
+
((ctx: ExtensionContext) =>
|
|
941
|
+
readArchivedGoalsFromBase({
|
|
942
|
+
cwd: ctx.cwd,
|
|
943
|
+
sessionManager: ctx.sessionManager,
|
|
944
|
+
}));
|
|
945
|
+
const readGoalTasks =
|
|
946
|
+
deps.readGoalTasks ??
|
|
947
|
+
((ctx: ExtensionContext, goalId: string) =>
|
|
948
|
+
readGoalTaskTree(
|
|
949
|
+
{ cwd: ctx.cwd, sessionManager: ctx.sessionManager },
|
|
950
|
+
goalId,
|
|
951
|
+
));
|
|
952
|
+
const installAuditor =
|
|
953
|
+
deps.installAuditor ??
|
|
954
|
+
((goalId: string) => {
|
|
955
|
+
requireGoalXSupport().setGoalAuditorOverride(goalId, {
|
|
956
|
+
...buildOpsxAuditorOverride(),
|
|
957
|
+
});
|
|
958
|
+
});
|
|
959
|
+
const readApproval =
|
|
960
|
+
deps.readApproval ??
|
|
961
|
+
((cwd: string, changeId: string): PlanApproval | undefined => {
|
|
962
|
+
const recorded = readPlanApproval(changeDirPath(cwd, changeId));
|
|
963
|
+
return recorded ? { changeId, ...recorded } : undefined;
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
const readWindowDelta =
|
|
967
|
+
deps.readWindowDelta ??
|
|
968
|
+
(async (ctx: ExtensionContext, goalId: string) => {
|
|
969
|
+
const scope = await computeReviewScope(
|
|
970
|
+
{ cwd: ctx.cwd, sessionManager: ctx.sessionManager },
|
|
971
|
+
goalId,
|
|
972
|
+
);
|
|
973
|
+
return scope.degraded ? undefined : scope.paths;
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
pi.registerCommand("opsx:implement", {
|
|
977
|
+
description:
|
|
978
|
+
"Implement an OpenSpec change: agent mode (opsx-agent profile + worker dispatches) or direct main-session mode, gated by the goal completion audit.",
|
|
979
|
+
async handler(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
980
|
+
const parsed = parseImplementArgs(args);
|
|
981
|
+
if (parsed.error) {
|
|
982
|
+
sendNotice(
|
|
983
|
+
pi,
|
|
984
|
+
IMPLEMENT_NOTICE_CUSTOM_TYPE,
|
|
985
|
+
`Usage: /opsx:implement [--agent|--direct] <change-id> (${parsed.error})`,
|
|
986
|
+
);
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
if (!parsed.changeId) {
|
|
990
|
+
sendNotice(
|
|
991
|
+
pi,
|
|
992
|
+
IMPLEMENT_NOTICE_CUSTOM_TYPE,
|
|
993
|
+
"Usage: /opsx:implement [--agent|--direct] <change-id>",
|
|
994
|
+
);
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// Fail-closed approval gate (task 7.3): implementation is refused until
|
|
999
|
+
// the user approved this exact change's plan. The decision lives in the
|
|
1000
|
+
// change's append-only plan ledger, so it survives a restart.
|
|
1001
|
+
if (
|
|
1002
|
+
!canEnterImplementation(
|
|
1003
|
+
readApproval(ctx.cwd, parsed.changeId),
|
|
1004
|
+
parsed.changeId,
|
|
1005
|
+
)
|
|
1006
|
+
) {
|
|
1007
|
+
sendNotice(
|
|
1008
|
+
pi,
|
|
1009
|
+
IMPLEMENT_NOTICE_CUSTOM_TYPE,
|
|
1010
|
+
`The plan for "${parsed.changeId}" is not approved yet. Run /opsx:plan ${parsed.changeId}, finish the plan artifacts, and approve the plan in the approval dialog before implementing.`,
|
|
1011
|
+
);
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
const sessionId = sessionKey(ctx);
|
|
1016
|
+
const guard = startImplementFlow(sessionId, {
|
|
1017
|
+
changeId: parsed.changeId,
|
|
1018
|
+
mode: parsed.mode ?? "direct",
|
|
1019
|
+
phase: "starting",
|
|
1020
|
+
});
|
|
1021
|
+
if (!guard.ok) {
|
|
1022
|
+
sendNotice(pi, IMPLEMENT_NOTICE_CUSTOM_TYPE, guard.reason);
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// Fail-closed goal-x gate (spec R15): the whole implementation flow runs
|
|
1027
|
+
// on a goal-x goal, so a missing pi-goal-x refuses the command before
|
|
1028
|
+
// anything starts — no mode dialog, no start instruction, no create_goal
|
|
1029
|
+
// payload. The official track and /opsx:plan never reach this gate.
|
|
1030
|
+
try {
|
|
1031
|
+
requireGoalXSupport();
|
|
1032
|
+
} catch (error) {
|
|
1033
|
+
endImplementFlow(sessionId);
|
|
1034
|
+
sendNotice(
|
|
1035
|
+
pi,
|
|
1036
|
+
IMPLEMENT_NOTICE_CUSTOM_TYPE,
|
|
1037
|
+
error instanceof OpsxDependencyMissingError
|
|
1038
|
+
? error.message
|
|
1039
|
+
: `Cannot start the implementation flow: ${describeError(error)}`,
|
|
1040
|
+
);
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const mode =
|
|
1045
|
+
parsed.mode ??
|
|
1046
|
+
(await selectImplementationMode(ctx, deps.events ?? pi.events));
|
|
1047
|
+
if (!mode) {
|
|
1048
|
+
endImplementFlow(sessionId);
|
|
1049
|
+
sendNotice(
|
|
1050
|
+
pi,
|
|
1051
|
+
IMPLEMENT_NOTICE_CUSTOM_TYPE,
|
|
1052
|
+
"No implementation mode was selected; nothing was started. Re-run /opsx:implement with --agent or --direct.",
|
|
1053
|
+
);
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
updateImplementFlow(sessionId, { mode });
|
|
1057
|
+
|
|
1058
|
+
const tasksMarkdown = readTasksMarkdown(ctx.cwd, parsed.changeId);
|
|
1059
|
+
const assembly = assembleOpsxObjective({
|
|
1060
|
+
changeId: parsed.changeId,
|
|
1061
|
+
tasksMarkdown,
|
|
1062
|
+
boundaries: boundariesForMode(mode, agentAllowWrite),
|
|
1063
|
+
});
|
|
1064
|
+
if (!assembly.ok) {
|
|
1065
|
+
endImplementFlow(sessionId);
|
|
1066
|
+
sendNotice(
|
|
1067
|
+
pi,
|
|
1068
|
+
IMPLEMENT_NOTICE_CUSTOM_TYPE,
|
|
1069
|
+
`${assembly.message} ${assembly.guidance}`,
|
|
1070
|
+
);
|
|
1071
|
+
publishNotify(deps.events ?? pi.events, {
|
|
1072
|
+
eventId: "integration-error",
|
|
1073
|
+
label: `Cannot assemble the opsx objective for ${parsed.changeId}`,
|
|
1074
|
+
});
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
if (mode === "agent") {
|
|
1079
|
+
try {
|
|
1080
|
+
await enterMode(pi, ctx);
|
|
1081
|
+
} catch (error) {
|
|
1082
|
+
endImplementFlow(sessionId);
|
|
1083
|
+
sendNotice(
|
|
1084
|
+
pi,
|
|
1085
|
+
IMPLEMENT_NOTICE_CUSTOM_TYPE,
|
|
1086
|
+
`Cannot enter the opsx-agent restricted mode: ${describeError(error)}`,
|
|
1087
|
+
);
|
|
1088
|
+
publishNotify(deps.events ?? pi.events, {
|
|
1089
|
+
eventId: "integration-error",
|
|
1090
|
+
label: `Cannot enter the opsx-agent restricted mode for ${parsed.changeId}`,
|
|
1091
|
+
});
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// The implementation flow takes over, so the plan mode is over.
|
|
1097
|
+
const endedPlan = endPlanFlow(sessionId);
|
|
1098
|
+
if (endedPlan) {
|
|
1099
|
+
publishLifecycle(deps.events ?? pi.events, {
|
|
1100
|
+
type: "mode_exited",
|
|
1101
|
+
change: endedPlan,
|
|
1102
|
+
mode: "plan",
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
updateImplementFlow(sessionId, { phase: "implementing" });
|
|
1106
|
+
pi.sendMessage(
|
|
1107
|
+
{
|
|
1108
|
+
customType: IMPLEMENT_START_CUSTOM_TYPE,
|
|
1109
|
+
content: renderImplementStart(assembly, mode),
|
|
1110
|
+
display: true,
|
|
1111
|
+
},
|
|
1112
|
+
{ deliverAs: "steer" },
|
|
1113
|
+
);
|
|
1114
|
+
appendFlowEntry(pi, OPSX_ENTRY_TYPES.modeEntered, {
|
|
1115
|
+
changeId: parsed.changeId,
|
|
1116
|
+
mode,
|
|
1117
|
+
});
|
|
1118
|
+
publishLifecycle(deps.events ?? pi.events, {
|
|
1119
|
+
type: "implement_started",
|
|
1120
|
+
change: parsed.changeId,
|
|
1121
|
+
mode,
|
|
1122
|
+
});
|
|
1123
|
+
},
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
pi.on(
|
|
1127
|
+
"turn_end",
|
|
1128
|
+
createImplementTurnEndHandler(pi, {
|
|
1129
|
+
readGoals,
|
|
1130
|
+
installAuditor,
|
|
1131
|
+
readArchivedGoals,
|
|
1132
|
+
}),
|
|
1133
|
+
);
|
|
1134
|
+
// Install early so a same-turn completion still gets the override.
|
|
1135
|
+
pi.on(
|
|
1136
|
+
"tool_result",
|
|
1137
|
+
createImplementGoalToolResultHandler(pi, { readGoals, installAuditor }),
|
|
1138
|
+
);
|
|
1139
|
+
// Lifecycle events sourced from the agent's own goal/subagent tool traffic
|
|
1140
|
+
// (task 9.3: task_dispatched, task_completed, final_verdict) plus the
|
|
1141
|
+
// tasks.md reconciliation after a tick (task 6.3).
|
|
1142
|
+
pi.on(
|
|
1143
|
+
"tool_result",
|
|
1144
|
+
createImplementLifecycleToolHandler(pi, {
|
|
1145
|
+
events: deps.events,
|
|
1146
|
+
readGoalTasks,
|
|
1147
|
+
}),
|
|
1148
|
+
);
|
|
1149
|
+
// The enforced "review before tick" gate (task 8.2).
|
|
1150
|
+
pi.on("tool_call", createImplementTickGateHandler(pi, { readWindowDelta }));
|
|
1151
|
+
}
|