@xzzpig/pi-openspec-x 0.1.0 → 0.2.1

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.
@@ -44,6 +44,13 @@ import {
44
44
  } from "./dependencies.ts";
45
45
  import { sendNotice, sessionKey } from "./command-util.ts";
46
46
  import { DispatchReviewTracker, evaluateTickGate } from "./dispatch-gate.ts";
47
+ import {
48
+ childReportFlowId,
49
+ childReportReadyForTask,
50
+ childRunsForFlow,
51
+ refreshForCompletion,
52
+ type ChildReportRun,
53
+ } from "./child-report-ingestion.ts";
47
54
  import {
48
55
  buildOpsxAuditorOverride,
49
56
  renderArchiveGuidance,
@@ -60,9 +67,11 @@ import {
60
67
  } from "./flow-recovery.ts";
61
68
  import {
62
69
  readArchivedGoals as readArchivedGoalsFromBase,
70
+ readFocusedGoalSnapshot,
63
71
  readGoalBaseSummaries,
64
72
  readGoalTaskTree,
65
73
  type ArchivedGoalRef,
74
+ type FocusedGoalSnapshot,
66
75
  } from "./goal-base.ts";
67
76
  import {
68
77
  emitSilentUiSpan,
@@ -127,6 +136,131 @@ interface ImplementFlow {
127
136
  /** goal-x goal id once the main agent creates it. */
128
137
  goalId?: string;
129
138
  phase: "starting" | "implementing" | "reviewing";
139
+ /**
140
+ * Entry id of the `modeEntered` entry that opened this flow. The flow key is
141
+ * `(parentSessionId, modeEnteredEntryId, changeId, mode, goalId?)`, so a
142
+ * later flow for the same change is a different flow (design D5).
143
+ */
144
+ modeEnteredEntryId?: string;
145
+ /**
146
+ * Set when the durable flow binding could not be written or verified. A flow
147
+ * with a damaged binding may not be used to authorize a task completion.
148
+ */
149
+ bindingError?: string;
150
+ }
151
+
152
+ /** Main-branch custom entry recording a flow's goal binding (design D5). */
153
+ export const FLOW_BINDING_ENTRY = "pi-openspec-x/flow-binding";
154
+
155
+ /** Version of the flow-binding payload. */
156
+ export const FLOW_BINDING_VERSION = 1;
157
+
158
+ /** The durable flow⇄goal binding this package writes and re-reads. */
159
+ export interface FlowBinding {
160
+ version: typeof FLOW_BINDING_VERSION;
161
+ modeEnteredEntryId: string;
162
+ changeId: string;
163
+ mode: ImplementationMode;
164
+ goalId: string;
165
+ }
166
+
167
+ function asFlowBinding(value: unknown): FlowBinding | undefined {
168
+ if (!value || typeof value !== "object") return undefined;
169
+ const candidate = value as Record<string, unknown>;
170
+ if (candidate.version !== FLOW_BINDING_VERSION) return undefined;
171
+ if (typeof candidate.modeEnteredEntryId !== "string") return undefined;
172
+ if (typeof candidate.changeId !== "string") return undefined;
173
+ if (candidate.mode !== "agent" && candidate.mode !== "direct") {
174
+ return undefined;
175
+ }
176
+ if (typeof candidate.goalId !== "string" || !candidate.goalId) {
177
+ return undefined;
178
+ }
179
+ return {
180
+ version: FLOW_BINDING_VERSION,
181
+ modeEnteredEntryId: candidate.modeEnteredEntryId,
182
+ changeId: candidate.changeId,
183
+ mode: candidate.mode,
184
+ goalId: candidate.goalId,
185
+ };
186
+ }
187
+
188
+ /** The branch entries the recovery reader consumes. */
189
+ interface BranchEntryLike {
190
+ type?: string;
191
+ id?: string;
192
+ customType?: string;
193
+ data?: unknown;
194
+ }
195
+
196
+ function readBranch(ctx: {
197
+ sessionManager?: { getBranch(): readonly unknown[] };
198
+ }): BranchEntryLike[] {
199
+ try {
200
+ return (ctx.sessionManager?.getBranch() ?? []) as BranchEntryLike[];
201
+ } catch {
202
+ return [];
203
+ }
204
+ }
205
+
206
+ /**
207
+ * The current leaf id, when the host exposes it. Older fakes and non-session
208
+ * callers may not, and a missing id only means the flow key cannot include the
209
+ * entry id — never a reason to throw on a command path.
210
+ */
211
+ function leafIdOf(ctx: {
212
+ sessionManager?: { getLeafId?(): string | null };
213
+ }): string | undefined {
214
+ try {
215
+ return ctx.sessionManager?.getLeafId?.() ?? undefined;
216
+ } catch {
217
+ return undefined;
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Write the flow binding with a direct append and confirm it landed on the
223
+ * current branch. Unlike the best-effort display entries, a binding that did
224
+ * not persist must never be treated as if it had: the flow would then be
225
+ * re-bound from a guess on the next start.
226
+ */
227
+ export function writeFlowBinding(
228
+ pi: Pick<ExtensionAPI, "appendEntry">,
229
+ ctx: { sessionManager?: { getBranch(): readonly unknown[] } },
230
+ binding: FlowBinding,
231
+ ): { ok: true } | { ok: false; reason: string } {
232
+ try {
233
+ pi.appendEntry(FLOW_BINDING_ENTRY, binding);
234
+ } catch (error) {
235
+ return {
236
+ ok: false,
237
+ reason: `the flow binding could not be written: ${describeError(error)}`,
238
+ };
239
+ }
240
+ const branch = readBranch(ctx);
241
+ const last = branch[branch.length - 1];
242
+ if (last?.customType !== FLOW_BINDING_ENTRY) {
243
+ return {
244
+ ok: false,
245
+ reason:
246
+ "the flow binding was not confirmed on the current branch; completion cannot be authorized",
247
+ };
248
+ }
249
+ return { ok: true };
250
+ }
251
+
252
+ /** The latest flow binding on the branch, if any. */
253
+ export function readFlowBinding(ctx: {
254
+ sessionManager?: { getBranch(): readonly unknown[] };
255
+ }): FlowBinding | undefined {
256
+ const branch = readBranch(ctx);
257
+ for (let index = branch.length - 1; index >= 0; index -= 1) {
258
+ const entry = branch[index];
259
+ if (entry?.customType !== FLOW_BINDING_ENTRY) continue;
260
+ const binding = asFlowBinding(entry.data);
261
+ if (binding) return binding;
262
+ }
263
+ return undefined;
130
264
  }
131
265
 
132
266
  const flows = new Map<string, ImplementFlow>();
@@ -195,6 +329,9 @@ export function resetImplementFlowsForTests(): void {
195
329
  trackers.clear();
196
330
  windowDeltaCache.clear();
197
331
  taskSyncWarned.clear();
332
+ taskPreStates.clear();
333
+ publishedTicks.clear();
334
+ trackerSeededFlows.clear();
198
335
  }
199
336
 
200
337
  export function activeImplementFlow(
@@ -203,6 +340,138 @@ export function activeImplementFlow(
203
340
  return flows.get(sessionId);
204
341
  }
205
342
 
343
+ /**
344
+ * Restore an implement flow from the current branch after a cold start
345
+ * (design D5).
346
+ *
347
+ * The flow registry is process memory, so a restart leaves it empty while the
348
+ * branch still holds a `modeEntered` entry and (usually) a `flow-binding`. The
349
+ * binding is checked against goal-x's real records — never guessed from "the
350
+ * only goal in the list" — and a flow whose binding cannot be confirmed is
351
+ * refused rather than silently allowed to complete without its gate.
352
+ */
353
+ /**
354
+ * Seed the gate's dispatch observations from the durable child-report runs.
355
+ *
356
+ * The tracker is in-memory, so a restart leaves it empty even though the runs
357
+ * and their receipts were restored from the branch. `evaluateTickGate` checks
358
+ * the tracker *before* it looks at a receipt, so without this every completion
359
+ * is refused after a restart with "no observed opsx-worker dispatch" and the
360
+ * work already done can never be accepted (design D5: restore the gate Map,
361
+ * not just the flow).
362
+ */
363
+ const trackerSeededFlows = new Set<string>();
364
+
365
+ function seedTrackerFromRestoredRuns(
366
+ sessionId: string,
367
+ changeId: string,
368
+ ): void {
369
+ const key = `${sessionId}:${changeId}`;
370
+ if (trackerSeededFlows.has(key)) return;
371
+ const runs = childRunsForFlow(childReportFlowId(sessionId, changeId));
372
+ // No runs yet means the child-report flow has not been restored yet, NOT
373
+ // that there is nothing to restore: on `session_start` this flow rebinds
374
+ // before the ingestion layer rebuilds its runs. Leave it unseeded so the
375
+ // next call tries again instead of latching an empty tracker.
376
+ if (runs.length === 0) return;
377
+ trackerSeededFlows.add(key);
378
+ const tracker = trackerFor(sessionId);
379
+ for (const run of runs) {
380
+ // A run with no attributable task keeps the conservative meaning it had
381
+ // live: the dispatch counts for every task rather than deadlocking one.
382
+ tracker.noteDispatch(run.taskId);
383
+ }
384
+ }
385
+
386
+ export async function rebindImplementFlow(
387
+ ctx: ExtensionContext,
388
+ deps: {
389
+ readGoals: (ctx: ExtensionContext) => Promise<GoalBaseSummary[]>;
390
+ },
391
+ ): Promise<{ ok: true; flow?: ImplementFlow } | { ok: false; reason: string }> {
392
+ const sessionId = sessionKey(ctx);
393
+ const existing = flows.get(sessionId);
394
+ if (existing) return { ok: true, flow: existing };
395
+
396
+ const branch = readBranch(ctx);
397
+ let modeEntered:
398
+ | { id: string; changeId: string; mode: ImplementationMode }
399
+ | undefined;
400
+ for (let index = branch.length - 1; index >= 0; index -= 1) {
401
+ const entry = branch[index];
402
+ // A modeExited before any modeEntered means this branch's flow is over.
403
+ if (entry?.customType === OPSX_ENTRY_TYPES.modeExited) break;
404
+ if (entry?.customType !== OPSX_ENTRY_TYPES.modeEntered) continue;
405
+ const data = entry.data as
406
+ | { changeId?: unknown; mode?: unknown }
407
+ | undefined;
408
+ const mode =
409
+ data?.mode === "agent" || data?.mode === "direct" ? data.mode : undefined;
410
+ if (typeof data?.changeId === "string" && mode && entry.id) {
411
+ modeEntered = { id: entry.id, changeId: data.changeId, mode };
412
+ }
413
+ break;
414
+ }
415
+ if (!modeEntered) return { ok: true };
416
+
417
+ let goals: GoalBaseSummary[] = [];
418
+ try {
419
+ goals = await deps.readGoals(ctx);
420
+ } catch {
421
+ goals = [];
422
+ }
423
+ const goal = findOpsxGoals(goals).find(
424
+ (candidate) => candidate.changeId === modeEntered.changeId,
425
+ );
426
+
427
+ const binding = readFlowBinding(ctx);
428
+ const bound =
429
+ binding && binding.modeEnteredEntryId === modeEntered.id
430
+ ? binding
431
+ : undefined;
432
+
433
+ if (bound) {
434
+ if (!goal || goal.goalId !== bound.goalId) {
435
+ return {
436
+ ok: false,
437
+ reason: `The recorded flow binding for ${bound.changeId} names goal ${bound.goalId}, but goal-x reports ${goal?.goalId ?? "no matching goal"}. The completion gate cannot be restored; resume the goal with /goal-resume or start a new flow.`,
438
+ };
439
+ }
440
+ const flow: ImplementFlow = {
441
+ changeId: bound.changeId,
442
+ mode: bound.mode,
443
+ goalId: bound.goalId,
444
+ phase: "reviewing",
445
+ modeEnteredEntryId: modeEntered.id,
446
+ };
447
+ flows.set(sessionId, flow);
448
+ seedTrackerFromRestoredRuns(sessionId, flow.changeId);
449
+ return { ok: true, flow };
450
+ }
451
+
452
+ // No durable binding: recover only when goal-x itself can confirm ownership
453
+ // from the objective's opsx change marker, and mark the flow as recovered so
454
+ // its evidence is known to be weaker.
455
+ if (!goal) {
456
+ return {
457
+ ok: false,
458
+ reason: `An opsx implementation flow for ${modeEntered.changeId} is recorded on this branch, but no goal can be confirmed for it. Its tasks cannot be completed until the flow is re-established.`,
459
+ };
460
+ }
461
+ const flow: ImplementFlow = {
462
+ changeId: modeEntered.changeId,
463
+ mode: modeEntered.mode,
464
+ goalId: goal.goalId,
465
+ phase: "reviewing",
466
+ modeEnteredEntryId: modeEntered.id,
467
+ bindingError:
468
+ "recovered from the branch without a durable flow binding; the goal binding was confirmed from goal-x's records",
469
+ };
470
+ flows.set(sessionId, flow);
471
+ seedTrackerFromRestoredRuns(sessionId, flow.changeId);
472
+ return { ok: true, flow };
473
+ }
474
+
206
475
  /**
207
476
  * Register a new flow. Refuses when one is already active for the session:
208
477
  * two concurrent implementation flows would fight over tasks.md and the goal
@@ -383,6 +652,12 @@ export interface ImplementFlowDeps {
383
652
  ctx: ExtensionContext,
384
653
  goalId: string,
385
654
  ) => Promise<string[] | undefined>;
655
+ /**
656
+ * Injectable authoritative-focus reader for the tick gate (design D6);
657
+ * defaults to the goal-x focus snapshot. The worker conditions apply only
658
+ * when the focused goal is this flow's own goal.
659
+ */
660
+ readFocus?: (ctx: ExtensionContext) => Promise<FocusedGoalSnapshot>;
386
661
  }
387
662
 
388
663
  /**
@@ -404,6 +679,7 @@ type OpsxGoalRefLike = ReturnType<typeof findOpsxGoals>[number];
404
679
  */
405
680
  async function ensureAuditorInstalled(
406
681
  pi: Pick<ExtensionAPI, "sendMessage" | "appendEntry" | "events">,
682
+ ctx: { sessionManager?: { getBranch(): readonly unknown[] } },
407
683
  sessionId: string,
408
684
  goal: OpsxGoalRefLike,
409
685
  flow: ImplementFlow,
@@ -420,6 +696,27 @@ async function ensureAuditorInstalled(
420
696
  return;
421
697
  }
422
698
  updateImplementFlow(sessionId, { goalId: goal.goalId, phase: "reviewing" });
699
+ // Design D5: bind the flow to the goal it actually created, durably. A
700
+ // binding that cannot be written (or confirmed) marks the flow's evidence
701
+ // damaged, so a later completion cannot be authorized from a guess.
702
+ if (!flow.modeEnteredEntryId) {
703
+ updateImplementFlow(sessionId, {
704
+ bindingError:
705
+ "the flow has no modeEntered entry id, so it cannot be bound to its goal",
706
+ });
707
+ } else {
708
+ const binding = writeFlowBinding(pi, ctx, {
709
+ version: FLOW_BINDING_VERSION,
710
+ modeEnteredEntryId: flow.modeEnteredEntryId,
711
+ changeId: flow.changeId,
712
+ mode: flow.mode,
713
+ goalId: goal.goalId,
714
+ });
715
+ if (!binding.ok) {
716
+ updateImplementFlow(sessionId, { bindingError: binding.reason });
717
+ sendNotice(pi, IMPLEMENT_NOTICE_CUSTOM_TYPE, binding.reason);
718
+ }
719
+ }
423
720
  appendFlowEntry(pi, OPSX_ENTRY_TYPES.phaseChanged, {
424
721
  changeId: flow.changeId,
425
722
  phase: "reviewing",
@@ -468,7 +765,7 @@ function createImplementGoalToolResultHandler(
468
765
  (candidate) => candidate.changeId === flow.changeId,
469
766
  );
470
767
  if (!goal) return;
471
- await ensureAuditorInstalled(pi, sessionId, goal, flow, deps);
768
+ await ensureAuditorInstalled(pi, ctx, sessionId, goal, flow, deps);
472
769
  };
473
770
  }
474
771
 
@@ -480,7 +777,6 @@ const SUBAGENT_TOOL_NAME = "subagent";
480
777
  const UPDATE_GOAL_TOOL_NAME = "update_goal";
481
778
  const UPDATE_GOAL_TASK_TOOL_NAME = "update_goal_task";
482
779
  const OPSX_WORKER_AGENT = "opsx-worker";
483
- const REPORT_WORK_TOOL_NAME = "report_work";
484
780
  const WORKER_DISPATCH_TASK_RE = /\[OPSX WORKER DISPATCH task=([^\]]+)\]/;
485
781
  const DISPATCH_TASK_RE = /\btask\s*[=:]?\s*(\d+(?:\.\d+)*)\b/i;
486
782
 
@@ -604,6 +900,83 @@ async function runTaskSyncCheck(
604
900
  );
605
901
  }
606
902
 
903
+ /**
904
+ * Each task's status before a completion request ran, keyed by toolCallId. The
905
+ * result handler needs the pre-state to tell a real transition from a task that
906
+ * was already complete (design D7).
907
+ */
908
+ const taskPreStates = new Map<string, Map<string, string | undefined>>();
909
+
910
+ /** `<goalId>:<taskId>:<completedAt>` keys already published, for de-duping. */
911
+ const publishedTicks = new Set<string>();
912
+
913
+ /**
914
+ * Read the v3 goal record out of an `update_goal_task` result. goal-x returns
915
+ * logical rejections (missing evidence, unfinished subtasks, a disabled or
916
+ * already-complete task) with `isError` false, so the returned record — not the
917
+ * absence of an error — is the only truthful verdict (design D7).
918
+ */
919
+ function goalRecordFromDetails(details: unknown):
920
+ | {
921
+ id: string;
922
+ tasks: Map<string, { status?: string; completedAt?: string }>;
923
+ }
924
+ | undefined {
925
+ if (typeof details !== "object" || details === null) return undefined;
926
+ const envelope = details as Record<string, unknown>;
927
+ if (envelope.version !== 3) return undefined;
928
+ const goal = envelope.goal;
929
+ if (typeof goal !== "object" || goal === null) return undefined;
930
+ const record = goal as Record<string, unknown>;
931
+ const id = record.id;
932
+ if (typeof id !== "string" || id.length === 0) return undefined;
933
+ const tasks = new Map<string, { status?: string; completedAt?: string }>();
934
+ const taskList = record.taskList;
935
+ const list =
936
+ typeof taskList === "object" && taskList !== null
937
+ ? (taskList as Record<string, unknown>).tasks
938
+ : undefined;
939
+ if (!Array.isArray(list)) return { id, tasks };
940
+ for (const entry of list) {
941
+ if (typeof entry !== "object" || entry === null) continue;
942
+ const task = entry as Record<string, unknown>;
943
+ if (typeof task.id !== "string") continue;
944
+ tasks.set(task.id, {
945
+ ...(typeof task.status === "string" ? { status: task.status } : {}),
946
+ ...(typeof task.completedAt === "string"
947
+ ? { completedAt: task.completedAt }
948
+ : {}),
949
+ });
950
+ }
951
+ return { id, tasks };
952
+ }
953
+
954
+ /**
955
+ * Capture each task's status before a completion request executes. Runs on
956
+ * `tool_call` so the result handler can compare against it (design D7).
957
+ */
958
+ function createImplementTaskSnapshotHandler(
959
+ deps: { readGoalTasks?: ImplementFlowDeps["readGoalTasks"] } = {},
960
+ ): ExtensionHandler<ToolCallEvent, ToolCallEventResult> {
961
+ return async (event, ctx) => {
962
+ if (event.toolName !== UPDATE_GOAL_TASK_TOOL_NAME) return {};
963
+ const goalId = flows.get(sessionKey(ctx))?.goalId;
964
+ const readGoalTasks = deps.readGoalTasks;
965
+ if (!goalId || !readGoalTasks) return {};
966
+ let tasks: GoalTaskLike[] | undefined;
967
+ try {
968
+ tasks = await readGoalTasks(ctx, goalId);
969
+ } catch {
970
+ return {};
971
+ }
972
+ if (!tasks) return {};
973
+ const states = new Map<string, string | undefined>();
974
+ for (const task of tasks) states.set(task.id, task.status);
975
+ taskPreStates.set(event.toolCallId, states);
976
+ return {};
977
+ };
978
+ }
979
+
607
980
  /**
608
981
  * Publish the lifecycle events whose source is the main agent's own tool
609
982
  * traffic (task 9.3): `task_dispatched` for an opsx-worker dispatch,
@@ -639,27 +1012,44 @@ function createImplementLifecycleToolHandler(
639
1012
  return;
640
1013
  }
641
1014
 
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
1015
  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
- });
1016
+ const requested = completeTaskIdsFrom(event.input);
1017
+ const pre = taskPreStates.get(event.toolCallId);
1018
+ taskPreStates.delete(event.toolCallId);
1019
+ if (requested.length === 0) return;
1020
+ // goal-x reports logical rejections without setting `isError`, so an
1021
+ // absent error proves nothing. Only a task that actually moved from
1022
+ // non-complete to complete, in this flow's own goal record, with a fresh
1023
+ // `completedAt`, may be published as a completion (design D7).
1024
+ const goal = goalRecordFromDetails(event.details);
1025
+ if (!goal || !flow.goalId || goal.id !== flow.goalId) return;
1026
+ if (!pre) return;
1027
+ const confirmed: string[] = [];
1028
+ for (const taskId of requested) {
1029
+ const after = goal.tasks.get(taskId);
1030
+ if (!after || after.status !== "complete" || !after.completedAt) return;
1031
+ if (pre.get(taskId) === "complete") return;
1032
+ const key = `${goal.id}:${taskId}:${after.completedAt}`;
1033
+ if (publishedTicks.has(key)) return;
1034
+ confirmed.push(taskId);
1035
+ }
1036
+ for (const taskId of confirmed) {
1037
+ const completedAt = goal.tasks.get(taskId)?.completedAt;
1038
+ if (completedAt) {
1039
+ publishedTicks.add(`${goal.id}:${taskId}:${completedAt}`);
1040
+ }
1041
+ trackerFor(sessionId).noteTick(taskId);
1042
+ appendFlowEntry(pi, OPSX_ENTRY_TYPES.taskTicked, {
1043
+ changeId: flow.changeId,
1044
+ taskId,
1045
+ });
1046
+ publishLifecycle(events, {
1047
+ type: "task_completed",
1048
+ change: flow.changeId,
1049
+ mode: flow.mode,
1050
+ taskId,
1051
+ });
1052
+ }
663
1053
  // The tick was written back; reconcile the goal tree against tasks.md
664
1054
  // once per flow (task 6.3: tasks.md wins, divergence warns).
665
1055
  await runTaskSyncCheck(pi, sessionId, flow, ctx, deps);
@@ -678,38 +1068,95 @@ function createImplementLifecycleToolHandler(
678
1068
  };
679
1069
  }
680
1070
 
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;
1071
+ /**
1072
+ * Convert an accepted child run into the gate's work-report shape.
1073
+ *
1074
+ * Every field is read structurally and a missing one simply means the gate
1075
+ * will hold: the receipt is produced by the child, never by the parent model.
1076
+ */
1077
+ function workReportFromRun(run: ChildReportRun): WorkReportLike | undefined {
1078
+ const report = run.report;
1079
+ if (!run.taskId || !report) return undefined;
1080
+ const changedFiles = Array.isArray(report.changedFiles)
1081
+ ? report.changedFiles.filter(
1082
+ (entry): entry is string => typeof entry === "string",
1083
+ )
1084
+ : [];
1085
+ const verification = report.selfVerification;
692
1086
  const selfVerification =
693
1087
  typeof verification === "object" && verification !== null
694
1088
  ? (verification as { command?: string; result?: string })
695
1089
  : {};
696
1090
  return {
697
- taskId,
698
- changedFiles: files,
699
- claims: stringField(input, "claims") ?? "",
1091
+ taskId: run.taskId,
1092
+ changedFiles,
1093
+ claims: typeof report.claims === "string" ? report.claims : "",
700
1094
  selfVerification,
701
- ...(typeof input.verificationPassed === "boolean"
702
- ? { verificationPassed: input.verificationPassed }
1095
+ ...(typeof report.verificationPassed === "boolean"
1096
+ ? { verificationPassed: report.verificationPassed }
703
1097
  : {}),
704
1098
  };
705
1099
  }
706
1100
 
1101
+ /**
1102
+ * Resolve the accepted worker report for a task (design D3/D7).
1103
+ *
1104
+ * The report is read back from the persisted ready receipt of the task's
1105
+ * latest run, so the parent model never has to transcribe it and a failed or
1106
+ * superseded run cannot be resurrected by a later completion request.
1107
+ */
1108
+ export function rememberWorkerReportReceipt(
1109
+ flowId: string,
1110
+ taskId: string,
1111
+ ): WorkReportLike | undefined {
1112
+ const ready = childReportReadyForTask(flowId, taskId);
1113
+ if (!ready.ready) return undefined;
1114
+ return workReportFromRun(ready.run);
1115
+ }
1116
+
1117
+ /**
1118
+ * Every task a completion request would complete, from either payload shape.
1119
+ *
1120
+ * `update_goal_task` accepts a single `task_id`+`status` and an atomic
1121
+ * `updates: [...]` batch. Both must be gated: a handler that reads only
1122
+ * `task_id` lets the batch form through untouched (design D7).
1123
+ */
1124
+ function completeTaskIdsFrom(input: Record<string, unknown>): string[] {
1125
+ const single = stringField(input, "task_id");
1126
+ if (single && stringField(input, "status") === "complete") return [single];
1127
+ const updates = input.updates;
1128
+ if (!Array.isArray(updates)) return [];
1129
+ const ids: string[] = [];
1130
+ for (const entry of updates) {
1131
+ if (typeof entry !== "object" || entry === null) continue;
1132
+ const record = entry as Record<string, unknown>;
1133
+ if (stringField(record, "status") !== "complete") continue;
1134
+ const id = stringField(record, "task_id");
1135
+ if (id) ids.push(id);
1136
+ }
1137
+ return ids;
1138
+ }
1139
+
707
1140
  /**
708
1141
  * The enforced tick gate (task 8.2; spec MUST "未审查不得勾选"). Blocks an
709
1142
  * `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.
1143
+ * has produced an accepted report that reconciles with the window delta. Direct
1144
+ * mode is ungated: it has no dispatch to review.
712
1145
  */
1146
+ /** Whether two focus reads describe the same focus generation (design D6). */
1147
+ function sameFocusToken(
1148
+ before: FocusedGoalSnapshot["token"],
1149
+ after: FocusedGoalSnapshot["token"],
1150
+ ): boolean {
1151
+ return (
1152
+ before.sessionId === after.sessionId &&
1153
+ before.branchGeneration === after.branchGeneration &&
1154
+ before.storageRoot === after.storageRoot &&
1155
+ before.focusEntryId === after.focusEntryId &&
1156
+ before.goalId === after.goalId
1157
+ );
1158
+ }
1159
+
713
1160
  function createImplementTickGateHandler(
714
1161
  pi: Pick<ExtensionAPI, "sendMessage" | "appendEntry" | "events">,
715
1162
  deps: {
@@ -717,16 +1164,60 @@ function createImplementTickGateHandler(
717
1164
  ctx: ExtensionContext,
718
1165
  goalId: string,
719
1166
  ) => Promise<string[] | undefined>;
1167
+ readFocus?: (ctx: ExtensionContext) => Promise<FocusedGoalSnapshot>;
1168
+ /** Needed to re-confirm a cold-started flow's goal binding (design D5). */
1169
+ readGoals?: (ctx: ExtensionContext) => Promise<GoalBaseSummary[]>;
720
1170
  } = {},
721
1171
  ): ExtensionHandler<ToolCallEvent, ToolCallEventResult> {
722
1172
  return async (event, ctx) => {
723
1173
  if (event.toolName !== UPDATE_GOAL_TASK_TOOL_NAME) return {};
724
- if (stringField(event.input, "status") !== "complete") return {};
725
1174
  const sessionId = sessionKey(ctx);
726
- const flow = flows.get(sessionId);
1175
+ let flow = flows.get(sessionId);
1176
+ if (!flow) {
1177
+ // A cold start leaves the flow registry empty while the branch still
1178
+ // carries the flow's own entries. Re-bind before deciding: returning
1179
+ // early here would silently skip the gate on every restart, and a flow
1180
+ // that cannot be confirmed is refused rather than guessed (design D5).
1181
+ const rebound = await rebindImplementFlow(ctx, {
1182
+ readGoals: deps.readGoals ?? (async () => []),
1183
+ });
1184
+ if (!rebound.ok) return { block: true, reason: rebound.reason };
1185
+ flow = rebound.flow;
1186
+ }
727
1187
  if (!flow || flow.mode !== "agent") return {};
728
- const taskId = stringField(event.input, "task_id");
729
- if (!taskId) return {};
1188
+
1189
+ // Worker conditions apply only to this flow's own goal (design D6): an
1190
+ // ordinary or unrelated focused goal keeps its original behaviour, while a
1191
+ // focus that cannot be determined is refused rather than guessed. A flow
1192
+ // with no bound goal has nothing to compare against, so the worker
1193
+ // conditions still apply to it.
1194
+ let focusToken: FocusedGoalSnapshot["token"] | undefined;
1195
+ if (deps.readFocus && flow.goalId !== undefined) {
1196
+ let focus: FocusedGoalSnapshot;
1197
+ try {
1198
+ focus = await deps.readFocus(ctx);
1199
+ } catch {
1200
+ return {
1201
+ block: true,
1202
+ reason:
1203
+ "The focused goal could not be read, so this completion cannot be authorized for an agent-mode flow.",
1204
+ };
1205
+ }
1206
+ if (focus.state === "unknown") {
1207
+ return {
1208
+ block: true,
1209
+ reason: `The focused goal cannot be determined (${focus.reason ?? "no reason given"}), so this completion cannot be authorized for an agent-mode flow.`,
1210
+ };
1211
+ }
1212
+ // Only skip when the focus is positively known to be elsewhere.
1213
+ if (focus.state !== "known-focused" || focus.goalId !== flow.goalId) {
1214
+ return {};
1215
+ }
1216
+ focusToken = focus.token;
1217
+ }
1218
+
1219
+ const taskIds = completeTaskIdsFrom(event.input);
1220
+ if (taskIds.length === 0) return {};
730
1221
 
731
1222
  const goalId = flow.goalId;
732
1223
  const readDelta = deps.readWindowDelta;
@@ -736,27 +1227,71 @@ function createImplementTickGateHandler(
736
1227
  readDelta(ctx, goalId),
737
1228
  )
738
1229
  : undefined;
739
- const decision = evaluateTickGate(trackerFor(sessionId), {
740
- taskId,
741
- ...(observedChangedFiles ? { observedChangedFiles } : {}),
742
- });
743
- if (decision.allow) {
744
- for (const warning of decision.warnings) {
1230
+
1231
+ const warnings: string[] = [];
1232
+ const flowId = childReportFlowId(sessionId, flow.changeId);
1233
+ // The notifier can wake the session before the native completion event is
1234
+ // delivered, so refresh the flow's runs before asking for a receipt. One
1235
+ // bounded round; the gate never polls or sleeps (design D3).
1236
+ refreshForCompletion(flowId);
1237
+
1238
+ // The refresh above awaited I/O, so the focus may have moved underneath it.
1239
+ // Re-read the generation and refuse a request decided against a stale
1240
+ // context rather than applying a stale verdict to the new focus (design D6).
1241
+ if (deps.readFocus && focusToken) {
1242
+ let after: FocusedGoalSnapshot;
1243
+ try {
1244
+ after = await deps.readFocus(ctx);
1245
+ } catch {
1246
+ return {
1247
+ block: true,
1248
+ reason:
1249
+ "The focused goal could not be re-read after the report refresh, so this completion cannot be authorized.",
1250
+ };
1251
+ }
1252
+ if (!sameFocusToken(focusToken, after.token)) {
1253
+ return {
1254
+ block: true,
1255
+ reason:
1256
+ "The focused goal changed while the worker report was being refreshed, so this completion must be re-submitted under the current focus.",
1257
+ };
1258
+ }
1259
+ }
1260
+ // The tracker is process state, so a restart leaves it empty while the
1261
+ // branch still holds the receipts. Seed it here rather than only at rebind
1262
+ // time: on `session_start` this flow may rebind before the child-report
1263
+ // flow is restored, and the gate must never refuse work that was already
1264
+ // reported (design D5).
1265
+ seedTrackerFromRestoredRuns(sessionId, flow.changeId);
1266
+ for (const taskId of taskIds) {
1267
+ const receipt = rememberWorkerReportReceipt(flowId, taskId);
1268
+ const decision = evaluateTickGate(trackerFor(sessionId), {
1269
+ taskId,
1270
+ ...(receipt ? { receipt } : {}),
1271
+ ...(observedChangedFiles ? { observedChangedFiles } : {}),
1272
+ });
1273
+ if (!decision.allow) {
1274
+ // goal-x applies `updates` as one atomic batch, so a single unmet
1275
+ // condition blocks the whole request rather than letting a partial
1276
+ // batch land (design D7).
745
1277
  sendNotice(
746
1278
  pi,
747
1279
  IMPLEMENT_NOTICE_CUSTOM_TYPE,
748
- `[OPSX TICK GATE] ${warning}`,
1280
+ `[OPSX TICK GATE] ${decision.reason}`,
749
1281
  );
1282
+ return { block: true, reason: decision.reason };
750
1283
  }
751
- return {};
1284
+ warnings.push(...decision.warnings);
752
1285
  }
753
1286
 
754
- sendNotice(
755
- pi,
756
- IMPLEMENT_NOTICE_CUSTOM_TYPE,
757
- `[OPSX TICK GATE] ${decision.reason}`,
758
- );
759
- return { block: true, reason: decision.reason };
1287
+ for (const warning of warnings) {
1288
+ sendNotice(
1289
+ pi,
1290
+ IMPLEMENT_NOTICE_CUSTOM_TYPE,
1291
+ `[OPSX TICK GATE] ${warning}`,
1292
+ );
1293
+ }
1294
+ return {};
760
1295
  };
761
1296
  }
762
1297
 
@@ -904,7 +1439,7 @@ export function createImplementTurnEndHandler(
904
1439
  }
905
1440
 
906
1441
  if (!goal) return;
907
- await ensureAuditorInstalled(pi, sessionId, goal, flow, deps);
1442
+ await ensureAuditorInstalled(pi, ctx, sessionId, goal, flow, deps);
908
1443
  };
909
1444
  }
910
1445
 
@@ -973,6 +1508,14 @@ export function registerImplementFlow(
973
1508
  return scope.degraded ? undefined : scope.paths;
974
1509
  });
975
1510
 
1511
+ const readFocus =
1512
+ deps.readFocus ??
1513
+ ((ctx: ExtensionContext) =>
1514
+ readFocusedGoalSnapshot({
1515
+ cwd: ctx.cwd,
1516
+ sessionManager: ctx.sessionManager,
1517
+ }));
1518
+
976
1519
  pi.registerCommand("opsx:implement", {
977
1520
  description:
978
1521
  "Implement an OpenSpec change: agent mode (opsx-agent profile + worker dispatches) or direct main-session mode, gated by the goal completion audit.",
@@ -1115,6 +1658,12 @@ export function registerImplementFlow(
1115
1658
  changeId: parsed.changeId,
1116
1659
  mode,
1117
1660
  });
1661
+ // The modeEntered entry id is part of the flow key (design D5): a second
1662
+ // flow for the same change in the same session is a different flow.
1663
+ const modeEnteredEntryId = leafIdOf(ctx);
1664
+ if (modeEnteredEntryId) {
1665
+ updateImplementFlow(sessionId, { modeEnteredEntryId });
1666
+ }
1118
1667
  publishLifecycle(deps.events ?? pi.events, {
1119
1668
  type: "implement_started",
1120
1669
  change: parsed.changeId,
@@ -1147,5 +1696,16 @@ export function registerImplementFlow(
1147
1696
  }),
1148
1697
  );
1149
1698
  // The enforced "review before tick" gate (task 8.2).
1150
- pi.on("tool_call", createImplementTickGateHandler(pi, { readWindowDelta }));
1699
+ pi.on(
1700
+ "tool_call",
1701
+ createImplementTickGateHandler(pi, {
1702
+ readWindowDelta,
1703
+ readFocus,
1704
+ readGoals,
1705
+ }),
1706
+ );
1707
+ // Pre-completion task snapshots, so the result handler can prove a real
1708
+ // transition instead of trusting the absence of a tool error (design D7).
1709
+ // Registered after the gate so the gate stays the first `tool_call` handler.
1710
+ pi.on("tool_call", createImplementTaskSnapshotHandler({ readGoalTasks }));
1151
1711
  }