@zhushanwen/pi-goal 0.2.0 → 0.3.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.
@@ -31,8 +31,8 @@ import {
31
31
  } from "./templates";
32
32
  import {
33
33
  type GoalSession,
34
- persistGoalState,
35
- updateWidget,
34
+ persistAndUpdate,
35
+ sendGoalContextMessage,
36
36
  writeGoalHistoryEntry,
37
37
  } from "./tool-handler";
38
38
 
@@ -56,9 +56,17 @@ export async function handleAgentEnd(pi: ExtensionAPI, session: GoalSession, ctx
56
56
  await handleTerminalStateAgentEnd(pi, session, ctx, checkStale); return;
57
57
  }
58
58
  if (!isActiveStatus(session.state.status)) return;
59
+
60
+ // ESC / user abort during text generation also pauses the goal
61
+ if (ctx.signal?.aborted) {
62
+ session.pendingPause = true;
63
+ const progress = checkProgress(session.state, session.tasksCompletedAtAgentStart);
64
+ await handleStallAndContinuation(pi, session, ctx, progress, checkStale);
65
+ return;
66
+ }
67
+
59
68
  const budgetAction = await handleBudgetChecks(pi, session, ctx, checkBudgetOnTurnEnd(session.state), checkStale);
60
69
  if (budgetAction !== "continue") return;
61
- session.state.turnCount++;
62
70
  const progress = checkProgress(session.state, session.tasksCompletedAtAgentStart);
63
71
  const progressAction = handleProgressAndTasks(pi, session, ctx, progress, checkStale);
64
72
  if (progressAction !== "continue") return;
