@zhushanwen/pi-goal 0.3.0 → 0.4.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.
Files changed (49) hide show
  1. package/README.md +13 -12
  2. package/package.json +1 -1
  3. package/src/__tests__/command-adapter.test.ts +404 -0
  4. package/src/__tests__/deserialize-state.test.ts +65 -94
  5. package/src/__tests__/event-adapter.test.ts +410 -0
  6. package/src/__tests__/goal-control-adapter.test.ts +303 -0
  7. package/src/__tests__/index.test.ts +119 -0
  8. package/src/__tests__/service.test.ts +317 -0
  9. package/src/__tests__/session.test.ts +220 -0
  10. package/src/__tests__/stale-checker.test.ts +78 -0
  11. package/src/__tests__/stubs/pi-sdk.ts +5 -0
  12. package/src/adapters/command-adapter.ts +360 -0
  13. package/src/adapters/event-adapter.ts +17 -0
  14. package/src/adapters/event-handlers/agent-end.ts +177 -0
  15. package/src/adapters/event-handlers/agent-start.ts +17 -0
  16. package/src/adapters/event-handlers/before-agent-start.ts +157 -0
  17. package/src/adapters/event-handlers/message-end.ts +37 -0
  18. package/src/adapters/event-handlers/session-start.ts +24 -0
  19. package/src/adapters/event-handlers/shared.ts +46 -0
  20. package/src/adapters/event-handlers/turn-end.ts +37 -0
  21. package/src/adapters/goal-control-adapter.ts +296 -0
  22. package/src/adapters/ports.ts +89 -0
  23. package/src/commands.ts +8 -23
  24. package/src/constants.ts +1 -8
  25. package/src/engine/__tests__/budget.test.ts +182 -0
  26. package/src/engine/__tests__/goal.test.ts +126 -0
  27. package/src/engine/budget.ts +146 -0
  28. package/src/engine/goal.ts +62 -0
  29. package/src/engine/types.ts +80 -0
  30. package/src/index.ts +110 -355
  31. package/src/persistence.ts +83 -0
  32. package/src/ports.ts +68 -0
  33. package/src/projection/__tests__/prompts.test.ts +245 -0
  34. package/src/projection/__tests__/widget.test.ts +255 -0
  35. package/src/projection/prompts.ts +250 -0
  36. package/src/projection/widget.ts +254 -0
  37. package/src/service.ts +276 -0
  38. package/src/session.ts +114 -0
  39. package/src/__tests__/is-task-done.test.ts +0 -43
  40. package/src/__tests__/validate-update-tasks.test.ts +0 -172
  41. package/src/action-handlers.ts +0 -412
  42. package/src/agent-end-handler.ts +0 -306
  43. package/src/before-agent-start-handler.ts +0 -180
  44. package/src/budget.ts +0 -159
  45. package/src/command-handler.ts +0 -299
  46. package/src/state.ts +0 -274
  47. package/src/templates.ts +0 -274
  48. package/src/tool-handler.ts +0 -291
  49. package/src/widget.ts +0 -225
package/src/index.ts CHANGED
@@ -1,445 +1,200 @@
1
1
  /**
2
- * Pi /goal Extension — Codex-style persistent goal-driven autonomous loop
2
+ * Pi /goal Extension — 工厂入口(重构后架构)
3
3
  *
4
- * 功能:
5
- * - 持久化目标设定,支持 pause/resume/clear/update
6
- * - Evidence-based completion(完成任务必须提供具体证据)
7
- * - Token 预算 + 时间预算(含 70%/90% 预警)
8
- * - Blocked 状态检测(连续 stall 自动阻塞)
9
- * - Steering 模板化(continuation / budget-limit / objective-updated)
10
- * - 任务清单追踪
4
+ * 注册 command / events,全部委托 adapters 层。
5
+ * __goalInit 内部调 service.createGoal(FR-4.1)。
11
6
  *
12
- * 健壮性保障:
13
- * - goalId snapshot 防止旧回调操作新 goal
14
- * - 时间累计统一由 persistState 管理,无双写
15
- * - before_agent_start 注入 context,agent_end 负责 continuation(预算检查/进度评估/续跑)
16
- * - deserializeState 向后兼容旧格式
17
- * - isProcessing 防重入(agent_end 重入时直接返回)
7
+ * 架构(D-21 双路径 + Ports/Adapters):
8
+ * - engine/:零 Pi 依赖的纯状态机(goal/budget/types)
9
+ * - ports.ts:机器可检查的能力边界
10
+ * - adapters/ports.ts:Pi → ServicePorts 桥接(单一构造点)
11
+ * - service.ts:事件入口协调器(applyEvent)
12
+ * - adapters/:Pi 桥接(command-adapter / event-adapter / ports)
13
+ * - projection/:渲染(widget / prompts)
14
+ *
15
+ * FR-4.2/D-16:ctx 必填,移除 lastCtx 模块级可变状态。
16
+ * FR-6.4:移除 hasPendingInjection。
17
+ * FR-6.7:移除 pendingPause(ESC 改用 ctx.signal.aborted 守卫,在 event-adapter)。
18
18
  */
