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