@zhushanwen/pi-goal 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +3 -3
  2. package/package.json +3 -3
  3. package/src/__tests__/circuit-breaker.test.ts +1 -1
  4. package/src/__tests__/criteria-array-adapter.test.ts +4 -4
  5. package/src/__tests__/criteria-array-migration.test.ts +0 -2
  6. package/src/__tests__/deserialize-state.test.ts +28 -6
  7. package/src/__tests__/event-adapter.test.ts +1 -1
  8. package/src/__tests__/goal-control-adapter.test.ts +5 -5
  9. package/src/__tests__/goal-control-rpc.test.ts +1 -1
  10. package/src/__tests__/ports.test.ts +1 -1
  11. package/src/__tests__/schema.test.ts +15 -0
  12. package/src/__tests__/service.test.ts +5 -5
  13. package/src/__tests__/session.test.ts +0 -2
  14. package/src/__tests__/stale-checker.test.ts +2 -2
  15. package/src/adapters/command-adapter.ts +2 -4
  16. package/src/adapters/event-handlers/agent-end.ts +18 -1
  17. package/src/adapters/event-handlers/before-agent-start.ts +2 -2
  18. package/src/adapters/event-handlers/message-end.ts +2 -14
  19. package/src/adapters/goal-control-adapter.ts +1 -1
  20. package/src/adapters/ports.ts +17 -23
  21. package/src/engine/__tests__/budget.test.ts +7 -7
  22. package/src/engine/__tests__/goal.test.ts +40 -1
  23. package/src/engine/budget.ts +7 -13
  24. package/src/engine/goal.ts +0 -2
  25. package/src/engine/types.ts +0 -2
  26. package/src/index.ts +17 -57
  27. package/src/persistence.ts +4 -5
  28. package/src/ports.ts +13 -3
  29. package/src/projection/__tests__/prompts.test.ts +19 -11
  30. package/src/projection/__tests__/widget.test.ts +9 -6
  31. package/src/projection/prompts.ts +8 -28
  32. package/src/projection/widget.ts +6 -38
  33. package/src/service.ts +6 -6
  34. package/src/adapters/event-adapter.ts +0 -17
  35. package/src/adapters/event-handlers/shared.ts +0 -27
package/README.md CHANGED
@@ -52,8 +52,8 @@ goal/
52
52
  ├── service.ts # 协调层 — createGoal / finalizeAndPersist / applyEvent
53
53
  ├── persistence.ts # serialize/deserialize + GoalHistoryEntry
54
54
  ├── session.ts # 运行时句柄 + 状态重建(append-only 只读最新一条,无 entry GC)
55
- ├── engine/ # 零 Pi 依赖的纯状态机(goal/budget/types)
56
- ├── adapters/ # Pi 桥接(goal-control-adapter / command-adapter / event-adapter
57
- │ # + event-handlers/ + ports / success-criteria)
55
+ ├── engine/ # 零 Pi 依赖的纯状态机(goal/budget/types/liveness)
56
+ ├── adapters/ # Pi 桥接(goal-control-adapter / command-adapter / ports /
57
+ │ # success-criteria + event-handlers/)
58
58
  └── projection/ # 渲染(widget / prompts / gui)
59
59
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-goal",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "description": "Codex-style /goal command for Pi — persistent goal-driven autonomous loop with evidence-based completion, token budget, agent-reported blocking, and steering templates.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -29,10 +29,10 @@
29
29
  "@earendil-works/pi-coding-agent": "^0.84.4",
30
30
  "@earendil-works/pi-tui": "^0.84.4",
31
31
  "typebox": "*",
32
- "@zhushanwen/pi-pending-notifications": "0.6.0"
32
+ "@zhushanwen/pi-pending-notifications": "0.7.1"
33
33
  },
34
34
  "dependencies": {
35
- "@xyz-agent/extension-protocol": "0.8.2"
35
+ "@xyz-agent/extension-protocol": "0.10.0"
36
36
  },
