@zhushanwen/pi-goal 0.14.1 → 0.14.3

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 CHANGED
@@ -6,7 +6,7 @@ Codex 风格的 `/goal` 命令 — 持久目标追踪:`goal_control` 工具创
6
6
 
7
7
  - **目标管理**:`goal_control` tool 是唯一创建入口(create / complete / report_blocked)。`/goal <objective>` 是提示词触发器——不直接启动,而是引导 AI 调 `goal_control create`(slug / objective / successCriteria 均由 AI 在 toolcall 时决定)
8
8
  - **证据验证**:complete 必须提供具体 evidence(改动的文件、通过的测试、运行的命令),且满足 create 时定义的每条 successCriteria 条件,不能空口完成
9
- - **预算控制**:仅 token 维度预算(time budget 已移除,耗时仅记账显示)。70% 预警、90% 发收尾 steering、100% 且已发收尾提示后进入 budget_limited 终态。默认不设预算,仅在用户显式要求时设置
9
+ - **预算控制**:仅 token 维度预算(耗时仅记账显示,不作预算维度)。70% 预警、90% 发收尾 steering、100% 且已发收尾提示后进入 budget_limited 终态。默认不设预算,仅在用户显式要求时设置
10
10
  - **阻塞处理**:无自动检测——blocked 只能由 agent 主动调 `goal_control report_blocked`(穷尽替代方案后);用户经 `/goal resume` 恢复(paused/blocked → active)
11
11
  - **持久化**:状态通过 session entries 保存(append-only),重启后从最新一条 goal-state entry 恢复,崩溃后保持原状态
12
12
 
@@ -19,7 +19,7 @@ Codex 风格的 `/goal` 命令 — 持久目标追踪:`goal_control` 工具创
19
19
 
