@zhushanwen/pi-goal 0.9.4 → 0.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-goal",
3
- "version": "0.9.4",
3
+ "version": "0.11.0",
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",
@@ -29,10 +29,10 @@
29
29
  "@earendil-works/pi-coding-agent": "^0.84.1",
30
30
  "@earendil-works/pi-tui": "^0.84.1",
31
31
  "typebox": "*",
32
- "@zhushanwen/pi-pending-notifications": "0.3.5"
32
+ "@zhushanwen/pi-pending-notifications": "0.5.0"
33
33
  },
34
34
  "dependencies": {
35
- "@xyz-agent/extension-protocol": "0.6.0"
35
+ "@xyz-agent/extension-protocol": "0.7.0"
36
36
  },
37
37
  "peerDependenciesMeta": {
38
38
  "@earendil-works/pi-tui": {
@@ -18,6 +18,7 @@ import {
18
18
  OBJECTIVE_DISPLAY_LIMIT,
19
19
  OBJECTIVE_TRUNCATE_KEEP,
20
20
  } from "../constants";
21
+ import { parseGoalArgs } from "../commands";
21
22
  import { createGoalState } from "../engine/goal";
22
23
  import type { GoalRuntimeState } from "../engine/types";
23
24
  import type { GoalHistoryEntry, SessionEntryLike } from "../ports";
@@ -139,6 +140,19 @@ describe("handleGoalCommand — status", () => {
139
140
  expect(text).toContain("test objective");
140
141
  expect(text).toContain("Status: active");
141
142
  });
143
+
144
+ it("U23: successCriteria 逐条编号多行显示(不再 join(\"; \") 内联)", async () => {
145
+ const h = makeHarness();
146
+ const session = createGoalSession();
147
+ session.state = makeActiveState({ successCriteria: ["s1", "s2"] });
148
+ await handleGoalCommand(h.pi, session, "status", h.ctx);
149
+ const text = notifyText(h).join("\n");
150
+ expect(text).toContain("Success criteria:");
151
+ expect(text).toContain("1. s1");
152
+ expect(text).toContain("2. s2");
153
+ // 旧形态(分号拼接内联一行)不再出现
154
+ expect(text).not.toContain("s1; s2");
155
+ });
142
156
  });
143
157
 
144
158
  // ── /goal pause(FR-3 用户暂停 active→paused)──