19
19
 
20
- import type { CustomEntry, ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
20
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
21
21
  import { Text } from "@mariozechner/pi-tui";
22
- import { type Static } from "typebox";
23
22
 
24
- import { handleAgentEnd } from "./agent-end-handler";
25
- import { handleBeforeAgentStart } from "./before-agent-start-handler";
26
- import { handleGoalCommand } from "./command-handler";
27
- import {
28
- ELLIPSIS_LENGTH,
29
- EXT_INIT_TASK_DESC_MAX,
30
- MAX_HISTORY_ENTRIES,
31
- } from "./constants";
23
+ import { handleGoalCommand } from "./adapters/command-adapter";
32
24
  import {
33
- createInitialState,
34
- deserializeState,
35
- getCompletedCount,
36
- type GoalExternalInit,
37
- isActiveStatus,
38
- isTerminalStatus,
39
- } from "./state";
40
- import {
41
- executeGoalAction,
42
- type GoalManagerDetails,
43
- GoalManagerParams,
44
- type GoalSession,
45
- HISTORY_ENTRY_TYPE,
46
- isGoalEntry,
47
- isStaleContextError,
48
- persistGoalState,
49
- updateWidget,
50
- } from "./tool-handler";
51
- import { toSingleLine } from "./widget";
52
-
53
- // ── Local Interfaces (avoid `any` on Pi callback/event signatures) ────
25
+ handleAgentEnd,
26
+ handleAgentStart,
27
+ handleBeforeAgentStart,
28
+ handleMessageEnd,
29
+ handleSessionStart,
30
+ handleTurnEnd,
31
+ } from "./adapters/event-adapter";
32
+ import { registerGoalControlTool } from "./adapters/goal-control-adapter";
33
+ import { buildPorts } from "./adapters/ports";
34
+ import { createGoal } from "./service";
35
+ import { createGoalSession, type GoalSession } from "./session";
36
+
37
+ // ── Local Interfaces (Like*Event — 避免 any on Pi callback/event signatures) ────
54
38
 
55
- /** Fields accessed from BeforeAgentStartEvent */
56
39
  interface BeforeAgentStartLikeEvent {
57
40
  type: "before_agent_start";
58
41
  prompt: string;
59
42
  systemPrompt: string;
60
43
  }
61
44
 
62
- /** Fields accessed from TurnEndEvent */
63
45
  interface TurnEndLikeEvent {
64
46
  type: "turn_end";
65
47
  turnIndex: number;
66
48
  }
67
49
 
68
- /** Subset of AgentMessage.usage accessed in message_end handler */
69
- interface LikeUsage {
70
- input?: number;
71
- output?: number;
72
- cacheRead?: number;
73
- totalTokens?: number;
74
- }
75
-
76
- /** Fields accessed from MessageEndEvent */
77
50
  interface MessageEndLikeEvent {
78
51
  type: "message_end";
79
52
  message: {
80
53
  role: string;
81
- usage?: LikeUsage;
54
+ usage?: { input?: number; output?: number; cacheRead?: number; totalTokens?: number };
82
55
  };
83
56
  }
84
57
 
85
- /** Fields accessed from AgentEndEvent */
86
58
  interface AgentEndLikeEvent {
87
59
  type: "agent_end";
88
60
  messages: unknown[];
89
61
  }
90
62
 
91
- /** Fields accessed from SessionStartEvent */
92
63
  interface SessionStartLikeEvent {
93
64
  type: "session_start";
94
65
  reason: string;
95
66
  }
96
67
 