37
37
  "peerDependenciesMeta": {
38
38
  "@earendil-works/pi-tui": {
@@ -11,7 +11,7 @@
11
11
  * 另覆盖:停发通知含 /goal resume 恢复指引 + goal 保持 active;/goal resume
12
12
  * 恢复通道重置计数;未封顶 active goal 的 resume 语义不变。
13
13
  *
14
- * fake timers + fake pi/ctx(不 import Pi SDK 真实实现,harness 对齐 event-adapter.test.ts)。
14
+ * fake timers + fake pi/ctx(不 import Pi SDK 真实实现,harness 对齐 __tests__/event-adapter.test.ts)。
15
15
  */
16
16
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
17
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -38,8 +38,10 @@ function makeFakePorts(): ServicePorts & {
38
38
  hasUI: true,
39
39
  isGui: false,
40
40
  setGuiWidget: () => {},
41
- fg: (_color: string, text: string) => text,
42
- bold: (text: string) => text,
41
+ theme: {
42
+ fg: (_color: string, text: string) => text,
43
+ bold: (text: string) => text,
44
+ },
43
45
  } as UiPort,
44
46
  messaging: {
45
47
  sendContextMessage: () => {},
@@ -47,8 +49,6 @@ function makeFakePorts(): ServicePorts & {
47
49
  },
48
50
  session: {
49
51
  getEntries: () => [],
50
- getContextUsage: () => null,
51
- signal: undefined,
52
52
  },
53
53
  };
54
54
  }
@@ -24,9 +24,7 @@ function makeLegacyData(successCriteria: unknown): Record<string, unknown> {
24
24
  timeStartedAt: 1000,
25
25
  timeUsedSeconds: 0,
26
26
  budget: {},
27
- lastProgressTurn: 0,
28
27
  budgetLimitSteeringSent: false,
29
- objectiveUpdatedAt: 1000,
30
28
  lastBlockerReason: null,
31
29
  tokenWarning70Sent: false,
32
30
  tokenWarning90Sent: false,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * FR-5/FR-7.3: deserializeState — 新格式严格解析(字段缺失 throw)
2
+ * FR-5/FR-7.3: deserializeState — 严格解析(必填缺失 throw;旧格式容忍面见 persistence.ts 各迁移点注释)
3
3
  *
4
4
  * round-trip property 覆盖全字段 serialize→deserialize 双向一致性,替代手写还原用例。
5
5
  * 保留:throw 路径(缺必填)+ 向后兼容(缺 optional)+ 旧 tasks 字段忽略。
@@ -32,9 +32,7 @@ const goalStateArb = fc.record({
32
32
  timeStartedAt: fc.integer({ min: 0 }),
33
33
  timeUsedSeconds: fc.nat(),
34
34
  budget: fc.record({ tokenBudget: fc.option(fc.integer({ min: 0 })) }),
35
- lastProgressTurn: fc.nat(),
36
35
  budgetLimitSteeringSent: fc.boolean(),
37
- objectiveUpdatedAt: fc.integer({ min: 0 }),
38
36
  lastBlockerReason: fc.option(fc.string(), { nil: null }), // string | null
39
37
  tokenWarning70Sent: fc.boolean(),
40
38
  tokenWarning90Sent: fc.boolean(),
@@ -59,9 +57,7 @@ const FULL_DATA: Record<string, unknown> = {
59
57
  timeStartedAt: 1000,
60
58
  timeUsedSeconds: 0,
61
59
  budget: {},
62
- lastProgressTurn: 0,
63
60
  budgetLimitSteeringSent: false,
64
- objectiveUpdatedAt: 1000,
65
61
  lastBlockerReason: null,
66
62
  tokenWarning70Sent: false,
67
63
  tokenWarning90Sent: false,
@@ -70,7 +66,7 @@ const FULL_DATA: Record<string, unknown> = {
70
66
  };
71
67
 
72
68
  describe("deserializeState — round-trip", () => {
73
- // ⭐ serialize→deserialize 深相等:覆盖全 18 字段双向一致性(含 optional 有/无两态)
69
+ // ⭐ serialize→deserialize 深相等:覆盖全 21 字段双向一致性(含 optional 有/无两态)
74
70
  it.prop([goalStateArb])("deserializeState(serializeState(s)) 深相等 s", (s) => {
75
71
  const rt = deserializeState(serializeState(s) as unknown as Record<string, unknown>);
76
72
  expect(rt).toEqual(s);
@@ -111,6 +107,32 @@ describe("deserializeState — 严格解析(缺必填 throw)", () => {
111
107
  });
112
108
  });
113
109
 
110
+ describe("deserializeState — legacy entry 含已删字段(P-m14-1,D2 白名单兼容)", () => {
111
+ // ext-simplify-03 D2:lastProgressTurn/objectiveUpdatedAt 已从 GoalRuntimeState 一步删除。
112
+ // 0.13.0 形状 entry 仍携带这两个字段——deserializeState 是白名单模式(只对已知 key 逐个
113
+ // req()/可选解析,从不遍历 entry 全部 key,与 tasks 字段先例同一机制),entry 多出的
114
+ // 字段天然被忽略:不 throw、state 完整重建(G3:旧数据加载兼容)。
115
+ const LEGACY_0_13: Record<string, unknown> = {
116
+ ...FULL_DATA,
117
+ lastProgressTurn: 3,
118
+ objectiveUpdatedAt: 2000,
119
+ };
120
+
121
+ it("0.13.0 形状 entry(含已删两字段)→ 不 throw,返回完整 state", () => {
122
+ const state = deserializeState(LEGACY_0_13);
123
+ expect(state.goalId).toBe("g1");
124
+ expect(state.objective).toBe("test");
125
+ expect(state.status).toBe("active");
126
+ expect(state.budgetLimitSteeringSent).toBe(false);
127
+ });
128
+
129
+ it("已删字段被白名单忽略:返回对象不含 lastProgressTurn/objectiveUpdatedAt", () => {
130
+ const state = deserializeState(LEGACY_0_13);
131
+ expect(state).not.toHaveProperty("lastProgressTurn");
132
+ expect(state).not.toHaveProperty("objectiveUpdatedAt");
133
+ });
134
+ });
135
+
114
136
  describe("deserializeState — optional 字段向后兼容(GAP-4 旧数据)", () => {
115
137
  it("缺 completedAtTurnIndex → undefined", () => {
116
138
  expect(deserializeState(FULL_DATA).completedAtTurnIndex).toBeUndefined();
@@ -1,5 +1,5 @@
1
1
  /**
2
- * event-adapter.ts 测试 — agent_end / before_agent_start 核心逻辑
2
+ * event-handlers 行为测试 — agent_end / before_agent_start 核心逻辑
3
3
  *
4
4
  * 覆盖 agent_end 的 continuation + ESC 守卫(FR-6.7)+
5
5
  * before_agent_start 的 AUTO_CLEAR/context wrap-up。
@@ -52,9 +52,11 @@ function makeFakePorts(): ServicePorts & {
52
52
  notifications.push({ text, level });
53
53
  },
54
54
  hasUI: true,
55
- // widget.asTheme 期望 fg/bold(ports.ts 的 uiPort 同样携带)
56
- fg: (_color: string, text: string) => text,
57
- bold: (text: string) => text,
55
+ // widget 渲染读 UiPort.theme(ports.ts 的 uiPort 同样以 theme 嵌套成员提供)
56
+ theme: {
57
+ fg: (_color: string, text: string) => text,
58
+ bold: (text: string) => text,
59
+ },
58
60
  } as UiPort,
59
61
  messaging: {
60
62
  sendContextMessage: () => {},
@@ -62,8 +64,6 @@ function makeFakePorts(): ServicePorts & {
62
64
  },
63
65
  session: {
64
66
  getEntries: () => [],
65
- getContextUsage: () => null,
66
- signal: undefined,
67
67
  },
68
68
  };
69
69
  }
@@ -47,7 +47,7 @@ interface FakeFixture {
47
47
  * 构造最小 fake pi / ctx。ctx.mode 由调用方覆盖(默认 "rpc")。
48
48
  *
49
49
  * ports.ts 的 buildPorts 读 ctx.ui.theme.fg/bold、ctx.ui.setWidget/setStatus/notify、
50
- * ctx.sessionManager.getEntries、ctx.getContextUsage、ctx.signal、ctx.hasUI——全部 mock。
50
+ * ctx.sessionManager.getEntries、ctx.hasUI——全部 mock。
51
51
  */
52
52
  function makeFixture(mode: "rpc" | "tui" | "json" | "print" = "rpc"): FakeFixture {
53
53
  const notifications: Array<{ text: string; level: string }> = [];
@@ -46,7 +46,7 @@ describe("buildPorts UiPort.setGuiWidget(marker 编码集成)", () => {
46
46
  const captured = getCaptured();
47
47
  expect(captured).toBeDefined();
48
48
  expect(captured).toHaveLength(1);
49
- // marker 前缀(host 侧 event-adapter 据此解码成 extension:widgetGui)
49
+ // marker 前缀(host 侧 EventAdapter 据此解码成 extension:widgetGui)
50
50
  expect(captured![0].startsWith(GUI_WIDGET_MARKER)).toBe(true);
51
51
  // 解码还原 GuiComponent
52
52
  const json = captured![0].slice(GUI_WIDGET_MARKER.length);
@@ -16,6 +16,8 @@ import { describe, expect, it } from "vitest";
16
16
  import { Value } from "typebox/value";
17
17
 
18
18
  import { GoalControlParams } from "../adapters/goal-control-adapter";
19
+ import { createGoalState } from "../engine/goal";
20
+ import { serializeState } from "../persistence";
19
21
 
20
22
  describe("GoalControlParams — 扁平 Object(OpenAI 兼容)", () => {
21
23
  // 顶层合规:序列化形态顶层必须有 type:"object",不得有 anyOf(否则被严格网关 400 拒绝)
@@ -52,3 +54,16 @@ describe("GoalControlParams — 扁平 Object(OpenAI 兼容)", () => {
52
54
  expect(Value.Check(GoalControlParams, params)).toBe(false);
53
55
  });
54
56
  });
57
+
58
+ // ── 持久化 schema:serializeState 输出(P-m14-2,ext-simplify-03 D2)──
59
+
60
+ describe("persisted goal-state schema — serializeState 输出(P-m14-2)", () => {
61
+ // D2 一步删除 lastProgressTurn/objectiveUpdatedAt 后,新写入的 goal-state entry
62
+ // 不得再携带这两个 write-only 字段。serializeState 是浅拷贝透传({ ...state, budget: {...} }),
63
+ // 字段从 GoalRuntimeState 删除后输出自动少两键——此处断言删除完整(fixture 残留即红)。
64
+ it("新写入 entry(serializeState(createGoalState))不含已删两字段", () => {
65
+ const serialized = serializeState(createGoalState("obj"));
66
+ expect(serialized).not.toHaveProperty("lastProgressTurn");
67
+ expect(serialized).not.toHaveProperty("objectiveUpdatedAt");
68
+ });
69
+ });
@@ -34,9 +34,11 @@ function makeFakePorts(): ServicePorts & {
34
34
  setStatus: () => {},
35
35
  notify: () => {},
36
36
  hasUI: true,
37
- // ThemeLike 直接成员(widget 经 asTheme 取出,见 projection/widget.ts)
38
- fg: (_color: string, text: string) => text,
39
- bold: (text: string) => text,
37
+ // ThemeLike 嵌套成员(UiPort.theme 显式声明,updateWidget 直读 uiPort.theme)
38
+ theme: {
39
+ fg: (_color: string, text: string) => text,
40
+ bold: (text: string) => text,
41
+ },
40
42
  },
41
43
  messaging: {
42
44
  sendContextMessage: () => {},
@@ -44,8 +46,6 @@ function makeFakePorts(): ServicePorts & {
44
46
  },
45
47
  session: {
46
48
  getEntries: () => [],
47
- getContextUsage: () => null,
48
- signal: undefined,
49
49
  },
50
50
  };
51
51
  }
@@ -31,8 +31,6 @@ function makeFakeSessionPort(entries: SessionEntryLike[]): SessionPort {
31
31
  return {
32
32
  // 模拟 Pi SDK filter-copy 语义:返回新数组,避免测试掩盖 splice GC 失效
33
33
  getEntries: () => entries.slice(),
34
- getContextUsage: () => null,
35
- signal: undefined,
36
34
  };
37
35
  }
38
36
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * event-adapter 并发保护辅助函数测试(TC-3)
2
+ * agent-end stale-check 辅助函数测试(TC-3)
3
3
  *
4
4
  * 直接测试 makeStaleChecker 的快照语义,
5
5
  * 非间接覆盖。重点:makeStaleChecker 在 state=null 时 snapshot=undefined,
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { describe, expect, it } from "vitest";
9
9
 
10
- import { makeStaleChecker } from "../adapters/event-handlers/shared";
10
+ import { makeStaleChecker } from "../adapters/event-handlers/agent-end";
11
11
  import { createGoalState } from "../engine/goal";
12
12
  import type { GoalRuntimeState } from "../engine/types";
13
13
  import { createGoalSession } from "../session";
@@ -154,7 +154,7 @@ function handleResume(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionCont
154
154
  return;
155
155
  }
156
156
  if (state.status !== "active") {
157
- state.status = "active";
157
+ state.status = transitionStatus(state.status, "active");
158
158
  state.timeStartedAt = Date.now();
159
159
  }
160
160
  // W5:resume = 新激活周期——熔断计数清零。主判据总次数(continuationsSent)只经
@@ -284,7 +284,7 @@ function handleClear(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionConte
284
284
 
285
285
  /**
286
286
  * FR-8.4 G-002:重塑(reset)。重置 objective/budget flags/
287
- * currentTurnIndex/lastProgressTurn,保留 goalId。
287
+ * currentTurnIndex,保留 goalId。
288
288
  * active 状态下向 AI 注入 objective-updated steering。
289
289
  */
290
290
  function handleUpdate(
@@ -313,9 +313,7 @@ function handleUpdate(
313
313
  const oldObjective = state.objective;
314
314
  // FR-8.4 G-002: 重塑(重置,保留 goalId)
315
315
  state.objective = newObjective;
316
- state.objectiveUpdatedAt = Date.now();
317
316
  state.currentTurnIndex = 0;
318
- state.lastProgressTurn = 0;
319
317
  state.budgetLimitSteeringSent = false;
320
318
  state.tokenWarning70Sent = false;
321
319
  state.tokenWarning90Sent = false;
@@ -43,7 +43,6 @@ import { serializeState } from "../../persistence";
43
43
  import { cancelContinuationTimer, type GoalSession } from "../../session";
44
44
  import type { ServicePorts } from "../../service";
45
45
  import { buildPorts } from "../ports";
46
- import { makeStaleChecker } from "./shared";
47
46
 
48
47
  export async function handleAgentEnd(
49
48
  pi: ExtensionAPI,
@@ -353,3 +352,21 @@ function deliverContinuation(ports: ServicePorts, session: GoalSession): void {
353
352
  ports.messaging.sendContextMessage(continuationPrompt(state), "followUp");
354
353
  ports.persistence.appendState(serializeState(state));
355
354
  }
355
+
356
+ /**
357
+ * 构造 stale-check 闭包(FR-8.2 G-020):入口快照 goalId,后续判断是否被新 goal 覆盖。
358
+ *
359
+ * 用法(agent_end):
360
+ * ```ts
361
+ * const checkStale = makeStaleChecker(session);
362
+ * // ... 长流程 ...
363
+ * if (checkStale()) return; // goal 被覆盖,本次 agent_end 作废
364
+ * ```
365
+ *
366
+ * 语义:snapshot 时 session.state 可能为 null(首次启动),此时 snapshotGoalId
367
+ * 为 undefined;后续若有新 goal(goalId !== undefined)即视为 stale。
368
+ */
369
+ export function makeStaleChecker(session: GoalSession): () => boolean {
370
+ const snapshotGoalId = session.state?.goalId;
371
+ return () => !session.state || session.state.goalId !== snapshotGoalId;
372
+ }
@@ -20,7 +20,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
20
20
  import { AUTO_CLEAR_TURNS, CONTEXT_USAGE_RATIO_LIMIT } from "../../constants";
21
21
  import { isActiveStatus, isTerminalStatus } from "../../engine/goal";
22
22
  import { contextInjectionPrompt } from "../../projection/prompts";
23
- import { asTheme, renderTerminalStatusLine } from "../../projection/widget";
23
+ import { renderTerminalStatusLine } from "../../projection/widget";
24
24
  import type { GoalSession } from "../../session";
25
25
  import { cancelContinuationTimer, clearGoalSession } from "../../session";
26
26
  import { buildPorts } from "../ports";
@@ -92,7 +92,7 @@ function handleTerminalStateBeforeAgent(
92
92
  return;
93
93
  }
94
94
  // 折叠 status bar(终态显示)
95
- const statusText = renderTerminalStatusLine(state, asTheme(buildPorts(pi, ctx).ui));
95
+ const statusText = renderTerminalStatusLine(state, buildPorts(pi, ctx).ui.theme);
96
96
  if (statusText && ctx.hasUI) ctx.ui.setStatus("goal", statusText);
97
97
  if (ctx.hasUI) ctx.ui.setWidget("goal", undefined);
98
98
  }
@@ -7,27 +7,15 @@
7
7
  * 不 persist / updateWidget。
8
8
  */
9
9
 
10
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import type { ExtensionContext, MessageEndEvent } from "@earendil-works/pi-coding-agent";
11
11
 
12
12
  import { applyEvent } from "../../service";
13
13
  import type { GoalSession } from "../../session";
14
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
15
  export async function handleMessageEnd(
28
16
  session: GoalSession,
29
17
  ctx: ExtensionContext,
30
- event: MessageEndLikeEvent,
18
+ event: MessageEndEvent,
31
19
  ): Promise<void> {
32
20
  if (!session.state) return;
33
21
  // FR-6.7 ESC 守卫
@@ -28,7 +28,7 @@
28
28
  *
29
29
  * executionMode: "sequential"——状态变更 tool,不可与同批其他 tool 并行执行。
30
30
  *
31
- * 错误处理:用 throw new Error(CLAUDE.md Tool 设计规范),不返回错误成功模式。
31
+ * 错误处理:用 throw new Error(extension-conventions.md Tool 设计规范),不返回错误成功模式。
32
32
  */
33
33
 
34
34
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
@@ -1,12 +1,15 @@
1
1
  /**
2
2
  * Ports 桥接 — Pi → ServicePorts 适配(adapters 层)
3
3
  *
4
- * 单一 ports 构造点(DRY):command-adapter / event-adapter / index 共用。
4
+ * 单一 ports 构造点(DRY):command-adapter / goal-control-adapter / event-handlers / index 共用。
5
5
  *
6
6
  * - persistence: pi.appendEntry 映射到 appendState / appendHistory(type 字符串区分)
7
- * - ui: ctx.ui 的 setWidget/setStatus/notify + hasUI + theme 的 fg/bold(满足 ThemeLike 形状)
7
+ * - ui: ctx.ui 的 setWidget/setStatus/notify + hasUI + isGui + theme(fg/bold 适配 ThemeLike)
8
8
  * - messaging: pi.sendMessage 映射到 sendContextMessage / sendUserMessage
9
- * - session: ctx.sessionManager.getEntries + ctx.getContextUsage + ctx.signal
9
+ * - session: ctx.sessionManager.getEntries
10
+ *
11
+ * port/ctx 双通道惯例:port 对象仅用于传入 service/session 的实参;
12
+ * handler 自用的 UI/日志直呼 ctx(如 ctx.ui.notify、ctx.ui.setWidget)。
10
13
  */
11
14
 
12
15
  import type { ExtensionAPI, ExtensionContext, ThemeColor } from "@earendil-works/pi-coding-agent";
@@ -22,10 +25,6 @@ import type { ServicePorts } from "../service";
22
25
  *
23
26
  * persistence 的 appendState 用 ENTRY_TYPE,appendHistory 用 HISTORY_ENTRY_TYPE,
24
27
  * 与 serializeState / makeHistoryEntry 的输出对齐(session.ts reconstructGoalState 据此识别)。
25
- *
26
- * UiPort 接口未声明 fg/bold(D-22:只声明机器可检查的能力边界),
27
- * 构造满足 UiPort & ThemeLike 的对象后整体断言为 UiPort(多出的 fg/bold 运行时存在,
28
- * projection/widget.ts 的 asTheme 用 `as unknown as ThemeLike` 单步断言取出)。
29
28
  */
30
29
  export function buildPorts(pi: ExtensionAPI, ctx: ExtensionContext): ServicePorts {
31
30
  const persistence: PersistencePort = {
@@ -40,7 +39,7 @@ export function buildPorts(pi: ExtensionAPI, ctx: ExtensionContext): ServicePort
40
39
  },
41
40
  };
42
41
 
43
- const uiPort = {
42
+ const uiPort: UiPort = {
44
43
  setWidget(name: string, content: string[] | string | undefined): void {
45
44
  // SDK setWidget 重载只接受 string[] | undefined(或 factory)。本地 UiPort 允许单 string,
46
45
  // 此处归一化:string 包一层成 [string],再透传。
@@ -50,7 +49,7 @@ export function buildPorts(pi: ExtensionAPI, ctx: ExtensionContext): ServicePort
50
49
  setGuiWidget(name: string, result: GuiRenderResult | undefined): void {
51
50
  // GUI 协议 widget:guiSetWidget 在 RPC 模式用 marker 编码 GuiRenderResult JSON
52
51
  // (component + meta 宿主元数据)进 string[],复用 ctx.ui.setWidget 通道;
53
- // host 侧 event-adapter 检测 marker 解码还原。guiSetWidget 无 isGui 守卫
52
+ // host 侧 EventAdapter(xyz-agent runtime 的 pi 事件适配层)检测 marker 解码还原。guiSetWidget 无 isGui 守卫
54
53
  // (helpers.ts 仅查 ctx.ui?.setWidget 存在性),isGui 判定在 updateWidget
55
54
  // 外层(projection/widget.ts)。
56
55
  //
@@ -74,16 +73,18 @@ export function buildPorts(pi: ExtensionAPI, ctx: ExtensionContext): ServicePort
74
73
  // 断言同 setGuiWidget:custom 泛型签名静态不兼容,mode 单字段运行时可靠。
75
74
  return isGuiCapable(ctx as GuiContext);
76
75
  },
77
- // ThemeLike 形状:透传 ctx.ui.theme 的 fg/bold。
76
+ // ThemeLike 适配:透传 ctx.ui.theme 的 fg/bold。
78
77
  // Theme.fg 只接受 ThemeColor 字面量 union(SDK 契约);projection 层保证传入 union 内字面量,
79
78
  // string → ThemeColor 是宽到窄单步断言(无需 unknown 中转)。
80
- fg(color: string, text: string): string {
81
- return ctx.ui.theme.fg(color as ThemeColor, text);
82
- },
83
- bold(text: string): string {
84
- return ctx.ui.theme.bold(text);
79
+ theme: {
80
+ fg(color: string, text: string): string {
81
+ return ctx.ui.theme.fg(color as ThemeColor, text);
82
+ },
83
+ bold(text: string): string {
84
+ return ctx.ui.theme.bold(text);
85
+ },
85
86
  },
86
- } as UiPort;
87
+ };
87
88
 
88
89
  const messaging: MessagingPort = {
89
90
  sendContextMessage: (content, deliverAs, customType): void => {
@@ -103,13 +104,6 @@ export function buildPorts(pi: ExtensionAPI, ctx: ExtensionContext): ServicePort
103
104
 
104
105
  const session: SessionPort = {
105
106
  getEntries: () => ctx.sessionManager.getEntries(),
106
- getContextUsage: () => {
107
- const usage = ctx.getContextUsage();
108
- return usage ? { tokens: usage.tokens, contextWindow: usage.contextWindow } : null;
109
- },
110
- get signal(): AbortSignal | undefined {
111
- return ctx.signal;
112
- },
113
107
  };
114
108
 
115
109
  return { persistence, ui: uiPort, messaging, session };
@@ -71,21 +71,21 @@ describe("checkBudgetOnTurnEnd — token 阈值", () => {
71
71
  it("token < 70% → 无预警", () => {
72
72
  expect(checkBudgetOnTurnEnd(makeState({ tokensUsed: 600, budget: { tokenBudget: 1000 } })).warnings).toEqual([]);
73
73
  });
74
- it("token >= 70% 未发 → warning70 token", () => {
74
+ it("token >= 70% 未发 → warning70", () => {
75
75
  const s = makeState({ tokensUsed: 700, budget: { tokenBudget: 1000 } });
76
- expect(checkBudgetOnTurnEnd(s).warnings).toContainEqual({ type: "warning70", dimension: "token" });
76
+ expect(checkBudgetOnTurnEnd(s).warnings).toContainEqual({ type: "warning70" });
77
77
  });
78
78
  it("token >= 70% 已发 → 不重复", () => {
79
79
  const s = makeState({ tokensUsed: 750, tokenWarning70Sent: true, budget: { tokenBudget: 1000 } });
80
- expect(checkBudgetOnTurnEnd(s).warnings).not.toContainEqual({ type: "warning70", dimension: "token" });
80
+ expect(checkBudgetOnTurnEnd(s).warnings).not.toContainEqual({ type: "warning70" });
81
81
  });
82
82
  it("token >= 90% 未发 steering → shouldSendSteering", () => {
83
83
  const s = makeState({ tokensUsed: 950, budget: { tokenBudget: 1000 } });
84
84
  expect(checkBudgetOnTurnEnd(s).shouldSendSteering).toBe(true);
85
85
  });
86
- it("token >= 100% 已发 steering → terminal exceeded token", () => {
86
+ it("token >= 100% 已发 steering → terminal exceeded", () => {
87
87
  const s = makeState({ tokensUsed: 1000, budgetLimitSteeringSent: true, budget: { tokenBudget: 1000 } });
88
- expect(checkBudgetOnTurnEnd(s).terminal).toEqual({ type: "exceeded", dimension: "token" });
88
+ expect(checkBudgetOnTurnEnd(s).terminal).toEqual({ type: "exceeded" });
89
89
  });
90
90
  });
91
91
 
@@ -96,9 +96,9 @@ describe("checkBudgetOnResume", () => {
96
96
  it("无预算 → null", () => {
97
97
  expect(checkBudgetOnResume(makeState())).toBeNull();
98
98
  });
99
- it("token 超额 → exceeded token", () => {
99
+ it("token 超额 → exceeded", () => {
100
100
  const s = makeState({ tokensUsed: 1000, budget: { tokenBudget: 1000 } });
101
- expect(checkBudgetOnResume(s)).toEqual({ type: "exceeded", dimension: "token" });
101
+ expect(checkBudgetOnResume(s)).toEqual({ type: "exceeded" });
102
102
  });
103
103
  it("未超额 → null", () => {
104
104
  const s = makeState({ tokensUsed: 500, budget: { tokenBudget: 1000 } });
@@ -6,6 +6,9 @@
6
6
  */
7
7
  import { describe, expect } from "vitest";
8
8
  import { it, fc } from "@fast-check/vitest";
9
+ import { readdirSync, readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { fileURLToPath } from "node:url";
9
12
 
10
13
  import { createGoalState, isActiveStatus, isTerminalStatus, transitionStatus } from "../goal";
11
14
  import { VALID_TRANSITIONS } from "../types";
@@ -53,7 +56,6 @@ describe("createGoalState", () => {
53
56
  tokensUsed: 0,
54
57
  timeUsedSeconds: 0,
55
58
  currentTurnIndex: 0,
56
- lastProgressTurn: 0,
57
59
  lastTurnTokensUsed: 0,
58
60
  budgetLimitSteeringSent: false,
59
61
  tokenWarning70Sent: false,
@@ -84,3 +86,40 @@ describe("createGoalState", () => {
84
86
  });
85
87
  });
86
88
  });
89
+
90
+ // ── E1 守卫:状态变更唯一执行点(.status 裸赋值扫描)──
91
+
92
+ describe("E1 守卫:src 内禁止 .status 裸赋值(必须走 transitionStatus 查表)", () => {
93
+ it("扫描 src/**/*.ts(排除 __tests__):0 命中", () => {
94
+ // 正则逐字来自设计 docs/design/ext-simplify-03-goal.md §5.4「E1 守卫设计」,不得自行修改:
95
+ // (?![=>]) 排除 ==/===/=>(比较与箭头函数形态);(?!\s*transitionStatus\b) 放行白名单查表赋值。
96
+ // 扫描粒度 = 整文件内容(非逐行):\s* 必须能跨行吃换行,才能排除
97
+ // `state.status =\n transitionStatus(...)` 这类跨行白名单赋值形态,逐行实现会误报。
98
+ const re = /\.status\s*=(?![=>])(?!\s*transitionStatus\b)/g;
99
+ const srcDir = fileURLToPath(new URL("../../", import.meta.url));
100
+ const offenders: string[] = [];
101
+ const walk = (dir: string): void => {
102
+ for (const dirent of readdirSync(dir, { withFileTypes: true })) {
103
+ if (dirent.name === "__tests__") continue;
104
+ const abs = join(dir, dirent.name);
105
+ if (dirent.isDirectory()) {
106
+ walk(abs);
107
+ } else if (dirent.isFile() && dirent.name.endsWith(".ts")) {
108
+ const content = readFileSync(abs, "utf8");
109
+ let match: RegExpExecArray | null;
110
+ while ((match = re.exec(content)) !== null) {
111
+ // 行号 = 命中起点之前的换行数 + 1
112
+ const line = content.slice(0, match.index).split("\n").length;
113
+ offenders.push(`${abs}:${line}`);
114
+ }
115
+ }
116
+ }
117
+ };
118
+ walk(srcDir);
119
+ expect(
120
+ offenders,
121
+ `检测到 ${offenders.length} 处 .status 裸赋值(状态变更必须走 transitionStatus 查表,见设计 §5.4 E1 / VALID_TRANSITIONS engine/types.ts):\n`
122
+ + offenders.join("\n"),
123
+ ).toEqual([]);
124
+ });
125
+ });
@@ -35,20 +35,14 @@ export interface TickResult {
35
35
  timeStartedAt: number;
36
36
  }
37
37
 
38
- /** 预算维度(time budget 已移除,仅 token)。 */
39
- export type BudgetDimension = "token";
40
-
41
- export type BudgetDecision =
42
- | { type: "warning70"; dimension: BudgetDimension }
43
- | { type: "warning90"; dimension: BudgetDimension };
38
+ export type BudgetDecision = { type: "warning70" } | { type: "warning90" };
44
39
 
45
40
  export interface BudgetCheckResult {
46
- terminal: { type: "exceeded"; dimension: BudgetDimension } | null;
41
+ terminal: { type: "exceeded" } | null;
47
42
  warnings: BudgetDecision[];
48
43
  shouldSendSteering: boolean;
49
44
  }
50
45
 
51
-
52
46
  // ── token 累加(FR-8.6)──────────────────────────────
53
47
 
54
48
  export function accumulateTokens(currentTokensUsed: number, usage: TokenUsage): number {
@@ -130,15 +124,15 @@ export function checkBudgetOnTurnEnd(state: GoalRuntimeState): BudgetCheckResult
130
124
  if (state.budget.tokenBudget) {
131
125
  const tokenPct = state.tokensUsed / state.budget.tokenBudget;
132
126
  if (tokenPct >= 1 && state.budgetLimitSteeringSent) {
133
- result.terminal = { type: "exceeded", dimension: "token" };
127
+ result.terminal = { type: "exceeded" };
134
128
  return result;
135
129
  }
136
130
  if (tokenPct >= BUDGET_RATIO_HIGH && !state.budgetLimitSteeringSent) {
137
131
  result.shouldSendSteering = true;
138
132
  } else if (tokenPct >= BUDGET_RATIO_HIGH && !state.tokenWarning90Sent) {
139
- result.warnings.push({ type: "warning90", dimension: "token" });
133
+ result.warnings.push({ type: "warning90" });
140
134
  } else if (tokenPct >= BUDGET_RATIO_LOW && !state.tokenWarning70Sent) {
141
- result.warnings.push({ type: "warning70", dimension: "token" });
135
+ result.warnings.push({ type: "warning70" });
142
136
  }
143
137
  }
144
138
 
@@ -147,9 +141,9 @@ export function checkBudgetOnTurnEnd(state: GoalRuntimeState): BudgetCheckResult
147
141
 
148
142
  // ── resume 预算重检 ──────────────────────────────────
149
143
 
150
- export function checkBudgetOnResume(state: GoalRuntimeState): { type: "exceeded"; dimension: BudgetDimension } | null {
144
+ export function checkBudgetOnResume(state: GoalRuntimeState): { type: "exceeded" } | null {
151
145
  if (state.budget.tokenBudget && state.tokensUsed >= state.budget.tokenBudget) {
152
- return { type: "exceeded", dimension: "token" };
146
+ return { type: "exceeded" };
153
147
  }
154
148
  return null;
155
149
  }
@@ -50,9 +50,7 @@ export function createGoalState(
50
50
  timeStartedAt: now,
51
51
  timeUsedSeconds: 0,
52
52
  budget: { ...DEFAULT_BUDGET, ...budgetOverrides },
53
- lastProgressTurn: 0,
54
53
  budgetLimitSteeringSent: false,
55
- objectiveUpdatedAt: now,
56
54
  lastBlockerReason: null,
57
55
  tokenWarning70Sent: false,
58
56
  tokenWarning90Sent: false,
@@ -71,9 +71,7 @@ export interface GoalRuntimeState {
71
71
  timeStartedAt: number;
72
72
  timeUsedSeconds: number;
73
73
  budget: BudgetConfig;
74
- lastProgressTurn: number;
75
74
  budgetLimitSteeringSent: boolean;
76
- objectiveUpdatedAt: number;
77
75
  lastBlockerReason: string | null;
78
76
  // token 维度预警 flag(time budget 已移除,仅 token 70/90 两个 flag)
79
77
  tokenWarning70Sent: boolean;
package/src/index.ts CHANGED
@@ -9,69 +9,29 @@
9
9
  * - ports.ts:机器可检查的能力边界
10
10
  * - adapters/ports.ts:Pi → ServicePorts 桥接(单一构造点)
11
11
  * - service.ts:事件入口协调器(applyEvent)
12
- * - adapters/:Pi 桥接(command-adapter / event-adapter / ports)
12
+ * - adapters/:Pi 桥接(command-adapter / event-handlers / ports)
13
13
  * - projection/:渲染(widget / prompts)
14
14
  *
15
15
  * FR-4.2/D-16:ctx 必填,移除 lastCtx 模块级可变状态。
16
16
  * FR-6.4:移除 hasPendingInjection。
17
- * FR-6.7:移除 pendingPause(ESC 改用 ctx.signal.aborted 守卫,在 event-adapter)。
17
+ * FR-6.7:移除 pendingPause(ESC 改用 ctx.signal.aborted 守卫,在各 event handler)。
18
18
  */
19
19
 
20
- import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
20
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, MessageEndEvent } from "@earendil-works/pi-coding-agent";
21
21
  import { Text } from "@earendil-works/pi-tui";
22
22
 
23
23
  import { handleGoalCommand } from "./adapters/command-adapter";
24
- import {
25
- handleAgentEnd,
26
- handleBeforeAgentStart,
27
- handleMessageEnd,
28
- handleSessionShutdown,
29
- handleSessionStart,
30
- handleTurnEnd,
31
- type MessageEndLikeEvent,
32
- } from "./adapters/event-adapter";
24
+ import { handleAgentEnd } from "./adapters/event-handlers/agent-end";
25
+ import { handleBeforeAgentStart } from "./adapters/event-handlers/before-agent-start";
26
+ import { handleMessageEnd } from "./adapters/event-handlers/message-end";
27
+ import { handleSessionShutdown } from "./adapters/event-handlers/session-shutdown";
28
+ import { handleSessionStart } from "./adapters/event-handlers/session-start";
29
+ import { handleTurnEnd } from "./adapters/event-handlers/turn-end";
33
30
  import { registerGoalControlTool } from "./adapters/goal-control-adapter";
34
31
  import { buildPorts } from "./adapters/ports";
35
32
  import { createGoal } from "./service";
36
33
  import { createGoalSession, type GoalSession } from "./session";
37
34
 
38
- // ── Local Interfaces (Like*Event — 避免 any on Pi callback/event signatures) ────
39
-
40
- interface BeforeAgentStartLikeEvent {
41
- type: "before_agent_start";
42
- prompt: string;
43
- systemPrompt: string;
44
- }
45
-
46
- interface TurnEndLikeEvent {
47
- type: "turn_end";
48
- turnIndex: number;
49
- }
50
-
51
- interface AgentEndLikeEvent {
52
- type: "agent_end";
53
- messages: unknown[];
54
- }
55
-
56
- interface SessionStartLikeEvent {
57
- type: "session_start";
58
- reason: string;
59
- }
60
-
61
- interface SessionShutdownLikeEvent {
62
- type: "session_shutdown";
63
- reason: "quit" | "reload" | "new" | "resume" | "fork";
64
- }
65
-
66
- interface LikeCustomMessage {
67
- customType: string;
68
- content: string | unknown;
69
- }
70
-
71
- interface LikeMessageRenderOptions {
72
- expanded: boolean;
73
- }
74
-
75
35
  // ── Extension Factory ─────────────────────────────────
76
36
 
77
37
  export default function goalExtension(pi: ExtensionAPI) {
@@ -106,32 +66,32 @@ export default function goalExtension(pi: ExtensionAPI) {
106
66
 
107
67
  registerGoalControlTool(pi, session);
108
68
 
109
- // ── Events(全部委托 adapters/event-adapter)────────
69
+ // ── Events(全部委托 adapters/event-handlers)────────
110
70
 
111
- pi.on("before_agent_start", async (_event: BeforeAgentStartLikeEvent, ctx: ExtensionContext) => {
71
+ pi.on("before_agent_start", async (_event, ctx: ExtensionContext) => {
112
72
  return handleBeforeAgentStart(pi, session, ctx);
113
73
  });
114
74
 
115
- pi.on("turn_end", async (_event: TurnEndLikeEvent, ctx: ExtensionContext) => {
75
+ pi.on("turn_end", async (_event, ctx: ExtensionContext) => {
116
76
  await handleTurnEnd(pi, session, ctx);
117
77
  });
118
78
 
119
- pi.on("message_end", async (event: MessageEndLikeEvent, ctx: ExtensionContext) => {
79
+ pi.on("message_end", async (event: MessageEndEvent, ctx: ExtensionContext) => {
120
80
  await handleMessageEnd(session, ctx, event);
121
81
  });
122
82
 
123
- pi.on("agent_end", async (_event: AgentEndLikeEvent, ctx: ExtensionContext) => {
83
+ pi.on("agent_end", async (_event, ctx: ExtensionContext) => {
124
84
  await handleAgentEnd(pi, session, ctx);
125
85
  });
126
86
 
127
- pi.on("session_start", async (_event: SessionStartLikeEvent, ctx: ExtensionContext) => {
87
+ pi.on("session_start", async (_event, ctx: ExtensionContext) => {
128
88
  await handleSessionStart(pi, session, ctx);
129
89
  });
130
90
 
131
91
  // MF-R2-1 根修:session_shutdown 在 runner invalidate 之前触发(此刻 pi/ctx
132
92
  // 仍可用),取消待发的退避 continuation timer——覆盖 reload / new / resume /
133
93
  // fork / quit 全部失效路径,旧 timer 不再携 stale ctx 闭包存活到到期
134
- pi.on("session_shutdown", async (_event: SessionShutdownLikeEvent, _ctx: ExtensionContext) => {
94
+ pi.on("session_shutdown", async (_event, _ctx: ExtensionContext) => {
135
95
  await handleSessionShutdown(session);
136
96
  });
137
97
 
@@ -141,7 +101,7 @@ export default function goalExtension(pi: ExtensionAPI) {
141
101
  for (const customType of goalMessageTypes) {
142
102
  pi.registerMessageRenderer(
143
103
  customType,
144
- (message: LikeCustomMessage, _options: LikeMessageRenderOptions, theme: Theme) => {
104
+ (message, _options, theme) => {
145
105
  const prefix =
146
106
  message.customType === "goal-context-exceeded"
147
107
  ? theme.fg("error", "[GOAL Budget] ")
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * 持久化层 — serialize/deserialize + history entry 构造
3
3
  *
4
- * FR-5: 移除旧格式兼容,字段缺失直接 throw(tasks 字段例外——向后兼容忽略)。
5
- * 零 Pi 依赖。
4
+ * FR-5:必填字段缺失直接 throw;旧格式容忍面(time_limited → budget_limited 迁移、
5
+ * successCriteria 旧格式归一、W5 熔断计数缺省归零、slug/completedAtTurnIndex 可选解析、
6
+ * tasks 及未知字段白名单忽略)见 deserializeState 各迁移点注释。零 Pi 依赖。
6
7
  */
7
8
 
8
9
  import type { GoalRuntimeState, GoalStatus } from "./engine/types";
@@ -22,7 +23,7 @@ export function serializeState(state: GoalRuntimeState): GoalRuntimeState {
22
23
  };
23
24
  }
24
25
 
25
- // ── deserialize(FR-5 严格解析,缺字段 throw)──────────
26
+ // ── deserialize(FR-5 严格解析,缺必填字段 throw)──────────
26
27
 
27
28
  /**
28
29
  * 旧持久化状态归一化(唯一迁移点)。
@@ -95,9 +96,7 @@ export function deserializeState(data: Record<string, unknown>): GoalRuntimeStat
95
96
  timeStartedAt: req("timeStartedAt"),
96
97
  timeUsedSeconds: req("timeUsedSeconds"),
97
98
  budget: req("budget"),
98
- lastProgressTurn: req("lastProgressTurn"),
99
99
  budgetLimitSteeringSent: req("budgetLimitSteeringSent"),
100
- objectiveUpdatedAt: req("objectiveUpdatedAt"),
101
100
  lastBlockerReason: req("lastBlockerReason"),
102
101
  tokenWarning70Sent: req("tokenWarning70Sent"),
103
102
  tokenWarning90Sent: req("tokenWarning90Sent"),
package/src/ports.ts CHANGED
@@ -35,6 +35,17 @@ export interface PersistencePort {
35
35
  appendHistory(entry: GoalHistoryEntry): void;
36
36
  }
37
37
 
38
+ // ── ThemeLike ────────────────────────────────────────
39
+
40
+ /**
41
+ * projection 层的 Theme 抽象。不 import Pi 的 ThemeColor。
42
+ * adapter 层负责把 Pi 的 theme(fg 接收 ThemeColor)适配到此签名(fg 接收 string)。
43
+ */
44
+ export interface ThemeLike {
45
+ fg: (color: string, text: string) => string;
46
+ bold: (text: string) => string;
47
+ }
48
+
38
49
  // ── UiPort ───────────────────────────────────────────
39
50
 
40
51
  export interface UiPort {
@@ -54,6 +65,8 @@ export interface UiPort {
54
65
  readonly hasUI: boolean;
55
66
  /** 是否 GUI 渲染模式(RPC → GuiComponent;TUI → 原生文本行) */
56
67
  readonly isGui: boolean;
68
+ /** 终端主题能力(fg/bold 着色),widget/status 文本行渲染消费(E3 显式声明) */
69
+ readonly theme: ThemeLike;
57
70
  }
58
71
 
59
72
  // ── MessagingPort ────────────────────────────────────
@@ -75,7 +88,4 @@ export interface SessionEntryLike {
75
88
 
76
89
  export interface SessionPort {
77
90
  getEntries(): SessionEntryLike[];
78
- // tokens 与 Pi SDK 的 ContextUsage.tokens 对齐:number | null(null = 未知,如压缩后未到下次 LLM 响应)。
79
- getContextUsage(): { tokens: number | null; contextWindow: number } | null;
80
- readonly signal: AbortSignal | undefined;
81
91
  }
@@ -1,8 +1,8 @@
1
1
  /**
2
- * projection/prompts.ts 测试 — prompt 生成函数 + formatBudget 2 样式
2
+ * projection/prompts.test.ts 测试 — prompt 生成函数 + budget 片段格式化
3
3
  *
4
4
  * 覆盖:
5
- * - formatBudget 2 种 style(percent/line)
5
+ * - formatBudgetPercent / formatBudgetLine
6
6
  * - escapeXmlText(XML 注入防护)
7
7
  * - continuationPrompt / budgetLimitPrompt / objectiveUpdatedPrompt / contextInjectionPrompt
8
8
  *
@@ -19,7 +19,8 @@ import {
19
19
  contextInjectionPrompt,
20
20
  continuationPrompt,
21
21
  escapeXmlText,
22
- formatBudget,
22
+ formatBudgetLine,
23
+ formatBudgetPercent,
23
24
  objectiveUpdatedPrompt,
24
25
  } from "../prompts";
25
26
 
@@ -32,31 +33,38 @@ function makeState(overrides?: Partial<GoalRuntimeState>): GoalRuntimeState {
32
33
  };
33
34
  }
34
35
 
35
- // ── formatBudget 2 样式(FR-3.4 唯一收敛出口)────────
36
+ // ── budget 片段格式化 ────────────────────────────────
36
37
 
37
- describe("formatBudget — 2 styles (FR-3.4)", () => {
38
- it("percent: Token 百分比", () => {
38
+ describe("formatBudgetPercent", () => {
39
+ it("有预算 → Token 百分比", () => {
39
40
  const state = makeState({
40
41
  budget: { tokenBudget: 1000 },
41
42
  tokensUsed: 500,
42
43
  });
43
- const out = formatBudget(state, "percent");
44
+ const out = formatBudgetPercent(state);
44
45
  expect(out).toContain("Token: 50%");
45
46
  });
46
47
 
47
- it("percent: 无预算 → 空字符串", () => {
48
+ it("无预算 → 空字符串", () => {
48
49
  const state = makeState();
49
- expect(formatBudget(state, "percent")).toBe("");
50
+ expect(formatBudgetPercent(state)).toBe("");
50
51
  });
52
+ });
51
53
 
52
- it("line: 剩余/总量格式", () => {
54
+ describe("formatBudgetLine", () => {
55
+ it("有预算 → 剩余/总量格式", () => {
53
56
  const state = makeState({
54
57
  budget: { tokenBudget: 1000 },
55
58
  tokensUsed: 300,
56
59
  });
57
- const out = formatBudget(state, "line");
60
+ const out = formatBudgetLine(state);
58
61
  expect(out).toContain("Tokens: 700/1000");
59
62
  });
63
+
64
+ it("无预算 → 空字符串", () => {
65
+ const state = makeState();
66
+ expect(formatBudgetLine(state)).toBe("");
67
+ });
60
68
  });
61
69
 
62
70
  // ── escapeXmlText(XML 注入防护)──────────────────────
@@ -14,14 +14,13 @@ import { describe, expect, it } from "vitest";
14
14
 
15
15
  import { createGoalState } from "../../engine/goal";
16
16
  import type { GoalRuntimeState, GoalStatus } from "../../engine/types";
17
- import type { UiPort } from "../../ports";
17
+ import type { ThemeLike, UiPort } from "../../ports";
18
18
  import { createGoalSession } from "../../session";
19
19
  import { OBJECTIVE_DISPLAY_LIMIT, OBJECTIVE_TRUNCATE_KEEP } from "../../constants";
20
20
  import {
21
21
  renderStatusLine,
22
22
  renderTerminalStatusLine,
23
23
  renderWidgetLines,
24
- type ThemeLike,
25
24
  toSingleLine,
26
25
  updateWidget,
27
26
  } from "../widget";
@@ -221,8 +220,10 @@ describe("updateWidget (FR-6.6 hasUI guard)", () => {
221
220
  calls.push({ method: "setStatus", args: [name, text] });
222
221
  },
223
222
  notify() {},
224
- fg: (_color: string, text: string) => text,
225
- bold: (text: string) => text,
223
+ theme: {
224
+ fg: (_color: string, text: string) => text,
225
+ bold: (text: string) => text,
226
+ },
226
227
  } as UiPort;
227
228
  return { ui, calls };
228
229
  }
@@ -300,8 +301,10 @@ describe("updateWidget GUI 协议分支(isGui=true)", () => {
300
301
  calls.push({ method: "setStatus", args: [name, text] });
301
302
  },
302
303
  notify() {},
303
- fg: (_color: string, text: string) => text,
304
- bold: (text: string) => text,
304
+ theme: {
305
+ fg: (_color: string, text: string) => text,
306
+ bold: (text: string) => text,
307
+ },
305
308
  } as UiPort;
306
309
  return { ui, calls };
307
310
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Steering prompt 模板(projection 层)
3
3
  *
4
- * 类型 import 自 engine/types.ts。FR-3.4:formatBudget 单一出口。
4
+ * 类型 import 自 engine/types.ts。
5
5
  *
6
6
  * 设计原则(来自 Codex 调研):
7
7
  * - Completion audit: 逐项证据验证,intent/partial progress 不是 evidence
@@ -42,31 +42,10 @@ function successCriteriaBlock(state: GoalRuntimeState): string {
42
42
  return `<successCriteria>\n${items}\n</successCriteria>\n`;
43
43
  }
44
44
 
45
- // ── FR-3.4:唯一 budget 格式化收敛出口 ─────────────────
45
+ // ── budget 片段格式化 ────────────────────────────────
46
46
 
47
- export type BudgetFormatStyle = "percent" | "line";
48
-
49
- /**
50
- * FR-3.4 唯一 budget 格式化收敛出口。
51
- *
52
- * 2 种输出样式:
53
- * - "percent" → ` (Token: N%)`(contextInjectionPrompt 用)
54
- * - "line" → ` | Tokens: remaining/total`(continuationPrompt 用)
55
- *
56
- * @param state runtime state(读 budget / tokensUsed)
57
- * @param style 输出形式
58
- */
59
- export function formatBudget(
60
- state: GoalRuntimeState,
61
- style: BudgetFormatStyle,
62
- ): string {
63
- if (style === "percent") {
64
- return formatBudgetPercent(state);
65
- }
66
- return formatBudgetLine(state);
67
- }
68
-
69
- function formatBudgetPercent(state: GoalRuntimeState): string {
47
+ /** ` (Token: N%)`(contextInjectionPrompt 用)。无预算返回空串。 */
48
+ export function formatBudgetPercent(state: GoalRuntimeState): string {
70
49
  if (state.budget.tokenBudget) {
71
50
  const pct = Math.round((state.tokensUsed / state.budget.tokenBudget) * PERCENT_FACTOR);
72
51
  return ` (Token: ${pct}%)`;
@@ -74,7 +53,8 @@ function formatBudgetPercent(state: GoalRuntimeState): string {
74
53
  return "";
75
54
  }
76
55
 
77
- function formatBudgetLine(state: GoalRuntimeState): string {
56
+ /** ` | Tokens: remaining/total`(continuationPrompt 用)。无预算返回空串。 */
57
+ export function formatBudgetLine(state: GoalRuntimeState): string {
78
58
  if (state.budget.tokenBudget) {
79
59
  const remaining = Math.max(state.budget.tokenBudget - state.tokensUsed, 0);
80
60
  return ` | Tokens: ${remaining}/${state.budget.tokenBudget}`;
@@ -86,7 +66,7 @@ function formatBudgetLine(state: GoalRuntimeState): string {
86
66
 
87
67
  export function continuationPrompt(state: GoalRuntimeState): string {
88
68
  const objective = escapeXmlText(state.objective);
89
- const budgetLine = formatBudget(state, "line");
69
+ const budgetLine = formatBudgetLine(state);
90
70
  const criteria = successCriteriaBlock(state);
91
71
 
92
72
  return (
@@ -190,7 +170,7 @@ export function objectiveUpdatedPrompt(
190
170
  */
191
171
  export function contextInjectionPrompt(state: GoalRuntimeState): string {
192
172
  const objective = escapeXmlText(state.objective);
193
- const budgetInfo = formatBudget(state, "percent");
173
+ const budgetInfo = formatBudgetPercent(state);
194
174
  const criteria = successCriteriaBlock(state);
195
175
 
196
176
  return (
@@ -2,8 +2,8 @@
2
2
  * Widget 渲染逻辑(projection 层)— 状态栏和侧边栏面板
3
3
  *
4
4
  * 设计要点:
5
- * - 不 import Pi 类型(ThemeColor → ThemeLike,fg 接收 string)
6
- * - 类型 import 自 engine/types.ts
5
+ * - 不 import Pi 类型(theme 能力经 ports.ts 的 ThemeLike 抽象,fg 接收 string)
6
+ * - 类型 import 自 engine/types.ts 与 ports.ts(ThemeLike 上移至接口层)
7
7
  * - 工具函数 import 自 engine/budget.ts
8
8
  * - 时间计算基于 state.timeUsedSeconds(不含 Date.now() 副作用段)
9
9
  * - updateWidget(session, uiPort) 含 FR-6.6 hasUI 守卫
@@ -26,19 +26,10 @@ import {
26
26
  import { getBudgetColor, getTokenUsagePercent } from "../engine/budget";
27
27
  import { isTerminalStatus } from "../engine/goal";
28
28
  import type { GoalRuntimeState } from "../engine/types";
29
- import type { UiPort } from "../ports";
29
+ import type { ThemeLike, UiPort } from "../ports";
30
30
  import type { GoalSession } from "../session";
31
31
  import { buildGoalGui } from "./gui";
32
32
 
33
- /**
34
- * projection 层的 Theme 抽象。不 import Pi 的 ThemeColor。
35
- * adapter 层负责把 Pi 的 theme(fg 接收 ThemeColor)适配到此签名(fg 接收 string)。
36
- */
37
- export interface ThemeLike {
38
- fg: (color: string, text: string) => string;
39
- bold: (text: string) => string;
40
- }
41
-
42
33
  /**
43
34
  * 将多行文本压缩为单行,用于 widget 渲染。
44
35
  * 多行 content 泄漏到 widget 会导致 markdown 表格/标题等破坏布局。
@@ -195,29 +186,6 @@ export function renderWidgetLines(state: GoalRuntimeState, th: ThemeLike): strin
195
186
 
196
187
  // ── updateWidget(FR-6.6 hasUI 守卫)──
197
188
 
198
- /**
199
- * 从 UiPort 取出 ThemeLike。
200
- *
201
- * ports.ts 的 UiPort 故意不暴露 theme(保持抽象最小)。adapter 层在
202
- * 构造 UiPort 实现时,把 Pi 的 ctx.ui.theme 的 fg/bold 方法挂到对象上,
203
- * 使该实现同时满足 UiPort 与 ThemeLike 形状。projection 层通过此单步断言取出。
204
- */
205
- function asTheme(uiPort: UiPort): ThemeLike {
206
- // UiPort 刻意不声明 fg/bold(D-22:只声明机器可检查的能力边界),与 ThemeLike 无类型重叠,
207
- // 必须 unknown 中转——这是架构契约断言:ports.ts 构造 UiPort 实现时已把 ctx.ui.theme 的
208
- // fg/bold 挂到对象上(buildPorts 的 uiPort 对象含全部字段),运行时必然存在。
209
- // eslint-disable-next-line taste/no-unsafe-cast
210
- return uiPort as unknown as ThemeLike;
211
- }
212
-
213
- /**
214
- * 导出 asTheme:单一 theme 提取断言点(TS-1)。
215
- *
216
- * adapter 层(event-adapter handleTerminalStateBeforeAgent)复用本函数,
217
- * 避免重复 `ctx.ui.theme as unknown as ThemeLike` 断言。
218
- */
219
- export { asTheme };
220
-
221
189
  /**
222
190
  * 刷新 widget + status bar。
223
191
  *
@@ -243,7 +211,7 @@ export function updateWidget(session: GoalSession, uiPort: UiPort): void {
243
211
 
244
212
  // 终态折叠为单行 status bar
245
213
  if (isTerminalStatus(session.state.status)) {
246
- const statusText = renderTerminalStatusLine(session.state, asTheme(uiPort));
214
+ const statusText = renderTerminalStatusLine(session.state, uiPort.theme);
247
215
  if (statusText) {
248
216
  uiPort.setStatus("goal", statusText);
249
217
  }
@@ -252,11 +220,11 @@ export function updateWidget(session: GoalSession, uiPort: UiPort): void {
252
220
  return;
253
221
  }
254
222
 
255
- uiPort.setStatus("goal", renderStatusLine(session.state, asTheme(uiPort)));
223
+ uiPort.setStatus("goal", renderStatusLine(session.state, uiPort.theme));
256
224
  if (isGui) {
257
225
  // 复用 projection/gui.ts 的 buildGoalGui(整个 GuiRenderResult:component + meta 宿主元数据)
258
226
  uiPort.setGuiWidget("goal", buildGoalGui(session.state));
259
227
  } else {
260
- uiPort.setWidget("goal", renderWidgetLines(session.state, asTheme(uiPort)));
228
+ uiPort.setWidget("goal", renderWidgetLines(session.state, uiPort.theme));
261
229
  }
262
230
  }
package/src/service.ts CHANGED
@@ -60,7 +60,7 @@ export function tickState(state: GoalRuntimeState): void {
60
60
  * 这类「active → 非活跃」的转换,调用方必须在改 status **之前**先调本函数
61
61
  * (或先调 {@link tickState} 捕获最后运行段)。
62
62
  *
63
- * 导出供 command-adapter / event-adapter 复用(DRY:所有路径共用同一持久化语义)。
63
+ * 导出供 command-adapter / goal-control-adapter 复用(DRY:所有路径共用同一持久化语义)。
64
64
  */
65
65
  export function persistState(session: GoalSession, ports: ServicePorts): void {
66
66
  if (!session.state) return;
@@ -154,9 +154,9 @@ export function createGoal(
154
154
  /**
155
155
  * 唯一终态序列入口(FR-3.3 / AC-3)。
156
156
  *
157
- * 收口所有 active→terminal 转换的完整副作用序列,消除此前散在 service /
158
- * command-adapter / event-adapter 的重复 `transitionStatus + completedAtTurnIndex +
159
- * writeHistory + appendState` 序列。
157
+ * 收口所有 active→terminal 转换的完整副作用序列——service(budget 终态)与
158
+ * command-adapter(clear / resume 超预算)、goal-control-adapter(complete)共用
159
+ * 同一 `transitionStatus + completedAtTurnIndex + writeHistory + appendState` 序列。
160
160
  *
161
161
  * 序列(严格顺序):
162
162
  * 1. tickState(state)(FR-6.5:转 terminal 前累加当前运行段——此时 status 仍为 active)
@@ -237,10 +237,10 @@ function toMessageEndData(eventData: unknown): MessageEndEventData | null {
237
237
 
238
238
  /**
239
239
  * 路径 B 入口。异步事件,无返回值(副作用直接 mutate session.state)。
240
- * 并发保护(isProcessing / stale-check)在 event-adapter,不在此层。
240
+ * 并发保护(isProcessing / stale-check)在 event-handlers(agent-end.ts),不在此层。
241
241
  *
242
242
  * 本函数作为简单事件的统一入口(message_end / turn_end)。
243
- * 复杂事件(before_agent_start / agent_end / session_start)由 event-adapter
243
+ * 复杂事件(before_agent_start / agent_end / session_start)由 event-handlers
244
244
  * 直接实现,调 engine 纯函数 + service 辅助函数。
245
245
  *
246
246
  * H3:不再返回 effect 数组。turn_end 的 updateWidget 副作用由调用方
@@ -1,17 +0,0 @@
1
- /**
2
- * Event adapter — 薄路由(#4 拆分后)。
3
- *
4
- * 6 个事件 handler + 1 个共享辅助拆在 event-handlers/,本文件只做 re-export。
5
- * handler 逻辑见各自文件。pi.on 注册在 index.ts(保持不变)。
6
- *
7
- * 行为等价(#4 验收):handler 逻辑原样搬迁,仅 persistAndUpdate 签名 ports 化
8
- * (见 service.ts)+ turn_end 的 buildPorts 调用从两次收敛为一次。
9
- */
10
-
11
- export { handleAgentEnd } from "./event-handlers/agent-end";
12
- export { handleBeforeAgentStart } from "./event-handlers/before-agent-start";
13
- export type { MessageEndLikeEvent } from "./event-handlers/message-end";
14
- export { handleMessageEnd } from "./event-handlers/message-end";
15
- export { handleSessionShutdown } from "./event-handlers/session-shutdown";
16
- export { handleSessionStart } from "./event-handlers/session-start";
17
- export { handleTurnEnd } from "./event-handlers/turn-end";
@@ -1,27 +0,0 @@
1
- /**
2
- * event-handlers 共享辅助(adapters/event-handlers 层)。
3
- *
4
- * - makeStaleChecker:FR-8.2 G-020 goalId snapshot,agent_end 入口构造、每个副作用前 checkStale
5
- *
6
- * handleAgentEnd 直接操纵 session.isProcessing(历史实现),无需 acquire/release 封装。
7
- */
8
-
9
- import type { GoalSession } from "../../session";
10
-
11
- /**
12
- * 构造 stale-check 闭包:入口快照 goalId,后续判断是否被新 goal 覆盖。
13
- *
14
- * 用法(agent_end):
15
- * ```ts
16
- * const checkStale = makeStaleChecker(session);
17
- * // ... 长流程 ...
18
- * if (checkStale()) return; // goal 被覆盖,本次 agent_end 作废
19
- * ```
20
- *
21
- * 语义:snapshot 时 session.state 可能为 null(首次启动),此时 snapshotGoalId
22
- * 为 undefined;后续若有新 goal(goalId !== undefined)即视为 stale。
23
- */
24
- export function makeStaleChecker(session: GoalSession): () => boolean {
25
- const snapshotGoalId = session.state?.goalId;
26
- return () => !session.state || session.state.goalId !== snapshotGoalId;
27
- }