@@ -291,12 +305,72 @@ describe("handleGoalCommand — update (FR-8.4 G-002)", () => {
291
305
  const session = createGoalSession();
292
306
  session.state = makeActiveState({
293
307
  objective: "old objective",
294
- successCriteria: "old criteria",
308
+ successCriteria: ["old criteria"],
295
309
  });
296
310
  await handleGoalCommand(h.pi, session, "update new obj --criteria new criteria text", h.ctx);
297
311
 
298
312
  expect(session.state!.objective).toBe("new obj");
299
- expect(session.state!.successCriteria).toBe("new criteria text");
313
+ // /goal update --criteria 按分号拆分为 string[]
314
+ expect(session.state!.successCriteria).toEqual(["new criteria text"]);
315
+ });
316
+
317
+ it("U22: --criteria 多条(分号分隔)→ 数组直赋值替换", async () => {
318
+ const h = makeHarness();
319
+ const session = createGoalSession();
320
+ session.state = makeActiveState({
321
+ objective: "old objective",
322
+ successCriteria: ["old criteria"],
323
+ });
324
+ await handleGoalCommand(h.pi, session, "update new obj --criteria new a; new b", h.ctx);
325
+
326
+ expect(session.state!.successCriteria).toEqual(["new a", "new b"]);
327
+ });
328
+
329
+ it("U22 决策④: --criteria 拆分后全空(;;)→ 保留旧值(不写入 [\";;\"] 等非法数组)", async () => {
330
+ const h = makeHarness();
331
+ const session = createGoalSession();
332
+ session.state = makeActiveState({
333
+ objective: "old objective",
334
+ successCriteria: ["old criteria"],
335
+ });
336
+ await handleGoalCommand(h.pi, session, "update new obj --criteria ;;", h.ctx);
337
+
338
+ expect(session.state!.objective).toBe("new obj");
339
+ expect(session.state!.successCriteria).toEqual(["old criteria"]);
340
+ });
341
+
342
+ it("U22 决策④: --criteria 值拆分后全空(空格分号)→ 保留旧值", async () => {
343
+ const h = makeHarness();
344
+ const session = createGoalSession();
345
+ session.state = makeActiveState({
346
+ objective: "old objective",
347
+ successCriteria: ["old criteria"],
348
+ });
349
+ // 注:纯尾随空白(`--criteria `)在 parse 层被 trim 后 flag 不匹配、
350
+ // 并入 objective(既有语义);此处用「分隔符后仅空白+分号」构造拆分后全空
351
+ await handleGoalCommand(h.pi, session, "update new obj --criteria ;", h.ctx);
352
+
353
+ expect(session.state!.objective).toBe("new obj");
354
+ expect(session.state!.successCriteria).toEqual(["old criteria"]);
355
+ });
356
+
357
+ it("U22: 条目含换行 → throw 带 Correct: 正例,且 state 重塑字段不动(校验前置)", async () => {
358
+ const h = makeHarness();
359
+ const session = createGoalSession();
360
+ session.state = makeActiveState({
361
+ objective: "old objective",
362
+ slug: "old-slug",
363
+ successCriteria: ["old criteria"],
364
+ });
365
+ // 单段内含换行(无分号)→ parse 拆为一条含 \n 的条目,handleUpdate 校验拒绝
366
+ await expect(
367
+ handleGoalCommand(h.pi, session, "update new obj --criteria line1\nline2", h.ctx),
368
+ ).rejects.toThrow(/single-line[\s\S]*Correct:/);
369
+
370
+ expect(session.state!.objective).toBe("old objective");
371
+ expect(session.state!.slug).toBe("old-slug");
372
+ expect(session.state!.successCriteria).toEqual(["old criteria"]);
373
+ expect(h.states).toHaveLength(0); // 未 persist
300
374
  });
301
375
 