97
- /** Shape of CustomMessage passed to registerMessageRenderer */
98
68
  interface LikeCustomMessage {
99
69
  customType: string;
100
70
  content: string | unknown;
101
71
  }
102
72
 
103
- /** Options bag for registerMessageRenderer callback */
104
73
  interface LikeMessageRenderOptions {
105
74
  expanded: boolean;
106
75
  }
107
76
 
108
- /** Result shape accessed in renderResult */
109
- interface LikeToolResult {
110
- content: Array<{ type: string; text?: string }>;
111
- details?: GoalManagerDetails;
112
- }
113
-
114
- /** Options bag for renderResult callback */
115
- interface LikeToolRenderResultOptions {
116
- expanded: boolean;
117
- }
118
-
119
- // ── State Reconstruction ─────────────────────────────
120
-
121
- function reconstructGoalState(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionContext): void {
122
- session.state = null;
123
- const entries = ctx.sessionManager.getEntries();
124
-
125
- for (let i = entries.length - 1; i >= 0; i--) {
126
- const entry = entries[i]!;
127
- if (isGoalEntry(entry)) {
128
- const data = entry.data as Record<string, unknown> | undefined;
129
- if (data) {
130
- try {
131
- session.state = deserializeState(data);
132
- } catch {
133
- session.state = null;
134
- }
135
- }
136
- break;
137
- }
138
- }
139
-
140
- if (!session.state) return;
141
-
142
- // 非终态 → 恢复为 active
143
- if (!isTerminalStatus(session.state.status) && session.state.status !== "paused") {
144
- session.state.status = "active";
145
- session.state.timeStartedAt = Date.now();
146
- }
147
-
148
- // Entry GC — 标记旧的 goal-state entries
149
- const goalEntryIndices: number[] = [];
150
- let latestFound = false;
151
- for (let i = entries.length - 1; i >= 0; i--) {
152
- const entry = entries[i];
153
- if (isGoalEntry(entry)) {
154
- if (!latestFound) latestFound = true;
155
- else goalEntryIndices.push(i);
156
- }
157
- }
158
- for (const idx of goalEntryIndices) {
159
- entries.splice(idx, 1);
160
- }
161
-
162
- // Goal-history entry GC
163
- const historyIndices: number[] = [];
164
- for (let i = 0; i < entries.length; i++) {
165
- const entry = entries[i]!;
166
- if (entry.type === "custom" && (entry as CustomEntry).customType === HISTORY_ENTRY_TYPE) {
167
- historyIndices.push(i);
168
- }
169
- }
170
- if (historyIndices.length > MAX_HISTORY_ENTRIES) {
171
- const toDelete = historyIndices.slice(0, historyIndices.length - MAX_HISTORY_ENTRIES);
172
- for (let i = toDelete.length - 1; i >= 0; i--) {
173
- entries.splice(toDelete[i]!, 1);
174
- }
175
- }
176
- }
177
-
178
77
  // ── Extension Factory ─────────────────────────────────
179
78
 
