@zhushanwen/pi-goal 0.8.0 → 0.8.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-goal",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Codex-style /goal command for Pi — persistent goal-driven autonomous loop with evidence-based completion, token/time budgets, blocked detection, and steering templates.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -106,6 +106,14 @@ describe("handleComplete — active 守卫 + evidence + finalizeAndPersist", ()
106
106
  handleComplete({ action: "complete", evidence: " " }, session, makeFakePorts()),
107
107
  ).toThrow(/evidence/);
108
108
  });
109
+
110
+ it("evidence undefined → throw(schema 降级后 handler 唯一兜底:'required' 错误)", () => {
111
+ const session = createGoalSession();
112
+ session.state = activeState();
113
+ expect(() => handleComplete({ action: "complete" }, session, makeFakePorts())).toThrow(
114
+ /evidence.*required/,
115
+ );
116
+ });
109
117
  });
110
118
 
111
119
  // ── handleCreate ─────────────────────────────────────
@@ -142,6 +150,13 @@ describe("handleCreate — slug+objective 必填 + 非终态守卫 + createGoal"
142
150
  ).toThrow(/objective/);
143
151
  });
144
152
 
153
+ it("objective undefined → throw(schema 降级后 handler 唯一兜底:'required' 错误)", () => {
154
+ const session = createGoalSession();
155
+ expect(() => handleCreate({ action: "create", successCriteria: "y" }, session, makeFakePorts())).toThrow(
156
+ /objective.*required/,
157
+ );
158
+ });
159
+
145
160
  it("successCriteria 空 → throw(即使有 slug + objective)", () => {
146
161
  const session = createGoalSession();
147
162
  expect(() =>
@@ -153,6 +168,13 @@ describe("handleCreate — slug+objective 必填 + 非终态守卫 + createGoal"
153
168
  ).toThrow(/successCriteria/);
154
169
  });
155
170
 
171
+ it("successCriteria undefined → throw(schema 降级后 handler 唯一兜底:'required' 错误)", () => {
172
+ const session = createGoalSession();
173
+ expect(() => handleCreate({ action: "create", objective: "x" }, session, makeFakePorts())).toThrow(
174
+ /successCriteria.*required/,
175
+ );
176
+ });
177
+
156
178
  const NON_TERMINAL_STATUSES: GoalRuntimeState["status"][] = ["active", "paused", "blocked"];
157
179
  it.each(NON_TERMINAL_STATUSES)("已有 %s goal → throw(D25 非终态守卫)", (status) => {
158
180
  const session = createGoalSession();
@@ -250,4 +272,12 @@ describe("handleReportBlocked — active 守卫 + tick + transition + persist",
250
272
  handleReportBlocked({ action: "report_blocked", reason: "" }, session, makeFakePorts()),
251
273
  ).toThrow(/reason/);
252
274
  });
275
+
276
+ it("reason undefined → throw(schema 降级后 handler 唯一兜底:'required' 错误)", () => {
277
+ const session = createGoalSession();
278
+ session.state = activeState();
279
+ expect(() => handleReportBlocked({ action: "report_blocked" }, session, makeFakePorts())).toThrow(
280
+ /reason.*required/,
281
+ );
282
+ });
253
283
  });