302
376
  it("不带 --criteria → 保留旧 successCriteria(不静默丢失验证标准)", async () => {
@@ -304,12 +378,12 @@ describe("handleGoalCommand — update (FR-8.4 G-002)", () => {
304
378
  const session = createGoalSession();
305
379
  session.state = makeActiveState({
306
380
  objective: "old objective",
307
- successCriteria: "old criteria",
381
+ successCriteria: ["old criteria"],
308
382
  });
309
383
  await handleGoalCommand(h.pi, session, "update new obj", h.ctx);
310
384
 
311
385
  expect(session.state!.objective).toBe("new obj");
312
- expect(session.state!.successCriteria).toBe("old criteria");
386
+ expect(session.state!.successCriteria).toEqual(["old criteria"]);
313
387
  });
314
388
 
315
389
  it("active 状态重塑 → 注入 objectiveUpdated steering", async () => {
@@ -322,6 +396,32 @@ describe("handleGoalCommand — update (FR-8.4 G-002)", () => {
322
396
  });
323
397
  });
324
398
 
399
+ // ── parseGoalArgs — --criteria 分号拆分(U20/U21)──
400
+
401
+ describe("parseGoalArgs — --criteria 分号拆分(U20/U21)", () => {
402
+ it("U20: 分号多段 → 逐段 trim + 去空段(\"a; b; c\" → [\"a\",\"b\",\"c\"])", () => {
403
+ const parsed = parseGoalArgs("update new obj --criteria a; b; c");
404
+ expect(parsed.action).toBe("update");
405
+ expect(parsed.objective).toBe("new obj");
406
+ expect(parsed.criteria).toEqual(["a", "b", "c"]);
407
+ });
408
+
409
+ it("U20: 单段 → 单元素数组(\"single\" → [\"single\"])", () => {
410
+ const parsed = parseGoalArgs("update new obj --criteria single");
411
+ expect(parsed.criteria).toEqual(["single"]);
412
+ });
413
+
414
+ it("U21: 连续分号 → 空段被过滤(\"a;;b\" → [\"a\",\"b\"])", () => {
415
+ const parsed = parseGoalArgs("update new obj --criteria a;;b");
416
+ expect(parsed.criteria).toEqual(["a", "b"]);
417
+ });
418
+
419
+ it("U21 决策④: 拆分后全空(\";;\")→ criteria undefined(不产出空数组)", () => {
420
+ const parsed = parseGoalArgs("update new obj --criteria ;;");
421
+ expect(parsed.criteria).toBeUndefined();
422
+ });
423
+ });
424
+
325
425
  // ── /goal set(提示词触发器:sendUserMessage 让 AI 调 goal_control create)──
326
426
 
327
427
  describe("handleGoalCommand — set (提示词触发器 + #11/D25 拒绝非终态)", () => {
@@ -0,0 +1,213 @@
1
+ /**
2
+ * goal_control adapter — successCriteria string[] handler 测试
3
+ *
4
+ * W1:handleCreate 接收 string[] 类型的 successCriteria,验证:
5
+ * - 数组存入 state
6
+ * - 空数组/空串项 → throw
7
+ * - 完整字段后 state.successCriteria 为 string[]
8
+ */
9
+ import { describe, expect, it } from "vitest";
10
+ import { Value } from "typebox/value";
11
+
12
+ import { GoalControlParams, handleCreate, type GoalControlParamsT } from "../adapters/goal-control-adapter";
13
+ import type { GoalRuntimeState } from "../engine/types";
14
+ import type { UiPort } from "../ports";
15
+ import type { ServicePorts } from "../service";
16
+ import { createGoalSession } from "../session";
17
+
18
+ function makeFakePorts(): ServicePorts & {
19
+ states: GoalRuntimeState[];
20
+ history: unknown[];
21
+ notifications: Array<{ text: string; level: string }>;
22
+ } {
23
+ const states: GoalRuntimeState[] = [];
24
+ const history: unknown[] = [];
25
+ const notifications: Array<{ text: string; level: string }> = [];
26
+ return {
27
+ states,
28
+ history,
29
+ notifications,
30
+ persistence: {
31
+ appendState: (s) => { states.push(s); },
32
+ appendHistory: (e) => { history.push(e); },
33
+ },
34
+ ui: {
35
+ setWidget: () => {},
36
+ setStatus: () => {},
37
+ notify: (text, level) => { notifications.push({ text, level }); },
38
+ hasUI: true,
39
+ isGui: false,
40
+ setGuiWidget: () => {},
41
+ fg: (_color: string, text: string) => text,
42
+ bold: (text: string) => text,
43
+ } as UiPort,
44
+ messaging: {
45
+ sendContextMessage: () => {},
46
+ sendUserMessage: () => {},
47
+ },
48
+ session: {
49
+ getEntries: () => [],
50
+ getContextUsage: () => null,
51
+ signal: undefined,
52
+ },
53
+ };
54
+ }
55
+
56
+ describe("handleCreate — successCriteria string[]", () => {
57
+ it("successCriteria string[] → state.successCriteria 为 string[]", () => {
58
+ const session = createGoalSession();
59
+ const ports = makeFakePorts();
60
+
61
+ const details = handleCreate(
62
+ {
63
+ action: "create",
64
+ slug: "test-goal",
65
+ objective: "do something",
66
+ successCriteria: ["criterion 1", "criterion 2", "criterion 3"],
67
+ },
68
+ session,
69
+ ports,
70
+ );
71
+
72
+ expect(details.action).toBe("create");
73
+ expect(details.status).toBe("active");
74
+ expect(session.state!.successCriteria).toEqual(["criterion 1", "criterion 2", "criterion 3"]);
75
+ });
76
+
77
+ it("successCriteria 空数组 → throw", () => {
78
+ const session = createGoalSession();
79
+ const ports = makeFakePorts();
80
+
81
+ expect(() =>
82
+ handleCreate(
83
+ {
84
+ action: "create",
85
+ objective: "do something",
86
+ successCriteria: [],
87
+ },
88
+ session,
89
+ ports,
90
+ ),
91
+ ).toThrow(/successCriteria/);
92
+ });
93
+
94
+ it("successCriteria 含空串项 → throw(U6:文案含 Correct: 恢复正例)", () => {
95
+ const session = createGoalSession();
96
+ const ports = makeFakePorts();
97
+
98
+ expect(() =>
99
+ handleCreate(
100
+ {
101
+ action: "create",
102
+ objective: "do something",
103
+ successCriteria: ["valid", " ", "also valid"],
104
+ },
105
+ session,
106
+ ports,
107
+ ),
108
+ ).toThrow(/successCriteria[\s\S]*Correct:/);
109
+ });
110
+
111
+ it.each([
112
+ ["LF", ["line1\nline2"]],
113
+ ["CRLF", ["a\r\nb"]],
114
+ ["孤立 CR", ["a\rb"]],
115
+ ])("U7: 条目含换行(%s)→ throw 且文案含「单行」提示与 Correct: 正例", (_label, criteria) => {
116
+ const session = createGoalSession();
117
+ const ports = makeFakePorts();
118
+
119
+ expect(() =>
120
+ handleCreate(
121
+ {
122
+ action: "create",
123
+ objective: "do something",
124
+ successCriteria: criteria,
125
+ },
126
+ session,
127
+ ports,
128
+ ),
129
+ ).toThrow(/successCriteria[\s\S]*single-line[\s\S]*Correct:/);
130
+ });
131
+
132
+ it("U28②: 非 string 元素(绕过 schema 直调 handler)→ 业务 throw 含 Correct:,不产生 TypeError", () => {
133
+ const session = createGoalSession();
134
+ const ports = makeFakePorts();
135
+
136
+ // 直调 handler 绕过 schema 校验(测试/异常路径可能传入脏元素),断言运行时 typeof 防御
137
+ const dirtyParams = {
138
+ action: "create",
139
+ objective: "do something",
140
+ successCriteria: [1, null, true],
141
+ } as unknown as GoalControlParamsT;
142
+
143
+ expect(() => handleCreate(dirtyParams, session, ports)).toThrow(/successCriteria[\s\S]*Correct:/);
144
+ expect(() => handleCreate(dirtyParams, session, ports)).not.toThrow(TypeError);
145
+ });
146
+
147
+ it("U5b: 恰 1 条与恰 8 条(含 emoji/中文)→ schema 与 handleCreate 双层均不 throw,state 长度 1/8", () => {
148
+ // 恰 8 条,含 emoji/中文(pattern ^[^\r\n]+$ 不误杀多字节字符)
149
+ const eight = [
150
+ "通过测试✅",
151
+ "条件二:构建成功",
152
+ "tsc --noEmit clean",
153
+ "文件 X 存在且含内容 Y",
154
+ "docs updated 📄",
155
+ "review done",
156
+ "no regressions",
157
+ "ship it 🚀",
158
+ ];
159
+
160
+ // schema 层:minItems:1 / maxItems:8 边界正值
161
+ expect(
162
+ Value.Check(GoalControlParams, {
163
+ action: "create",
164
+ objective: "do something",
165
+ successCriteria: ["single"],
166
+ }),
167
+ ).toBe(true);
168
+ expect(
169
+ Value.Check(GoalControlParams, {
170
+ action: "create",
171
+ objective: "do something",
172
+ successCriteria: eight,
173
+ }),
174
+ ).toBe(true);
175
+
176
+ // handleCreate 直调层:均不 throw,state 数组长度 1/8
177
+ const session1 = createGoalSession();
178
+ expect(() =>
179
+ handleCreate(
180
+ { action: "create", objective: "do something", successCriteria: ["single"] },
181
+ session1,
182
+ makeFakePorts(),
183
+ ),
184
+ ).not.toThrow();
185
+ expect(session1.state!.successCriteria).toHaveLength(1);
186
+
187
+ const session8 = createGoalSession();
188
+ expect(() =>
189
+ handleCreate(
190
+ { action: "create", objective: "do something", successCriteria: eight },
191
+ session8,
192
+ makeFakePorts(),
193
+ ),
194
+ ).not.toThrow();
195
+ expect(session8.state!.successCriteria).toHaveLength(8);
196
+ });
197
+
198
+ it("successCriteria undefined → throw", () => {
199
+ const session = createGoalSession();
200
+ const ports = makeFakePorts();
201
+
202
+ expect(() =>
203
+ handleCreate(
204
+ {
205
+ action: "create",
206
+ objective: "do something",
207
+ },
208
+ session,
209
+ ports,
210
+ ),
211
+ ).toThrow(/successCriteria.*required/);
212
+ });
213
+ });
@@ -0,0 +1,146 @@
1
+ /**
2
+ * successCriteria 结构化迁移测试 — string → string[]
3
+ *
4
+ * W1:successCriteria 从单条自由文本 string 结构化为条件数组 string[]
5
+ *(1~8 条、每条单行短条件)。旧持久化数据兼容迁移:按行拆分(/\r\n|\r|\n/)、
6
+ * 逐条 trim、去空行,空态归一 undefined,脏数据防御性丢弃(plan 决策①②③⑤)。
7
+ */
8
+ import { describe, expect, it } from "vitest";
9
+
10
+ import { deserializeState, makeHistoryEntry, serializeState } from "../persistence";
11
+ import { createGoalState } from "../engine/goal";
12
+ import type { GoalRuntimeState } from "../engine/types";
13
+
14
+ // ── 旧数据 fixture 工厂 ───────────────────────────────
15
+
16
+ /** 构造含指定 successCriteria 的旧持久化数据(其余必填字段齐全)。 */
17
+ function makeLegacyData(successCriteria: unknown): Record<string, unknown> {
18
+ return {
19
+ goalId: "g1",
20
+ objective: "test",
21
+ status: "active",
22
+ successCriteria,
23
+ tokensUsed: 0,
24
+ timeStartedAt: 1000,
25
+ timeUsedSeconds: 0,
26
+ budget: {},
27
+ lastProgressTurn: 0,
28
+ budgetLimitSteeringSent: false,
29
+ objectiveUpdatedAt: 1000,
30
+ lastBlockerReason: null,
31
+ tokenWarning70Sent: false,
32
+ tokenWarning90Sent: false,
33
+ lastTurnTokensUsed: 0,
34
+ currentTurnIndex: 0,
35
+ };
36
+ }
37
+
38
+ // ── 类型断言辅助 ─────────────────────────────────────
39
+
40
+ describe("GoalRuntimeState.successCriteria — string[] 类型", () => {
41
+ it("createGoalState 返回 string[] 类型的 successCriteria", () => {
42
+ const state = createGoalState("obj", undefined, undefined, ["criterion 1", "criterion 2"]);
43
+ expect(Array.isArray(state.successCriteria)).toBe(true);
44
+ expect(state.successCriteria).toEqual(["criterion 1", "criterion 2"]);
45
+ });
46
+
47
+ it("createGoalState 无 successCriteria → undefined", () => {
48
+ const state = createGoalState("obj");
49
+ expect(state.successCriteria).toBeUndefined();
50
+ });
51
+ });
52
+
53
+ // ── 持久化 round-trip(新格式 string[])───────────────
54
+
55
+ describe("serializeState / deserializeState — string[] round-trip", () => {
56
+ it("successCriteria string[] 正确 round-trip", () => {
57
+ const state = createGoalState("obj", undefined, "slug", ["a", "b", "c"]);
58
+ const serialized = serializeState(state);
59
+ const deserialized = deserializeState(serialized as unknown as Record<string, unknown>);
60
+ expect(deserialized.successCriteria).toEqual(["a", "b", "c"]);
61
+ });
62
+
63
+ it("successCriteria undefined 正确 round-trip", () => {
64
+ const state = createGoalState("obj");
65
+ const serialized = serializeState(state);
66
+ const deserialized = deserializeState(serialized as unknown as Record<string, unknown>);
67
+ expect(deserialized.successCriteria).toBeUndefined();
68
+ });
69
+
70
+ it("makeHistoryEntry 数组 round-trip 深相等(U15b:history 数据数组形态不丢)", () => {
71
+ const state = createGoalState("obj", undefined, "slug", ["a", "b"]);
72
+ const entry = makeHistoryEntry(state);
73
+ // history entry 经 JSON 序列化(appendHistory 落盘)→ 反序列化后仍为同形态数组
74
+ const restored = JSON.parse(JSON.stringify(entry)) as { successCriteria?: string[] };
75
+ expect(restored.successCriteria).toEqual(["a", "b"]);
76
+ });
77
+ });
78
+
79
+ // ── 旧持久化数据迁移(string → string[],按行拆分)────
80
+
81
+ describe("deserializeState — 旧数据迁移 string → string[](按行拆分)", () => {
82
+ it("U10: 单行 string → 单元素数组", () => {
83
+ const state = deserializeState(makeLegacyData("tests pass"));
84
+ expect(state.successCriteria).toEqual(["tests pass"]);
85
+ });
86
+
87
+ it("U11: 多行 string(含空白与空行)→ 逐条 trim、去空行", () => {
88
+ const state = deserializeState(makeLegacyData("a\n b \n\nc"));
89
+ expect(state.successCriteria).toEqual(["a", "b", "c"]);
90
+ });
91
+
92
+ it("U11b: CRLF 与混合换行均正确拆分(无 \\r 残留)", () => {
93
+ expect(deserializeState(makeLegacyData("cond-1\r\ncond-2\r\ncond-3")).successCriteria).toEqual([
94
+ "cond-1",
95
+ "cond-2",
96
+ "cond-3",
97
+ ]);
98
+ expect(deserializeState(makeLegacyData("a\nb\r\nc\rd")).successCriteria).toEqual(["a", "b", "c", "d"]);
99
+ });
100
+
101
+ it("U11c: 纯分隔符(\\r\\n / \\r / \\n)→ undefined(空态归一)", () => {
102
+ expect(deserializeState(makeLegacyData("\r\n")).successCriteria).toBeUndefined();
103
+ expect(deserializeState(makeLegacyData("\r")).successCriteria).toBeUndefined();
104
+ expect(deserializeState(makeLegacyData("\n")).successCriteria).toBeUndefined();
105
+ });
106
+
107
+ it("U12: 新格式 string[] → 原样保留(不二次拆分、不 trim)", () => {
108
+ const state = deserializeState(makeLegacyData(["item a", "item b"]));
109
+ expect(state.successCriteria).toEqual(["item a", "item b"]);
110
+ });
111
+
112
+ it("U12: 缺 successCriteria 字段 → undefined", () => {
113
+ const data = makeLegacyData(undefined);
114
+ delete data.successCriteria;
115
+ expect(deserializeState(data).successCriteria).toBeUndefined();
116
+ });
117
+
118
+ it("U13: 旧 string 拆分后 10 条 → 全部保留不截断(迁移不丢数据)", () => {
119
+ const tenLines = Array.from({ length: 10 }, (_, i) => `cond ${i + 1}`).join("\n");
120
+ const state = deserializeState(makeLegacyData(tenLines));
121
+ expect(state.successCriteria).toHaveLength(10);
122
+ expect(state.successCriteria).toEqual(Array.from({ length: 10 }, (_, i) => `cond ${i + 1}`));
123
+ });
124
+
125
+ it("U14: 全空白 string(含空串)→ undefined(空态归一)", () => {
126
+ expect(deserializeState(makeLegacyData(" \n ")).successCriteria).toBeUndefined();
127
+ expect(deserializeState(makeLegacyData(" ")).successCriteria).toBeUndefined();
128
+ expect(deserializeState(makeLegacyData("")).successCriteria).toBeUndefined();
129
+ });
130
+
131
+ it("U14b: 脏数据类型 → 防御性丢弃为 undefined(不 throw)", () => {
132
+ for (const dirty of [42, null, true, ["a", ["b", "c"]], ["a", null]]) {
133
+ const state = deserializeState(makeLegacyData(dirty));
134
+ expect(state.successCriteria).toBeUndefined();
135
+ }
136
+ });
137
+ });
138
+
139
+ // ── makeHistoryEntry 类型链 ──────────────────────────
140
+
141
+ describe("makeHistoryEntry — 数组形态透传", () => {
142
+ it("state.successCriteria 数组透传到 entry(类型链不变形)", () => {
143
+ const state = createGoalState("obj", undefined, "slug", ["x", "y"]) as GoalRuntimeState;
144
+ expect(makeHistoryEntry(state).successCriteria).toEqual(["x", "y"]);
145
+ });
146
+ });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * goal_control prompt guidance — successCriteria 三层引导文本独立 prompt-lock
3
+ *
4
+ * W1(plan U24):粒度约束分布在三层,每层独立锁死,删掉任一层约束本文件对应 describe 必红:
5
+ * 1. 参数层:GoalControlParams.successCriteria 的 schema description(导出对象直读)
6
+ * 2. 工具层:registerGoalControlTool 工具 description 的 create 段(源码文本切片)
7
+ * 3. guidelines 层:promptGuidelines 的 create 条目(源码文本按赋值块切片)
8
+ *
9
+ * 为什么源码文本断言:description / promptGuidelines 内联在 registerGoalControlTool、
10
+ * 未导出常量;按变量边界切片(marker 定位 + 范围内断言),禁止跨块 [\s\S]*? 全文穿越——
11
+ * 否则删掉某层约束后其余层文本仍能命中,测试假绿(review 发现的历史缺陷)。
12
+ */
13
+ import { readFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ import { describe, expect, it } from "vitest";
18
+
19
+ import { GoalControlParams } from "../adapters/goal-control-adapter";
20
+
21
+ const __dirname = dirname(fileURLToPath(import.meta.url));
22
+ const ADAPTER_SRC = readFileSync(
23
+ join(__dirname, "../adapters/goal-control-adapter.ts"),
24
+ "utf-8",
25
+ );
26
+
27
+ /** 按 marker 切片(含 start,不含 end)。marker 缺失时抛可定位错误,防切片静默错位。 */
28
+ function sliceBetween(src: string, startMarker: string, endMarker: string): string {
29
+ const start = src.indexOf(startMarker);
30
+ if (start === -1) {
31
+ throw new Error(`start marker not found in goal-control-adapter.ts: ${startMarker}`);
32
+ }
33
+ const end = src.indexOf(endMarker, start + startMarker.length);
34
+ if (end === -1) {
35
+ throw new Error(`end marker "${endMarker}" not found after "${startMarker}" in goal-control-adapter.ts`);
36
+ }
37
+ return src.slice(start, end);
38
+ }
39
+
40
+ // ── 第 1 层切片:参数 schema(从模块导出直读,非文本匹配)──────
41
+
42
+ const successCriteriaSchema = GoalControlParams.properties.successCriteria;
43
+ const PARAM_DESCRIPTION =
44
+ typeof successCriteriaSchema.description === "string" ? successCriteriaSchema.description : "";
45
+
46
+ // ── 第 2 层切片:工具 description 模板字面量("description: `" 到闭合反引号)──
47
+
48
+ const TOOL_DESCRIPTION = sliceBetween(ADAPTER_SRC, "description: `", "`");
49
+ // create 段:从 "- create:" 行首到 "- complete:" 行首(同在 description 块内)
50
+ const TOOL_DESCRIPTION_CREATE_SEGMENT = sliceBetween(TOOL_DESCRIPTION, "- create:", "- complete:");
51
+
52
+ // ── 第 3 层切片:promptGuidelines 赋值块 ─────────────────────
53
+ // 闭合 "]" 不能用 indexOf 找——块内 guideline 文本自身含 "string[]" 的 "]",
54
+ // 故按行扫描 trim() === "]," 的行作块尾(缩进无关)。
55
+
56
+ function extractPromptGuidelinesBlock(src: string): string {
57
+ const startMarker = "promptGuidelines: [";
58
+ const start = src.indexOf(startMarker);
59
+ if (start === -1) {
60
+ throw new Error(`start marker not found in goal-control-adapter.ts: ${startMarker}`);
61
+ }
62
+ const blockLines: string[] = [];
63
+ for (const line of src.slice(start).split("\n")) {
64
+ blockLines.push(line);
65
+ if (line.trim() === "],") return blockLines.join("\n");
66
+ }
67
+ throw new Error('promptGuidelines block closing "]," line not found in goal-control-adapter.ts');
68
+ }
69
+
70
+ const PROMPT_GUIDELINES_BLOCK = extractPromptGuidelinesBlock(ADAPTER_SRC);
71
+ // create 条目:块内以 `"create:` 开头的单行 guideline(源码中每条 guideline 独占一行)
72
+ const CREATE_GUIDELINE_LINE = PROMPT_GUIDELINES_BLOCK.split("\n").find((line) =>
73
+ line.trim().startsWith('"create:'),
74
+ );
75
+ if (CREATE_GUIDELINE_LINE === undefined) {
76
+ throw new Error('create guideline line not found in promptGuidelines block of goal-control-adapter.ts');
77
+ }
78
+
79
+ // ── 第 1 层:successCriteria 参数 schema description ─────────
80
+
81
+ describe("第 1 层:successCriteria 参数 schema description(粒度约束)", () => {
82
+ it("schema 结构为 string[](type: array + items type: string)", () => {
83
+ expect(successCriteriaSchema.type).toBe("array");
84
+ expect(successCriteriaSchema.items.type).toBe("string");
85
+ });
86
+
87
+ it("description 含条数区间语义(1~8 条,与 minItems/maxItems 对齐)", () => {
88
+ expect(PARAM_DESCRIPTION).toMatch(/1~8\s*条/);
89
+ });
90
+
91
+ it("description 含每条单行短条件语义", () => {
92
+ expect(PARAM_DESCRIPTION).toContain("单行");
93
+ });
94
+
95
+ it("description 含数组语义(完成条件数组)", () => {
96
+ expect(PARAM_DESCRIPTION).toContain("完成条件数组");
97
+ });
98
+
99
+ it("description 含高层终态条件语义(非愿景、非细粒度清单)", () => {
100
+ expect(PARAM_DESCRIPTION).toContain("高层终态条件");
101
+ expect(PARAM_DESCRIPTION).toContain("不是愿景");
102
+ });
103
+
104
+ it("description 含只引用不复制 + 禁止倾倒完整规格语义", () => {
105
+ expect(PARAM_DESCRIPTION).toContain("细粒度检查清单放 todo/plan");
106
+ expect(PARAM_DESCRIPTION).toContain("只引用不复制");
107
+ expect(PARAM_DESCRIPTION).toContain("禁止倾倒完整规格");
108
+ });
109
+ });
110
+
111
+ // ── 第 2 层:工具 description create 段 ─────────────────────
112
+
113
+ describe("第 2 层:工具 description create 段(粒度约束)", () => {
114
+ it("create 段含 successCriteria 条数约束(3~8 条高层终态条件)", () => {
115
+ expect(TOOL_DESCRIPTION_CREATE_SEGMENT).toContain("successCriteria");
116
+ expect(TOOL_DESCRIPTION_CREATE_SEGMENT).toMatch(/3~8\s*条/);
117
+ expect(TOOL_DESCRIPTION_CREATE_SEGMENT).toContain("高层终态条件");
118
+ });
119
+
120
+ it("create 段含细粒度清单只引用不复制语义", () => {
121
+ expect(TOOL_DESCRIPTION_CREATE_SEGMENT).toContain("细粒度检查清单放 todo/plan");
122
+ expect(TOOL_DESCRIPTION_CREATE_SEGMENT).toContain("只引用不复制");
123
+ });
124
+
125
+ it("create 段含禁止倾倒完整规格语义", () => {
126
+ expect(TOOL_DESCRIPTION_CREATE_SEGMENT).toContain("禁止倾倒完整规格");
127
+ });
128
+ });
129
+
130
+ // ── 第 3 层:promptGuidelines create 条目 ────────────────────
131
+
132
+ describe("第 3 层:promptGuidelines create 条目(粒度约束)", () => {
133
+ it("create 条目含 3~8 条高层终态条件 + string[] 语义", () => {
134
+ expect(CREATE_GUIDELINE_LINE).toMatch(/3~8 high-level terminal conditions as string\[\]/);
135
+ });
136
+
137
+ it("create 条目含细粒度清单只引用不复制语义(reference, do not copy)", () => {
138
+ expect(CREATE_GUIDELINE_LINE).toContain("Fine-grained checklists belong in todo/plan");
139
+ expect(CREATE_GUIDELINE_LINE).toContain("reference them, do not copy");
140
+ });
141
+
142
+ it("create 条目含禁止倾倒完整规格语义(Do NOT dump full specs)", () => {
143
+ expect(CREATE_GUIDELINE_LINE).toContain("Do NOT dump full specs into successCriteria");
144
+ });
145
+ });