180
79
  export default function goalExtension(pi: ExtensionAPI) {
181
- const session: GoalSession = {
182
- state: null,
183
- tasksCompletedAtAgentStart: 0,
184
- hasPendingInjection: false,
185
- isProcessing: false, // P1-3: 防重入
186
- pendingPause: false, // ESC 中断标记
187
- };
80
+ const session: GoalSession = createGoalSession();
188
81
 
189
- // Capture latest ctx for external API (initializeGoalFromExternal needs it for persistGoalState)
190
- let lastCtx: ExtensionContext | undefined;
191
-
192
- pi.registerTool({
193
- name: "goal_manager",
194
- label: "Goal Manager",
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[])",
208
- promptSnippet: "Manage task list, completion status, and exit for /goal mode",
209
- promptGuidelines: [
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)",
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 ...'",
213
- "[Append] When discovering omissions during execution, use add_tasks to append — do not re-call create_tasks",
214
- "[Completion] After completing a task, call update_tasks with status=completed and provide evidence (e.g. 'test X passed', 'file F created')",
215
- "[Goal completion] Only call complete_goal when all tasks are completed or verified with overall evidence",
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",
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.",
218
- "[Progress] Use list_tasks anytime to check remaining tasks and budget",
219
- "[Cancel] To cancel a task, use update_tasks with status=cancelled. Cancelled tasks do not block goal completion",
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.",
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",
225
- "[Forbidden] Do not re-call create_tasks to overwrite existing incomplete tasks — use add_tasks to append",
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>",
235
- ],
236
- parameters: GoalManagerParams,
237
-
238
- async execute(_toolCallId: string, params: Static<typeof GoalManagerParams>, signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext) {
239
- lastCtx = ctx;
240
- try {
241
- // P1-4: 透传 signal
242
- return await executeGoalAction(pi, session, params, ctx, signal);
243
- } catch (err) {
244
- // P1-2: Stale context 检测 — 静默吞掉
245
- if (isStaleContextError(err)) {
246
- return {
247
- content: [{ type: "text" as const, text: "Goal context stale after compact or session replacement." }],
248
- isError: true as const,
249
- };
250
- }
251
- const msg = err instanceof Error ? err.message : String(err);
252
- const inputSummary = JSON.stringify(params, null, 2); // eslint-disable-line no-magic-numbers -- JSON.stringify indent
253
- return {
254
- content: [{ type: "text", text: `${msg}\n\nInput: ${inputSummary}` }],
255
- isError: true,
256
- };
257
- }
258
- },
259
-
260
- renderCall(args: Static<typeof GoalManagerParams>, theme: Theme) {
261
- let text = theme.fg("toolTitle", theme.bold("goal_manager ")) + theme.fg("muted", args.action);
262
- if (args.tasks) text += ` ${theme.fg("dim", `(${args.tasks.length} tasks)`)}`;
263
- if (args.updates) text += ` ${theme.fg("dim", `(${args.updates.length} updates)`)}`;
264
- if (args.taskId !== undefined) text += ` ${theme.fg("accent", `#${args.taskId}`)}`;
265
- if (args.texts) text += ` ${theme.fg("dim", `(${args.texts.length} subtasks)`)}`;
266
- if (args.subUpdates) text += ` ${theme.fg("dim", `(${args.subUpdates.length} subtask updates)`)}`;
267
- if (args.subIds) text += ` ${theme.fg("dim", `del #${args.subIds.join(",")}`)}`;
268
- return new Text(text, 0, 0);
269
- },
270
-
271
- renderResult(result: LikeToolResult, { expanded }: LikeToolRenderResultOptions, theme: Theme) {
272
- const details = result.details as GoalManagerDetails | undefined;
273
- if (!details || !Array.isArray(details.tasks)) {
274
- const text = result.content[0];
275
- return new Text(text?.type === "text" ? (text.text ?? "") : "", 0, 0);
276
- }
277
- const tasks = details.tasks;
278
- const completed = getCompletedCount(tasks);
279
- const summary = theme.fg("success", `✓ ${completed}/${tasks.length} completed`);
280
- if (!expanded || tasks.length === 0) {
281
- return new Text(summary, 0, 0);
282
- }
283
- const lines = [summary];
284
- for (const t of tasks) {
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", "☐");
294
- const descText = toSingleLine(t.description);
295
- const desc = (t.status === "verified" || t.status === "completed" || t.status === "cancelled")
296
- ? theme.fg("dim", descText)
297
- : theme.fg("text", descText);
298
- lines.push(` ${icon} ${theme.fg("accent", `#${t.id}`)} ${desc}`);
299
- // Subtask items in expanded view — collapse when all completed
300
- if (t.subtasks && t.subtasks.length > 0) {
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
- }
312
- }
313
- }
314
- }
315
- return new Text(lines.join("\n"), 0, 0);
316
- },
317
- });
318
-
319
- // ── Command: /goal ─────────────────────────────────
82
+ // ── Command: /goal ────────────────────────────────
320
83
 
321
84
  pi.registerCommand("goal", {
322
85
  description:
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",
86
+ "Goal-driven mode: /goal <objective> [--tokens N] [--timeout N] | /goal resume | /goal clear | /goal update <new-objective> | /goal status | /goal history",
324
87
  handler: async (args: string | undefined, ctx: ExtensionCommandContext) => {
325
88
  await handleGoalCommand(pi, session, args, ctx);
326
89
  },
327
90
  });
328
91
 
