@zhushanwen/pi-goal 0.3.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.
- package/README.md +13 -12
- package/package.json +1 -1
- package/src/__tests__/command-adapter.test.ts +404 -0
- package/src/__tests__/deserialize-state.test.ts +65 -94
- package/src/__tests__/event-adapter.test.ts +339 -0
- package/src/__tests__/goal-control-adapter.test.ts +303 -0
- package/src/__tests__/index.test.ts +119 -0
- package/src/__tests__/service.test.ts +317 -0
- package/src/__tests__/session.test.ts +220 -0
- package/src/__tests__/stale-checker.test.ts +78 -0
- package/src/__tests__/stubs/pi-sdk.ts +5 -0
- package/src/adapters/command-adapter.ts +360 -0
- package/src/adapters/event-adapter.ts +17 -0
- package/src/adapters/event-handlers/agent-end.ts +177 -0
- package/src/adapters/event-handlers/agent-start.ts +17 -0
- package/src/adapters/event-handlers/before-agent-start.ts +115 -0
- package/src/adapters/event-handlers/message-end.ts +37 -0
- package/src/adapters/event-handlers/session-start.ts +24 -0
- package/src/adapters/event-handlers/shared.ts +46 -0
- package/src/adapters/event-handlers/turn-end.ts +37 -0
- package/src/adapters/goal-control-adapter.ts +296 -0
- package/src/adapters/ports.ts +89 -0
- package/src/commands.ts +8 -23
- package/src/constants.ts +1 -8
- package/src/engine/__tests__/budget.test.ts +182 -0
- package/src/engine/__tests__/goal.test.ts +126 -0
- package/src/engine/budget.ts +146 -0
- package/src/engine/goal.ts +62 -0
- package/src/engine/types.ts +80 -0
- package/src/index.ts +110 -355
- package/src/persistence.ts +83 -0
- package/src/ports.ts +68 -0
- package/src/projection/__tests__/prompts.test.ts +245 -0
- package/src/projection/__tests__/widget.test.ts +255 -0
- package/src/projection/prompts.ts +250 -0
- package/src/projection/widget.ts +254 -0
- package/src/service.ts +276 -0
- package/src/session.ts +114 -0
- package/src/__tests__/is-task-done.test.ts +0 -43
- package/src/__tests__/validate-update-tasks.test.ts +0 -172
- package/src/action-handlers.ts +0 -412
- package/src/agent-end-handler.ts +0 -306
- package/src/before-agent-start-handler.ts +0 -180
- package/src/budget.ts +0 -159
- package/src/command-handler.ts +0 -299
- package/src/state.ts +0 -274
- package/src/templates.ts +0 -274
- package/src/tool-handler.ts +0 -291
- package/src/widget.ts +0 -225
package/src/index.ts
CHANGED
|
@@ -1,445 +1,200 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pi /goal Extension —
|
|
2
|
+
* Pi /goal Extension — 工厂入口(重构后架构)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
* -
|
|
14
|
-
* -
|
|
15
|
-
* -
|
|
16
|
-
* -
|
|
17
|
-
* -
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
} from "./
|
|
40
|
-
import {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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?:
|
|
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
|
-
//
|
|
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]
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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
|
-
|
|
345
|
-
session.state.currentTurnIndex++;
|
|
346
|
-
updateWidget(session, ctx);
|
|
107
|
+
await handleTurnEnd(pi, session, ctx);
|
|
347
108
|
});
|
|
348
109
|
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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(
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
? theme.fg("
|
|
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
|
-
|
|
399
|
-
|
|
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:
|
|
140
|
+
// ── External API: __goalInit(FR-4 双轨消除)──────────
|
|
404
141
|
|
|
405
142
|
/**
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
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
|
-
|
|
155
|
+
const api = pi as unknown as Record<string, unknown>;
|
|
156
|
+
api.__goalInit = (
|
|
411
157
|
objective: string,
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
): boolean {
|
|
416
|
-
if (
|
|
417
|
-
|
|
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
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
+
}
|