@zhushanwen/pi-goal 0.2.0 → 0.4.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.
Files changed (48) hide show
  1. package/README.md +13 -12
  2. package/package.json +8 -4
  3. package/src/__tests__/command-adapter.test.ts +404 -0
  4. package/src/__tests__/deserialize-state.test.ts +80 -0
  5. package/src/__tests__/event-adapter.test.ts +339 -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 +16 -0
  12. package/src/__tests__/stubs/typebox.ts +14 -0
  13. package/src/adapters/command-adapter.ts +360 -0
  14. package/src/adapters/event-adapter.ts +17 -0
  15. package/src/adapters/event-handlers/agent-end.ts +177 -0
  16. package/src/adapters/event-handlers/agent-start.ts +17 -0
  17. package/src/adapters/event-handlers/before-agent-start.ts +115 -0
  18. package/src/adapters/event-handlers/message-end.ts +37 -0
  19. package/src/adapters/event-handlers/session-start.ts +24 -0
  20. package/src/adapters/event-handlers/shared.ts +46 -0
  21. package/src/adapters/event-handlers/turn-end.ts +37 -0
  22. package/src/adapters/goal-control-adapter.ts +296 -0
  23. package/src/adapters/ports.ts +89 -0
  24. package/src/commands.ts +7 -19
  25. package/src/constants.ts +1 -3
  26. package/src/engine/__tests__/budget.test.ts +182 -0
  27. package/src/engine/__tests__/goal.test.ts +126 -0
  28. package/src/engine/budget.ts +146 -0
  29. package/src/engine/goal.ts +62 -0
  30. package/src/engine/types.ts +80 -0
  31. package/src/index.ts +110 -333
  32. package/src/persistence.ts +83 -0
  33. package/src/ports.ts +68 -0
  34. package/src/projection/__tests__/prompts.test.ts +245 -0
  35. package/src/projection/__tests__/widget.test.ts +255 -0
  36. package/src/projection/prompts.ts +250 -0
  37. package/src/projection/widget.ts +254 -0
  38. package/src/service.ts +276 -0
  39. package/src/session.ts +114 -0
  40. package/src/action-handlers.ts +0 -341
  41. package/src/agent-end-handler.ts +0 -304
  42. package/src/before-agent-start-handler.ts +0 -182
  43. package/src/budget.ts +0 -159
  44. package/src/command-handler.ts +0 -280
  45. package/src/state.ts +0 -250
  46. package/src/templates.ts +0 -232
  47. package/src/tool-handler.ts +0 -252
  48. package/src/widget.ts +0 -186
package/src/index.ts CHANGED
@@ -1,423 +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
- MAX_HISTORY_ENTRIES,
29
- } from "./constants";
23
+ import { handleGoalCommand } from "./adapters/command-adapter";
30
24
  import {
31
- createInitialState,
32
- deserializeState,
33
- getCompletedCount,
34
- isActiveStatus,
35
- isTerminalStatus,
36
- type GoalExternalInit,
37
- } from "./state";
38
- import {
39
- executeGoalAction,
40
- type GoalManagerDetails,
41
- GoalManagerParams,
42
- type GoalSession,
43
- HISTORY_ENTRY_TYPE,
44
- isGoalEntry,
45
- isStaleContextError,
46
- persistGoalState,
47
- updateWidget,
48
- } from "./tool-handler";
49
- import { toSingleLine } from "./widget";
50
-
51
- // ── 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) ────
52
38
 
53
- /** Fields accessed from BeforeAgentStartEvent */
54
39
  interface BeforeAgentStartLikeEvent {
55
40
  type: "before_agent_start";
56
41
  prompt: string;
57
42
  systemPrompt: string;
58
43
  }
59
44
 
60
- /** Fields accessed from TurnEndEvent */
61
45
  interface TurnEndLikeEvent {
62
46
  type: "turn_end";
63
47
  turnIndex: number;
64
48
  }
65
49
 
66
- /** Subset of AgentMessage.usage accessed in message_end handler */
67
- interface LikeUsage {
68
- input?: number;
69
- output?: number;
70
- cacheRead?: number;
71
- totalTokens?: number;
72
- }
73
-
74
- /** Fields accessed from MessageEndEvent */
75
50
  interface MessageEndLikeEvent {
76
51
  type: "message_end";
77
52
  message: {
78
53
  role: string;
79
- usage?: LikeUsage;
54
+ usage?: { input?: number; output?: number; cacheRead?: number; totalTokens?: number };
80
55
  };
81
56
  }
82
57
 
83
- /** Fields accessed from AgentEndEvent */
84
58
  interface AgentEndLikeEvent {
85
59
  type: "agent_end";
86
60
  messages: unknown[];
87
61
  }
