@xzzpig/pi-openspec-x 0.1.0 → 0.2.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 +21 -0
- package/README.md +6 -2
- package/package.json +4 -4
- package/src/agent-prompts.ts +4 -1
- package/src/agents.ts +25 -0
- package/src/child-report-extensions/gap-analysis.ts +23 -0
- package/src/child-report-extensions/plan-review.ts +21 -0
- package/src/child-report-extensions/worker.ts +26 -0
- package/src/child-report-ingestion.ts +1530 -0
- package/src/child-report-reader.ts +948 -0
- package/src/dispatch-gate.ts +25 -18
- package/src/goal-base.ts +394 -0
- package/src/implement-command.ts +620 -60
- package/src/index.ts +113 -9
- package/src/objective.ts +2 -2
- package/src/plan-command.ts +46 -0
- package/src/progress-observer.ts +48 -54
- package/src/report-tools.ts +10 -9
- package/src/worker-dispatch.ts +2 -2
package/src/implement-command.ts
CHANGED
|
@@ -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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
if (
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
-
/**
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
const
|
|
689
|
-
|
|
690
|
-
)
|
|
691
|
-
|
|
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
|
|
699
|
-
claims:
|
|
1091
|
+
taskId: run.taskId,
|
|
1092
|
+
changedFiles,
|
|
1093
|
+
claims: typeof report.claims === "string" ? report.claims : "",
|
|
700
1094
|
selfVerification,
|
|
701
|
-
...(typeof
|
|
702
|
-
? { 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
|
|
711
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
729
|
-
|
|
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
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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] ${
|
|
1280
|
+
`[OPSX TICK GATE] ${decision.reason}`,
|
|
749
1281
|
);
|
|
1282
|
+
return { block: true, reason: decision.reason };
|
|
750
1283
|
}
|
|
751
|
-
|
|
1284
|
+
warnings.push(...decision.warnings);
|
|
752
1285
|
}
|
|
753
1286
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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(
|
|
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
|
}
|