@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.
- package/README.md +13 -12
- package/package.json +8 -4
- package/src/__tests__/command-adapter.test.ts +404 -0
- package/src/__tests__/deserialize-state.test.ts +80 -0
- 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 +16 -0
- package/src/__tests__/stubs/typebox.ts +14 -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 +7 -19
- package/src/constants.ts +1 -3
- 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 -333
- 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/action-handlers.ts +0 -341
- package/src/agent-end-handler.ts +0 -304
- package/src/before-agent-start-handler.ts +0 -182
- package/src/budget.ts +0 -159
- package/src/command-handler.ts +0 -280
- package/src/state.ts +0 -250
- package/src/templates.ts +0 -232
- package/src/tool-handler.ts +0 -252
- package/src/widget.ts +0 -186
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 事件 5: before_agent_start(context wrap-up + injection)。
|
|
3
|
+
*
|
|
4
|
+
* FR-8.1 G-007 + FR-8.6。返回 message(注入到 LLM context)或 undefined(无注入)。
|
|
5
|
+
*
|
|
6
|
+
* 分支顺序:
|
|
7
|
+
* 1. 终态:currentTurnIndex - completedAtTurnIndex >= AUTO_CLEAR_TURNS(2) → clearGoalSession
|
|
8
|
+
* 2. ADR-002:Context 使用率 > 85% → 保持 active + 注入 wrap-up 指令
|
|
9
|
+
* 3. 正常:注入 contextInjectionPrompt
|
|
10
|
+
*
|
|
11
|
+
* 无 ESC 守卫(before_agent_start 是 agent 开始前的信号,此时无 aborted 可能)。
|
|
12
|
+
*
|
|
13
|
+
* 全解耦:不再做 staleness 检测(原依赖 pi.__todoGetList,跨 ext 失效),
|
|
14
|
+
* 不再探测 plan extension(原 typeof pi.__planStart,跨 ext 失效)。
|
|
15
|
+
* contextInjectionPrompt 恒定建议 plan mode(AI 自行决定是否用)。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
19
|
+
|
|
20
|
+
import { AUTO_CLEAR_TURNS, CONTEXT_USAGE_RATIO_LIMIT } from "../../constants";
|
|
21
|
+
import { isActiveStatus, isTerminalStatus } from "../../engine/goal";
|
|
22
|
+
import { contextInjectionPrompt } from "../../projection/prompts";
|
|
23
|
+
import { asTheme, renderTerminalStatusLine } from "../../projection/widget";
|
|
24
|
+
import type { GoalSession } from "../../session";
|
|
25
|
+
import { clearGoalSession } from "../../session";
|
|
26
|
+
import { buildPorts } from "../ports";
|
|
27
|
+
|
|
28
|
+
interface BeforeAgentStartResult {
|
|
29
|
+
message: {
|
|
30
|
+
customType: string;
|
|
31
|
+
content: string;
|
|
32
|
+
display: boolean;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function handleBeforeAgentStart(
|
|
37
|
+
pi: ExtensionAPI,
|
|
38
|
+
session: GoalSession,
|
|
39
|
+
ctx: ExtensionContext,
|
|
40
|
+
): Promise<BeforeAgentStartResult | undefined> {
|
|
41
|
+
if (!session.state) return;
|
|
42
|
+
|
|
43
|
+
// 终态处理
|
|
44
|
+
if (isTerminalStatus(session.state.status)) {
|
|
45
|
+
handleTerminalStateBeforeAgent(pi, session, ctx);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (!isActiveStatus(session.state.status)) return;
|
|
49
|
+
|
|
50
|
+
// Context 使用率检查(ADR-002:保持 active,仅注入提示)
|
|
51
|
+
const ctxResult = checkContextUsage(session, ctx);
|
|
52
|
+
if (ctxResult) return ctxResult;
|
|
53
|
+
|
|
54
|
+
// 正常 context injection。
|
|
55
|
+
// 全解耦:planAvailable 恒 true(contextInjectionPrompt 恒定建议 plan mode,AI 自行决定)。
|
|
56
|
+
return {
|
|
57
|
+
message: {
|
|
58
|
+
customType: "goal-context",
|
|
59
|
+
content: contextInjectionPrompt(session.state, session.state.timeUsedSeconds, true),
|
|
60
|
+
display: false,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* FR-8.1 G-007:终态 goal 在 AUTO_CLEAR_TURNS(2) 轮后自动清理。
|
|
67
|
+
* 未到清理阈值时:折叠 status bar(显示终态单行),清 widget。
|
|
68
|
+
*/
|
|
69
|
+
function handleTerminalStateBeforeAgent(
|
|
70
|
+
pi: ExtensionAPI,
|
|
71
|
+
session: GoalSession,
|
|
72
|
+
ctx: ExtensionContext,
|
|
73
|
+
): void {
|
|
74
|
+
const state = session.state!;
|
|
75
|
+
const turnsInTerminal = state.currentTurnIndex - (state.completedAtTurnIndex ?? 0);
|
|
76
|
+
if (turnsInTerminal >= AUTO_CLEAR_TURNS) {
|
|
77
|
+
clearGoalSession(session, buildPorts(pi, ctx).ui);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// 折叠 status bar(终态显示)
|
|
81
|
+
const statusText = renderTerminalStatusLine(state, asTheme(buildPorts(pi, ctx).ui));
|
|
82
|
+
if (statusText && ctx.hasUI) ctx.ui.setStatus("goal", statusText);
|
|
83
|
+
if (ctx.hasUI) ctx.ui.setWidget("goal", undefined);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* ADR-002 context usage 提示:getContextUsage 超过 CONTEXT_USAGE_RATIO_LIMIT(0.85)
|
|
88
|
+
* → goal **保持 active**(不转 paused),仅注入 wrap-up 指令让 AI 自行 complete/cancel。
|
|
89
|
+
* 不做状态变更、不 persist、不 tick(资源保护通过"提示"而非"状态机"实现)。
|
|
90
|
+
*/
|
|
91
|
+
function checkContextUsage(
|
|
92
|
+
_session: GoalSession,
|
|
93
|
+
_ctx: ExtensionContext,
|
|
94
|
+
): BeforeAgentStartResult | undefined {
|
|
95
|
+
const usage = _ctx.getContextUsage();
|
|
96
|
+
if (
|
|
97
|
+
usage &&
|
|
98
|
+
usage.contextWindow > 0 &&
|
|
99
|
+
(usage.tokens ?? 0) / usage.contextWindow > CONTEXT_USAGE_RATIO_LIMIT
|
|
100
|
+
) {
|
|
101
|
+
return {
|
|
102
|
+
message: {
|
|
103
|
+
customType: "goal-context-exceeded",
|
|
104
|
+
content:
|
|
105
|
+
"[GOAL — context space low, must wrap up now]\n" +
|
|
106
|
+
"1. Check remaining work and verify what is genuinely completed\n" +
|
|
107
|
+
"2. Only report completion for work backed by concrete evidence\n" +
|
|
108
|
+
"3. Summarize current progress and remaining work\n" +
|
|
109
|
+
"Do not start new work.",
|
|
110
|
+
display: false,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 事件 3: message_end(FR-6.7 ESC 守卫 + token 累加)。
|
|
3
|
+
*
|
|
4
|
+
* FR-6.7 ESC 守卫:ctx.signal.aborted 时跳过 token 累加。
|
|
5
|
+
* FR-8.6: token 累加算法(委托 service.applyEvent("message_end"))。
|
|
6
|
+
*
|
|
7
|
+
* 不 persist / updateWidget。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
11
|
+
|
|
12
|
+
import { applyEvent } from "../../service";
|
|
13
|
+
import type { GoalSession } from "../../session";
|
|
14
|
+
|
|
15
|
+
export interface MessageEndLikeEvent {
|
|
16
|
+
message: {
|
|
17
|
+
role: string;
|
|
18
|
+
usage?: {
|
|
19
|
+
input?: number;
|
|
20
|
+
output?: number;
|
|
21
|
+
cacheRead?: number;
|
|
22
|
+
totalTokens?: number;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function handleMessageEnd(
|
|
28
|
+
session: GoalSession,
|
|
29
|
+
ctx: ExtensionContext,
|
|
30
|
+
event: MessageEndLikeEvent,
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
if (!session.state) return;
|
|
33
|
+
// FR-6.7 ESC 守卫
|
|
34
|
+
if (ctx.signal?.aborted) return;
|
|
35
|
+
|
|
36
|
+
applyEvent(session, "message_end", event);
|
|
37
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 事件 4: session_start(状态重建)。
|
|
3
|
+
*
|
|
4
|
+
* 调 reconstructGoalState 重建持久化状态 + updateWidget。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
8
|
+
|
|
9
|
+
import { updateWidget } from "../../projection/widget";
|
|
10
|
+
import type { GoalSession } from "../../session";
|
|
11
|
+
import { reconstructGoalState } from "../../session";
|
|
12
|
+
import { buildPorts } from "../ports";
|
|
13
|
+
|
|
14
|
+
export async function handleSessionStart(
|
|
15
|
+
pi: ExtensionAPI,
|
|
16
|
+
session: GoalSession,
|
|
17
|
+
ctx: ExtensionContext,
|
|
18
|
+
): Promise<void> {
|
|
19
|
+
const ports = buildPorts(pi, ctx);
|
|
20
|
+
reconstructGoalState(session, ports.session);
|
|
21
|
+
if (session.state) {
|
|
22
|
+
updateWidget(session, ports.ui);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* event-handlers 共享辅助(adapters/event-handlers 层)。
|
|
3
|
+
*
|
|
4
|
+
* - makeStaleChecker:FR-8.2 G-020 goalId snapshot,agent_end 入口构造、每个副作用前 checkStale
|
|
5
|
+
* - acquireProcessing / releaseProcessing:FR-8.2 G-021 isProcessing 防重入的锁语义封装
|
|
6
|
+
*
|
|
7
|
+
* 当前 handleAgentEnd 直接操纵 session.isProcessing(历史实现),未调用 acquire/release;
|
|
8
|
+
* 这两个封装保留供后续 handler 复用 + 被 stale-checker.test.ts 直接测试。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { GoalSession } from "../../session";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 构造 stale-check 闭包:入口快照 goalId,后续判断是否被新 goal 覆盖。
|
|
15
|
+
*
|
|
16
|
+
* 用法(agent_end):
|
|
17
|
+
* ```ts
|
|
18
|
+
* const checkStale = makeStaleChecker(session);
|
|
19
|
+
* // ... 长流程 ...
|
|
20
|
+
* if (checkStale()) return; // goal 被覆盖,本次 agent_end 作废
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* 语义:snapshot 时 session.state 可能为 null(首次启动),此时 snapshotGoalId
|
|
24
|
+
* 为 undefined;后续若有新 goal(goalId !== undefined)即视为 stale。
|
|
25
|
+
*/
|
|
26
|
+
export function makeStaleChecker(session: GoalSession): () => boolean {
|
|
27
|
+
const snapshotGoalId = session.state?.goalId;
|
|
28
|
+
return () => !session.state || session.state.goalId !== snapshotGoalId;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* FR-8.2 G-021:isProcessing 防重入。
|
|
33
|
+
*
|
|
34
|
+
* agent_end 可能并发触发(多 message),重入时直接返回(不重复预算检查/续跑)。
|
|
35
|
+
* 通过 session.isProcessing flag 实现(定义在 session.ts)。
|
|
36
|
+
* 本函数为 agent_end 提供「锁住 + 解锁」语义。
|
|
37
|
+
*/
|
|
38
|
+
export function acquireProcessing(session: GoalSession): boolean {
|
|
39
|
+
if (session.isProcessing) return false; // 已被占用
|
|
40
|
+
session.isProcessing = true;
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function releaseProcessing(session: GoalSession): void {
|
|
45
|
+
session.isProcessing = false;
|
|
46
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 事件 2: turn_end(FR-6.7 ESC 守卫 + 递增)。
|
|
3
|
+
*
|
|
4
|
+
* FR-6.7 ESC 守卫:ctx.signal.aborted 时跳过递增(ESC 不算 goal turn)。
|
|
5
|
+
* 正常路径:currentTurnIndex++ + updateWidget。
|
|
6
|
+
*
|
|
7
|
+
* 委托 service.applyEvent("turn_end")——它递增 currentTurnIndex 并返回
|
|
8
|
+
* EventEffect[{kind:"updateWidget"}]。adapter 执行该 effect。
|
|
9
|
+
*
|
|
10
|
+
* 不 persist(与旧 index.ts 行为对齐——turn_end 只内存变更 + widget)。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
14
|
+
|
|
15
|
+
import { updateWidget } from "../../projection/widget";
|
|
16
|
+
import { applyEvent } from "../../service";
|
|
17
|
+
import type { GoalSession } from "../../session";
|
|
18
|
+
import { buildPorts } from "../ports";
|
|
19
|
+
|
|
20
|
+
export async function handleTurnEnd(
|
|
21
|
+
pi: ExtensionAPI,
|
|
22
|
+
session: GoalSession,
|
|
23
|
+
ctx: ExtensionContext,
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
if (!session.state) return;
|
|
26
|
+
// FR-6.7 ESC 守卫
|
|
27
|
+
if (ctx.signal?.aborted) return;
|
|
28
|
+
|
|
29
|
+
const ports = buildPorts(pi, ctx);
|
|
30
|
+
const effects = applyEvent(session, "turn_end", undefined, ports);
|
|
31
|
+
// 执行 effects(updateWidget 等)
|
|
32
|
+
for (const effect of effects) {
|
|
33
|
+
if (effect.kind === "updateWidget") {
|
|
34
|
+
updateWidget(session, ports.ui);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* goal_control tool — agent 控制入口(create / complete / report_blocked)
|
|
3
|
+
*
|
|
4
|
+
* #3:替代已删除的 goal_manager tool。
|
|
5
|
+
*
|
|
6
|
+
* 职责分层:
|
|
7
|
+
* - execute 层(adapter 职责):signal 守卫
|
|
8
|
+
* - handler 层(goal 业务,契约对齐 code-architecture §3 handleCreate/handleComplete/handleReportBlocked):
|
|
9
|
+
* active 守卫 + evidence/reason 必填 + 状态转换
|
|
10
|
+
*
|
|
11
|
+
* 全解耦:goal 不再读 todo/plan 状态。complete 不做 todo 完成前置硬检查——
|
|
12
|
+
* todo 是否全完成由 AI 自行判断,goal 仅通过 prompt 软建议(见 prompts.ts)。
|
|
13
|
+
*
|
|
14
|
+
* 复用 engine/service 既有函数,不重写:
|
|
15
|
+
* - create: service.createGoal(FR-3.1 唯一创建入口;非终态旧 goal 拒绝,对齐 D25 / Codex create_goal)
|
|
16
|
+
* - complete: finalizeAndPersist(state, "complete", ...)(内部已含 tickState → finalizeGoal → persist)
|
|
17
|
+
* - report_blocked: 手动 tickState(status 仍 active 才累加当前运行段)→ transitionStatus(active→blocked) → persistState
|
|
18
|
+
*
|
|
19
|
+
* create 不调 sendUserMessage:toolcall 时 AI 已在 turn 中,返回结果后自行续跑
|
|
20
|
+
* (与 /goal set 的 followUp 触发区分;对齐 Codex create_goal 不自动续跑)。
|
|
21
|
+
*
|
|
22
|
+
* executionMode: "sequential"——状态变更 tool,不可与同批其他 tool 并行执行。
|
|
23
|
+
*
|
|
24
|
+
* 错误处理:用 throw new Error(CLAUDE.md Tool 设计规范),不返回错误成功模式。
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { StringEnum } from "@mariozechner/pi-ai";
|
|
28
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
|
|
29
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
30
|
+
import { type Static, Type } from "typebox";
|
|
31
|
+
|
|
32
|
+
import { isActiveStatus, isTerminalStatus, transitionStatus } from "../engine/goal";
|
|
33
|
+
import type { BudgetConfig, GoalStatus } from "../engine/types";
|
|
34
|
+
import { updateWidget } from "../projection/widget";
|
|
35
|
+
import { createGoal, finalizeAndPersist, persistState, type ServicePorts, tickState } from "../service";
|
|
36
|
+
import type { GoalSession } from "../session";
|
|
37
|
+
import { buildPorts } from "./ports";
|
|
38
|
+
|
|
39
|
+
// ── Params schema ────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
const GoalControlParams = Type.Object({
|
|
42
|
+
action: StringEnum(["create", "complete", "report_blocked"] as const),
|
|
43
|
+
slug: Type.Optional(
|
|
44
|
+
Type.String({
|
|
45
|
+
description:
|
|
46
|
+
"Required for 'create'. A short kebab-case identifier you generate to title this goal in the status bar (e.g. 'refactor-auth', 'fix-login-bug'). Keep it concise and descriptive.",
|
|
47
|
+
}),
|
|
48
|
+
),
|
|
49
|
+
objective: Type.Optional(
|
|
50
|
+
Type.String({
|
|
51
|
+
description:
|
|
52
|
+
"Required for 'create'. The concrete objective to start pursuing. Only create a goal when explicitly requested by the user.",
|
|
53
|
+
}),
|
|
54
|
+
),
|
|
55
|
+
evidence: Type.Optional(
|
|
56
|
+
Type.String({
|
|
57
|
+
description:
|
|
58
|
+
"Required for 'complete'. Concrete completion evidence (files created/modified, tests passed, commands run). Do not mark complete on assumption, intent, or partial progress.",
|
|
59
|
+
}),
|
|
60
|
+
),
|
|
61
|
+
reason: Type.Optional(
|
|
62
|
+
Type.String({
|
|
63
|
+
description:
|
|
64
|
+
"Required for 'report_blocked'. The specific blocking condition and what was already tried (at least 3 approaches). Do NOT use for uncertainty, slow/hard work, or incomplete progress — keep working.",
|
|
65
|
+
}),
|
|
66
|
+
),
|
|
67
|
+
tokenBudget: Type.Optional(
|
|
68
|
+
Type.Number({
|
|
69
|
+
description: "Optional, for 'create'. Positive token budget for the new goal. Omit unless the user specifies one.",
|
|
70
|
+
}),
|
|
71
|
+
),
|
|
72
|
+
timeBudgetMinutes: Type.Optional(
|
|
73
|
+
Type.Number({
|
|
74
|
+
description: "Optional, for 'create'. Positive time budget in minutes for the new goal. Omit unless the user specifies one.",
|
|
75
|
+
}),
|
|
76
|
+
),
|
|
77
|
+
completedTasks: Type.Optional(
|
|
78
|
+
Type.Number({
|
|
79
|
+
description: "Optional. Number of completed tasks, written into goal history on 'complete'. Defaults to 0.",
|
|
80
|
+
}),
|
|
81
|
+
),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
export type GoalControlActionParams = Static<typeof GoalControlParams>;
|
|
85
|
+
|
|
86
|
+
// ── Details(renderResult 数据来源)──────────────────
|
|
87
|
+
|
|
88
|
+
export interface GoalControlDetails {
|
|
89
|
+
action: "create" | "complete" | "report_blocked";
|
|
90
|
+
goalId: string;
|
|
91
|
+
status: GoalStatus;
|
|
92
|
+
slug?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── 业务 handler(契约对齐 §3,可测:fake ports)──────
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* create 业务逻辑:slug + objective 均必填 + 非终态旧 goal 守卫 + service.createGoal。
|
|
99
|
+
*
|
|
100
|
+
* slug:AI 生成的短标识,仅 widget 标题 + history 用,不注入 prompt。
|
|
101
|
+
* objective:完整描述,注入每轮 context prompt(保证方向感)。
|
|
102
|
+
*
|
|
103
|
+
* 全解耦:不读 todo/plan。toolcall 时 AI 已在 turn 中,**不**调 sendUserMessage
|
|
104
|
+
* (AI 返回后自行续跑,对齐 Codex create_goal)。
|
|
105
|
+
*
|
|
106
|
+
* 守卫用 D25 严格语义:非终态 active/paused/blocked 全挡,提示用 /goal resume 或
|
|
107
|
+
* /goal clear——防 AI 静默覆盖含未完成工作的 goal。
|
|
108
|
+
* 终态旧 goal 走 createGoal 快速路径覆盖(createGoal 内部 active 守卫,终态可覆盖)。
|
|
109
|
+
*/
|
|
110
|
+
export function handleCreate(
|
|
111
|
+
params: GoalControlActionParams,
|
|
112
|
+
session: GoalSession,
|
|
113
|
+
ports: ServicePorts,
|
|
114
|
+
): GoalControlDetails {
|
|
115
|
+
const objective = params.objective?.trim();
|
|
116
|
+
if (!objective) {
|
|
117
|
+
throw new Error("'objective' is required for create. Describe the concrete objective to pursue.");
|
|
118
|
+
}
|
|
119
|
+
const slug = params.slug?.trim();
|
|
120
|
+
if (!slug) {
|
|
121
|
+
throw new Error("'slug' is required for create. Provide a short kebab-case identifier (e.g. 'refactor-auth').");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// D25 严格守卫:非终态旧 goal(active/paused/blocked)→ 拒绝创建(防静默覆盖未完成工作)
|
|
125
|
+
if (session.state && !isTerminalStatus(session.state.status)) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Goal already active (status: ${session.state.status}). Use /goal resume to continue or /goal clear to reset before creating a new one.`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// budget 校验:非法预算直接拒绝,不静默截断
|
|
132
|
+
const budget: Partial<BudgetConfig> = {};
|
|
133
|
+
if (params.tokenBudget !== undefined) {
|
|
134
|
+
if (params.tokenBudget <= 0) {
|
|
135
|
+
throw new Error("'tokenBudget' must be greater than 0.");
|
|
136
|
+
}
|
|
137
|
+
budget.tokenBudget = params.tokenBudget;
|
|
138
|
+
}
|
|
139
|
+
if (params.timeBudgetMinutes !== undefined) {
|
|
140
|
+
if (params.timeBudgetMinutes <= 0) {
|
|
141
|
+
throw new Error("'timeBudgetMinutes' must be greater than 0.");
|
|
142
|
+
}
|
|
143
|
+
budget.timeBudgetMinutes = params.timeBudgetMinutes;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// FR-3.1: 唯一创建入口(isExternalInit=false)。终态旧 goal 走覆盖快速路径。
|
|
147
|
+
const created = createGoal(session, objective, budget, ports, false, slug);
|
|
148
|
+
if (!created) {
|
|
149
|
+
// createGoal 内部 active 守卫兜底(理论上上面守卫已挡;防御性)
|
|
150
|
+
throw new Error("Goal already active. Cannot create a new one.");
|
|
151
|
+
}
|
|
152
|
+
updateWidget(session, ports.ui);
|
|
153
|
+
|
|
154
|
+
const state = session.state!;
|
|
155
|
+
const budgetNotice: string[] = [];
|
|
156
|
+
if (budget.tokenBudget) budgetNotice.push(`Token budget: ${budget.tokenBudget}`);
|
|
157
|
+
if (budget.timeBudgetMinutes) budgetNotice.push(`Time budget: ${budget.timeBudgetMinutes} min`);
|
|
158
|
+
ports.ui.notify([`Goal created [${slug}]: ${objective}`, ...budgetNotice].join("\n"), "info");
|
|
159
|
+
|
|
160
|
+
return { action: "create", goalId: state.goalId, status: state.status, slug };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* complete 业务逻辑:active 守卫 + evidence 必填 + finalizeAndPersist。
|
|
165
|
+
*
|
|
166
|
+
* 全解耦后不再做 todo 完成前置检查——todo 是否全完成由 AI 自行判断(prompt 软建议)。
|
|
167
|
+
*/
|
|
168
|
+
export function handleComplete(
|
|
169
|
+
params: GoalControlActionParams,
|
|
170
|
+
session: GoalSession,
|
|
171
|
+
ports: ServicePorts,
|
|
172
|
+
): GoalControlDetails {
|
|
173
|
+
const state = session.state;
|
|
174
|
+
if (!state) throw new Error("Goal mode not active.");
|
|
175
|
+
if (!isActiveStatus(state.status)) {
|
|
176
|
+
throw new Error(`Goal is not active (status: ${state.status}). Only an active goal can be completed.`);
|
|
177
|
+
}
|
|
178
|
+
const evidence = params.evidence?.trim();
|
|
179
|
+
if (!evidence) {
|
|
180
|
+
throw new Error("'evidence' is required for complete. Provide concrete completion evidence.");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// FR-3.3: 唯一终态序列入口(内部:tickState → finalizeGoal(transition+history) → persist)
|
|
184
|
+
finalizeAndPersist(state, "complete", params.completedTasks ?? 0, ports);
|
|
185
|
+
updateWidget(session, ports.ui);
|
|
186
|
+
ports.ui.notify(`Goal completed: ${state.objective}`, "info");
|
|
187
|
+
|
|
188
|
+
return { action: "complete", goalId: state.goalId, status: state.status };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* report_blocked 业务逻辑:active 守卫 + reason 必填 + tickState + transitionStatus + persistState。
|
|
193
|
+
*
|
|
194
|
+
* 必须在 transitionStatus **之前** tickState,使 tick 看到 active 状态并累加当前运行段;
|
|
195
|
+
* 否则转 blocked 后 persistState 内部的 tick 因 status≠active 不累加,丢失最后一段运行时间。
|
|
196
|
+
*/
|
|
197
|
+
export function handleReportBlocked(
|
|
198
|
+
params: GoalControlActionParams,
|
|
199
|
+
session: GoalSession,
|
|
200
|
+
ports: ServicePorts,
|
|
201
|
+
): GoalControlDetails {
|
|
202
|
+
const state = session.state;
|
|
203
|
+
if (!state) throw new Error("Goal mode not active.");
|
|
204
|
+
if (state.status !== "active") {
|
|
205
|
+
throw new Error(`Goal is not active (status: ${state.status}). Only an active goal can report_blocked.`);
|
|
206
|
+
}
|
|
207
|
+
const reason = params.reason?.trim();
|
|
208
|
+
if (!reason) {
|
|
209
|
+
throw new Error("'reason' is required for report_blocked. Describe the blocking condition.");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
state.lastBlockerReason = reason;
|
|
213
|
+
// 先 tickState 累加当前运行段(此时 status 仍为 active)
|
|
214
|
+
tickState(state);
|
|
215
|
+
state.status = transitionStatus(state.status, "blocked");
|
|
216
|
+
|
|
217
|
+
persistState(session, ports);
|
|
218
|
+
updateWidget(session, ports.ui);
|
|
219
|
+
ports.ui.notify(`Goal blocked: ${reason}`, "warning");
|
|
220
|
+
|
|
221
|
+
return { action: "report_blocked", goalId: state.goalId, status: state.status };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── Tool 注册 ────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
export function registerGoalControlTool(pi: ExtensionAPI, session: GoalSession): void {
|
|
227
|
+
pi.registerTool({
|
|
228
|
+
name: "goal_control",
|
|
229
|
+
label: "Goal Control",
|
|
230
|
+
description:
|
|
231
|
+
"Manage the goal for this thread.\n\nActions:\n- create: start a new goal. Requires `slug` (a short kebab-case identifier you generate) and `objective` (the full description). Only use when the user explicitly asks to start a goal; do not infer goals from ordinary tasks. Fails if a goal is already active/paused/blocked (use /goal resume or /goal clear first).\n- complete: mark the active goal complete. Requires `evidence` with concrete proof (files/tests/commands). Recommend finishing all todos (including verification todos) first, but you decide.\n- report_blocked: mark the active goal blocked by a real blocker. Requires `reason` describing the block and what was tried. Only after genuine exhaustion of alternatives.",
|
|
232
|
+
promptSnippet:
|
|
233
|
+
"Use goal_control to manage the thread goal: create (with slug + objective, only when user asks) or complete (with evidence) or report_blocked (with reason, after trying alternatives).",
|
|
234
|
+
executionMode: "sequential",
|
|
235
|
+
parameters: GoalControlParams,
|
|
236
|
+
|
|
237
|
+
async execute(
|
|
238
|
+
_toolCallId: string,
|
|
239
|
+
params: GoalControlActionParams,
|
|
240
|
+
signal: AbortSignal | undefined,
|
|
241
|
+
_onUpdate: unknown,
|
|
242
|
+
ctx: ExtensionContext,
|
|
243
|
+
): Promise<{ content: Array<{ type: "text"; text: string }>; details: GoalControlDetails }> {
|
|
244
|
+
if (signal?.aborted) {
|
|
245
|
+
throw new Error("goal_control aborted by signal.");
|
|
246
|
+
}
|
|
247
|
+
const ports = buildPorts(pi, ctx);
|
|
248
|
+
|
|
249
|
+
let details: GoalControlDetails;
|
|
250
|
+
if (params.action === "create") {
|
|
251
|
+
details = handleCreate(params, session, ports);
|
|
252
|
+
} else if (params.action === "complete") {
|
|
253
|
+
// 全解耦:不再做 todo 完成前置硬检查。AI 自行判断 todo 是否全完成(prompt 软建议)。
|
|
254
|
+
details = handleComplete(params, session, ports);
|
|
255
|
+
} else {
|
|
256
|
+
details = handleReportBlocked(params, session, ports);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const text =
|
|
260
|
+
details.action === "create"
|
|
261
|
+
? `Goal created.\nGoal ID: ${details.goalId}\nSlug: ${details.slug ?? ""}\nObjective: ${params.objective?.trim() ?? ""}`
|
|
262
|
+
: details.action === "complete"
|
|
263
|
+
? `Goal completed.\nGoal ID: ${details.goalId}`
|
|
264
|
+
: `Goal reported blocked.\nGoal ID: ${details.goalId}\nReason: ${params.reason?.trim() ?? ""}`;
|
|
265
|
+
return { content: [{ type: "text", text }], details };
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
renderCall(args: Record<string, unknown>, theme: Theme): Text {
|
|
269
|
+
const action = args.action as string;
|
|
270
|
+
const slug = typeof args.slug === "string" ? args.slug : "";
|
|
271
|
+
const actionLabel =
|
|
272
|
+
action === "create"
|
|
273
|
+
? theme.fg("accent", "create") + (slug ? theme.fg("dim", ` ${slug}`) : "")
|
|
274
|
+
: action === "complete"
|
|
275
|
+
? theme.fg("success", "complete")
|
|
276
|
+
: theme.fg("error", "report_blocked");
|
|
277
|
+
return new Text(theme.fg("toolTitle", theme.bold("goal_control ")) + actionLabel, 0, 0);
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
renderResult(result: unknown, _options: { expanded: boolean }, theme: Theme): Text {
|
|
281
|
+
const d = (result as { details?: GoalControlDetails }).details;
|
|
282
|
+
if (!d) return new Text(theme.fg("dim", "goal_control"), 0, 0);
|
|
283
|
+
const statusColor =
|
|
284
|
+
d.status === "active"
|
|
285
|
+
? "accent"
|
|
286
|
+
: d.status === "complete"
|
|
287
|
+
? "success"
|
|
288
|
+
: d.status === "blocked"
|
|
289
|
+
? "error"
|
|
290
|
+
: "muted";
|
|
291
|
+
const label = d.action === "create" ? "Created" : d.action === "complete" ? "Completed" : "Blocked";
|
|
292
|
+
const slugSuffix = d.slug ? theme.fg("dim", ` ${d.slug}`) : "";
|
|
293
|
+
return new Text(theme.fg(statusColor, `◆ Goal ${label}`) + slugSuffix, 0, 0);
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ports 桥接 — Pi → ServicePorts 适配(adapters 层)
|
|
3
|
+
*
|
|
4
|
+
* 单一 ports 构造点(DRY):command-adapter / event-adapter / index 共用。
|
|
5
|
+
*
|
|
6
|
+
* - persistence: pi.appendEntry 映射到 appendState / appendHistory(type 字符串区分)
|
|
7
|
+
* - ui: ctx.ui 的 setWidget/setStatus/notify + hasUI + theme 的 fg/bold(满足 ThemeLike 形状)
|
|
8
|
+
* - messaging: pi.sendMessage 映射到 sendContextMessage / sendUserMessage
|
|
9
|
+
* - session: ctx.sessionManager.getEntries + ctx.getContextUsage + ctx.signal
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
13
|
+
|
|
14
|
+
import { ENTRY_TYPE, HISTORY_ENTRY_TYPE } from "../persistence";
|
|
15
|
+
import type { MessagingPort, PersistencePort, SessionPort, UiPort } from "../ports";
|
|
16
|
+
import type { ServicePorts } from "../service";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 把 Pi 的 pi / ctx 适配为 ServicePorts。
|
|
20
|
+
*
|
|
21
|
+
* persistence 的 appendState 用 ENTRY_TYPE,appendHistory 用 HISTORY_ENTRY_TYPE,
|
|
22
|
+
* 与 serializeState / makeHistoryEntry 的输出对齐(session.ts reconstructGoalState 据此识别)。
|
|
23
|
+
*
|
|
24
|
+
* UiPort 接口未声明 fg/bold(D-22:只声明机器可检查的能力边界),
|
|
25
|
+
* 构造满足 UiPort & ThemeLike 的对象后整体断言为 UiPort(多出的 fg/bold 运行时存在,
|
|
26
|
+
* projection/widget.ts 的 asTheme 用 `as unknown as ThemeLike` 单步断言取出)。
|
|
27
|
+
*/
|
|
28
|
+
export function buildPorts(pi: ExtensionAPI, ctx: ExtensionContext): ServicePorts {
|
|
29
|
+
const persistence: PersistencePort = {
|
|
30
|
+
appendState: (state): void => {
|
|
31
|
+
pi.appendEntry(ENTRY_TYPE, state);
|
|
32
|
+
},
|
|
33
|
+
appendHistory: (entry): void => {
|
|
34
|
+
pi.appendEntry(HISTORY_ENTRY_TYPE, entry);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const uiPort = {
|
|
39
|
+
setWidget(name: string, content: string[] | string | undefined): void {
|
|
40
|
+
ctx.ui.setWidget(name, content);
|
|
41
|
+
},
|
|
42
|
+
setStatus(name: string, text: string | undefined): void {
|
|
43
|
+
ctx.ui.setStatus(name, text);
|
|
44
|
+
},
|
|
45
|
+
notify(text: string, level: "info" | "warning" | "error"): void {
|
|
46
|
+
ctx.ui.notify(text, level);
|
|
47
|
+
},
|
|
48
|
+
get hasUI(): boolean {
|
|
49
|
+
return Boolean(ctx.hasUI);
|
|
50
|
+
},
|
|
51
|
+
// ThemeLike 形状:透传 ctx.ui.theme 的 fg/bold。
|
|
52
|
+
// `as never` 是合法的单步断言(ThemeColor 是 string 子集,运行时安全)。
|
|
53
|
+
fg(color: string, text: string): string {
|
|
54
|
+
return ctx.ui.theme.fg(color as never, text);
|
|
55
|
+
},
|
|
56
|
+
bold(text: string): string {
|
|
57
|
+
return ctx.ui.theme.bold(text);
|
|
58
|
+
},
|
|
59
|
+
} as UiPort;
|
|
60
|
+
|
|
61
|
+
const messaging: MessagingPort = {
|
|
62
|
+
sendContextMessage: (content, deliverAs, customType): void => {
|
|
63
|
+
pi.sendMessage(
|
|
64
|
+
{
|
|
65
|
+
customType: customType ?? "goal-context",
|
|
66
|
+
content,
|
|
67
|
+
display: false,
|
|
68
|
+
},
|
|
69
|
+
{ deliverAs },
|
|
70
|
+
);
|
|
71
|
+
},
|
|
72
|
+
sendUserMessage: (content, deliverAs): void => {
|
|
73
|
+
pi.sendUserMessage(content, { deliverAs });
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const session: SessionPort = {
|
|
78
|
+
getEntries: () => ctx.sessionManager.getEntries(),
|
|
79
|
+
getContextUsage: () => {
|
|
80
|
+
const usage = ctx.getContextUsage();
|
|
81
|
+
return usage ? { tokens: usage.tokens, contextWindow: usage.contextWindow } : null;
|
|
82
|
+
},
|
|
83
|
+
get signal(): AbortSignal | undefined {
|
|
84
|
+
return ctx.signal;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return { persistence, ui: uiPort, messaging, session };
|
|
89
|
+
}
|