88
62
 
89
- /** Fields accessed from SessionStartEvent */
90
63
  interface SessionStartLikeEvent {
91
64
  type: "session_start";
92
65
  reason: string;
93
66
  }
94
67
 
95
- /** Shape of CustomMessage passed to registerMessageRenderer */
96
68
  interface LikeCustomMessage {
97
69
  customType: string;
98
70
  content: string | unknown;
99
71
  }
100
72
 
101
- /** Options bag for registerMessageRenderer callback */
102
73
  interface LikeMessageRenderOptions {
103
74
  expanded: boolean;
104
75
  }
105
76
 
106
- /** Result shape accessed in renderResult */
107
- interface LikeToolResult {
108
- content: Array<{ type: string; text?: string }>;
109
- details?: GoalManagerDetails;
110
- }
111
-
112
- /** Options bag for renderResult callback */
113
- interface LikeToolRenderResultOptions {
114
- expanded: boolean;
115
- }
116
-
117
- // ── State Reconstruction ─────────────────────────────
118
-
119
- function reconstructGoalState(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionContext): void {
120
- session.state = null;
121
- const entries = ctx.sessionManager.getEntries();
122
-
123
- for (let i = entries.length - 1; i >= 0; i--) {
124
- const entry = entries[i]!;
125
- if (isGoalEntry(entry)) {
126
- const data = entry.data as Record<string, unknown> | undefined;
127
- if (data) {
128
- try {
129
- session.state = deserializeState(data);
130
- } catch {
131
- session.state = null;
132
- }
133
- }
134
- break;
135
- }
136
- }
137
-
138
- if (!session.state) return;
139
-
140
- // 非终态 → 恢复为 active
141
- if (!isTerminalStatus(session.state.status) && session.state.status !== "paused") {
142
- session.state.status = "active";
143
- session.state.timeStartedAt = Date.now();
144
- }
145
-
146
- // Entry GC — 标记旧的 goal-state entries
147
- const goalEntryIndices: number[] = [];
148
- let latestFound = false;
149
- for (let i = entries.length - 1; i >= 0; i--) {
150
- const entry = entries[i];
151
- if (isGoalEntry(entry)) {
152
- if (!latestFound) latestFound = true;
153
- else goalEntryIndices.push(i);
154
- }
155
- }
156
- for (const idx of goalEntryIndices) {
157
- entries.splice(idx, 1);
158
- }
159
-
160
- // Goal-history entry GC
161
- const historyIndices: number[] = [];
162
- for (let i = 0; i < entries.length; i++) {
163
- const entry = entries[i]!;
164
- if (entry.type === "custom" && (entry as CustomEntry).customType === HISTORY_ENTRY_TYPE) {
165
- historyIndices.push(i);
166
- }
167
- }
168
- if (historyIndices.length > MAX_HISTORY_ENTRIES) {
169
- const toDelete = historyIndices.slice(0, historyIndices.length - MAX_HISTORY_ENTRIES);
170
- for (let i = toDelete.length - 1; i >= 0; i--) {
171
- entries.splice(toDelete[i]!, 1);
172
- }
173
- }
174
- }
175
-
176
77
  // ── Extension Factory ─────────────────────────────────
177
78
 