@@ -1,17 +1,30 @@
1
1
  /**
2
- * goal_control schema — discriminated union 校验测试(TC6 / CT3)
2
+ * goal_control schema — 扁平 Object 校验测试(OpenAI 兼容)
3
3
  *
4
- * schema 为 Type.Union(无 discriminator keyword)+ 各分支 additionalProperties:false,
5
- * 以 action literal 区分分支。pi 生产校验器为 typebox/compile 的 Compile(schema).Check(args),
6
- * 与此处 Value.Check 同源(同一 typebox schema 语义),故用 Value.Check 验证拒绝行为。
4
+ * schema 为扁平 Type.Object + action 字段级 Type.Union(等价 enum)+ 各字段 Optional +
5
+ * additionalProperties:false。OpenAI function calling 要求 parameters 顶层 type:"object",
6
+ * 顶层 Type.Union 序列化后只有 anyOf 无 type 字段,会被严格 OpenAI 兼容网关 400 拒绝
7
+ * 整个会话——故采用扁平结构(范式对齐 scheduler ScheduleControlParams)。
8
+ *
9
+ * typebox schema 对象即序列化形态:GoalControlParams.type 就是发往 provider 的 JSON 顶层 type。
10
+ * pi 生产校验器为 typebox/compile 的 Compile(schema).Check(args),与此处 Value.Check 同源,
11
+ * 故用 Value.Check 验证。
12
+ *
13
+ * 分支隔离从 schema 层降级为运行时 handler 字段存在性校验(见 goal-control-adapter.test.ts)。
7
14
  */
8
15
  import { describe, expect, it } from "vitest";
9
16
  import { Value } from "typebox/value";
10
17
 
11
18
  import { GoalControlParams } from "../adapters/goal-control-adapter";
12
19
 