@@ -81,9 +89,7 @@ async function handleTerminalStateAgentEnd(
81
89
  checkStale: () => boolean,
82
90
  ): Promise<void> {
83
91
  const state = session.state!;
84
- persistGoalState(pi, session, ctx);
85
- if (checkStale()) return;
86
- updateWidget(session, ctx);
92
+ if (persistAndUpdate(pi, session, ctx, checkStale)) return;
87
93
  if (state.status === "complete") {
88
94
  ctx.ui.notify(
89
95
  `Objective completed ✓ (${getCompletedCount(state.tasks)}/${state.tasks.length} tasks, ${state.currentTurnIndex} turns)`,
@@ -118,9 +124,7 @@ async function handleBudgetChecks(
118
124
  session.state!.status = transitionStatus(session.state!.status, dim === "token" ? "budget_limited" : "time_limited");
119
125
  session.state!.completedAtTurnIndex = session.state!.currentTurnIndex;
120
126
  writeGoalHistoryEntry(pi, session);
121
- persistGoalState(pi, session, ctx);
122
- if (checkStale()) return "stop";
123
- updateWidget(session, ctx);
127
+ if (persistAndUpdate(pi, session, ctx, checkStale)) return "stop";
124
128
  ctx.ui.notify(
125
129
  dim === "token"
126
130
  ? "Token budget exhausted, Goal terminated."
@@ -132,10 +136,8 @@ async function handleBudgetChecks(
132
136
  // 90% steering → 收尾
133
137
  if (budgetResult.shouldSendSteering) {
134
138
  session.state!.budgetLimitSteeringSent = true;
135
- persistGoalState(pi, session, ctx);
136
- if (checkStale()) return "stop";
137
- updateWidget(session, ctx);
138
- pi.sendUserMessage(budgetLimitPrompt(session.state!, "token"), { deliverAs: "steer" });
139
+ if (persistAndUpdate(pi, session, ctx, checkStale)) return "stop";
140
+ sendGoalContextMessage(pi, budgetLimitPrompt(session.state!, "token"), "steer");
139
141
  return "stop";
140
142
  }
141
143
  if (checkStale()) return "stop";
@@ -174,9 +176,7 @@ function handleAllTasksDone(
174
176
  state.status = transitionStatus(state.status, "complete");
175
177
  state.completedAtTurnIndex = state.currentTurnIndex;
176
178
  writeGoalHistoryEntry(pi, session);
177
- persistGoalState(pi, session, ctx);
178
- if (checkStale()) return "stop";
179
- updateWidget(session, ctx);
179
+ if (persistAndUpdate(pi, session, ctx, checkStale)) return "stop";
180
180
  ctx.ui.notify(
181
181
  `All tasks completed, Goal auto-closed. (${progress.completedCount}/${progress.totalCount} tasks, ${state.currentTurnIndex} turns)`,
182
182
  "info",
@@ -184,21 +184,22 @@ function handleAllTasksDone(
184
184
  return "stop";
185
185
  }
186
186
  if (progress.budgetTight) {
187
- pi.sendUserMessage(
187
+ sendGoalContextMessage(
188
+ pi,
188
189
  `All tasks completed, token budget ${Math.round(state.tokensUsed / state.budget.tokenBudget! * PERCENT_FACTOR)}% used.` +
189
190
  `Call goal_manager's complete_goal now with overall evidence.` +
190
191
  `\n\nObjective: ${state.objective}`,
191
- { deliverAs: "steer" },
192
+ "steer",
192
193
  );
193
194
  } else {
194
- pi.sendUserMessage(
195
+ sendGoalContextMessage(
196
+ pi,
195
197
  `All ${progress.totalCount} tasks completed. Call goal_manager's complete_goal with overall evidence.` +
196
198
  `\n\nObjective: ${state.objective}`,
197
- { deliverAs: "followUp" },
199
+ "followUp",
198
200
  );
199
201
  }
200
- persistGoalState(pi, session, ctx);
201
- updateWidget(session, ctx);
202
+ persistAndUpdate(pi, session, ctx);
202
203
  return "stop";
203
204
  }
204
205
 
@@ -211,22 +212,20 @@ function handleNoTasksOrMaxTurns(
211
212
  state.status = transitionStatus(state.status, "cancelled");
212
213
  state.completedAtTurnIndex = state.currentTurnIndex;
213
214
  writeGoalHistoryEntry(pi, session);
214
- persistGoalState(pi, session, ctx);
215
- if (checkStale()) return "stop";
216
- updateWidget(session, ctx);
215
+ if (persistAndUpdate(pi, session, ctx, checkStale)) return "stop";
217
216
  ctx.ui.notify(
218
217
  `Max turns reached (${state.budget.maxTurns}), LLM did not create task list.`,
219
218
  "warning",
220
219
  );
221
220
  return "stop";
222
221
  }
223
- pi.sendUserMessage(
224
- `No task list created yet. Call goal_manager's create_tasks immediately to decompose the work into verifiable task steps.` +
222
+ sendGoalContextMessage(
223
+ pi,
224
+ `No task list created yet. First check if the objective is already satisfied — if yes, call goal_manager's cancel_goal with cancelReason. Otherwise call create_tasks immediately to decompose the work into verifiable task steps.` +
225
225
  `\n\nObjective: ${state.objective}`,
226
- { deliverAs: "followUp" },
226
+ "followUp",
227
227
  );
228
- persistGoalState(pi, session, ctx);
229
- updateWidget(session, ctx);
228
+ persistAndUpdate(pi, session, ctx);
230
229
  return "stop";
231
230
  }
232
231
 
@@ -239,9 +238,7 @@ function handleMaxTurnsReached(
239
238
  state.status = transitionStatus(state.status, "cancelled");
240
239
  state.completedAtTurnIndex = state.currentTurnIndex;
241
240
  writeGoalHistoryEntry(pi, session);
242
- persistGoalState(pi, session, ctx);
243
- if (checkStale()) return "stop";
244
- updateWidget(session, ctx);
241
+ if (persistAndUpdate(pi, session, ctx, checkStale)) return "stop";
245
242
  ctx.ui.notify(
246
243
  `Max turns reached (${state.budget.maxTurns}), ${incomplete.length} tasks still incomplete.`,
247
244
  "warning",
@@ -258,6 +255,15 @@ async function handleStallAndContinuation(
258
255
  const state = session.state!;
259
256
  if (checkStale()) return;
260
257
 
258
+ // ESC pause: if tool call was aborted, pause instead of continuing
259
+ if (session.pendingPause) {
260
+ session.pendingPause = false;
261
+ state.status = transitionStatus(state.status, "paused");
262
+ if (persistAndUpdate(pi, session, ctx, checkStale)) return;
263
+ ctx.ui.notify("Goal paused (user interrupt). Use /goal resume to continue.", "info");
264
+ return;
265
+ }
266
+
261
267
  // Stall 检测
262
268
  updateStallCounter(state, progress.isStalled);
263
269
  if (state.stallCount >= state.budget.maxStallTurns) {
@@ -268,13 +274,11 @@ async function handleStallAndContinuation(
268
274
 
269
275
  // 去抖 + Continuation
270
276
  if (!consumeTokensForDebounce(state)) {
271
- persistGoalState(pi, session, ctx);
272
- updateWidget(session, ctx);
277
+ persistAndUpdate(pi, session, ctx);
273
278
  return;
274
279
  }
275
- persistGoalState(pi, session, ctx);
276
- updateWidget(session, ctx);
277
- pi.sendUserMessage(continuationPrompt(state), { deliverAs: "followUp" });
280
+ persistAndUpdate(pi, session, ctx);
281
+ sendGoalContextMessage(pi, continuationPrompt(state), "followUp");
278
282
  }
279
283
 
280
284
  /** 更新 stall 计数。stall 时递增,否则重置。 */
@@ -287,9 +291,7 @@ function updateStallCounter(state: { stallCount: number; lastProgressTurn: numbe
287
291
  function markGoalBlocked(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionContext, checkStale: () => boolean): void {
288
292
  const state = session.state!;
289
293
  state.status = transitionStatus(state.status, "blocked");
290
- persistGoalState(pi, session, ctx);
291
- if (checkStale()) return;
292
- updateWidget(session, ctx);
294
+ if (persistAndUpdate(pi, session, ctx, checkStale)) return;
293
295
  ctx.ui.notify(
294
296
  `${state.stallCount} consecutive turns without progress, Goal auto-blocked. Use /goal resume to continue or /goal clear to reset.`,
295
297
  "warning",
@@ -18,7 +18,7 @@ import {
18
18
  type GoalTask,
19
19
  isActiveStatus,
20
20
  isTerminalStatus,
21
- isTerminalTaskStatus,
21
+ isTaskDone,
22
22
  transitionStatus,
23
23
  } from "./state";
24
24
  import {
@@ -28,8 +28,7 @@ import {
28
28
  import {
29
29
  clearGoalSession,
30
30
  type GoalSession,
31
- persistGoalState,
32
- updateWidget,
31
+ persistAndUpdate,
33
32
  } from "./tool-handler";
34
33
  import { renderTerminalStatusLine } from "./widget";
35
34
 
@@ -100,7 +99,7 @@ function checkStaleness(session: GoalSession) {
100
99
  let allTerminal = true;
101
100
 
102
101
  for (const task of state.tasks) {
103
- if (!isTerminalTaskStatus(task.status)) {
102
+ if (!isTaskDone(task)) {
104
103
  allTerminal = false;
105
104
  const staleTurns = state.currentTurnIndex - task.lastUpdatedTurn;
106
105
  if (staleTurns >= TASK_STALL_TURN_THRESHOLD) {
@@ -163,8 +162,7 @@ function checkContextUsage(
163
162
  if (usage && usage.contextWindow > 0 && (usage.tokens ?? 0) / usage.contextWindow > CONTEXT_USAGE_RATIO_LIMIT) {
164
163
  const state = session.state!;
165
164
  state.status = transitionStatus(state.status, "paused");
166
- persistGoalState(pi, session, ctx);
167
- updateWidget(session, ctx);
165
+ persistAndUpdate(pi, session, ctx);
168
166
  return {
169
167
  message: {
170
168
  customType: "goal-context-exceeded",
package/src/budget.ts CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  SECONDS_PER_MINUTE,
17
17
  } from "./constants";
18
18
  import type { GoalRuntimeState } from "./state";
19
- import { getElapsedTimeSeconds } from "./state";
19
+ import { getCompletedCount, getElapsedTimeSeconds, getIncompleteTasks } from "./state";
20
20
 
21
21
  // ── 决策类型 ────────────────────────────────────────
22
22
 
@@ -139,8 +139,8 @@ export interface ProgressCheck {
139
139
  }
140
140
 
141
141
  export function checkProgress(state: GoalRuntimeState, tasksCompletedAtStart: number): ProgressCheck {
142
- const incomplete = state.tasks.filter((t) => t.status === "pending" || t.status === "in_progress");
143
- const completedCount = state.tasks.filter((t) => t.status === "completed").length;
142
+ const incomplete = getIncompleteTasks(state.tasks);
143
+ const completedCount = getCompletedCount(state.tasks);
144
144
  const totalCount = state.tasks.length;
145
145
  const progressThisRound = completedCount - tasksCompletedAtStart;
146
146
 
@@ -31,8 +31,9 @@ import { objectiveUpdatedPrompt } from "./templates";
31
31
  import {
32
32
  clearGoalSession,
33
33
  type GoalSession,
34
+ persistAndUpdate,
34
35
  persistGoalState,
35
- updateWidget,
36
+ sendGoalContextMessage,
36
37
  writeGoalHistoryEntry,
37
38
  } from "./tool-handler";
38
39
 
@@ -51,6 +52,7 @@ export async function handleGoalCommand(
51
52
  case "resume": return handleResume(pi, session, ctx);
52
53
  case "history": return handleHistory(ctx);
53
54
  case "clear": return handleClear(pi, session, ctx);
55
+ case "abort": return handleAbort(pi, session, ctx);
54
56
  case "update": return handleUpdate(pi, session, parsed.objective, ctx);
55
57
  case "set": return handleSet(pi, session, parsed.objective ?? "", parsed.budget, ctx);
56
58
  }
@@ -89,8 +91,7 @@ function handlePause(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionConte
89
91
  return;
90
92
  }
91
93
  session.state.status = transitionStatus(session.state.status, "paused");
92
- persistGoalState(pi, session, ctx);
93
- updateWidget(session, ctx);
94
+ persistAndUpdate(pi, session, ctx);
94
95
  ctx.ui.notify("Goal paused. Use /goal resume to continue.", "info");
95
96
  }
96
97
 
@@ -116,13 +117,11 @@ function handleResume(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionCont
116
117
  if (resumeBudgetCheck) {
117
118
  const dim = resumeBudgetCheck.dimension;
118
119
  state.status = transitionStatus(state.status, dim === "token" ? "budget_limited" : "time_limited");
119
- persistGoalState(pi, session, ctx);
120
- updateWidget(session, ctx);
120
+ persistAndUpdate(pi, session, ctx);
121
121
  ctx.ui.notify(`${dim === "token" ? "Token" : "Time"} budget exhausted, cannot resume. Use /goal clear to reset.`, "warning");
122
122
  return;
123
123
  }
124
- persistGoalState(pi, session, ctx);
125
- updateWidget(session, ctx);
124
+ persistAndUpdate(pi, session, ctx);
126
125
 
127
126
  const incomplete = getIncompleteTasks(state.tasks);
128
127
  if (incomplete.length > 0) {
@@ -191,6 +190,29 @@ function handleClear(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionConte
191
190
  ctx.ui.notify("Goal cleared.", "info");
192
191
  }
193
192
 
193
+ // ── /goal abort ──────────────────────────────────────
194
+
195
+ function handleAbort(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionContext): void {
196
+ if (!session.state) { ctx.ui.notify("Goal mode not active.", "info"); return; }
197
+ if (isTerminalStatus(session.state.status)) {
198
+ ctx.ui.notify(`Goal is already in terminal state (${session.state.status}).`, "warning");
199
+ return;
200
+ }
201
+ if (session.state.tasks.length > 0) {
202
+ const nonCancelled = session.state.tasks.filter(t => t.status !== "cancelled");
203
+ if (nonCancelled.length > 0) {
204
+ ctx.ui.notify(`Cannot abort: ${nonCancelled.length} non-cancelled tasks exist. Use /goal clear to force cancel.`, "warning");
205
+ return;
206
+ }
207
+ }
208
+ session.state.status = "cancelled";
209
+ session.state.completedAtTurnIndex = session.state.currentTurnIndex;
210
+ writeGoalHistoryEntry(pi, session);
211
+ persistGoalState(pi, session, ctx);
212
+ clearGoalSession(session, ctx);
213
+ ctx.ui.notify("Goal aborted: no work needed.", "info");
214
+ }
215
+
194
216
  // ── /goal update ──────────────────────────────────────
195
217
 
196
218
  function handleUpdate(
@@ -208,19 +230,17 @@ function handleUpdate(
208
230
  state.objectiveUpdatedAt = Date.now();
209
231
  state.tasks = [];
210
232
  state.stallCount = 0;
211
- state.turnCount = 0;
212
233
  state.currentTurnIndex = 0;
213
234
  state.lastProgressTurn = 0;
214
235
  state.budgetLimitSteeringSent = false;
215
236
  state.budgetWarning70Sent = false;
216
237
  state.budgetWarning90Sent = false;
217
238
  session.tasksCompletedAtAgentStart = 0;
218
- persistGoalState(pi, session, ctx);
219
- updateWidget(session, ctx);
239
+ persistAndUpdate(pi, session, ctx);
220
240
  ctx.ui.notify(`Objective updated:\nPrevious: ${oldObjective}\nNew: ${newObjective}`, "info");
221
241
 
222
242
  if (isActiveStatus(state.status)) {
223
- pi.sendUserMessage(objectiveUpdatedPrompt(state, oldObjective), { deliverAs: "steer" });
243
+ sendGoalContextMessage(pi, objectiveUpdatedPrompt(state, oldObjective), "steer");
224
244
  }
225
245
  }
226
246
 
@@ -263,8 +283,7 @@ function handleSet(
263
283
  session.tasksCompletedAtAgentStart = 0;
264
284
  session.hasPendingInjection = false;
265
285
 
266
- persistGoalState(pi, session, ctx);
267
- updateWidget(session, ctx);
286
+ persistAndUpdate(pi, session, ctx);
268
287
 
269
288
  const budgetNotice: string[] = [];
270
289
  if (budget.tokenBudget) budgetNotice.push(`Token budget: ${budget.tokenBudget}`);
package/src/commands.ts CHANGED
@@ -6,7 +6,7 @@ import { MAX_STALL_CAP, MAX_TURNS_CAP, UPDATE_PREFIX_LENGTH } from "./constants"
6
6
  import type { BudgetConfig } from "./state";
7
7
 
8
8
  export interface GoalCommandArgs {
9
- action: "set" | "status" | "pause" | "resume" | "clear" | "update" | "history";
9
+ action: "set" | "status" | "pause" | "resume" | "clear" | "abort" | "update" | "history";
10
10
  objective?: string;
11
11
  budget?: Partial<BudgetConfig>;
12
12
  }
@@ -28,6 +28,9 @@ export function parseGoalArgs(raw: string): GoalCommandArgs {
28
28
  if (trimmed === "clear") {
29
29
  return { action: "clear" };
30
30
  }
31
+ if (trimmed === "abort") {
32
+ return { action: "abort" };
33
+ }
31
34
  if (trimmed === "history") {
32
35
  return { action: "history" };
33
36
  }
package/src/constants.ts CHANGED
@@ -42,3 +42,8 @@ export const OBJECTIVE_TRUNCATE_KEEP = 77; // DISPLAY_LIMIT - 3 for "..."
42
42
  export const TASK_STALL_TURN_THRESHOLD = 10; // task/subtask 停滞提醒阈值(turn 数)
43
43
  export const AUTO_CLEAR_TURNS = 2; // 终态后自动清理轮数
44
44
  export const MAX_HISTORY_ENTRIES = 20; // goal-history entry GC 上限
45
+
46
+ export const VERIFY_METHOD_WIDGET_LEN = 30; // widget 中验证方法显示的截断长度
47
+ export const EXT_INIT_TASK_DESC_MAX = 60; // external init 中 task description 截断上限
48
+ export const TASK_DESC_MAX_LENGTH = 80; // task description 截断上限(normalizeDescription)
49
+ export const ELLIPSIS_LENGTH = 3;
package/src/index.ts CHANGED
@@ -25,15 +25,17 @@ import { handleAgentEnd } from "./agent-end-handler";
25
25
  import { handleBeforeAgentStart } from "./before-agent-start-handler";
26
26
  import { handleGoalCommand } from "./command-handler";
27
27
  import {
28
+ ELLIPSIS_LENGTH,
29
+ EXT_INIT_TASK_DESC_MAX,
28
30
  MAX_HISTORY_ENTRIES,
29
31
  } from "./constants";
30
32
  import {
31
33
  createInitialState,
32
34
  deserializeState,
33
35
  getCompletedCount,
36
+ type GoalExternalInit,
34
37
  isActiveStatus,
35
38
  isTerminalStatus,
36
- type GoalExternalInit,
37
39
  } from "./state";
38
40
  import {
39
41
  executeGoalAction,
@@ -181,6 +183,7 @@ export default function goalExtension(pi: ExtensionAPI) {
181
183
  tasksCompletedAtAgentStart: 0,
182
184
  hasPendingInjection: false,
183
185
  isProcessing: false, // P1-3: 防重入
186
+ pendingPause: false, // ESC 中断标记
184
187
  };
185
188
 
186
189
  // Capture latest ctx for external API (initializeGoalFromExternal needs it for persistGoalState)
@@ -189,34 +192,46 @@ export default function goalExtension(pi: ExtensionAPI) {
189
192
  pi.registerTool({
190
193
  name: "goal_manager",
191
194
  label: "Goal Manager",
192
- description:
193
- "Goal mode task manager. This tool is only available after starting a goal via the /goal command. AI cannot trigger it proactively. If Goal mode is not active, calling this tool will error." +
194
- "\n\nAvailable actions:" +
195
- "\n- create_tasks: Decompose the objective into a task list (call once at goal start). Each task description must be a one-line summary (max 60 chars), no newlines or markdown" +
196
- "\n- add_tasks: Append new tasks to the existing list (when omissions are discovered). Each task description must be a one-line summary (max 60 chars), no newlines or markdown" +
197
- "\n- update_tasks: Batch update task statuses (completed requires evidence, cancelled does not block goal completion)" +
198
- "\n- list_tasks: View progress and remaining budget" +
199
- "\n- complete_goal: Mark the objective as achieved (all tasks must be completed + evidence)" +
200
- "\n- cancel_goal: Cancel the current goal (use when user wants to exit/stop)" +
201
- "\n- report_blocked: Report being blocked (use when encountering unsolvable issues)" +
202
- "\n- add_subtasks: Add subtasks to a specified task (params: taskId, texts[]). Use this instead of todo tool in Goal mode" +
203
- "\n- update_subtasks: Batch update subtask statuses (params: taskId, subUpdates[])" +
204
- "\n- delete_subtasks: Delete subtasks from a specified task (params: taskId, subIds[])",
195
+ description:
196
+ "Goal mode task manager. This tool is only available after starting a goal via the /goal command. AI cannot trigger it proactively. If Goal mode is not active, calling this tool will error." +
197
+ "\n\nAvailable actions:" +
198
+ "\n- create_tasks: Decompose the objective into a task list (call once at goal start). Each task description must be a one-line summary (max 60 chars), no newlines or markdown. Accept verifications array for task verification." +
199
+ "\n- add_tasks: Append new tasks to the existing list (when omissions are discovered). Fails if no tasks exist yet — use create_tasks first. Each task description must be a one-line summary (max 60 chars), no newlines or markdown. Accept verifications array." +
200
+ "\n- update_tasks: Batch update task statuses. Must follow state machine: pending→in_progress→completed→verified. Cannot skip states (pending→completed is forbidden). Exactly one task should be in_progress at a time. Completed requires evidence. Cancelled does not block goal completion. Completing a task with verification triggers a verification prompt — then call update_tasks with status=verified and actual=<result>." +
201
+ "\n- list_tasks: View progress and remaining budget" +
202
+ "\n- complete_goal: Mark the objective as achieved. Only call when ALL tasks are completed or verified with concrete evidence. Do not mark complete merely because the budget is nearly exhausted or because you are stopping work. Before calling, use list_tasks to verify all tasks are done." +
203
+ "\n- cancel_goal: Cancel the current goal (use when user wants to exit/stop)" +
204
+ "\n- report_blocked: Report being blocked. Only use after trying at least 3 alternative approaches to the same blocking condition. Do not use merely because the work is hard, slow, uncertain, incomplete, or would benefit from clarification. Requires a specific reason describing what is blocking and what you have already tried." +
205
+ "\n- add_subtasks: Add subtasks to a specified task (params: taskId, texts[]). Use this instead of todo tool in Goal mode" +
206
+ "\n- update_subtasks: Batch update subtask statuses (params: taskId, subUpdates[])" +
207
+ "\n- delete_subtasks: Delete subtasks from a specified task (params: taskId, subIds[])",
205
208
  promptSnippet: "Manage task list, completion status, and exit for /goal mode",
206
209
  promptGuidelines: [
207
210
  "[Workflow] After receiving the objective, the first step must be create_tasks to decompose. Do not re-call if task list already exists",
211
+ "[Status flow] Tasks must follow this order: pending → in_progress → completed → verified. You CANNOT skip in_progress. Before marking completed, always set status=in_progress first. Transitions: pending→in_progress | pending→cancelled, in_progress→completed | in_progress→cancelled, completed→verified (only if task has verification)",
208
212
  "[Format] Each task description must be a one-line summary, max 60 chars. No newlines, markdown, or detailed parameter lists — those go in execution phase. Example: 'Fix hook-registry dedup logic' not 'Fix hook-registry dedup + transport-execute enhancementConfig guard + failover-loop ...'",
209
213
  "[Append] When discovering omissions during execution, use add_tasks to append — do not re-call create_tasks",
210
214
  "[Completion] After completing a task, call update_tasks with status=completed and provide evidence (e.g. 'test X passed', 'file F created')",
211
- "[Goal completion] Only call complete_goal when all tasks are completed with overall evidence",
215
+ "[Goal completion] Only call complete_goal when all tasks are completed or verified with overall evidence",
212
216
  "[Exit] When user says 'stop', 'exit', 'cancel', '不用了', '结束', etc. indicating they don't want to continue, immediately call cancel_goal — do not guide them through complete_goal",
213
- "[Blocked] When encountering unsolvable technical issues, call report_blocked with the reason",
217
+ "[Blocked] Do not call report_blocked the first time a blocker appears. Try at least 3 alternative approaches first. Only report blocked when genuinely at an impasse without user input — not because work is hard, slow, uncertain, or incomplete. Once you call report_blocked, include what you have already tried in the reason.",
214
218
  "[Progress] Use list_tasks anytime to check remaining tasks and budget",
215
219
  "[Cancel] To cancel a task, use update_tasks with status=cancelled. Cancelled tasks do not block goal completion",
216
- "[Forbidden] Do not mark tasks as completed without evidence, and do not call complete_goal without evidence",
220
+ "[Audit] Before marking a task completed, verify against actual current state (files, command output, test results). Intent, partial progress, or 'it should work' are NOT evidence. Do not redefine success around work already done — preserve original scope. Uncertain or indirect evidence means not completed, keep working.",
221
+ "[Fidelity] Optimize for movement toward the requested end state, not the easiest passing change. Do not substitute a narrower or safer solution because it is easier to verify. An edit is aligned only if it makes the requested final state more true.",
222
+ "[Forbidden] Do not mark tasks as completed without evidence, and do not call complete_goal without evidence. Do not mark completed due to budget exhaustion or because you are stopping work.",
217
223
  "[Forbidden] Do not force task completion when the user explicitly wants to exit — call cancel_goal directly",
224
+ "[Quick exit] When no tasks have been created and you determine the objective is already met, call cancel_goal with cancelReason instead of creating tasks",
218
225
  "[Forbidden] Do not re-call create_tasks to overwrite existing incomplete tasks — use add_tasks to append",
219
226
  "[Subtask] For fine-grained step tracking in Goal mode, use add_subtasks — do not use the todo tool",
227
+ "[Verification] Each task should have a concrete verification method. Use verifications param in create_tasks/add_tasks. Templates:",
228
+ "[Verification] - Command: method='pnpm --filter <pkg> typecheck', expected='zero type errors'",
229
+ "[Verification] - Test: method='pnpm --filter <pkg> test', expected='all tests pass'",
230
+ "[Verification] - File: method='check <path> exists and contains <content>', expected='file exists with matching content'",
231
+ "[Verification] - Manual: method='manual check <specific items>', expected='<expected result'",
232
+ "[Verification] Multiple related tasks can share one verification — set verification on the LAST related task only",
233
+ "[Verification] Do NOT create a separate 'run tests' task for verification — use verification field instead",
234
+ "[Verification] When a task with verification is completed, run the verification command with bash. Then call update_tasks with status=verified and actual=<result>",
220
235
  ],
221
236
  parameters: GoalManagerParams,
222
237
 
@@ -260,35 +275,40 @@ export default function goalExtension(pi: ExtensionAPI) {
260
275
  return new Text(text?.type === "text" ? (text.text ?? "") : "", 0, 0);
261
276
  }
262
277
  const tasks = details.tasks;
263
- const completed = tasks.filter((t) => t.status === "completed").length;
278
+ const completed = getCompletedCount(tasks);
264
279
  const summary = theme.fg("success", `✓ ${completed}/${tasks.length} completed`);
265
280
  if (!expanded || tasks.length === 0) {
266
281
  return new Text(summary, 0, 0);
267
282
  }
268
283
  const lines = [summary];
269
284
  for (const t of tasks) {
270
- const icon = t.status === "completed"
271
- ? theme.fg("success", "✓")
272
- : t.status === "in_progress"
273
- ? theme.fg("warning", "●")
274
- : t.status === "cancelled"
275
- ? theme.fg("dim", "✗")
276
- : theme.fg("dim", "☐");
285
+ const icon = t.status === "verified"
286
+ ? theme.fg("success", "◉")
287
+ : t.status === "completed"
288
+ ? theme.fg("success", "✓")
289
+ : t.status === "in_progress"
290
+ ? theme.fg("warning", "●")
291
+ : t.status === "cancelled"
292
+ ? theme.fg("dim", "✗")
293
+ : theme.fg("dim", "☐");
277
294
  const descText = toSingleLine(t.description);
278
- const desc = (t.status === "completed" || t.status === "cancelled")
295
+ const desc = (t.status === "verified" || t.status === "completed" || t.status === "cancelled")
279
296
  ? theme.fg("dim", descText)
280
297
  : theme.fg("text", descText);
281
298
  lines.push(` ${icon} ${theme.fg("accent", `#${t.id}`)} ${desc}`);
282
- // Subtask items in expanded view
299
+ // Subtask items in expanded view — collapse when all completed
283
300
  if (t.subtasks && t.subtasks.length > 0) {
284
- for (const s of t.subtasks) {
285
- const subIcon = s.status === "completed"
286
- ? theme.fg("success", "\u2713")
287
- : s.status === "in_progress"
288
- ? theme.fg("warning", "\u25cf")
289
- : theme.fg("dim", "\u25cb");
290
- const subText = s.status === "completed" ? theme.fg("dim", s.text) : theme.fg("muted", s.text);
291
- lines.push(` ${subIcon} ${theme.fg("dim", `${t.id}.${s.id}`)} ${subText}`);
301
+ const allSubCompleted = t.subtasks.every((s: { status: string }) => s.status === "completed");
302
+ if (!allSubCompleted) {
303
+ for (const s of t.subtasks) {
304
+ const subIcon = s.status === "completed"
305
+ ? theme.fg("success", "\u2713")
306
+ : s.status === "in_progress"
307
+ ? theme.fg("warning", "\u25cf")
308
+ : theme.fg("dim", "\u25cb");
309
+ const subText = s.status === "completed" ? theme.fg("dim", s.text) : theme.fg("muted", s.text);
310
+ lines.push(` ${subIcon} ${theme.fg("dim", `${t.id}.${s.id}`)} ${subText}`);
311
+ }
292
312
  }
293
313
  }
294
314
  }
@@ -300,7 +320,7 @@ export default function goalExtension(pi: ExtensionAPI) {
300
320
 
301
321
  pi.registerCommand("goal", {
302
322
  description:
303
- "Goal-driven mode: /goal <objective> [--tokens N] [--timeout N] [--max-turns N] | /goal pause | /goal resume | /goal clear | /goal update <new-objective> | /goal status | /goal history",
323
+ "Goal-driven mode: /goal <objective> [--tokens N] [--timeout N] [--max-turns N] | /goal pause | /goal resume | /goal abort | /goal clear | /goal update <new-objective> | /goal status | /goal history",
304
324
  handler: async (args: string | undefined, ctx: ExtensionCommandContext) => {
305
325
  await handleGoalCommand(pi, session, args, ctx);
306
326
  },
@@ -391,6 +411,7 @@ export default function goalExtension(pi: ExtensionAPI) {
391
411
  objective: string,
392
412
  tasks: string[],
393
413
  budget?: { tokenBudget?: number; timeBudgetMinutes?: number; maxTurns?: number },
414
+ ctx?: ExtensionContext,
394
415
  ): boolean {
395
416
  if (session.state && isActiveStatus(session.state.status)) {
396
417
  return false;
@@ -402,16 +423,17 @@ export default function goalExtension(pi: ExtensionAPI) {
402
423
  // Create tasks (same logic as handleCreateTasks)
403
424
  session.state.tasks = tasks.map((desc, i) => ({
404
425
  id: i + 1,
405
- description: desc.length > 60 ? desc.slice(0, 57) + "..." : desc,
426
+ description: desc.length > EXT_INIT_TASK_DESC_MAX ? desc.slice(0, EXT_INIT_TASK_DESC_MAX - ELLIPSIS_LENGTH) + "..." : desc,
406
427
  status: "pending" as const,
407
428
  lastUpdatedTurn: session.state!.currentTurnIndex,
408
429
  }));
409
430
 
410
- // Persist state so it survives session reconstruction
411
- // Note: ctx is captured from the last event handler invocation — acceptable
412
- // because initializeGoalFromExternal is called synchronously during tool execution.
413
- if (lastCtx) {
414
- persistGoalState(pi, session, lastCtx);
431
+ // Persist state so it survives session reconstruction.
432
+ // Prefer explicitly passed ctx (from the calling extension's tool execute),
433
+ // fallback to lastCtx captured from this extension's own event handlers.
434
+ const persistCtx = ctx ?? lastCtx;
435
+ if (persistCtx) {
436
+ persistGoalState(pi, session, persistCtx);
415
437
  }
416
438
 
417
439
  return true;