178
79
  export default function goalExtension(pi: ExtensionAPI) {
179
- const session: GoalSession = {
180
- state: null,
181
- tasksCompletedAtAgentStart: 0,
182
- hasPendingInjection: false,
183
- isProcessing: false, // P1-3: 防重入
184
- };
185
-
186
- // Capture latest ctx for external API (initializeGoalFromExternal needs it for persistGoalState)
187
- let lastCtx: ExtensionContext | undefined;
188
-
189
- pi.registerTool({
190
- name: "goal_manager",
191
- 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[])",
205
- promptSnippet: "Manage task list, completion status, and exit for /goal mode",
206
- promptGuidelines: [
207
- "[Workflow] After receiving the objective, the first step must be create_tasks to decompose. Do not re-call if task list already exists",
208
- "[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
- "[Append] When discovering omissions during execution, use add_tasks to append — do not re-call create_tasks",
210
- "[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",
212
- "[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",
214
- "[Progress] Use list_tasks anytime to check remaining tasks and budget",
215
- "[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",
217
- "[Forbidden] Do not force task completion when the user explicitly wants to exit — call cancel_goal directly",
218
- "[Forbidden] Do not re-call create_tasks to overwrite existing incomplete tasks — use add_tasks to append",
219
- "[Subtask] For fine-grained step tracking in Goal mode, use add_subtasks — do not use the todo tool",
220
- ],
221
- parameters: GoalManagerParams,
80
+ const session: GoalSession = createGoalSession();
222
81
 
223
- async execute(_toolCallId: string, params: Static<typeof GoalManagerParams>, signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext) {
224
- lastCtx = ctx;
225
- try {
226
- // P1-4: 透传 signal
227
- return await executeGoalAction(pi, session, params, ctx, signal);
228
- } catch (err) {
229
- // P1-2: Stale context 检测 — 静默吞掉
230
- if (isStaleContextError(err)) {
231
- return {
232
- content: [{ type: "text" as const, text: "Goal context stale after compact or session replacement." }],
233
- isError: true as const,
234
- };
235
- }
236
- const msg = err instanceof Error ? err.message : String(err);
237
- const inputSummary = JSON.stringify(params, null, 2); // eslint-disable-line no-magic-numbers -- JSON.stringify indent
238
- return {
239
- content: [{ type: "text", text: `${msg}\n\nInput: ${inputSummary}` }],
240
- isError: true,
241
- };
242
- }
243
- },
244
-
245
- renderCall(args: Static<typeof GoalManagerParams>, theme: Theme) {
246
- let text = theme.fg("toolTitle", theme.bold("goal_manager ")) + theme.fg("muted", args.action);
247
- if (args.tasks) text += ` ${theme.fg("dim", `(${args.tasks.length} tasks)`)}`;
248
- if (args.updates) text += ` ${theme.fg("dim", `(${args.updates.length} updates)`)}`;
249
- if (args.taskId !== undefined) text += ` ${theme.fg("accent", `#${args.taskId}`)}`;
250
- if (args.texts) text += ` ${theme.fg("dim", `(${args.texts.length} subtasks)`)}`;
251
- if (args.subUpdates) text += ` ${theme.fg("dim", `(${args.subUpdates.length} subtask updates)`)}`;
252
- if (args.subIds) text += ` ${theme.fg("dim", `del #${args.subIds.join(",")}`)}`;
253
- return new Text(text, 0, 0);
254
- },
255
-
256
- renderResult(result: LikeToolResult, { expanded }: LikeToolRenderResultOptions, theme: Theme) {
257
- const details = result.details as GoalManagerDetails | undefined;
258
- if (!details || !Array.isArray(details.tasks)) {
259
- const text = result.content[0];
260
- return new Text(text?.type === "text" ? (text.text ?? "") : "", 0, 0);
261
- }
262
- const tasks = details.tasks;
263
- const completed = tasks.filter((t) => t.status === "completed").length;
264
- const summary = theme.fg("success", `✓ ${completed}/${tasks.length} completed`);
265
- if (!expanded || tasks.length === 0) {
266
- return new Text(summary, 0, 0);
267
- }
268
- const lines = [summary];
269
- 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", "☐");
277
- const descText = toSingleLine(t.description);
278
- const desc = (t.status === "completed" || t.status === "cancelled")
279
- ? theme.fg("dim", descText)
280
- : theme.fg("text", descText);
281
- lines.push(` ${icon} ${theme.fg("accent", `#${t.id}`)} ${desc}`);
282
- // Subtask items in expanded view
283
- 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}`);
292
- }
293
- }
294
- }
295
- return new Text(lines.join("\n"), 0, 0);
296
- },
297
- });
298
-
299
- // ── Command: /goal ─────────────────────────────────
82
+ // ── Command: /goal ────────────────────────────────
300
83
 
301
84
  pi.registerCommand("goal", {
302
85
  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",
86
+ "Goal-driven mode: /goal <objective> [--tokens N] [--timeout N] | /goal resume | /goal clear | /goal update <new-objective> | /goal status | /goal history",
304
87
  handler: async (args: string | undefined, ctx: ExtensionCommandContext) => {
305
88
  await handleGoalCommand(pi, session, args, ctx);
306
89
  },
307
90
  });
308
91
 
309
- // ── 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
+
310
98
  pi.on("before_agent_start", async (_event: BeforeAgentStartLikeEvent, ctx: ExtensionContext) => {
311
- lastCtx = ctx;
312
99
  return handleBeforeAgentStart(pi, session, ctx);
313
100
  });
314
101
 
315
- // ── Event: agent_start ─────────────────────────────
316
-
317
102
  pi.on("agent_start", async () => {
318
- if (!session.state || !isActiveStatus(session.state.status)) return;
319
- session.tasksCompletedAtAgentStart = getCompletedCount(session.state.tasks);
103
+ await handleAgentStart(session);
320
104
  });
321
105
 
322
- // ── Event: turn_end ────────────────────────────────
323
106
  pi.on("turn_end", async (_event: TurnEndLikeEvent, ctx: ExtensionContext) => {
324
- if (!session.state) return;
325
- session.state.currentTurnIndex++;
326
- updateWidget(session, ctx);
107
+ await handleTurnEnd(pi, session, ctx);
327
108
  });
328
109
 
329
- // ── Event: message_end (token accounting) ──────────
330
- pi.on("message_end", async (event: MessageEndLikeEvent, _ctx: ExtensionContext) => {
331
- if (!session.state || !isActiveStatus(session.state.status)) return;
332
- if (event.message.role !== "assistant") return;
333
-
334
- const usage = event.message.usage;
335
- if (usage) {
336
- const input = usage.input ?? 0;
337
- const output = usage.output ?? 0;
338
- const cacheRead = usage.cacheRead ?? 0;
339
- if (input > 0 || output > 0) {
340
- session.state.tokensUsed += Math.max(input - cacheRead, 0) + output;
341
- } else if (usage.totalTokens) {
342
- session.state.tokensUsed += usage.totalTokens;
343
- }
344
- }
110
+ pi.on("message_end", async (event: MessageEndLikeEvent, ctx: ExtensionContext) => {
111
+ await handleMessageEnd(session, ctx, event);
345
112
  });
346
113
 
347
- // ── Event: agent_end ───────────────────────────────
348
114
  pi.on("agent_end", async (_event: AgentEndLikeEvent, ctx: ExtensionContext) => {
349
115
  await handleAgentEnd(pi, session, ctx);
350
116
  });
351
117
 
352
- // ── Event: session_start (state reconstruction) ───
353
118
  pi.on("session_start", async (_event: SessionStartLikeEvent, ctx: ExtensionContext) => {
354
- lastCtx = ctx;
355
- reconstructGoalState(pi, session, ctx);
356
- if (session.state) {
357
- session.tasksCompletedAtAgentStart = getCompletedCount(session.state.tasks);
358
- updateWidget(session, ctx);
359
- }
119
+ await handleSessionStart(pi, session, ctx);
360
120
  });
361
121
 
362
122
  // ── Message Renderers ──────────────────────────────
363
123
 
364
- const goalMessageTypes = [
365
- "goal-context",
366
- "goal-context-exceeded",
367
- "goal-staleness-reminder",
368
- ];
369
-
124
+ const goalMessageTypes = ["goal-context", "goal-context-exceeded"];
370
125
  for (const customType of goalMessageTypes) {
371
- pi.registerMessageRenderer(customType, (message: LikeCustomMessage, _options: LikeMessageRenderOptions, theme: Theme) => {
372
- const prefix =
373
- message.customType === "goal-context-exceeded"
374
- ? theme.fg("error", "[GOAL Budget] ")
375
- : message.customType === "goal-staleness-reminder"
376
- ? 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] ")
377
132
  : theme.fg("accent", "[GOAL] ");
378
- const content = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
379
- return new Text(prefix + theme.fg("dim", content), 0, 0);
380
- });
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
+ );
381
138
  }
382
139
 
383
- // ── External API: initializeGoalFromExternal ──────────
140
+ // ── External API: __goalInit(FR-4 双轨消除)──────────
384
141
 
385
142
  /**
386
- * Allow other extensions (e.g. coding-workflow) to programmatically initialize a goal.
387
- * Skips the /goal command flow — directly creates state + tasks.
388
- * 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 缺失
389
154
  */
390
- function initializeGoalFromExternal(
155
+ const api = pi as unknown as Record<string, unknown>;
156
+ api.__goalInit = (
391
157
  objective: string,
392
- tasks: string[],
393
- budget?: { tokenBudget?: number; timeBudgetMinutes?: number; maxTurns?: number },
394
- ): boolean {
395
- if (session.state && isActiveStatus(session.state.status)) {
396
- return false;
397
- }
398
-
399
- session.state = createInitialState(objective, budget);
400
- session.tasksCompletedAtAgentStart = 0;
401
-
402
- // Create tasks (same logic as handleCreateTasks)
403
- session.state.tasks = tasks.map((desc, i) => ({
404
- id: i + 1,
405
- description: desc.length > 60 ? desc.slice(0, 57) + "..." : desc,
406
- status: "pending" as const,
407
- lastUpdatedTurn: session.state!.currentTurnIndex,
408
- }));
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
+ }
409
166
 
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);
415
- }
167
+ // ── Cross-extension API 类型(单一 source of truth,API-1)──────────
416
168
 
417
- return true;
418
- }
419
-
420
- // Expose on pi for cross-extension access
421
- const api = pi as unknown as Record<string, unknown>;
422
- 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;
423
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
+ }