13
- describe("GoalControlParams — discriminated union(TC6)", () => {
14
- // valid:各分支完整 + optional 字段 → 通过
20
+ describe("GoalControlParams — 扁平 Object(OpenAI 兼容)", () => {
21
+ // 顶层合规:序列化形态顶层必须有 type:"object",不得有 anyOf(否则被严格网关 400 拒绝)
22
+ it("顶层 type === 'object' 且无 anyOf(OpenAI function calling 合规)", () => {
23
+ expect(GoalControlParams.type).toBe("object");
24
+ expect(GoalControlParams.anyOf).toBeUndefined();
25
+ });
26
+
27
+ // valid:各 action 完整 + optional 字段 → 通过
15
28
  it.each([
16
29
  ["create 完整", { action: "create", objective: "x", successCriteria: "y" }],
17
30
  ["create + slug", { action: "create", slug: "refactor-auth", objective: "x", successCriteria: "y" }],
@@ -22,16 +35,19 @@ describe("GoalControlParams — discriminated union(TC6)", () => {
22
35
  expect(Value.Check(GoalControlParams, params)).toBe(true);
23
36
  });
24
37
 
25
- // invalid:缺必填 / 额外字段(additionalProperties:false 代表性用例)/ 分支隔离 / 未知 action
38
+ // 关键语义变更:扁平化后所有声明的字段(含跨 action 的)均为 Optional,
39
+ // schema 不再做分支隔离。{action:"complete", evidence, objective} 放行
40
+ // (objective 是声明的 Optional 字段,schema 层无法区分它属于哪个 action)。
41
+ // 分支隔离降级为运行时 handler 字段存在性校验,见 goal-control-adapter.test.ts。
42
+ it("跨 action 字段(complete + objective)现在通过(分支隔离降级为运行时校验)", () => {
43
+ expect(Value.Check(GoalControlParams, { action: "complete", evidence: "x", objective: "leak" })).toBe(true);
44
+ });
45
+
46
+ // invalid:缺 action / 未知 action / 额外字段(additionalProperties:false)
26
47
  it.each([
27
- ["create 缺 objective", { action: "create", successCriteria: "y" }],
28
- ["create 缺 successCriteria", { action: "create", objective: "x" }],
48
+ ["缺 action", { objective: "x", successCriteria: "y" }],
49
+ ["未知 action(不在 union 内)", { action: "unknown" }],
29
50
  ["create 含额外字段(additionalProperties:false)", { action: "create", objective: "x", successCriteria: "y", foo: 1 }],
30
- ["create 含 complete 分支字段 evidence(分支隔离)", { action: "create", objective: "x", successCriteria: "y", evidence: "leak" }],
31
- ["complete 缺 evidence", { action: "complete" }],
32
- ["report_blocked 缺 reason", { action: "report_blocked" }],
33
- ["未知 action", { action: "unknown" }],
34
- ["缺 action", { objective: "x" }],
35
51
  ])("invalid: %s → 拒绝", (_name, params) => {
36
52
  expect(Value.Check(GoalControlParams, params)).toBe(false);
37
53
  });
@@ -19,10 +19,12 @@
19
19
  * create 不调 sendUserMessage:toolcall 时 AI 已在 turn 中,返回结果后自行续跑
20
20
  * (与 /goal set 的 followUp 触发区分;对齐 Codex create_goal 不自动续跑)。
21
21
  *
22
- * schema:discriminated union(C3)——Type.Union 无 discriminator keyword,各分支以
23
- * action literal 区分 + additionalProperties:false。pi 生产校验器为 typebox/compile
24
- * 的 Compile(schema).Check(args),缺失必填字段在 schema 层即被拒绝;execute 内仅保留
25
- * .trim() 空字符串校验(LLM 可能传空串穿透 schema)。
22
+ * schema:扁平 Type.Object(OpenAI 兼容,C3)——parameters 顶层必须是 type:"object",
23
+ * 顶层 Type.Union 序列化后只有 anyOf 无 type,会被严格 OpenAI 兼容网关 400 拒绝。故采用
24
+ * 扁平 Object + action 字段级 Type.Union(等价 enum)+ 各字段 Optional + additionalProperties:false。
25
+ * 分支隔离从 schema 层降级为运行时 handler 字段存在性校验(见各 handler 开头);
26
+ * pi 生产校验器为 typebox/compile 的 Compile(schema).Check(args),缺失/错误 action、额外字段
27
+ * 在 schema 层拒绝,缺必填字段由 handler 校验兜底。
26
28
  *
27
29
  * executionMode: "sequential"——状态变更 tool,不可与同批其他 tool 并行执行。
28
30
  *
@@ -46,62 +48,60 @@ import { buildPorts } from "./ports";
46
48
  // ── Params schema(discriminated union,C3)────────────
47
49
 
48
50
  /**
49
- * goal_control 参数 schema。三分支以 action literal 区分(无 discriminator keyword),
50
- * 各分支 additionalProperties:false,缺失必填在 schema 层拒绝。
51
+ * goal_control 参数 schema。扁平 Type.Object + action 字段级 Type.Union(等价 enum)。
52
+ *
53
+ * OpenAI function calling 规范要求 parameters 顶层必须是 type:"object";顶层 Type.Union
54
+ * 序列化后只有 anyOf 无 type 字段,会被严格的 OpenAI 兼容网关 400 拒绝整个会话。故采用
55
+ * 扁平 Object + action enum,分支隔离从 schema 层降级为运行时 handler 字段存在性校验
56
+ * (见各 handler 开头)。范式对齐 scheduler ScheduleControlParams。
57
+ *
58
+ * additionalProperties:false 阻挡未知字段;缺失/错误 action 仍由 schema 层拒绝。
51
59
  */
52
- const CreateParams = Type.Object(
60
+ export const GoalControlParams = Type.Object(
53
61
  {
54
- action: Type.Literal("create"),
62
+ action: Type.Union(
63
+ [Type.Literal("create"), Type.Literal("complete"), Type.Literal("report_blocked")],
64
+ { description: "create | complete | report_blocked" },
65
+ ),
55
66
  slug: Type.Optional(
56
67
  Type.String({
57
68
  description: "可选。状态栏标题用的短 kebab-case 标识,仅用于显示,不注入 prompt。",
58
69
  }),
59
70
  ),
60
- objective: Type.String({
61
- description:
71
+ objective: Type.Optional(
72
+ Type.String({
73
+ description:
62
74
  "必填。用你自己的话重述真实目标——用户实际想要达成什么,而非字面复述请求。模糊时推断最可能的意图并明确陈述。",
63
- }),
64
- successCriteria: Type.String({
65
- description:
75
+ }),
76
+ ),
77
+ successCriteria: Type.Optional(
78
+ Type.String({
79
+ description:
66
80
  "必填。可检查的完成条件(如「测试通过」「文件 X 存在且含内容 Y」「命令 Z 输出 W」),不是愿景或「能用就行」。这是 complete 前必须逐条满足的门槛。",
67
- }),
81
+ }),
82
+ ),
68
83
  tokenBudget: Type.Optional(
69
84
  Type.Number({
70
85
  description: "可选。新目标的 token 预算(正数)。除非用户指定,否则省略。",
71
86
  }),
72
87
  ),
73
- },
74
- { additionalProperties: false },
75
- );
76
-
77
- const CompleteParams = Type.Object(
78
- {
79
- action: Type.Literal("complete"),
80
- evidence: Type.String({
81
- description:
88
+ evidence: Type.Optional(
89
+ Type.String({
90
+ description:
82
91
  "必填。具体完成证据(改动/新建的文件、通过的测试、运行的命令)。不要基于假设、意图或部分进度标记完成。",
83
- }),
84
- },
85
- { additionalProperties: false },
86
- );
87
-
88
- const ReportBlockedParams = Type.Object(
89
- {
90
- action: Type.Literal("report_blocked"),
91
- reason: Type.String({
92
- description:
92
+ }),
93
+ ),
94
+ reason: Type.Optional(
95
+ Type.String({
96
+ description:
93
97
  "必填。具体阻塞条件及已尝试的方案。不要用于不确定、困难、缓慢或未完成的工作——继续做。",
94
- }),
98
+ }),
99
+ ),
95
100
  },
96
101
  { additionalProperties: false },
97
102
  );
98
103
 
99
- export const GoalControlParams = Type.Union([CreateParams, CompleteParams, ReportBlockedParams]);
100
-
101
- export type CreateActionParams = Static<typeof CreateParams>;
102
- export type CompleteActionParams = Static<typeof CompleteParams>;
103
- export type ReportBlockedActionParams = Static<typeof ReportBlockedParams>;
104
- export type GoalControlActionParams = Static<typeof GoalControlParams>;
104
+ export type GoalControlParamsT = Static<typeof GoalControlParams>;
105
105
 
106
106
  // ── Details(renderResult 数据来源)──────────────────
107
107
 
@@ -131,13 +131,16 @@ export interface GoalControlDetails {
131
131
  * 终态旧 goal 走 createGoal 快速路径覆盖(createGoal 内部 active 守卫,终态可覆盖)。
132
132
  */
133
133
  export function handleCreate(
134
- params: CreateActionParams,
134
+ params: GoalControlParamsT,
135
135
  session: GoalSession,
136
136
  ports: ServicePorts,
137
137
  ): GoalControlDetails {
138
+ if (params.objective === undefined) {
139
+ throw new Error("'objective' required for create. Correct: {\"action\":\"create\",\"objective\":\"...\",\"successCriteria\":\"...\"}");
140
+ }
138
141
  const objective = params.objective.trim();
139
142
  if (!objective) {
140
- // schema 已挡缺失,此处仅挡空串(LLM 可能传空串穿透 schema)
143
+ // schema 已放行(扁平化后 optional),此处挡空串(LLM 可能传空串)
141
144
  throw new Error(
142
145
  "'objective' must not be empty. Describe the concrete objective to pursue. Correct: {\"action\":\"create\",\"slug\":\"<kebab-case>\",\"objective\":\"<concrete objective>\",\"successCriteria\":\"<checkable conditions>\"}",
143
146
  );
@@ -145,6 +148,9 @@ export function handleCreate(
145
148
  // slug 真 optional(TC11):缺失时 fallback goalId 截断(与 buildGoalGui 口径一致),不强制必填
146
149
  const slugInput = params.slug?.trim();
147
150
 
151
+ if (params.successCriteria === undefined) {
152
+ throw new Error("'successCriteria' required for create. Correct: {\"action\":\"create\",\"objective\":\"...\",\"successCriteria\":\"...\"}");
153
+ }
148
154
  const successCriteria = params.successCriteria.trim();
149
155
  if (!successCriteria) {
150
156
  throw new Error(
@@ -192,7 +198,7 @@ export function handleCreate(
192
198
  * 全解耦后不再做 todo 完成前置检查——todo 是否全完成由 AI 自行判断(prompt 软建议)。
193
199
  */
194
200
  export function handleComplete(
195
- params: CompleteActionParams,
201
+ params: GoalControlParamsT,
196
202
  session: GoalSession,
197
203
  ports: ServicePorts,
198
204
  ): GoalControlDetails {
@@ -201,6 +207,9 @@ export function handleComplete(
201
207
  if (!isActiveStatus(state.status)) {
202
208
  throw new Error(`Goal is not active (status: ${state.status}). Only an active goal can be completed.`);
203
209
  }
210
+ if (params.evidence === undefined) {
211
+ throw new Error("'evidence' required for complete. Correct: {\"action\":\"complete\",\"evidence\":\"...\"}");
212
+ }
204
213
  const evidence = params.evidence.trim();
205
214
  if (!evidence) {
206
215
  throw new Error(
@@ -223,7 +232,7 @@ export function handleComplete(
223
232
  * 否则转 blocked 后 persistState 内部的 tick 因 status≠active 不累加,丢失最后一段运行时间。
224
233
  */
225
234
  export function handleReportBlocked(
226
- params: ReportBlockedActionParams,
235
+ params: GoalControlParamsT,
227
236
  session: GoalSession,
228
237
  ports: ServicePorts,
229
238
  ): GoalControlDetails {
@@ -232,6 +241,9 @@ export function handleReportBlocked(
232
241
  if (state.status !== "active") {
233
242
  throw new Error(`Goal is not active (status: ${state.status}). Only an active goal can report_blocked.`);
234
243
  }
244
+ if (params.reason === undefined) {
245
+ throw new Error("'reason' required for report_blocked. Correct: {\"action\":\"report_blocked\",\"reason\":\"...\"}");
246
+ }
235
247
  const reason = params.reason.trim();
236
248
  if (!reason) {
237
249
  throw new Error(
@@ -297,7 +309,7 @@ export function registerGoalControlTool(pi: ExtensionAPI, session: GoalSession):
297
309
 
298
310
  async execute(
299
311
  _toolCallId: string,
300
- params: GoalControlActionParams,
312
+ params: GoalControlParamsT,
301
313
  signal: AbortSignal | undefined,
302
314
  _onUpdate: unknown,
303
315
  ctx: ExtensionContext,
@@ -317,13 +329,14 @@ export function registerGoalControlTool(pi: ExtensionAPI, session: GoalSession):
317
329
  details = handleReportBlocked(params, session, ports);
318
330
  }
319
331
 
320
- // 用 params.action 判断(TS 据此收窄 params 到对应分支,安全访问 objective/reason)
332
+ // 扁平化后 params.objective/reason 为 string|undefined(schema 不再按 action 收窄);
333
+ // handler 已对必填字段做存在性校验,此处用 ?. 兜底,未定义时空串
321
334
  const text =
322
335
  params.action === "create"
323
- ? `Goal created.\nGoal ID: ${details.goalId}\nSlug: ${details.slug ?? ""}\nObjective: ${params.objective.trim()}`
336
+ ? `Goal created.\nGoal ID: ${details.goalId}\nSlug: ${details.slug ?? ""}\nObjective: ${params.objective?.trim() ?? ""}`
324
337
  : params.action === "complete"
325
338
  ? `Goal completed.\nGoal ID: ${details.goalId}`
326
- : `Goal reported blocked.\nGoal ID: ${details.goalId}\nReason: ${params.reason.trim()}`;
339
+ : `Goal reported blocked.\nGoal ID: ${details.goalId}\nReason: ${params.reason?.trim() ?? ""}`;
327
340
 
328
341
  // RPC 模式下附加 __gui__(用展开避免 details 来自 frozen 对象时加字段失败)
329
342
  if (ctx.mode === "rpc" && session.state) {