@signalridge/pi-goal 0.49.7

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/src/tools.ts ADDED
@@ -0,0 +1,334 @@
1
+ import {
2
+ DEFAULT_MAX_BYTES,
3
+ DEFAULT_MAX_LINES,
4
+ defineTool,
5
+ type ExtensionAPI,
6
+ truncateHead,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import { Type } from "typebox";
9
+ import { notifyTerminal, safeTerminalText } from "./errors.js";
10
+ import {
11
+ formatStatus,
12
+ GOAL_BLOCKED_TOOL,
13
+ GOAL_COMPLETE_TOOL,
14
+ type GoalRuntime,
15
+ goalIdRejectionReason,
16
+ isContradictoryCompletionSummary,
17
+ MAX_GOAL_ID_LENGTH,
18
+ STATUS_KEY,
19
+ transitionGoal,
20
+ truncateNotification,
21
+ } from "./runtime.js";
22
+
23
+ interface GoalCompleteDetails {
24
+ goal: string;
25
+ goal_id: string;
26
+ summary: string;
27
+ }
28
+
29
+ interface GoalBlockedDetails {
30
+ goal: string;
31
+ goal_id: string;
32
+ reason: string;
33
+ evidence: string;
34
+ repeated_turns: number;
35
+ }
36
+
37
+ const MAX_GOAL_TEXT_LENGTH = 4_000;
38
+ const MAX_COMPLETION_SUMMARY_LENGTH = 4_000;
39
+ const MAX_BLOCKER_REASON_LENGTH = 1_000;
40
+ const MAX_BLOCKER_EVIDENCE_LENGTH = 4_000;
41
+
42
+ export function registerGoalTools(pi: ExtensionAPI, runtime: GoalRuntime) {
43
+ const goalCompleteTool = defineTool({
44
+ name: GOAL_COMPLETE_TOOL,
45
+ label: "Goal Complete",
46
+ description:
47
+ "Mark the active /goal as complete after all required work is done and verified, using the current goal_id stale-turn guard. Do not use for partial progress, blockers, failing, or unverified work.",
48
+ promptSnippet: "Mark the active /goal as complete after fully finishing and verifying it, with the current goal_id",
49
+ promptGuidelines: [
50
+ "When a /goal is active, keep working until the goal is complete; do not stop with only a plan or partial progress.",
51
+ "Before calling goal_complete, audit the active goal requirement by requirement against the current files, command output, tests, or external state.",
52
+ "Pass the exact goal_id shown in the current /goal prompt; never reuse a goal_id from an older, stopped, replaced, or cleared turn.",
53
+ "Call goal_complete only after the requested goal is fully implemented, verified, and no known required work remains; otherwise keep working.",
54
+ ],
55
+ parameters: Type.Object({
56
+ goal_id: Type.String({
57
+ minLength: 1,
58
+ maxLength: MAX_GOAL_ID_LENGTH,
59
+ description:
60
+ "The exact goal_id shown in the current active /goal prompt. Used only to reject stale completion calls from older turns.",
61
+ }),
62
+ summary: Type.String({
63
+ minLength: 1,
64
+ maxLength: MAX_COMPLETION_SUMMARY_LENGTH,
65
+ description:
66
+ "State what was completed and what evidence verified it. Do not use this tool to report partial progress, blockers, failures, or remaining work.",
67
+ }),
68
+ }),
69
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
70
+ const completedGoal = runtime.activeGoal;
71
+ const goal = completedGoal?.text ?? "unknown goal";
72
+ const requestedGoalId = typeof params.goal_id === "string" ? params.goal_id.trim() : "";
73
+ const summary = typeof params.summary === "string" ? params.summary.trim() : "";
74
+
75
+ if (!completedGoal) {
76
+ const rejection = "Goal completion rejected: no active goal.";
77
+ notifyTerminal(ctx.ui, rejection, "warning");
78
+
79
+ return {
80
+ content: toolContent(rejection),
81
+ details: completionDetails(goal, requestedGoalId, summary),
82
+ };
83
+ }
84
+ const completingDuringBudgetWrapUp = runtime.hasActiveBudgetWrapUp();
85
+ if (!runtime.canRecordGoalUsage() && !completingDuringBudgetWrapUp) {
86
+ const rejection = "Goal completion rejected: current run does not own the active goal.";
87
+ notifyTerminal(ctx.ui, rejection, "warning");
88
+ return {
89
+ content: toolContent(rejection),
90
+ details: completionDetails(goal, requestedGoalId, summary),
91
+ };
92
+ }
93
+ if (hasPendingSkipForGoal(runtime, completedGoal.id)) {
94
+ runtime.recordGoalUsage(completedGoal, ctx);
95
+ runtime.persistGoal(completedGoal);
96
+ runtime.updateStatus(ctx, completedGoal);
97
+ runtime.clearBudgetWrapUp();
98
+ const rejection = "Goal completion rejected: goal is queued to be skipped.";
99
+ notifyTerminal(ctx.ui, rejection, "warning");
100
+ return {
101
+ content: toolContent(rejection),
102
+ details: completionDetails(goal, requestedGoalId, summary),
103
+ terminate: true,
104
+ };
105
+ }
106
+ const staleGoalRejection = goalIdRejectionReason(completedGoal, requestedGoalId);
107
+ if (staleGoalRejection) {
108
+ const rejection = `Goal completion rejected: ${staleGoalRejection}.`;
109
+ notifyTerminal(ctx.ui, rejection, "warning");
110
+ if (completingDuringBudgetWrapUp) {
111
+ runtime.recordGoalUsage(completedGoal, ctx);
112
+ runtime.persistGoal(completedGoal);
113
+ runtime.updateStatus(ctx, completedGoal);
114
+ runtime.clearBudgetWrapUp();
115
+ }
116
+
117
+ return {
118
+ content: toolContent(rejection),
119
+ details: completionDetails(goal, requestedGoalId, summary),
120
+ terminate: completingDuringBudgetWrapUp || undefined,
121
+ };
122
+ }
123
+ if (completedGoal.status !== "active" && !completingDuringBudgetWrapUp) {
124
+ const rejection = `Goal completion rejected: goal is ${completedGoal.status}, not active.`;
125
+ notifyTerminal(ctx.ui, rejection, "warning");
126
+
127
+ return {
128
+ content: toolContent(rejection),
129
+ details: completionDetails(goal, requestedGoalId, summary),
130
+ };
131
+ }
132
+
133
+ const rejectionReason = !summary
134
+ ? "summary is empty"
135
+ : summary.length > MAX_COMPLETION_SUMMARY_LENGTH
136
+ ? "summary is too long"
137
+ : isContradictoryCompletionSummary(summary)
138
+ ? "summary says the goal is not complete"
139
+ : undefined;
140
+ if (rejectionReason) {
141
+ runtime.recordGoalUsage(completedGoal, ctx);
142
+ runtime.persistGoal(completedGoal);
143
+ runtime.updateStatus(ctx, completedGoal);
144
+ const rejection = `Goal completion rejected: ${rejectionReason}.`;
145
+ notifyTerminal(ctx.ui, rejection, "warning");
146
+ if (completingDuringBudgetWrapUp) runtime.clearBudgetWrapUp();
147
+
148
+ return {
149
+ content: toolContent(rejection),
150
+ details: completionDetails(goal, requestedGoalId, summary),
151
+ terminate: completingDuringBudgetWrapUp || undefined,
152
+ };
153
+ }
154
+
155
+ runtime.activeGoal = transitionGoal(completedGoal, "complete");
156
+ runtime.setCompletionSummary(runtime.activeGoal.id, summary);
157
+ runtime.recordGoalUsage(runtime.activeGoal, ctx);
158
+ if (runtime.pendingQueueAction?.kind === "prioritize") {
159
+ runtime.persistGoal(runtime.activeGoal);
160
+ ctx.ui.setStatus(STATUS_KEY, "complete");
161
+ notifyTerminal(ctx.ui, `Goal complete: ${goal}. Priority goal waits for Pi to settle.`, "info");
162
+ return {
163
+ content: toolContent(`Goal complete: ${summary}`),
164
+ details: completionDetails(goal, requestedGoalId, summary),
165
+ terminate: true,
166
+ };
167
+ }
168
+ if (runtime.queuedGoals.length > 0) {
169
+ runtime.pendingQueueAction = {
170
+ kind: "advance",
171
+ goalId: runtime.activeGoal.id,
172
+ reason: "complete",
173
+ completedText: goal,
174
+ };
175
+ runtime.persistGoal(runtime.activeGoal);
176
+ ctx.ui.setStatus(STATUS_KEY, "complete");
177
+ notifyTerminal(ctx.ui, `Goal complete: ${goal}. Next goal queued: ${runtime.queuedGoals[0]?.text}`, "info");
178
+ return {
179
+ content: toolContent(`Goal complete: ${summary}\nNext goal queued: ${runtime.queuedGoals[0]?.text}`),
180
+ details: completionDetails(goal, requestedGoalId, summary),
181
+ terminate: true,
182
+ };
183
+ }
184
+ runtime.persistGoal(runtime.activeGoal);
185
+
186
+ ctx.ui.setStatus(STATUS_KEY, formatStatus(runtime.activeGoal));
187
+ runtime.clearActiveGoal(ctx);
188
+ runtime.showCompletionStatus(ctx);
189
+ notifyTerminal(ctx.ui, `Goal complete: ${goal}`, "info");
190
+
191
+ return {
192
+ content: toolContent(`Goal complete: ${summary}`),
193
+ details: completionDetails(goal, requestedGoalId, summary),
194
+ terminate: true,
195
+ };
196
+ },
197
+ });
198
+
199
+ const goalBlockedTool = defineTool({
200
+ name: GOAL_BLOCKED_TOOL,
201
+ label: "Goal Blocked",
202
+ description:
203
+ "Stop the active /goal only at a true impasse after the same blocker recurs for at least three consecutive goal turns, with the current goal_id and concrete evidence that user or external action is required. Do not use for ordinary clarification, uncertainty, or recoverable failures.",
204
+ promptSnippet: "Mark the active /goal blocked only after the same blocker recurs for three consecutive goal turns",
205
+ promptGuidelines: [
206
+ "Use goal_blocked only for a true impasse after the same blocker recurs for at least three consecutive goal turns and concrete evidence shows user or external action is required.",
207
+ "After a blocked goal is resumed, start a fresh three-turn blocker audit before using goal_blocked again.",
208
+ "Do not use goal_blocked for ordinary clarification, incomplete work, uncertainty, difficult tasks, or recoverable tool/provider failures.",
209
+ "Pass goal_blocked the exact current goal_id; never reuse a goal_id from an older, stopped, replaced, or cleared goal turn.",
210
+ ],
211
+ parameters: Type.Object({
212
+ goal_id: Type.String({
213
+ minLength: 1,
214
+ maxLength: MAX_GOAL_ID_LENGTH,
215
+ description: "The exact goal_id shown in the current active /goal prompt.",
216
+ }),
217
+ reason: Type.String({
218
+ minLength: 1,
219
+ maxLength: MAX_BLOCKER_REASON_LENGTH,
220
+ description: "The specific user or external action required to unblock the goal.",
221
+ }),
222
+ evidence: Type.String({
223
+ minLength: 1,
224
+ maxLength: MAX_BLOCKER_EVIDENCE_LENGTH,
225
+ description: "Concrete evidence from the repeated attempts that proves the impasse.",
226
+ }),
227
+ repeated_turns: Type.Integer({
228
+ minimum: 3,
229
+ description: "Number of separate turns spent trying to resolve this same blocker.",
230
+ }),
231
+ }),
232
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
233
+ const blockedGoal = runtime.activeGoal;
234
+ const goal = blockedGoal?.text ?? "unknown goal";
235
+ const requestedGoalId = typeof params.goal_id === "string" ? params.goal_id.trim() : "";
236
+ const reason = typeof params.reason === "string" ? params.reason.trim() : "";
237
+ const evidence = typeof params.evidence === "string" ? params.evidence.trim() : "";
238
+ const repeatedTurns = typeof params.repeated_turns === "number" ? params.repeated_turns : Number.NaN;
239
+ const reject = (rejectionReason: string, terminate = false) => {
240
+ const rejection = `goal_blocked rejected: ${rejectionReason}.`;
241
+ notifyTerminal(ctx.ui, rejection, "warning");
242
+ return {
243
+ content: toolContent(rejection),
244
+ details: blockerDetails(goal, requestedGoalId, reason, evidence, repeatedTurns),
245
+ ...(terminate ? { terminate: true as const } : {}),
246
+ };
247
+ };
248
+
249
+ if (!blockedGoal) return reject("no active goal");
250
+ if (!runtime.canRecordGoalUsage()) {
251
+ return reject("current run does not own the active goal");
252
+ }
253
+ if (hasPendingSkipForGoal(runtime, blockedGoal.id)) {
254
+ runtime.recordGoalUsage(blockedGoal, ctx);
255
+ runtime.persistGoal(blockedGoal);
256
+ runtime.updateStatus(ctx, blockedGoal);
257
+ runtime.clearBudgetWrapUp();
258
+ return reject("goal is queued to be skipped", true);
259
+ }
260
+ const staleGoalRejection = goalIdRejectionReason(blockedGoal, requestedGoalId);
261
+ if (staleGoalRejection) return reject(staleGoalRejection);
262
+ if (blockedGoal.status !== "active") {
263
+ return reject(`goal is ${blockedGoal.status}, not active`);
264
+ }
265
+ if (!reason) return reject("reason is empty");
266
+ if (reason.length > MAX_BLOCKER_REASON_LENGTH) return reject("reason is too long");
267
+ if (!evidence) return reject("evidence is empty");
268
+ if (evidence.length > MAX_BLOCKER_EVIDENCE_LENGTH) return reject("evidence is too long");
269
+ if (!Number.isInteger(repeatedTurns)) return reject("repeated_turns must be a whole number");
270
+ if (repeatedTurns < 3) return reject("repeated_turns must be at least 3");
271
+
272
+ const stoppedGoal = runtime.stopActiveGoal(ctx, {
273
+ kind: "blocker_report",
274
+ expectedGoalId: blockedGoal.id,
275
+ reason,
276
+ });
277
+ if (!stoppedGoal) return reject("active goal changed before blocker transition");
278
+ notifyTerminal(ctx.ui, `Goal blocked: ${truncateNotification(reason)}`, "warning");
279
+
280
+ return {
281
+ content: toolContent(`Goal blocked: ${reason}`),
282
+ details: blockerDetails(goal, requestedGoalId, reason, evidence, repeatedTurns),
283
+ terminate: true,
284
+ };
285
+ },
286
+ });
287
+
288
+ pi.registerTool(goalCompleteTool);
289
+ pi.registerTool(goalBlockedTool);
290
+ }
291
+
292
+ function toolContent(text: string) {
293
+ return [
294
+ {
295
+ type: "text" as const,
296
+ text: truncateHead(safeTerminalText(text), {
297
+ maxBytes: DEFAULT_MAX_BYTES,
298
+ maxLines: DEFAULT_MAX_LINES,
299
+ }).content,
300
+ },
301
+ ];
302
+ }
303
+
304
+ function completionDetails(goal: string, goalId: string, summary: string): GoalCompleteDetails {
305
+ return {
306
+ goal: goal.slice(0, MAX_GOAL_TEXT_LENGTH),
307
+ goal_id: goalId.slice(0, MAX_GOAL_ID_LENGTH),
308
+ summary: summary.slice(0, MAX_COMPLETION_SUMMARY_LENGTH),
309
+ };
310
+ }
311
+
312
+ function blockerDetails(
313
+ goal: string,
314
+ goalId: string,
315
+ reason: string,
316
+ evidence: string,
317
+ repeatedTurns: number,
318
+ ): GoalBlockedDetails {
319
+ return {
320
+ goal: goal.slice(0, MAX_GOAL_TEXT_LENGTH),
321
+ goal_id: goalId.slice(0, MAX_GOAL_ID_LENGTH),
322
+ reason: reason.slice(0, MAX_BLOCKER_REASON_LENGTH),
323
+ evidence: evidence.slice(0, MAX_BLOCKER_EVIDENCE_LENGTH),
324
+ repeated_turns: Number.isFinite(repeatedTurns) ? repeatedTurns : 0,
325
+ };
326
+ }
327
+
328
+ function hasPendingSkipForGoal(runtime: GoalRuntime, goalId: string) {
329
+ return (
330
+ runtime.pendingQueueAction?.kind === "advance" &&
331
+ runtime.pendingQueueAction.reason === "skip" &&
332
+ runtime.pendingQueueAction.goalId === goalId
333
+ );
334
+ }