329
- // ── Event: before_agent_start ──────────────────────
92
+ // ── Tool: goal_control(complete / report_blocked,#3 替代已删 goal_manager)──
93
+
94
+ registerGoalControlTool(pi, session);
95
+
96
+ // ── Events(全部委托 adapters/event-adapter)────────
97
+
330
98
  pi.on("before_agent_start", async (_event: BeforeAgentStartLikeEvent, ctx: ExtensionContext) => {
331
- lastCtx = ctx;
332
99
  return handleBeforeAgentStart(pi, session, ctx);
333
100
  });
334
101
 
335
- // ── Event: agent_start ─────────────────────────────
336
-
337
102
  pi.on("agent_start", async () => {
338
- if (!session.state || !isActiveStatus(session.state.status)) return;
339
- session.tasksCompletedAtAgentStart = getCompletedCount(session.state.tasks);
103
+ await handleAgentStart(session);
340
104
  });
341
105
 
342
- // ── Event: turn_end ────────────────────────────────
343
106
  pi.on("turn_end", async (_event: TurnEndLikeEvent, ctx: ExtensionContext) => {
344
- if (!session.state) return;
345
- session.state.currentTurnIndex++;
346
- updateWidget(session, ctx);
107
+ await handleTurnEnd(pi, session, ctx);
347
108
  });
348
109
 
349
- // ── Event: message_end (token accounting) ──────────
350
- pi.on("message_end", async (event: MessageEndLikeEvent, _ctx: ExtensionContext) => {
351
- if (!session.state || !isActiveStatus(session.state.status)) return;
352
- if (event.message.role !== "assistant") return;
353
-
354
- const usage = event.message.usage;
355
- if (usage) {
356
- const input = usage.input ?? 0;
357
- const output = usage.output ?? 0;
358
- const cacheRead = usage.cacheRead ?? 0;
359
- if (input > 0 || output > 0) {
360
- session.state.tokensUsed += Math.max(input - cacheRead, 0) + output;
361
- } else if (usage.totalTokens) {
362
- session.state.tokensUsed += usage.totalTokens;
363
- }
364
- }
110
+ pi.on("message_end", async (event: MessageEndLikeEvent, ctx: ExtensionContext) => {
111
+ await handleMessageEnd(session, ctx, event);
365
112
  });
366
113
 
367
- // ── Event: agent_end ───────────────────────────────
368
114
  pi.on("agent_end", async (_event: AgentEndLikeEvent, ctx: ExtensionContext) => {
369
115
  await handleAgentEnd(pi, session, ctx);
370
116
  });
371
117
 
372
- // ── Event: session_start (state reconstruction) ───
373
118
  pi.on("session_start", async (_event: SessionStartLikeEvent, ctx: ExtensionContext) => {
374
- lastCtx = ctx;
375
- reconstructGoalState(pi, session, ctx);
376
- if (session.state) {
377
- session.tasksCompletedAtAgentStart = getCompletedCount(session.state.tasks);
378
- updateWidget(session, ctx);
379
- }
119
+ await handleSessionStart(pi, session, ctx);
380
120
  });
381
121
 
382
122
  // ── Message Renderers ──────────────────────────────
383
123
 
384
- const goalMessageTypes = [
385
- "goal-context",
386
- "goal-context-exceeded",
387
- "goal-staleness-reminder",
388
- ];
389
-
124
+ const goalMessageTypes = ["goal-context", "goal-context-exceeded"];
390
125
  for (const customType of goalMessageTypes) {
391
- pi.registerMessageRenderer(customType, (message: LikeCustomMessage, _options: LikeMessageRenderOptions, theme: Theme) => {
392
- const prefix =
393
- message.customType === "goal-context-exceeded"
394
- ? theme.fg("error", "[GOAL Budget] ")
395
- : message.customType === "goal-staleness-reminder"
396
- ? theme.fg("warning", "[GOAL Reminder] ")
126
+ pi.registerMessageRenderer(
127
+ customType,
128
+ (message: LikeCustomMessage, _options: LikeMessageRenderOptions, theme: Theme) => {
129
+ const prefix =
130
+ message.customType === "goal-context-exceeded"
131
+ ? theme.fg("error", "[GOAL Budget] ")
397
132
  : theme.fg("accent", "[GOAL] ");
398
- const content = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
399
- return new Text(prefix + theme.fg("dim", content), 0, 0);
400
- });
133
+ const content =
134
+ typeof message.content === "string" ? message.content : JSON.stringify(message.content);
135
+ return new Text(prefix + theme.fg("dim", content), 0, 0);
136
+ },
137
+ );
401
138
  }
402
139
 
403
- // ── External API: initializeGoalFromExternal ──────────
140
+ // ── External API: __goalInit(FR-4 双轨消除)──────────
404
141
 
405
142
  /**
406
- * Allow other extensions (e.g. coding-workflow) to programmatically initialize a goal.
407
- * Skips the /goal command flow — directly creates state + tasks.
408
- * Returns true if initialized, false if goal already active.
143
+ * 允许其他扩展(coding-workflow / plan)通过 pi.__goalInit 编程式初始化 goal。
144
+ * 内部调 service.createGoal(FR-4.1——与 goal_control create 走同一创建逻辑)。
145
+ *
146
+ * FR-4.2/D-16: ctx 必填(消除 lastCtx 模块级可变状态)。
147
+ * ports 构造复用 adapters/ports.buildPorts(DRY:单一 ports 构造点)。
148
+ *
149
+ * @param objective 目标描述
150
+ * @param budget 预算配置,传 undefined 用默认值
151
+ * @param ctx **必填**——调用方的 ExtensionContext。省略会返回 false(创建失败)。
152
+ * @param slug 可选短标识(仅 widget 标题 + history 用,不注入 prompt)
153
+ * @returns true 创建成功;false 已有 active goal 或 ctx 缺失
409
154
  */
410
- function initializeGoalFromExternal(
155
+ const api = pi as unknown as Record<string, unknown>;
156
+ api.__goalInit = (
411
157
  objective: string,
412
- tasks: string[],
413
- budget?: { tokenBudget?: number; timeBudgetMinutes?: number; maxTurns?: number },
414
- ctx?: ExtensionContext,
415
- ): boolean {
416
- if (session.state && isActiveStatus(session.state.status)) {
417
- return false;
418
- }
419
-
420
- session.state = createInitialState(objective, budget);
421
- session.tasksCompletedAtAgentStart = 0;
422
-
423
- // Create tasks (same logic as handleCreateTasks)
424
- session.state.tasks = tasks.map((desc, i) => ({
425
- id: i + 1,
426
- description: desc.length > EXT_INIT_TASK_DESC_MAX ? desc.slice(0, EXT_INIT_TASK_DESC_MAX - ELLIPSIS_LENGTH) + "..." : desc,
427
- status: "pending" as const,
428
- lastUpdatedTurn: session.state!.currentTurnIndex,
429
- }));
158
+ budget: GoalInitBudget | undefined,
159
+ ctx: ExtensionContext,
160
+ slug?: string,
161
+ ): boolean => {
162
+ if (!ctx) return false;
163
+ return createGoal(session, objective, budget ?? {}, buildPorts(pi, ctx), true, slug);
164
+ };
165
+ }
430
166
 
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);
437
- }
167
+ // ── Cross-extension API 类型(单一 source of truth,API-1)──────────
438
168
 