20
20
  ```bash
21
21
  # symlink 方式(开发推荐;globalExtDir 平铺布局,目标不带分组层)
22
- ln -s /path/to/xyz-agent/extensions/universal/goal \
22
+ ln -s /path/to/taiji/extensions/universal/goal \
23
23
  ~/.pi/agent/extensions/goal
24
24
 
25
25
  # npm 方式(正式)
@@ -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 + __goalInit,全部委托 adapters)
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,10 +1,10 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-goal",
3
- "version": "0.14.1",
3
+ "version": "0.14.3",
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",
7
- "xyz-agent": {
7
+ "taiji": {
8
8
  "role": "universal"
9
9
  },
10
10
  "pi": {
@@ -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.1"
32
+ "@zhushanwen/pi-pending-notifications": "0.7.3"
33
33
  },
34
34
  "dependencies": {
35
- "@xyz-agent/extension-protocol": "0.10.0"
35
+ "@taiji/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(pi.__goalInit)
2
+ * index.ts 测试 — 工厂入口 + 跨扩展 API(goalInit slot)
3
3
  *
4
- * 覆盖 T1.8 (NFR-AC-8):__goalInit 签名 (objective, budget, ctx, slug?, successCriteria?),
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 工厂,再调 pi.__goalInit。
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 & { __goalInit?: (...args: never[]) => unknown };
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 & { __goalInit?: (...args: never[]) => unknown };
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
- // ── pi.__goalInit(NFR-AC-8 / T1.8)──────────────────
84
+ // ── goalInit slot(NFR-AC-8 / T1.8)──────────────────
72
85
 
73
- describe("pi.__goalInit(NFR-AC-8 / T1.8)", () => {
74
- it("工厂实例化后 __goalInit 存在;正常调用 → 创建 goal(返回 true + 持久化 state)", () => {
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
- expect(typeof pi.__goalInit).toBe("function");
78
- const ok = (pi.__goalInit as (o: string, b: unknown, c: ExtensionContext) => boolean)(
79
- "build feature X",
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 init = pi.__goalInit as (
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 = pi.__goalInit as (o: string, b: unknown, c: unknown) => boolean;
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 GUI 协议接线(marker 编码集成)。
2
+ * adapters/ports.ts 测试 — UiPort 双模 widget 接线(setWidgetDual 委托集成)。
3
3
  *
4
- * 验证 buildPorts 构造的 uiPort:
5
- * - setGuiWidget 经 guiSetWidget 编码 marker 进 ctx.ui.setWidget(RPC 模式)
6
- * - isGui 反映 ctx.mode(rpc → GUI 渲染通道;tui → 原生文本)
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 { GUI_WIDGET_MARKER, guiComponent } from "@xyz-agent/extension-protocol";
14
+ import {
15
+ GUI_WIDGET_MARKER,
16
+ guiComponent,
17
+ guiResult,
18
+ type DualWidgetContent,
19
+ } from "@taiji/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
- describe("buildPorts UiPort.setGuiWidget(marker 编码集成)", () => {
39
- it("RPC 模式 setGuiWidget → ctx.ui.setWidget 收到 marker 编码的 string[]", () => {
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.setGuiWidget("goal", comp);
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
- // 解码还原 GuiComponent
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.type).toBe("stats-line");
68
+ expect(parsed.v).toBe(1);
69
+ expect(parsed.component.type).toBe("stats-line");
55
70
  });
56
71
 
57
- it("setGuiWidget(undefined) → ctx.ui.setWidget 收到 undefined(清除语义)", () => {
58
- const { ctx, getCaptured } = makeCtx({ mode: "rpc" });
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.setGuiWidget("goal", undefined);
76
+ ports.ui.setWidget("goal", makeDualContent());
62
77
 
63
- expect(getCaptured()).toBeUndefined();
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
- describe("buildPorts UiPort.isGui(ctx.mode 反映)", () => {
68
- it("rpc 模式 → isGui=true(GUI 渲染通道有效)", () => {
69
- const { ctx } = makeCtx({ mode: "rpc" });
70
- expect(buildPorts(makePi(), ctx).ui.isGui).toBe(true);
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
- it("tui 模式 → isGui=false(走原生文本行)", () => {
74
- const { ctx } = makeCtx({ mode: "tui" });
75
- expect(buildPorts(makePi(), ctx).ui.isGui).toBe(false);
91
+ expect(getCaptured()).toBeUndefined();
76
92
  });
77
93
  });
@@ -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 + isGui + theme(fg/bold 适配 ThemeLike)
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 { guiSetWidget, isGuiCapable } from "@xyz-agent/extension-protocol";
17
- import type { GuiContext, GuiRenderResult } from "@xyz-agent/extension-protocol";
16
+ import { setWidgetDual } from "@taiji/extension-protocol";
17
+ import type { GuiContext } from "@taiji/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: string, content: string[] | string | undefined): void {
44
- // SDK setWidget 重载只接受 string[] | undefined(或 factory)。本地 UiPort 允许单 string,
45
- // 此处归一化:string 包一层成 [string],再透传。
46
- const lines = typeof content === "string" ? [content] : content;
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(guiSetWidget 仅读 ui.setWidget,custom 不参与)。
60
- guiSetWidget(ctx as GuiContext, name, result);
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
- * __goalInit 内部调 service.createGoal(FR-4.1)。
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: __goalInit(FR-4 双轨消除)──────────
116
+ // ── External API: goalInit slot(FR-4 双轨消除)──────────
117
117
 
118
118
  /**
119
- * 允许其他扩展(coding-workflow / plan)通过 pi.__goalInit 编程式初始化 goal。
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
- // 交叉类型单步断言(ExtensionAPI 可赋给 ExtensionAPI & { __goalInit? },无需 unknown 中转):
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
- * `pi.__goalInit` 的预算配置形状。
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
- * 跨扩展(coding-workflow / plan)通过 `pi.__goalInit` 编程式初始化 goal 时使用。
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
- * `pi.__goalInit` 的规范函数签名(API-1:单一 source of truth)。
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 goalInit = (pi as unknown as { __goalInit?: GoalInitFn }).__goalInit;
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 "@taiji/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
- * 设置 GUI 协议 widget(GuiRenderResult:component + meta 宿主元数据,
56
- * RPC 模式经 guiSetWidget marker 编码)。TUI 模式 adapter 内部 no-op
57
- * (走 setWidget 文本行)。undefined = 清除。
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
- setGuiWidget(name: string, result: GuiRenderResult | undefined): void;
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
- describe("updateWidget (FR-6.6 hasUI guard)", () => {
203
- interface RecordedCall {
204
- method: "setWidget" | "setGuiWidget" | "setStatus";
205
- args: unknown[];
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
- expect(widgetCall!.args[1]).toEqual(
276
- expect.arrayContaining([expect.stringContaining("Token:")]),
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 GUI 协议分支(isGui=true,复用 projection/gui.ts buildGoalGui)──
282
-
283
- describe("updateWidget GUI 协议分支(isGui=true)", () => {
284
- interface RecordedCall {
285
- method: "setWidget" | "setGuiWidget" | "setStatus";
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 guiCall = calls.find((c) => c.method === "setGuiWidget" && c.args[0] === "goal");
318
- expect(guiCall).toBeDefined();
319
- // v1.1:整个 GuiRenderResult(group 组合根 + meta 宿主元数据,head 由壳层渲染)
320
- const result = guiCall!.args[1] as {
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
- expect(result.component.type).toBe("group");
325
- expect(result.meta.progress).toMatchObject({ total: 10000 });
326
- // GUI 模式不走 TUI 文本行
327
- expect(calls.some((c) => c.method === "setWidget" && c.args[0] === "goal")).toBe(false);
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 + isGui 无预算 → setGuiWidget(group,meta 无 progress)", () => {
331
- const { ui, calls } = makeGuiUiPort(true, true);
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 guiCall = calls.find((c) => c.method === "setGuiWidget" && c.args[0] === "goal");
336
- expect(guiCall).toBeDefined();
337
- const result = guiCall!.args[1] as {
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(result.component.type).toBe("group");
342
- expect(result.meta.progress).toBeUndefined();
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
  });
@@ -19,7 +19,7 @@ import {
19
19
  guiResult,
20
20
  type TreeItem,
21
21
  type WidgetMeta,
22
- } from "@xyz-agent/extension-protocol";
22
+ } from "@taiji/extension-protocol";
23
23
 
24
24
  import { PERCENT_FACTOR, SHORT_ID_LENGTH } from "../constants";
25
25
  import { getBudgetSeverity } from "../engine/budget";
@@ -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
- * GUI 模式(RPC,uiPort.isGui):推送 buildGoalGui 的 GuiComponent(guiSetWidget marker
195
- * 编码,host 侧解码 → GuiComponentRenderer 渲染)。
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
- if (isGui) uiPort.setGuiWidget("goal", undefined);
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
- if (isGui) uiPort.setGuiWidget("goal", undefined);
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
- if (isGui) {
225
- // 复用 projection/gui.ts 的 buildGoalGui(整个 GuiRenderResult:component + meta 宿主元数据)
226
- uiPort.setGuiWidget("goal", buildGoalGui(session.state));
227
- } else {
228
- uiPort.setWidget("goal", renderWidgetLines(session.state, uiPort.theme));
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 与 __goalInit 都走它)
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
- * - __goalInit(index.ts)
124
+ * - goalInit slot(index.ts,跨扩展编程式入口)
125
125
  *
126
126
  * 注:/goal <objective> 命令路径已改为提示词触发器——不直接调本函数,
127
127
  * 而是 sendUserMessage 让 AI 调 goal_control create(slug + successCriteria 由 AI 生成)。