@zhushanwen/pi-goal 0.14.0 → 0.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +3 -3
- package/src/__tests__/criteria-array-adapter.test.ts +0 -2
- package/src/__tests__/index.test.ts +28 -25
- package/src/__tests__/ports.test.ts +41 -25
- package/src/adapters/ports.ts +9 -22
- package/src/index.ts +24 -11
- package/src/ports.ts +7 -9
- package/src/projection/__tests__/widget.test.ts +50 -102
- package/src/projection/widget.ts +10 -15
- package/src/service.ts +2 -2
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ pi install npm:@zhushanwen/pi-goal
|
|
|
45
45
|
goal/
|
|
46
46
|
├── index.ts # 入口 — re-export src/index.ts
|
|
47
47
|
└── src/
|
|
48
|
-
├── index.ts # 工厂入口(注册 command/tool/events +
|
|
48
|
+
├── index.ts # 工厂入口(注册 command/tool/events + goalInit slot,全部委托 adapters)
|
|
49
49
|
├── commands.ts # /goal 命令参数解析(仅识别 --tokens flag)
|
|
50
50
|
├── constants.ts # 语义常量
|
|
51
51
|
├── ports.ts # Pi 能力抽象(Persistence/Ui/Messaging/Session)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-goal",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.2",
|
|
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.7.
|
|
32
|
+
"@zhushanwen/pi-pending-notifications": "0.7.2"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@xyz-agent/extension-protocol": "0.
|
|
35
|
+
"@xyz-agent/extension-protocol": "0.11.0"
|
|
36
36
|
},
|
|
37
37
|
"peerDependenciesMeta": {
|
|
38
38
|
"@earendil-works/pi-tui": {
|
|
@@ -36,8 +36,6 @@ function makeFakePorts(): ServicePorts & {
|
|
|
36
36
|
setStatus: () => {},
|
|
37
37
|
notify: (text, level) => { notifications.push({ text, level }); },
|
|
38
38
|
hasUI: true,
|
|
39
|
-
isGui: false,
|
|
40
|
-
setGuiWidget: () => {},
|
|
41
39
|
theme: {
|
|
42
40
|
fg: (_color: string, text: string) => text,
|
|
43
41
|
bold: (text: string) => text,
|
|
@@ -1,27 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* index.ts 测试 — 工厂入口 + 跨扩展 API(
|
|
2
|
+
* index.ts 测试 — 工厂入口 + 跨扩展 API(goalInit slot)
|
|
3
3
|
*
|
|
4
|
-
* 覆盖 T1.8 (NFR-AC-8):
|
|
4
|
+
* 覆盖 T1.8 (NFR-AC-8):goalInit 签名 (objective, budget, ctx, slug?, successCriteria?),
|
|
5
5
|
* 结构上无法接收 tasks(task CRUD 已删除,D-16/FR-4 双轨消除)。active 守卫由 service.test.ts 覆盖。
|
|
6
6
|
*
|
|
7
|
-
* 用最小 fake pi + fake ctx 实例化 goalExtension
|
|
7
|
+
* 用最小 fake pi + fake ctx 实例化 goalExtension 工厂,再从 globalThis slot 读取 goalInit——
|
|
8
|
+
* mock 形态与真实通道同构(goal-bridge-cross-extension.md §3.4:桥的元教训是 mock 世界
|
|
9
|
+
* 构造「共享 pi 对象」掩盖了真实世界的 per-extension 隔离,两侧测试一律走 slot)。
|
|
8
10
|
*/
|
|
9
11
|
import { appendFileSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
10
12
|
import { tmpdir } from "node:os";
|
|
11
13
|
import { join } from "node:path";
|
|
12
14
|
|
|
13
15
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
|
-
import { describe, expect, it } from "vitest";
|
|
16
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
15
17
|
|
|
16
18
|
import { handleSessionStart } from "../adapters/event-handlers/session-start";
|
|
17
|
-
import goalExtension from "../index";
|
|
19
|
+
import goalExtension, { GOAL_INIT_SLOT_KEY, type GoalInitFn } from "../index";
|
|
18
20
|
import { contextInjectionPrompt } from "../projection/prompts";
|
|
19
21
|
import { createGoalSession } from "../session";
|
|
20
22
|
|
|
23
|
+
/** 每用例后清 slot,防跨用例 globalThis 泄漏(设计 §3.4:teardown 两侧通用) */
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
Reflect.set(globalThis, GOAL_INIT_SLOT_KEY, undefined);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** 从 globalThis slot 读取 goalInit(与 plan 侧 compact.ts 的消费形态一致) */
|
|
29
|
+
function readGoalInit(): GoalInitFn | undefined {
|
|
30
|
+
const fn = Reflect.get(globalThis, GOAL_INIT_SLOT_KEY);
|
|
31
|
+
return typeof fn === "function" ? (fn as GoalInitFn) : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
21
34
|
// ── Minimal fake pi / ctx ─────────────────────────────
|
|
22
35
|
|
|
23
36
|
interface FactoryFixture {
|
|
24
|
-
pi: ExtensionAPI
|
|
37
|
+
pi: ExtensionAPI;
|
|
25
38
|
ctx: ExtensionContext;
|
|
26
39
|
states: unknown[];
|
|
27
40
|
history: unknown[];
|
|
@@ -50,7 +63,7 @@ function makeFactoryFixture(): FactoryFixture {
|
|
|
50
63
|
sendUserMessage(content: unknown): void {
|
|
51
64
|
sendUser.push(content);
|
|
52
65
|
},
|
|
53
|
-
} as unknown as ExtensionAPI
|
|
66
|
+
} as unknown as ExtensionAPI;
|
|
54
67
|
|
|
55
68
|
const ctx = {
|
|
56
69
|
hasUI: true,
|
|
@@ -68,18 +81,15 @@ function makeFactoryFixture(): FactoryFixture {
|
|
|
68
81
|
return { pi, ctx, states, history, sendUser, commands };
|
|
69
82
|
}
|
|
70
83
|
|
|
71
|
-
// ──
|
|
84
|
+
// ── goalInit slot(NFR-AC-8 / T1.8)──────────────────
|
|
72
85
|
|
|
73
|
-
describe("
|
|
74
|
-
it("工厂实例化后
|
|
86
|
+
describe("goalInit slot(NFR-AC-8 / T1.8)", () => {
|
|
87
|
+
it("工厂实例化后 slot 内 goalInit 存在;正常调用 → 创建 goal(返回 true + 持久化 state)", () => {
|
|
75
88
|
const { pi, ctx, states } = makeFactoryFixture();
|
|
76
89
|
goalExtension(pi);
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
undefined,
|
|
81
|
-
ctx,
|
|
82
|
-
);
|
|
90
|
+
const goalInit = readGoalInit();
|
|
91
|
+
expect(typeof goalInit).toBe("function");
|
|
92
|
+
const ok = goalInit!("build feature X", undefined, ctx);
|
|
83
93
|
expect(ok).toBe(true);
|
|
84
94
|
expect(states).toHaveLength(1); // appendState 调用
|
|
85
95
|
const persisted = states[0] as { objective?: string };
|
|
@@ -90,14 +100,7 @@ describe("pi.__goalInit(NFR-AC-8 / T1.8)", () => {
|
|
|
90
100
|
const { pi, ctx, states } = makeFactoryFixture();
|
|
91
101
|
goalExtension(pi);
|
|
92
102
|
// GoalInitFn 签名防漂移:successCriteria 为 string[](数组入参,非旧 string)
|
|
93
|
-
const
|
|
94
|
-
o: string,
|
|
95
|
-
b: unknown,
|
|
96
|
-
c: ExtensionContext,
|
|
97
|
-
slug: string,
|
|
98
|
-
sc: string[],
|
|
99
|
-
) => boolean;
|
|
100
|
-
const ok = init("objective", undefined, ctx, "slug", ["cond A", "cond B"]);
|
|
103
|
+
const ok = readGoalInit()!("objective", undefined, ctx, "slug", ["cond A", "cond B"]);
|
|
101
104
|
expect(ok).toBe(true);
|
|
102
105
|
// appendState 持久化的 state 深透传 successCriteria 数组
|
|
103
106
|
const persisted = states[0] as { slug?: string; successCriteria?: string[] };
|
|
@@ -109,7 +112,7 @@ describe("pi.__goalInit(NFR-AC-8 / T1.8)", () => {
|
|
|
109
112
|
// FR-4.2/D-16: ctx 必填
|
|
110
113
|
const { pi } = makeFactoryFixture();
|
|
111
114
|
goalExtension(pi);
|
|
112
|
-
const init =
|
|
115
|
+
const init = readGoalInit()!;
|
|
113
116
|
expect(() => init("obj", undefined, undefined)).not.toThrow();
|
|
114
117
|
expect(init("obj", undefined, undefined)).toBe(false);
|
|
115
118
|
});
|
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* adapters/ports.ts 测试 — UiPort
|
|
2
|
+
* adapters/ports.ts 测试 — UiPort 双模 widget 接线(setWidgetDual 委托集成)。
|
|
3
3
|
*
|
|
4
|
-
* 验证 buildPorts 构造的 uiPort
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
4
|
+
* 验证 buildPorts 构造的 uiPort.setWidget(dual payload 签名):
|
|
5
|
+
* - rpc 模式:gui 臂经 guiSetWidget 编码 marker 进 ctx.ui.setWidget
|
|
6
|
+
* - tui 模式:text 臂透传(pi 原生文本行,无 marker)
|
|
7
|
+
* - undefined:清除语义(模式无关)
|
|
8
|
+
*
|
|
9
|
+
* 模式分派守卫单点在 protocol helpers.ts(守卫单点化),本测试只验证委托接线。
|
|
7
10
|
*
|
|
8
11
|
* 运行:cd extensions/universal/goal && npx vitest run src/__tests__/ports.test.ts
|
|
9
12
|
*/
|
|
10
13
|
import { describe, expect, it } from "vitest";
|
|
11
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
GUI_WIDGET_MARKER,
|
|
16
|
+
guiComponent,
|
|
17
|
+
guiResult,
|
|
18
|
+
type DualWidgetContent,
|
|
19
|
+
} from "@xyz-agent/extension-protocol";
|
|
12
20
|
|
|
13
21
|
import { buildPorts } from "../adapters/ports";
|
|
14
22
|
|
|
@@ -17,7 +25,7 @@ function makePi(): Parameters<typeof buildPorts>[0] {
|
|
|
17
25
|
return { appendEntry: () => {} } as unknown as Parameters<typeof buildPorts>[0];
|
|
18
26
|
}
|
|
19
27
|
|
|
20
|
-
/** mock ctx,捕获 ui.setWidget 收到的 lines(验证 marker
|
|
28
|
+
/** mock ctx,捕获 ui.setWidget 收到的 lines(验证 marker 编码 / text 透传)。 */
|
|
21
29
|
function makeCtx(opts: { mode?: string; hasUI?: boolean } = {}) {
|
|
22
30
|
let captured: string[] | undefined = undefined;
|
|
23
31
|
const ctx = {
|
|
@@ -35,43 +43,51 @@ function makeCtx(opts: { mode?: string; hasUI?: boolean } = {}) {
|
|
|
35
43
|
return { ctx, getCaptured: () => captured };
|
|
36
44
|
}
|
|
37
45
|
|
|
38
|
-
|
|
39
|
-
|
|
46
|
+
function makeDualContent(): DualWidgetContent {
|
|
47
|
+
return {
|
|
48
|
+
gui: guiResult(guiComponent("stats-line", { items: [{ value: "x" }] })),
|
|
49
|
+
text: ["tui line"],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("buildPorts UiPort.setWidget(setWidgetDual 委托)", () => {
|
|
54
|
+
it("rpc 模式 → ctx.ui.setWidget 收到 gui 臂 marker 编码的 string[]", () => {
|
|
40
55
|
const { ctx, getCaptured } = makeCtx({ mode: "rpc" });
|
|
41
56
|
const ports = buildPorts(makePi(), ctx);
|
|
42
|
-
const comp = guiComponent("stats-line", { items: [{ value: "x" }] });
|
|
43
57
|
|
|
44
|
-
ports.ui.
|
|
58
|
+
ports.ui.setWidget("goal", makeDualContent());
|
|
45
59
|
|
|
46
60
|
const captured = getCaptured();
|
|
47
61
|
expect(captured).toBeDefined();
|
|
48
62
|
expect(captured).toHaveLength(1);
|
|
49
63
|
// marker 前缀(host 侧 EventAdapter 据此解码成 extension:widgetGui)
|
|
50
64
|
expect(captured![0].startsWith(GUI_WIDGET_MARKER)).toBe(true);
|
|
51
|
-
// 解码还原
|
|
65
|
+
// 解码还原 GuiRenderResult 信封(TUI text 臂不出现)
|
|
52
66
|
const json = captured![0].slice(GUI_WIDGET_MARKER.length);
|
|
53
67
|
const parsed = JSON.parse(json);
|
|
54
|
-
expect(parsed.
|
|
68
|
+
expect(parsed.v).toBe(1);
|
|
69
|
+
expect(parsed.component.type).toBe("stats-line");
|
|
55
70
|
});
|
|
56
71
|
|
|
57
|
-
it("
|
|
58
|
-
const { ctx, getCaptured } = makeCtx({ mode: "
|
|
72
|
+
it("tui 模式 → ctx.ui.setWidget 收到 text 臂原生文本行(无 marker 前缀)", () => {
|
|
73
|
+
const { ctx, getCaptured } = makeCtx({ mode: "tui" });
|
|
59
74
|
const ports = buildPorts(makePi(), ctx);
|
|
60
75
|
|
|
61
|
-
ports.ui.
|
|
76
|
+
ports.ui.setWidget("goal", makeDualContent());
|
|
62
77
|
|
|
63
|
-
|
|
78
|
+
const captured = getCaptured();
|
|
79
|
+
expect(captured).toEqual(["tui line"]);
|
|
80
|
+
for (const line of captured!) {
|
|
81
|
+
expect(line.startsWith(GUI_WIDGET_MARKER)).toBe(false);
|
|
82
|
+
}
|
|
64
83
|
});
|
|
65
|
-
});
|
|
66
84
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
85
|
+
it("setWidget(undefined) → ctx.ui.setWidget 收到 undefined(清除语义,模式无关)", () => {
|
|
86
|
+
const { ctx, getCaptured } = makeCtx({ mode: "rpc" });
|
|
87
|
+
const ports = buildPorts(makePi(), ctx);
|
|
88
|
+
|
|
89
|
+
ports.ui.setWidget("goal", undefined);
|
|
72
90
|
|
|
73
|
-
|
|
74
|
-
const { ctx } = makeCtx({ mode: "tui" });
|
|
75
|
-
expect(buildPorts(makePi(), ctx).ui.isGui).toBe(false);
|
|
91
|
+
expect(getCaptured()).toBeUndefined();
|
|
76
92
|
});
|
|
77
93
|
});
|
package/src/adapters/ports.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
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 +
|
|
7
|
+
* - ui: ctx.ui 的 setWidget/setStatus/notify + hasUI + theme(fg/bold 适配 ThemeLike)
|
|
8
8
|
* - messaging: pi.sendMessage 映射到 sendContextMessage / sendUserMessage
|
|
9
9
|
* - session: ctx.sessionManager.getEntries
|
|
10
10
|
*
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { ExtensionAPI, ExtensionContext, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
16
|
-
import {
|
|
17
|
-
import type { GuiContext
|
|
16
|
+
import { setWidgetDual } from "@xyz-agent/extension-protocol";
|
|
17
|
+
import type { GuiContext } from "@xyz-agent/extension-protocol";
|
|
18
18
|
|
|
19
19
|
import { ENTRY_TYPE, HISTORY_ENTRY_TYPE } from "../persistence";
|
|
20
20
|
import type { MessagingPort, PersistencePort, SessionPort, UiPort } from "../ports";
|
|
@@ -40,24 +40,16 @@ export function buildPorts(pi: ExtensionAPI, ctx: ExtensionContext): ServicePort
|
|
|
40
40
|
};
|
|
41
41
|
|
|
42
42
|
const uiPort: UiPort = {
|
|
43
|
-
setWidget(name
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
ctx.ui.setWidget(name, lines);
|
|
48
|
-
},
|
|
49
|
-
setGuiWidget(name: string, result: GuiRenderResult | undefined): void {
|
|
50
|
-
// GUI 协议 widget:guiSetWidget 在 RPC 模式用 marker 编码 GuiRenderResult JSON
|
|
51
|
-
// (component + meta 宿主元数据)进 string[],复用 ctx.ui.setWidget 通道;
|
|
52
|
-
// host 侧 EventAdapter(xyz-agent runtime 的 pi 事件适配层)检测 marker 解码还原。guiSetWidget 无 isGui 守卫
|
|
53
|
-
// (helpers.ts 仅查 ctx.ui?.setWidget 存在性),isGui 判定在 updateWidget
|
|
54
|
-
// 外层(projection/widget.ts)。
|
|
43
|
+
setWidget(name, content): void {
|
|
44
|
+
// 双模 payload 委托 protocol setWidgetDual:推送/清屏 × GUI/TUI 的模式分派
|
|
45
|
+
// 由 helper 单点内化(守卫单点化说明见 extension-protocol helpers.ts),
|
|
46
|
+
// 本 adapter 不再自持 isGui 判定/守卫注释。
|
|
55
47
|
//
|
|
56
48
|
// 断言根因:pi ExtensionUIContext.custom 是泛型方法(返回 Promise<T>),
|
|
57
49
|
// 与 GuiContext.ui.custom 的具体返回类型静态不兼容,但 mode/hasUI/ui 其余
|
|
58
50
|
// 成员形状一致(ExtensionMode 与 GuiContext.mode union 完全相同)——
|
|
59
|
-
// 单层直接断言可过 tsc(
|
|
60
|
-
|
|
51
|
+
// 单层直接断言可过 tsc(setWidgetDual 只读 mode 与 ui.setWidget,custom 不参与)。
|
|
52
|
+
setWidgetDual(ctx as GuiContext, name, content);
|
|
61
53
|
},
|
|
62
54
|
setStatus(name: string, text: string | undefined): void {
|
|
63
55
|
ctx.ui.setStatus(name, text);
|
|
@@ -68,11 +60,6 @@ export function buildPorts(pi: ExtensionAPI, ctx: ExtensionContext): ServicePort
|
|
|
68
60
|
get hasUI(): boolean {
|
|
69
61
|
return Boolean(ctx.hasUI);
|
|
70
62
|
},
|
|
71
|
-
get isGui(): boolean {
|
|
72
|
-
// RPC 模式 = GUI 渲染通道有效;TUI/json/print 走 pi 原生渲染。
|
|
73
|
-
// 断言同 setGuiWidget:custom 泛型签名静态不兼容,mode 单字段运行时可靠。
|
|
74
|
-
return isGuiCapable(ctx as GuiContext);
|
|
75
|
-
},
|
|
76
63
|
// ThemeLike 适配:透传 ctx.ui.theme 的 fg/bold。
|
|
77
64
|
// Theme.fg 只接受 ThemeColor 字面量 union(SDK 契约);projection 层保证传入 union 内字面量,
|
|
78
65
|
// string → ThemeColor 是宽到窄单步断言(无需 unknown 中转)。
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Pi /goal Extension — 工厂入口(重构后架构)
|
|
3
3
|
*
|
|
4
4
|
* 注册 command / events,全部委托 adapters 层。
|
|
5
|
-
*
|
|
5
|
+
* goalInit slot 内部调 service.createGoal(FR-4.1)。
|
|
6
6
|
*
|
|
7
7
|
* 架构(D-21 双路径 + Ports/Adapters):
|
|
8
8
|
* - engine/:零 Pi 依赖的纯状态机(goal/budget/types)
|
|
@@ -113,25 +113,27 @@ export default function goalExtension(pi: ExtensionAPI) {
|
|
|
113
113
|
);
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
// ── External API:
|
|
116
|
+
// ── External API: goalInit slot(FR-4 双轨消除)──────────
|
|
117
117
|
|
|
118
118
|
/**
|
|
119
|
-
* 允许其他扩展(
|
|
119
|
+
* 允许其他扩展(plan)通过 goalInit slot 编程式初始化 goal。
|
|
120
120
|
* 内部调 service.createGoal(FR-4.1——与 goal_control create 走同一创建逻辑)。
|
|
121
121
|
*
|
|
122
122
|
* FR-4.2/D-16: ctx 必填(消除 lastCtx 模块级可变状态)。
|
|
123
123
|
* ports 构造复用 adapters/ports.buildPorts(DRY:单一 ports 构造点)。
|
|
124
124
|
*
|
|
125
|
+
* 通道形态(C-ext-06 globalThis slot 惯例):裸函数直挂——plan 侧使用点以 typeof 守卫
|
|
126
|
+
* 读取 `globalThis[Symbol.for("@zhushanwen/pi-goal.goalInit")]`(plan/src/compact.ts)。
|
|
127
|
+
* 不挂 pi API 对象:pi 0.84.4 为每个扩展创建独立 ExtensionAPI,挂 pi 对象的字段
|
|
128
|
+
* 跨扩展不可见(设计 docs/design/goal-bridge-cross-extension.md §2.3)。
|
|
129
|
+
*
|
|
125
130
|
* @param objective 目标描述
|
|
126
131
|
* @param budget 预算配置,传 undefined 用默认值
|
|
127
132
|
* @param ctx **必填**——调用方的 ExtensionContext。省略会返回 false(创建失败)。
|
|
128
133
|
* @param slug 可选短标识(仅 widget 标题 + history 用,不注入 prompt)
|
|
129
134
|
* @returns true 创建成功;false 已有 active goal 或 ctx 缺失
|
|
130
135
|
*/
|
|
131
|
-
|
|
132
|
-
// __goalInit 字段获得 GoalInitFn 类型(见下方导出),挂载与消费两侧签名强一致。
|
|
133
|
-
const api = pi as ExtensionAPI & { __goalInit?: GoalInitFn };
|
|
134
|
-
api.__goalInit = (
|
|
136
|
+
const goalInitFn: GoalInitFn = (
|
|
135
137
|
objective: string,
|
|
136
138
|
budget: GoalInitBudget | undefined,
|
|
137
139
|
ctx: ExtensionContext,
|
|
@@ -141,14 +143,24 @@ export default function goalExtension(pi: ExtensionAPI) {
|
|
|
141
143
|
if (!ctx) return false;
|
|
142
144
|
return createGoal(session, objective, budget ?? {}, buildPorts(pi, ctx), slug, successCriteria);
|
|
143
145
|
};
|
|
146
|
+
Reflect.set(globalThis, GOAL_INIT_SLOT_KEY, goalInitFn);
|
|
144
147
|
}
|
|
145
148
|
|
|
146
149
|
// ── Cross-extension API 类型(单一 source of truth,API-1)──────────
|
|
147
150
|
|
|
148
151
|
/**
|
|
149
|
-
*
|
|
152
|
+
* goalInit 跨扩展通道的 globalThis slot key(C-ext-06「包名.角色」全限定命名)。
|
|
153
|
+
*
|
|
154
|
+
* ⚠️ 必须与 `extensions/universal/plan/src/compact.ts` 的字符串字面量完全一致——
|
|
155
|
+
* 两侧不共享运行时模块(plan 对 goal 是 optional peer),靠同一字符串拿到同一 slot。
|
|
156
|
+
* 改名必须两侧同步。
|
|
157
|
+
*/
|
|
158
|
+
export const GOAL_INIT_SLOT_KEY = Symbol.for("@zhushanwen/pi-goal.goalInit");
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* goalInit slot 的预算配置形状。
|
|
150
162
|
*
|
|
151
|
-
* 跨扩展(
|
|
163
|
+
* 跨扩展(plan)通过 goalInit slot 编程式初始化 goal 时使用。
|
|
152
164
|
* 与 `BudgetConfig` 的差异:本类型只暴露外部可设的可选字段,且全部 optional。
|
|
153
165
|
*/
|
|
154
166
|
export interface GoalInitBudget {
|
|
@@ -156,12 +168,13 @@ export interface GoalInitBudget {
|
|
|
156
168
|
}
|
|
157
169
|
|
|
158
170
|
/**
|
|
159
|
-
*
|
|
171
|
+
* goalInit slot 的规范函数签名(API-1:单一 source of truth)。
|
|
160
172
|
*
|
|
161
173
|
* 跨扩展消费者应 import 本类型而非重复声明 inline alias,避免签名 drift:
|
|
162
174
|
* ```ts
|
|
163
175
|
* import type { GoalInitFn } from "@zhushanwen/pi-goal";
|
|
164
|
-
* const
|
|
176
|
+
* const fn = Reflect.get(globalThis, Symbol.for("@zhushanwen/pi-goal.goalInit"));
|
|
177
|
+
* const goalInit = typeof fn === "function" ? (fn as GoalInitFn) : undefined;
|
|
165
178
|
* ```
|
|
166
179
|
*
|
|
167
180
|
* @param objective 目标描述
|
package/src/ports.ts
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* adapter 层提供实现(包装 ctx / pi)。
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import type { DualWidgetContent } from "@xyz-agent/extension-protocol";
|
|
10
|
+
|
|
9
11
|
import type { GoalRuntimeState } from "./engine/types";
|
|
10
|
-
import type { GuiRenderResult } from "@xyz-agent/extension-protocol";
|
|
11
12
|
|
|
12
13
|
// ── GoalHistoryEntry(DTO,非 aggregate,D-09)─────────
|
|
13
14
|
|
|
@@ -49,22 +50,19 @@ export interface ThemeLike {
|
|
|
49
50
|
// ── UiPort ───────────────────────────────────────────
|
|
50
51
|
|
|
51
52
|
export interface UiPort {
|
|
52
|
-
/** 设置 widget(undefined = 清除)。hasUI=false 时 adapter 跳过(FR-6.6) */
|
|
53
|
-
setWidget(name: string, content: string[] | string | undefined): void;
|
|
54
53
|
/**
|
|
55
|
-
* 设置
|
|
56
|
-
* RPC
|
|
57
|
-
*
|
|
54
|
+
* 设置 widget(双模单调用)。content = { gui, text } 双模 payload / undefined 清除;
|
|
55
|
+
* 模式分派(RPC→gui 经 marker 通道 / TUI→text 原生文本行)由 protocol setWidgetDual
|
|
56
|
+
* 单点内化(守卫单点化说明见 extension-protocol core/helpers.ts,adapter 委托 helper)。
|
|
57
|
+
* hasUI 守卫留在调用方(FR-6.6)。
|
|
58
58
|
*/
|
|
59
|
-
|
|
59
|
+
setWidget(name: string, content: DualWidgetContent | undefined): void;
|
|
60
60
|
/** 设置 status bar */
|
|
61
61
|
setStatus(name: string, text: string | undefined): void;
|
|
62
62
|
/** 弹通知 */
|
|
63
63
|
notify(text: string, level: "info" | "warning" | "error"): void;
|
|
64
64
|
/** 是否有 UI(headless 为 false) */
|
|
65
65
|
readonly hasUI: boolean;
|
|
66
|
-
/** 是否 GUI 渲染模式(RPC → GuiComponent;TUI → 原生文本行) */
|
|
67
|
-
readonly isGui: boolean;
|
|
68
66
|
/** 终端主题能力(fg/bold 着色),widget/status 文本行渲染消费(E3 显式声明) */
|
|
69
67
|
readonly theme: ThemeLike;
|
|
70
68
|
}
|
|
@@ -197,37 +197,34 @@ describe("renderWidgetLines", () => {
|
|
|
197
197
|
});
|
|
198
198
|
});
|
|
199
199
|
|
|
200
|
-
// ── updateWidget(FR-6.6 hasUI
|
|
200
|
+
// ── updateWidget(FR-6.6 hasUI 守卫 + dual payload)──
|
|
201
201
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function makeUiPort(hasUI: boolean, isGui = false): { ui: UiPort; calls: RecordedCall[] } {
|
|
209
|
-
const calls: RecordedCall[] = [];
|
|
210
|
-
const ui = {
|
|
211
|
-
hasUI,
|
|
212
|
-
isGui,
|
|
213
|
-
setWidget(name: string, content: unknown) {
|
|
214
|
-
calls.push({ method: "setWidget", args: [name, content] });
|
|
215
|
-
},
|
|
216
|
-
setGuiWidget(name: string, component: unknown) {
|
|
217
|
-
calls.push({ method: "setGuiWidget", args: [name, component] });
|
|
218
|
-
},
|
|
219
|
-
setStatus(name: string, text: unknown) {
|
|
220
|
-
calls.push({ method: "setStatus", args: [name, text] });
|
|
221
|
-
},
|
|
222
|
-
notify() {},
|
|
223
|
-
theme: {
|
|
224
|
-
fg: (_color: string, text: string) => text,
|
|
225
|
-
bold: (text: string) => text,
|
|
226
|
-
},
|
|
227
|
-
} as UiPort;
|
|
228
|
-
return { ui, calls };
|
|
229
|
-
}
|
|
202
|
+
interface RecordedCall {
|
|
203
|
+
method: "setWidget" | "setStatus";
|
|
204
|
+
args: unknown[];
|
|
205
|
+
}
|
|
230
206
|
|
|
207
|
+
/** UiPort fake:记录 setWidget/setStatus 调用(setWidget 现为 dual payload 单签名)。 */
|
|
208
|
+
function makeUiPort(hasUI: boolean): { ui: UiPort; calls: RecordedCall[] } {
|
|
209
|
+
const calls: RecordedCall[] = [];
|
|
210
|
+
const ui = {
|
|
211
|
+
hasUI,
|
|
212
|
+
setWidget(name: string, content: unknown) {
|
|
213
|
+
calls.push({ method: "setWidget", args: [name, content] });
|
|
214
|
+
},
|
|
215
|
+
setStatus(name: string, text: unknown) {
|
|
216
|
+
calls.push({ method: "setStatus", args: [name, text] });
|
|
217
|
+
},
|
|
218
|
+
notify() {},
|
|
219
|
+
theme: {
|
|
220
|
+
fg: (_color: string, text: string) => text,
|
|
221
|
+
bold: (text: string) => text,
|
|
222
|
+
},
|
|
223
|
+
} as UiPort;
|
|
224
|
+
return { ui, calls };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
describe("updateWidget (FR-6.6 hasUI guard)", () => {
|
|
231
228
|
it("hasUI=false → 不调 setWidget/setStatus(headless 守卫)", () => {
|
|
232
229
|
const { ui, calls } = makeUiPort(false);
|
|
233
230
|
const session = createGoalSession();
|
|
@@ -263,7 +260,7 @@ describe("updateWidget (FR-6.6 hasUI guard)", () => {
|
|
|
263
260
|
expect(calls.some((c) => c.method === "setWidget" && c.args[1] === undefined)).toBe(true);
|
|
264
261
|
});
|
|
265
262
|
|
|
266
|
-
it("active → setStatus + setWidget
|
|
263
|
+
it("active → setStatus + setWidget dual payload(text 臂正常渲染)", () => {
|
|
267
264
|
const { ui, calls } = makeUiPort(true);
|
|
268
265
|
const session = createGoalSession();
|
|
269
266
|
session.state = makeState({ status: "active" });
|
|
@@ -272,90 +269,41 @@ describe("updateWidget (FR-6.6 hasUI guard)", () => {
|
|
|
272
269
|
expect(statusCall!.args[1]).toEqual(expect.stringContaining("◆"));
|
|
273
270
|
expect(statusCall!.args[1]).toEqual(expect.stringContaining("Turn"));
|
|
274
271
|
const widgetCall = calls.find((c) => c.method === "setWidget");
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
);
|
|
272
|
+
const payload = widgetCall!.args[1] as { gui: unknown; text: string[] };
|
|
273
|
+
expect(payload.text).toEqual(expect.arrayContaining([expect.stringContaining("Token:")]));
|
|
278
274
|
});
|
|
279
275
|
});
|
|
280
276
|
|
|
281
|
-
// ── updateWidget
|
|
282
|
-
|
|
283
|
-
describe("updateWidget
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
args: unknown[];
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function makeGuiUiPort(hasUI: boolean, isGui = false): { ui: UiPort; calls: RecordedCall[] } {
|
|
290
|
-
const calls: RecordedCall[] = [];
|
|
291
|
-
const ui = {
|
|
292
|
-
hasUI,
|
|
293
|
-
isGui,
|
|
294
|
-
setWidget(name: string, content: unknown) {
|
|
295
|
-
calls.push({ method: "setWidget", args: [name, content] });
|
|
296
|
-
},
|
|
297
|
-
setGuiWidget(name: string, component: unknown) {
|
|
298
|
-
calls.push({ method: "setGuiWidget", args: [name, component] });
|
|
299
|
-
},
|
|
300
|
-
setStatus(name: string, text: unknown) {
|
|
301
|
-
calls.push({ method: "setStatus", args: [name, text] });
|
|
302
|
-
},
|
|
303
|
-
notify() {},
|
|
304
|
-
theme: {
|
|
305
|
-
fg: (_color: string, text: string) => text,
|
|
306
|
-
bold: (text: string) => text,
|
|
307
|
-
},
|
|
308
|
-
} as UiPort;
|
|
309
|
-
return { ui, calls };
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
it("active + isGui + 有预算 → setGuiWidget(v1.1 result: group + meta) + 不调 setWidget TUI lines", () => {
|
|
313
|
-
const { ui, calls } = makeGuiUiPort(true, true);
|
|
277
|
+
// ── updateWidget dual payload(gui 臂 = projection/gui.ts buildGoalGui 信封)──
|
|
278
|
+
|
|
279
|
+
describe("updateWidget dual payload(gui 臂)", () => {
|
|
280
|
+
it("active + 有预算 → gui 臂 v1.1 信封(group + meta.progress)", () => {
|
|
281
|
+
const { ui, calls } = makeUiPort(true);
|
|
314
282
|
const session = createGoalSession();
|
|
315
283
|
session.state = makeState({ status: "active", budget: { tokenBudget: 10000 } });
|
|
316
284
|
updateWidget(session, ui);
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
component: { type: string };
|
|
322
|
-
meta: { title: string; progress?: { total: number } };
|
|
285
|
+
const widgetCall = calls.find((c) => c.method === "setWidget" && c.args[0] === "goal");
|
|
286
|
+
const payload = widgetCall!.args[1] as {
|
|
287
|
+
gui: { component: { type: string }; meta: { title: string; progress?: { total: number } } };
|
|
288
|
+
text: string[];
|
|
323
289
|
};
|
|
324
|
-
|
|
325
|
-
expect(
|
|
326
|
-
|
|
327
|
-
|
|
290
|
+
// v1.1:gui 臂整个 GuiRenderResult(group 组合根 + meta 宿主元数据,head 由壳层渲染)
|
|
291
|
+
expect(payload.gui.component.type).toBe("group");
|
|
292
|
+
expect(payload.gui.meta.progress).toMatchObject({ total: 10000 });
|
|
293
|
+
// text 臂同步构造(TUI 渲染行)
|
|
294
|
+
expect(payload.text.length).toBeGreaterThan(0);
|
|
328
295
|
});
|
|
329
296
|
|
|
330
|
-
it("active
|
|
331
|
-
const { ui, calls } =
|
|
297
|
+
it("active 无预算 → gui 臂 meta 无 progress", () => {
|
|
298
|
+
const { ui, calls } = makeUiPort(true);
|
|
332
299
|
const session = createGoalSession();
|
|
333
300
|
session.state = makeState({ status: "active" });
|
|
334
301
|
updateWidget(session, ui);
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
component: { type: string };
|
|
339
|
-
meta: { title: string; progress?: unknown };
|
|
302
|
+
const widgetCall = calls.find((c) => c.method === "setWidget" && c.args[0] === "goal");
|
|
303
|
+
const payload = widgetCall!.args[1] as {
|
|
304
|
+
gui: { component: { type: string }; meta: { title: string; progress?: unknown } };
|
|
340
305
|
};
|
|
341
|
-
expect(
|
|
342
|
-
expect(
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
it("cancelled + isGui → setGuiWidget(undefined)", () => {
|
|
346
|
-
const { ui, calls } = makeGuiUiPort(true, true);
|
|
347
|
-
const session = createGoalSession();
|
|
348
|
-
session.state = makeState({ status: "cancelled" });
|
|
349
|
-
updateWidget(session, ui);
|
|
350
|
-
expect(calls.some((c) => c.method === "setGuiWidget" && c.args[1] === undefined)).toBe(true);
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
it("isGui=false → 走 TUI setWidget(不调 setGuiWidget)", () => {
|
|
354
|
-
const { ui, calls } = makeGuiUiPort(true, false);
|
|
355
|
-
const session = createGoalSession();
|
|
356
|
-
session.state = makeState({ status: "active" });
|
|
357
|
-
updateWidget(session, ui);
|
|
358
|
-
expect(calls.some((c) => c.method === "setWidget" && c.args[0] === "goal")).toBe(true);
|
|
359
|
-
expect(calls.some((c) => c.method === "setGuiWidget")).toBe(false);
|
|
306
|
+
expect(payload.gui.component.type).toBe("group");
|
|
307
|
+
expect(payload.gui.meta.progress).toBeUndefined();
|
|
360
308
|
});
|
|
361
309
|
});
|
package/src/projection/widget.ts
CHANGED
|
@@ -191,20 +191,16 @@ export function renderWidgetLines(state: GoalRuntimeState, th: ThemeLike): strin
|
|
|
191
191
|
*
|
|
192
192
|
* FR-6.6:`uiPort.hasUI === false`(headless)时直接 return。
|
|
193
193
|
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
* TUI 模式:推送 renderWidgetLines 文本行(向后兼容)。
|
|
194
|
+
* widget 推送为双模单调用:setWidget 收 dual payload({ gui, text }),
|
|
195
|
+
* GUI/TUI 模式分派由 adapter 层委托的 protocol setWidgetDual 单点承担。
|
|
197
196
|
*
|
|
198
197
|
* 终态折叠为单行 status bar(widget 清除);cancelled/无 state 清除 widget + status。
|
|
199
198
|
*/
|
|
200
199
|
export function updateWidget(session: GoalSession, uiPort: UiPort): void {
|
|
201
200
|
if (!uiPort.hasUI) return;
|
|
202
201
|
|
|
203
|
-
const isGui = uiPort.isGui;
|
|
204
|
-
|
|
205
202
|
if (!session.state || session.state.status === "cancelled") {
|
|
206
|
-
|
|
207
|
-
else uiPort.setWidget("goal", undefined);
|
|
203
|
+
uiPort.setWidget("goal", undefined);
|
|
208
204
|
uiPort.setStatus("goal", undefined);
|
|
209
205
|
return;
|
|
210
206
|
}
|
|
@@ -215,16 +211,15 @@ export function updateWidget(session: GoalSession, uiPort: UiPort): void {
|
|
|
215
211
|
if (statusText) {
|
|
216
212
|
uiPort.setStatus("goal", statusText);
|
|
217
213
|
}
|
|
218
|
-
|
|
219
|
-
else uiPort.setWidget("goal", undefined);
|
|
214
|
+
uiPort.setWidget("goal", undefined);
|
|
220
215
|
return;
|
|
221
216
|
}
|
|
222
217
|
|
|
223
218
|
uiPort.setStatus("goal", renderStatusLine(session.state, uiPort.theme));
|
|
224
|
-
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
219
|
+
// 双模 payload 按值立即构造(buildGoalGui/renderWidgetLines 均为廉价纯函数):
|
|
220
|
+
// gui 臂复用 projection/gui.ts 的 buildGoalGui(GuiRenderResult:component + meta 宿主元数据)
|
|
221
|
+
uiPort.setWidget("goal", {
|
|
222
|
+
gui: buildGoalGui(session.state),
|
|
223
|
+
text: renderWidgetLines(session.state, uiPort.theme),
|
|
224
|
+
});
|
|
230
225
|
}
|
package/src/service.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* D-16: service 不持有 ctx,通过 ports 参数接收能力。
|
|
8
8
|
*
|
|
9
|
-
* FR-3.1: createGoal 唯一创建入口(/goal set 与
|
|
9
|
+
* FR-3.1: createGoal 唯一创建入口(/goal set 与 goalInit slot 都走它)
|
|
10
10
|
* FR-3.3: finalizeAndPersist 唯一终态序列入口(tick → finalizeGoal → persist)
|
|
11
11
|
* finalizeGoal 只做 transitionStatus + writeHistory(纯)
|
|
12
12
|
* FR-6.5: persist 前调 tick 累计时间
|
|
@@ -121,7 +121,7 @@ export function persistAndUpdate(
|
|
|
121
121
|
/**
|
|
122
122
|
* 唯一创建入口。两个调用源都走它:
|
|
123
123
|
* - goal_control create(toolcall,AI 提供 slug + objective + successCriteria)
|
|
124
|
-
* -
|
|
124
|
+
* - goalInit slot(index.ts,跨扩展编程式入口)
|
|
125
125
|
*
|
|
126
126
|
* 注:/goal <objective> 命令路径已改为提示词触发器——不直接调本函数,
|
|
127
127
|
* 而是 sendUserMessage 让 AI 调 goal_control create(slug + successCriteria 由 AI 生成)。
|