439
- return true;
440
- }
441
-
442
- // Expose on pi for cross-extension access
443
- const api = pi as unknown as Record<string, unknown>;
444
- api.__goalInit = initializeGoalFromExternal satisfies GoalExternalInit;
169
+ /**
170
+ * `pi.__goalInit` 的预算配置形状。
171
+ *
172
+ * 跨扩展(coding-workflow / plan)通过 `pi.__goalInit` 编程式初始化 goal 时使用。
173
+ * 与 `BudgetConfig` 的差异:本类型只暴露外部可设的可选字段,且全部 optional。
174
+ */
175
+ export interface GoalInitBudget {
176
+ tokenBudget?: number;
177
+ timeBudgetMinutes?: number;
445
178
  }
179
+
180
+ /**
181
+ * `pi.__goalInit` 的规范函数签名(API-1:单一 source of truth)。
182
+ *
183
+ * 跨扩展消费者应 import 本类型而非重复声明 inline alias,避免签名 drift:
184
+ * ```ts
185
+ * import type { GoalInitFn } from "@zhushanwen/pi-goal";
186
+ * const goalInit = (pi as unknown as { __goalInit?: GoalInitFn }).__goalInit;
187
+ * ```
188
+ *
189
+ * @param objective 目标描述
190
+ * @param budget 预算配置,传 undefined 用默认值
191
+ * @param ctx **必填**——调用方的 ExtensionContext。省略会返回 false(创建失败)。
192
+ * @param slug 可选短标识(仅 widget 标题 + history 用,不注入 prompt)
193
+ * @returns true 创建成功;false 已有 active goal 或 ctx 缺失
194
+ */
195
+ export type GoalInitFn = (
196
+ objective: string,
197
+ budget: GoalInitBudget | undefined,
198
+ ctx: ExtensionContext,
199
+ slug?: string,
200
+ ) => boolean;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * 持久化层 — serialize/deserialize + history entry 构造
3
+ *
4
+ * FR-5: 移除旧格式兼容,字段缺失直接 throw(tasks 字段例外——向后兼容忽略)。
5
+ * 零 Pi 依赖。
6
+ */
7
+
8
+ import type { GoalRuntimeState } from "./engine/types";
9
+ import type { GoalHistoryEntry } from "./ports";
10
+
11
+ // ── 常量 ──────────────────────────────────────────────
12
+
13
+ export const ENTRY_TYPE = "goal-state";
14
+ export const HISTORY_ENTRY_TYPE = "goal-history";
15
+
16
+ // ── serialize(深拷贝,纯函数)────────────────────────
17
+
18
+ export function serializeState(state: GoalRuntimeState): GoalRuntimeState {
19
+ return {
20
+ ...state,
21
+ budget: { ...state.budget },
22
+ };
23
+ }
24
+
25
+ // ── deserialize(FR-5 严格解析,缺字段 throw)──────────
26
+
27
+ /**
28
+ * 反序列化持久化 state。
29
+ *
30
+ * 向后兼容:旧 entry 可能含 `tasks` 字段(task CRUD 删除前的格式),此处忽略不 throw。
31
+ * 其余必填字段缺失仍 throw(FR-5)。
32
+ */
33
+ export function deserializeState(data: Record<string, unknown>): GoalRuntimeState {
34
+ const req = <T>(key: string): T => {
35
+ if (!(key in data) || data[key] === undefined) {
36
+ throw new Error(`Missing required field: ${key}`);
37
+ }
38
+ return data[key] as T;
39
+ };
40
+
41
+ return {
42
+ goalId: req("goalId"),
43
+ objective: req("objective"),
44
+ // slug 用可选解析:旧持久化数据无此字段,不能误用 req()(否则旧数据 throw → state 全丢,GAP-4)
45
+ slug: data.slug as string | undefined,
46
+ status: req("status"),
47
+ tokensUsed: req("tokensUsed"),
48
+ timeStartedAt: req("timeStartedAt"),
49
+ timeUsedSeconds: req("timeUsedSeconds"),
50
+ budget: req("budget"),
51
+ lastProgressTurn: req("lastProgressTurn"),
52
+ budgetLimitSteeringSent: req("budgetLimitSteeringSent"),
53
+ objectiveUpdatedAt: req("objectiveUpdatedAt"),
54
+ lastBlockerReason: req("lastBlockerReason"),
55
+ tokenWarning70Sent: req("tokenWarning70Sent"),
56
+ tokenWarning90Sent: req("tokenWarning90Sent"),
57
+ timeWarning70Sent: req("timeWarning70Sent"),
58
+ timeWarning90Sent: req("timeWarning90Sent"),
59
+ lastTurnTokensUsed: req("lastTurnTokensUsed"),
60
+ currentTurnIndex: req("currentTurnIndex"),
61
+ completedAtTurnIndex: data.completedAtTurnIndex as number | undefined,
62
+ };
63
+ }
64
+
65
+ // ── makeHistoryEntry ─────────────────────────────────
66
+
67
+ /**
68
+ * 从 state 构造 GoalHistoryEntry(纯函数)。
69
+ *
70
+ * totalTasks 暂置 0(task CRUD 已删除,#7 注入 todo 进度后可重填)。
71
+ */
72
+ export function makeHistoryEntry(state: GoalRuntimeState, completedTasks: number): GoalHistoryEntry {
73
+ return {
74
+ goalId: state.goalId,
75
+ objective: state.objective,
76
+ slug: state.slug,
77
+ status: state.status,
78
+ completedTasks,
79
+ totalTasks: 0,
80
+ elapsedSeconds: Math.floor(state.timeUsedSeconds),
81
+ timestamp: Date.now(),
82
+ };
83
+ }