tianshu-mcp 0.7.3 → 0.7.4

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/CHANGELOG.en.md CHANGED
@@ -8,6 +8,67 @@ Chinese version: [CHANGELOG.md](CHANGELOG.md)
8
8
 
9
9
  ---
10
10
 
11
+ ## [0.7.4] - 2026-09-28
12
+
13
+ ### Fixed
14
+
15
+ - **Project-scoped dispatch on ZCode 3.14.x always failed with `project_mismatch` (issue #24).**
16
+ ZCode 3.14.x removed the two DOM contracts tianshu-mcp used for **binding read-back**
17
+ (`data-project-path` and `data-testid^="workspace-item-"`), while `pathOf()` in
18
+ `src/agents/zcode/dom.ts:27` recognises nothing else — so `workspaceBinding().projectPath` was always
19
+ empty and `cdp.projects()` always returned `[]`, leaving the binding criterion unsatisfiable forever.
20
+ The failure happens **before the task brief is ever sent**, which made `autoFixRounds` inert
21
+ (`roundsUsed` stayed 0). The fix touches four places, all stemming from that one root cause:
22
+ - `selectors.ts:107` gains `projectMenuItem` (`[role="menuitemcheckbox"]`), and `projectItem` /
23
+ `projectPath` are now explicitly labelled as 3.11.x contracts;
24
+ - `workspaceBindingExpression()` in `dom.ts:38` now also returns `projectName` (the bound display name)
25
+ and `menuChecked` (display names of menu items with `aria-checked="true"`), treating multiple checked
26
+ items as ambiguity; it **never fabricates a path** — on 3.14.x `projectPath` stays an empty string, and
27
+ the decision layer uses that to distinguish evidence strength;
28
+ - `projects()` in `cdp.ts:279` falls back to harvesting the expanded menu **only when the old contracts
29
+ yield nothing**, and it includes the checked state and excludes "work outside a project", so the
30
+ 3.11.x result is byte-for-byte unchanged;
31
+ - `matchZcodeProject()` in `project.ts:61` gains a **display-name exact-match** branch and reports
32
+ `matchedBy`; a new `boundProjectVerdict()` implements the layered criterion — **strict path equality
33
+ whenever a path is available (unchanged semantics), display name when there is no path channel** —
34
+ and requires the project list to be free of same-name ambiguity.
35
+ - **Removed a fragile timing dependency**: `ensureProjectBound()` used to accept an externally supplied
36
+ item, which made "the menu happens to still be open" an implicit precondition. It is now an idempotent
37
+ self-check: read the verdict first, pass immediately when already bound, and only expand the menu and
38
+ click when it is not.
39
+ - **Better failure diagnostics**: the `project_mismatch` message now carries the trigger text, the menu
40
+ checked state and the path read-back, so users are no longer left with a bare "read-back mismatch".
41
+ - **On-device evidence entry point**: `scripts/probe-zcode.mjs` gains a `dom-contracts` subcommand that
42
+ reports hit counts for both legacy contracts, the trigger's full attributes, the menu item list and the
43
+ post-fix read-back — for re-checking this contract assumption against a real ZCode build.
44
+
45
+ ### Known limitation
46
+
47
+ - The ZCode 3.14.x DOM exposes a project's **display name** but not its absolute path, so the criterion is
48
+ "display name + global same-name disambiguation": more than one same-name item yields
49
+ `project_ambiguous` (fail-closed). Disambiguation requires the menu to be expanded (that is `projects()`'
50
+ data source); with the menu closed only the `binding` display-name evidence is used, and a missing list
51
+ never negates a binding. The 3.11.x path-equality chain keeps the highest priority and is unchanged.
52
+
53
+ ### Tests
54
+
55
+ - **14** new cases: 4 in `test/unit/zcode-dom.test.ts` (read-back contract under the missing 3.14.x
56
+ contracts, multi-check ambiguity, and no regression for 3.11.x), 6 in `test/unit/zcode-core.test.ts`
57
+ (display-name matching / same-name fail-closed / layered criterion / path priority), and 4 in
58
+ `test/integration/zcode-flow.test.ts` (already-bound dispatch without a stray click, switch-then-dispatch,
59
+ same-name ambiguity fail-closed, and a **counter-example** where missing display-name evidence still
60
+ fails closed with diagnostics). Full suite: **1383 passed / 12 skipped** (1395 tests, 115 files plus 3
61
+ real-browser files skipped by design).
62
+
63
+ > **On-device verification boundary (disclosed honestly)**: this machine has **no ZCode 3.14.x**, so the fix
64
+ > was not re-verified on hardware. The root cause (missing DOM contracts) is **deterministically reproducible
65
+ > locally** against a `linkedom` fixture: the new cases were first written to show three failing assertions
66
+ > (`projectName`/`menuChecked` absent, `projects()` empty, multi-check not treated as ambiguous) and then
67
+ > driven to green. The on-device measurements reported in issue #24 (`data-project-path` 0 hits,
68
+ > `workspace-item-` 0 hits, `menuitemcheckbox` intact) were **not independently reproduced**; the fix is
69
+ > designed against those contracts. Maintainers with a 3.14.x environment should run
70
+ > `npm run probe:zcode -- dom-contracts` to confirm.
71
+
11
72
  ## [0.7.3] - 2026-09-28
12
73
 
13
74
  ### Fixed
package/CHANGELOG.md CHANGED
@@ -7,6 +7,52 @@
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.7.4] - 2026-09-28
11
+
12
+ ### 修复
13
+
14
+ - **ZCode 3.14.x 有项目派单恒败于 `project_mismatch`(issue #24)**。ZCode 3.14.x 删除了 tianshu-mcp
15
+ 用于**绑定回读**的两处 DOM 契约(`data-project-path` 与 `data-testid^="workspace-item-"`),而
16
+ `src/agents/zcode/dom.ts:27` 的 `pathOf()` 只认这两个来源 —— 于是 `workspaceBinding().projectPath`
17
+ 恒为空、`cdp.projects()` 恒为 `[]`,绑定判据永不成立。失败发生在**发送任务书之前**,
18
+ `autoFixRounds` 形同虚设(`roundsUsed` 恒为 0)。修复落在四处(同一根因):
19
+ - `selectors.ts:107` 新增 `projectMenuItem`(`[role="menuitemcheckbox"]`),并把 `projectItem` /
20
+ `projectPath` 显式标注为 3.11.x 契约;
21
+ - `dom.ts:38` 的 `workspaceBindingExpression()` 增补 `projectName`(当前绑定显示名)与 `menuChecked`
22
+ (菜单中 `aria-checked="true"` 的显示名),多勾选判歧义;**不伪造路径** —— 3.14.x 下 `projectPath`
23
+ 仍为空串,判定层据此区分证据等级;
24
+ - `cdp.ts:279` 的 `projects()` 改为「旧契约完全落空时才从展开菜单采集」(含勾选态、排除
25
+ 「不在项目中工作」),保证 3.11.x 的采集结果逐字不变;
26
+ - `project.ts:61` 的 `matchZcodeProject()` 增加**显示名精确匹配**分支并返回 `matchedBy`,
27
+ 新增 `boundProjectVerdict()` 实现分层判据:**路径可得时严格判等(原语义不变),无路径渠道时按显示名**,
28
+ 且要求项目列表无同名歧义。
29
+ - **修掉一条被掩盖的脆弱时序**:`ensureProjectBound()` 原先接受外部传入的 item,使「菜单是否恰好还开着」
30
+ 成为隐式前提。现改为幂等自检 —— 每轮先读判定,已绑定即通过、未绑定才展开菜单并点击。
31
+ - **绑定失败诊断补齐**:`project_mismatch` 的错误信息现携带「触发器文本 / 菜单勾选态 / 路径回读」,
32
+ 用户不必再面对一句「回读不一致」而无法自救。
33
+ - **真机取证入口**:`scripts/probe-zcode.mjs` 新增 `dom-contracts` 子命令,输出两处旧契约的命中计数、
34
+ 触发器全属性、菜单项清单与修复后的回读结果(用于在真实 ZCode 上复核本次契约假设)。
35
+
36
+ ### 已知限制
37
+
38
+ - ZCode 3.14.x 的 DOM 只暴露项目**显示名**、不暴露绝对路径,因此判定采用「显示名 + 全局同名消歧」:
39
+ 同名项 > 1 直接判 `project_ambiguous`(fail-closed)。**能区分的前提是菜单已展开**(`projects()` 的数据源);
40
+ 菜单收起时只凭 `binding` 的显示名证据,不因列表缺失而否定绑定。3.11.x 的路径判等链路优先级最高,行为不变。
41
+
42
+ ### 测试
43
+
44
+ - 新增 **14** 用例:`test/unit/zcode-dom.test.ts` 4 条(3.14.x 契约缺席下的回读契约 + 多勾选歧义 +
45
+ 3.11.x 无退化)、`test/unit/zcode-core.test.ts` 6 条(显示名匹配 / 同名 fail-closed / 分层判据 / 路径优先)、
46
+ `test/integration/zcode-flow.test.ts` 4 条(已绑定直发不误点、未绑定切换后派发、同名歧义 fail-closed、
47
+ **缺显示名证据的反例**:仍 fail-closed 且错误信息带诊断)。全量 **1383 passed / 12 skipped**
48
+ (1395 项,115 文件 + 3 个真实浏览器文件按设计 skip)。
49
+
50
+ > **真机验证边界(如实披露)**:本机**无 ZCode 3.14.x**,未在真机复验。根因(DOM 契约缺席)在
51
+ > `linkedom` 夹具上可**本地决定性复现**:先写用例确认 3 处红灯(`projectName`/`menuChecked` 缺失、
52
+ > `projects()` 为空、多勾选不判歧义),再修到全绿。issue #24 报告的真机实测数据(`data-project-path`
53
+ > 0 命中、`workspace-item-` 0 命中、`menuitemcheckbox` 完好)**未被独立复现**,修复按该契约设计;
54
+ > 有 3.14.x 环境的维护者请跑 `npm run probe:zcode -- dom-contracts` 复核一次。
55
+
10
56
  ## [0.7.3] - 2026-09-28
11
57
 
12
58
  ### 修复
package/README.en.md CHANGED
@@ -77,7 +77,7 @@ git clone https://github.com/lanlan0811/tianshu-mcp.git
77
77
  cd tianshu-mcp
78
78
  npm ci
79
79
  npm run build # sync-version + tsc → dist/
80
- npm test # 867 passed / 12 skipped (879 tests, 85 files: unit/integration/protocol plus 3 real-browser files skipped by design)
80
+ npm test # 1383 passed / 12 skipped (1395 tests, 115 files: unit/integration/protocol plus 3 real-browser files skipped by design)
81
81
  ```
82
82
 
83
83
  ### Install the npm package
@@ -494,7 +494,7 @@ Qoder CN uses `agentId="qoder"` and requires an existing `projectPath` plus a re
494
494
  | agentId | driver / adapter | status | Notes |
495
495
  |---|---|---|---|
496
496
  | `codex` | `gui` / `codex-gui` | **ready** (`research` on macOS) | Desktop GUI over CDP (Windows: MSIX COM activation; macOS: spawn .app binary + CDP); supports `model`/`reasoningLevel`/`planDoc`/`designSystem`; wait-user, cancel and dispatch-guard semantics machine-verified (v0.3.2); Windows machine-verified; macOS basic closed loop machine-verified (v0.4.0) — stays `research` until the cancel/rework matrix is covered |
497
- | `zcode` | `gui` / `zcode-gui` | **research** | CDP GUI adapter with the Windows hardware loop passed; adapted to ZCode 3.11.2 model menu and project binding (v0.3.3), with hardened project/model read-back and initialization recovery (v0.3.4); supports project-less dispatch and `allowCreateProject` (v0.5.2, issue #12), and v0.5.3 fixed instance survival across server exit, new-task page switching and send-failure attribution; macOS basic closed loop machine-verified (2026-09-13, v0.4.0) — stays `research` until the cancel/rework/new-project matrix is covered |
497
+ | `zcode` | `gui` / `zcode-gui` | **research** | CDP GUI adapter with the Windows hardware loop passed; adapted to ZCode 3.11.2 model menu and project binding (v0.3.3), with hardened project/model read-back and initialization recovery (v0.3.4); supports project-less dispatch and `allowCreateProject` (v0.5.2, issue #12), and v0.5.3 fixed instance survival across server exit, new-task page switching and send-failure attribution; **v0.7.4 adapts to ZCode 3.14.x** (the product removed the `data-project-path` and `workspace-item-*` contracts, so the binding criterion is now "path first, display name plus global same-name disambiguation when no path channel exists", failing closed on ambiguity); macOS basic closed loop machine-verified (2026-09-13, v0.4.0) — stays `research` until the cancel/rework/new-project matrix is covered |
498
498
  | `traework` | `gui` / `traework-gui` | **ready** | CDP-driven TRAE SOLO CN desktop UI; all three panel modes machine-verified |
499
499
  | `kimicode` | `gui` / `kimicode-gui` | **ready** (`research` on macOS) | Kimi Code desktop (Electron, measured 1.0.2); **two renderer processes** (main window + `Kimi Browser Overlay` carrying the model/tier/mode menus); workspaces bind by full path and unregistered ones are imported through the native "add workspace" dialog; supports `model`/`reasoningLevel` and rejects `mode`; Windows machine-verified for the success path, unregistered-workspace import + auto-acceptance, and the failure → rework → re-acceptance same-session loop; cancellation/question answering are covered by hermetic integration tests only, and macOS stays `research` and fail-closed |
500
500
  | `qoder` | `gui` / `qoder-gui` | Windows real GUI loop passed; macOS **research** | Qoder CN only; full-path workspaces, unregistered directories imported via New Workspace + the native folder picker, default/custom models, global reasoning readback through Model Management, same-session answers and rework; Windows machine-verified for default-model development in an existing workspace, custom-model development in a newly imported workspace, and the controlled failure → plan → same-session repair → re-acceptance loop; macOS is `research` and fail-closed (dispatch disabled) |
package/README.md CHANGED
@@ -77,7 +77,7 @@ git clone https://github.com/lanlan0811/tianshu-mcp.git
77
77
  cd tianshu-mcp
78
78
  npm ci
79
79
  npm run build # sync-version + tsc → dist/
80
- npm test # 867 passed / 12 skipped(879 项,85 个测试文件:单元/集成/协议 + 3 个真实浏览器文件按设计 skip)
80
+ npm test # 1383 passed / 12 skipped(1395 项,115 个测试文件:单元/集成/协议 + 3 个真实浏览器文件按设计 skip)
81
81
  ```
82
82
 
83
83
  ### 安装 npm 包
@@ -495,7 +495,7 @@ Qoder CN 使用 `agentId="qoder"`,必须提供已有 `projectPath` 和可读 `
495
495
  | agentId | driver / adapter | status | 说明 |
496
496
  |---|---|---|---|
497
497
  | `codex` | `gui` / `codex-gui` | **ready**(macOS 为 `research`) | Codex 桌面端 GUI(Windows:MSIX COM 激活 + CDP;macOS:spawn .app + CDP);支持 `model`/`reasoningLevel`/`planDoc`/`designSystem`;等待用户确认、取消与重派护栏均已真机验证(v0.3.2);Windows 真机已验证;macOS 基本闭环已真机验证(v0.4.0),取消/返修矩阵补齐前保持 `research` |
498
- | `zcode` | `gui` / `zcode-gui` | **research** | CDP GUI adapter 已实现且 Windows 真机闭环通过;已适配 ZCode 3.11.2 模型菜单与项目绑定(v0.3.3),并加固项目/模型回读与初始化恢复(v0.3.4);支持无项目派发与 `allowCreateProject`(v0.5.2,issue #12),v0.5.3 修复实例跨 server 驻留、新建任务切页与发送失败归因;macOS 基本闭环已真机验证(2026-09-13,v0.4.0),取消/返修/新建项目矩阵补齐前保持 `research` |
498
+ | `zcode` | `gui` / `zcode-gui` | **research** | CDP GUI adapter 已实现且 Windows 真机闭环通过;已适配 ZCode 3.11.2 模型菜单与项目绑定(v0.3.3),并加固项目/模型回读与初始化恢复(v0.3.4);支持无项目派发与 `allowCreateProject`(v0.5.2,issue #12),v0.5.3 修复实例跨 server 驻留、新建任务切页与发送失败归因;**v0.7.4 适配 ZCode 3.14.x**(`data-project-path` 与 `workspace-item-*` 两处旧契约已被产品删除 → 绑定判据改为「路径优先、无路径渠道时按显示名 + 全局同名消歧」,同名即 fail-closed);macOS 基本闭环已真机验证(2026-09-13,v0.4.0),取消/返修/新建项目矩阵补齐前保持 `research` |
499
499
  | `traework` | `gui` / `traework-gui` | **ready** | CDP 驱动 TRAE SOLO CN 桌面 UI;三种面板模式真机验证通过 |
500
500
  | `kimicode` | `gui` / `kimicode-gui` | **ready**(macOS 为 `research`) | Kimi Code 桌面端(Electron,实测 1.0.2);**双渲染进程**(主窗口 + `Kimi Browser Overlay` 浮层承载模型/档位/模式菜单);工作区以完整路径绑定,未登记时经原生「添加工作区」对话框导入;支持 `model`/`reasoningLevel`,不支持 `mode`;Windows 真机已验证成功路径、未登记工作区导入 + 自动验收、失败 → 返修 → 再验收同会话闭环;取消/提问续答仅由 hermetic 集成测试覆盖,macOS 为 `research` 且 fail-closed |
501
501
  | `qoder` | `gui` / `qoder-gui` | Windows 真机闭环通过;macOS **research** | 仅 Qoder CN;完整路径工作区、未登记目录经新建工作区 + 原生目录选择器导入、默认/自定义模型、模型管理全局思考等级保存回读、原会话续答与返修;Windows 真机验证:已有工作区默认模型、新登记工作区自定义模型、受控失败 → 落计划 → 原会话返修 → 再验收均通过;macOS 为 `research` 且 fail-closed(禁止派发) |
@@ -178,7 +178,35 @@ export class ZcodeCdpClient {
178
178
  return normalizeZcodeModelSelection(raw);
179
179
  }
180
180
  async projects() {
181
- return this.evaluate(`(function(){const out=[];for(const s of ${candidateExpr("projectItem", this.selectors)})for(const e of document.querySelectorAll(s)){const testid=e.getAttribute('data-testid')||'';const testPath=testid.startsWith('workspace-item-')?testid.slice('workspace-item-'.length):undefined;const name=(e.getAttribute('data-project-name')||e.querySelector('[class*=name]')?.textContent||e.textContent||'').trim();const p=e.getAttribute('data-project-path')||e.querySelector('[data-project-path]')?.getAttribute('data-project-path')||testPath||e.getAttribute('title')||undefined;const id=e.getAttribute('data-project-id')||e.getAttribute('data-id')||testid||undefined;if(name)out.push({name,path:p,id})}return out})()`);
181
+ return this.evaluate(`(function(){
182
+ const norm=s=>(s||'').normalize('NFKC').trim();
183
+ const out=[],seen=new Set();
184
+ const push=(name,path,id,checked)=>{
185
+ const n=(name||'').trim();
186
+ if(!n)return;
187
+ const key=norm(n).toLocaleLowerCase();
188
+ if(seen.has(key))return;
189
+ seen.add(key);
190
+ out.push({name:n,path:path||undefined,id:id||undefined,checked});
191
+ };
192
+ for(const s of ${candidateExpr("projectItem", this.selectors)})for(const e of document.querySelectorAll(s)){
193
+ const testid=e.getAttribute('data-testid')||'';
194
+ const testPath=testid.startsWith('workspace-item-')?testid.slice('workspace-item-'.length):undefined;
195
+ const name=(e.getAttribute('data-project-name')||e.querySelector('[class*=name]')?.textContent||e.textContent||'').trim();
196
+ const p=e.getAttribute('data-project-path')||e.querySelector('[data-project-path]')?.getAttribute('data-project-path')||testPath||e.getAttribute('title')||undefined;
197
+ const id=e.getAttribute('data-project-id')||e.getAttribute('data-id')||testid||undefined;
198
+ push(name,p,id,undefined);
199
+ }
200
+ // issue #24:ZCode 3.14.x 已删除 workspace-item-*,项目列表只能从展开的下拉菜单采集。
201
+ // 仅在旧契约完全落空时启用,保证 3.11.x 的采集结果逐字不变。
202
+ if(!out.length)for(const s of ${candidateExpr("projectMenuItem", this.selectors)})for(const e of document.querySelectorAll(s)){
203
+ const name=(e.getAttribute('aria-label')||e.textContent||'').trim();
204
+ // 「不在项目中工作」是工作区切换项,不是项目——按本地化标签排除。
205
+ if(/不在项目中|outside a project|work outside/i.test(name))continue;
206
+ push(name,undefined,e.getAttribute('data-value')||undefined,e.getAttribute('aria-checked')==='true');
207
+ }
208
+ return out
209
+ })()`);
182
210
  }
183
211
  async clickProject(id, projectPath) {
184
212
  const displayName = projectPath ? projectDisplayName(projectPath) : "";
@@ -34,15 +34,25 @@ const trigger = triggerMatch.node;`;
34
34
  }
35
35
  export function workspaceBindingExpression(overrides) {
36
36
  return `(function(){${projectTriggerDom(overrides)}
37
- if (!trigger) return {triggerText:'',projectPath:'',ambiguous:triggerMatch.count>1};
37
+ if (!trigger) return {triggerText:'',projectPath:'',projectName:'',menuChecked:[],ambiguous:triggerMatch.count>1};
38
38
  const triggerText = labelOf(trigger);
39
39
  // A path anywhere in the sidebar is not evidence of the composer's binding.
40
40
  const explicit = pathOf(trigger) || pathOf(trigger.querySelector('[data-project-path]'));
41
41
  const rows = [...document.querySelectorAll('[data-testid^="workspace-item-"]')];
42
42
  const matches = rows.filter(e => norm(labelOf(e)) === norm(triggerText));
43
43
  const paths = [...new Set(matches.map(pathOf).filter(Boolean))];
44
+ // issue #24:ZCode 3.14.x 删除了 data-project-path 与 workspace-item-*,路径渠道归零。
45
+ // 此时当前绑定只剩「显示名」这一证据:展开菜单里 aria-checked=true 的项最权威
46
+ // (它是 ZCode 自己渲染的绑定状态),菜单未开时退回触发器文本。
47
+ // 绝不把显示名写进 projectPath——路径字段保持为空,判定层据此区分证据等级。
48
+ const checked = [...document.querySelectorAll('[role="menuitemcheckbox"][aria-checked="true"]')]
49
+ .filter(visible).map(labelOf).filter(Boolean);
50
+ const menuChecked = [...new Set(checked)];
51
+ const projectName = menuChecked.length===1 ? menuChecked[0]
52
+ : (menuChecked.length===0 ? triggerText : '');
44
53
  return {triggerText, projectPath:explicit || (paths.length===1?paths[0]:''),
45
- ambiguous:!explicit && paths.length>1};
54
+ projectName, menuChecked,
55
+ ambiguous:(!explicit && paths.length>1) || menuChecked.length>1};
46
56
  })()`;
47
57
  }
48
58
  export function modelSelectionExpression(overrides) {
@@ -43,16 +43,67 @@ export function matchZcodeProject(items, target, platform = process.platform) {
43
43
  const exact = items.filter((item) => item.path &&
44
44
  normalizeProjectPath(item.path, platform) === normalizeProjectPath(target, platform));
45
45
  if (exact.length === 1)
46
- return { item: exact[0], ambiguous: false };
46
+ return { item: exact[0], ambiguous: false, matchedBy: "path" };
47
47
  if (exact.length > 1)
48
48
  return { ambiguous: true };
49
49
  const api = platform === "win32" ? path.win32 : path.posix;
50
50
  const base = api.basename(target);
51
51
  const names = items.filter((item) => exactName(item.name, base, platform));
52
- if (names.length > 0)
52
+ if (names.length > 1)
53
53
  return { ambiguous: true };
54
+ if (names.length === 1) {
55
+ const only = names[0];
56
+ // 同名项带路径且与目标不符 → 列表里那个不是目标,保持 fail-closed(原语义)。
57
+ if (only.path &&
58
+ normalizeProjectPath(only.path, platform) !== normalizeProjectPath(target, platform))
59
+ return { ambiguous: true };
60
+ // 无路径渠道(ZCode 3.14.x):显示名是唯一可用证据,交给上层按显示名判定绑定。
61
+ return { item: only, ambiguous: false, matchedBy: "name" };
62
+ }
54
63
  return { ambiguous: false };
55
64
  }
65
+ /**
66
+ * 判定「当前 ZCode 工作区是否绑定到 target」(issue #24)。
67
+ *
68
+ * 判据分层,证据等级从强到弱:
69
+ * 1. 路径回读(`binding.projectPath`)——老版本(与未来若恢复路径渠道)的严格判等,语义不变。
70
+ * 2. 显示名(`binding.projectName`)——3.14.x 的唯一渠道;仅在名称一致时成立,
71
+ * 并要求项目列表未出现同名歧义。
72
+ * 绝不把显示名当路径:路径不一致时一定判不绑定,宁可多走一轮点击也不误投。
73
+ */
74
+ export function boundProjectVerdict(binding, match, target, platform = process.platform) {
75
+ if (binding.ambiguous)
76
+ return { bound: false, ambiguous: true, evidence: "工作区绑定回读本身已歧义" };
77
+ if (binding.projectPath) {
78
+ const ok = normalizeProjectPath(binding.projectPath, platform) === normalizeProjectPath(target, platform);
79
+ return { bound: ok, ambiguous: false, evidence: `路径回读=${binding.projectPath}` };
80
+ }
81
+ const expect = projectDisplayName(target, platform);
82
+ const boundName = (binding.projectName || "").trim();
83
+ if (!boundName)
84
+ return { bound: false, ambiguous: false, evidence: "未能回读当前绑定的显示名" };
85
+ if (!sameDisplayName(boundName, expect))
86
+ return {
87
+ bound: false,
88
+ ambiguous: false,
89
+ evidence: `当前绑定显示名=${boundName},目标=${expect}`,
90
+ };
91
+ // 名称一致:只有列表确实可得且表明同名歧义时才拒绝(列表不可得时不能据此否定)。
92
+ if (match.ambiguous)
93
+ return { bound: false, ambiguous: true, evidence: "项目列表存在同名项,无法消歧" };
94
+ if (match.item?.path &&
95
+ normalizeProjectPath(match.item.path, platform) !== normalizeProjectPath(target, platform))
96
+ return {
97
+ bound: false,
98
+ ambiguous: false,
99
+ evidence: `同名项目路径不符:${match.item.path}`,
100
+ };
101
+ return {
102
+ bound: true,
103
+ ambiguous: false,
104
+ evidence: `显示名一致=${boundName}${match.matchedBy === "name" ? "(无路径渠道,按 3.14.x 契约以显示名判定)" : ""}`,
105
+ };
106
+ }
56
107
  function exactName(a, b, platform) {
57
108
  return platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
58
109
  }
@@ -5,7 +5,7 @@ import { ZCODE_SETUP_DEFAULTS } from "../../config/schema.js";
5
5
  import { mkdirp } from "../../util/fs.js";
6
6
  import { parseZcodeModel, exactUiName, ZcodeModelReadbackError } from "./model.js";
7
7
  import { validateTaskReferences } from "./references.js";
8
- import { matchZcodeProject, normalizeProjectPath, isUnboundTriggerText, } from "./project.js";
8
+ import { boundProjectVerdict, matchZcodeProject, isUnboundTriggerText, } from "./project.js";
9
9
  import { judgeZcodePoll } from "./liveness.js";
10
10
  import { ZcodeCdpClient, CdpDisconnectedError, CdpUnavailableError, } from "./cdp.js";
11
11
  import { ensureZcodeInstance, listZcodeProcessesAsync, } from "./instance.js";
@@ -80,9 +80,10 @@ function prompt(ctx, refs, initialDispatch) {
80
80
  async function waitBound(cdp, target, deps) {
81
81
  for (let i = 0; i < 30; i++) {
82
82
  const binding = await cdp.workspaceBinding();
83
- if (!binding.ambiguous &&
84
- binding.projectPath &&
85
- normalizeProjectPath(binding.projectPath) === normalizeProjectPath(target))
83
+ // 与项目分支共用同一套分层判据(issue #24):路径可得时严格判等,3.14.x 无路径渠道时按显示名。
84
+ // 点击菜单项后菜单会收起、列表为空,所以这里不传列表——不能因列表缺失而否定已确认的绑定。
85
+ const verdict = boundProjectVerdict(binding, { ambiguous: false }, target);
86
+ if (verdict.bound)
86
87
  return true;
87
88
  await deps.sleep(300);
88
89
  }
@@ -404,37 +405,68 @@ export async function runZcodeTask(args) {
404
405
  await deps.sleep(Math.min(300, Math.max(0, deadline - Date.now())));
405
406
  }
406
407
  };
407
- const ensureProjectBound = async (initialItem) => {
408
- const currentBinding = await cdp.workspaceBinding();
409
- if (currentBinding.ambiguous)
410
- return { bound: false, ambiguous: true };
411
- if (currentBinding.projectPath &&
412
- normalizeProjectPath(currentBinding.projectPath) === normalizeProjectPath(ctx.projectPath))
413
- return { bound: true, ambiguous: false };
414
- let item = initialItem;
408
+ /**
409
+ * 绑定失败时的证据摘要:触发器文本、菜单勾选态与路径回读。
410
+ * issue #24 要求 fail-closed 时把这几项一并写进错误信息,用户在 ZCode 里能据此判断
411
+ * 到底绑到了哪个项目(而不是只看到一句「回读不一致」无从自救)。
412
+ */
413
+ const describeBinding = async () => {
414
+ try {
415
+ const b = await cdp.workspaceBinding();
416
+ return `触发器文本=${b.triggerText || "空"};菜单勾选=${b.menuChecked?.length ? b.menuChecked.join("、") : "无"};路径回读=${b.projectPath || "无"}`;
417
+ }
418
+ catch {
419
+ return "绑定状态回读失败";
420
+ }
421
+ };
422
+ /**
423
+ * 读一次「当前绑定是否为目标项目」的分层判定(issue #24):
424
+ * 路径可得时严格判等;无路径渠道(ZCode 3.14.x)时按显示名,并在列表可得时做同名消歧。
425
+ */
426
+ const readBoundVerdict = async () => {
427
+ const binding = await cdp.workspaceBinding();
428
+ const match = matchZcodeProject(await cdp.projects(), ctx.projectPath);
429
+ return { verdict: boundProjectVerdict(binding, match, ctx.projectPath), match };
430
+ };
431
+ /**
432
+ * 幂等确认绑定:每轮先读判定,已绑定即通过;未绑定则(必要时先展开菜单)点击目标项目再复检。
433
+ * 不再接受外部传入的 item——那会把「菜单是否恰好还开着」变成隐式前提(issue #24 暴露的脆弱时序)。
434
+ */
435
+ const ensureProjectBound = async () => {
415
436
  for (let round = 0; round <= gui.setupRecoveryMaxRetries; round++) {
416
- if (!item || round > 0) {
417
- // eslint-disable-next-line no-await-in-loop
418
- await cdp.dismissMenus();
419
- // eslint-disable-next-line no-await-in-loop
420
- if (!(await cdp.clickProjectTriggerAndConfirm(projectTriggerDeadline())).opened)
421
- continue;
437
+ // eslint-disable-next-line no-await-in-loop
438
+ const { verdict, match } = await readBoundVerdict();
439
+ if (verdict.bound)
440
+ return { bound: true, ambiguous: false };
441
+ if (verdict.ambiguous)
442
+ return { bound: false, ambiguous: true };
443
+ if (match.item) {
422
444
  // eslint-disable-next-line no-await-in-loop
423
- await deps.sleep(300);
445
+ const clicked = await cdp.clickProject(match.item.id, match.item.path ?? ctx.projectPath);
424
446
  // eslint-disable-next-line no-await-in-loop
425
- const matched = matchZcodeProject(await cdp.projects(), ctx.projectPath);
426
- if (matched.ambiguous)
427
- return { bound: false, ambiguous: true };
428
- item = matched.item;
447
+ if (clicked && (await waitBound(cdp, ctx.projectPath, deps)))
448
+ return { bound: true, ambiguous: false };
449
+ continue;
429
450
  }
430
- if (!item)
451
+ // 目标不在当前列表(菜单多未展开):显式展开菜单后再采集一次。
452
+ // eslint-disable-next-line no-await-in-loop
453
+ await cdp.dismissMenus();
454
+ // eslint-disable-next-line no-await-in-loop
455
+ if (!(await cdp.clickProjectTriggerAndConfirm(projectTriggerDeadline())).opened)
456
+ continue;
457
+ // eslint-disable-next-line no-await-in-loop
458
+ await deps.sleep(300);
459
+ // eslint-disable-next-line no-await-in-loop
460
+ const expanded = matchZcodeProject(await cdp.projects(), ctx.projectPath);
461
+ if (expanded.ambiguous)
462
+ return { bound: false, ambiguous: true };
463
+ if (!expanded.item)
431
464
  continue;
432
465
  // eslint-disable-next-line no-await-in-loop
433
- const clicked = await cdp.clickProject(item.id, item.path);
466
+ const clicked = await cdp.clickProject(expanded.item.id, expanded.item.path ?? ctx.projectPath);
434
467
  // eslint-disable-next-line no-await-in-loop
435
468
  if (clicked && (await waitBound(cdp, ctx.projectPath, deps)))
436
469
  return { bound: true, ambiguous: false };
437
- item = undefined;
438
470
  }
439
471
  return { bound: false, ambiguous: false };
440
472
  };
@@ -555,13 +587,13 @@ export async function runZcodeTask(args) {
555
587
  pendingQuestion: "项目路径存在歧义,请在 ZCode 中确认目标项目后调用 continue_task。",
556
588
  });
557
589
  if (matched.item) {
558
- const binding = await ensureProjectBound(matched.item);
590
+ const binding = await ensureProjectBound();
559
591
  if (!binding.bound)
560
592
  return result({
561
593
  hardFailure: true,
562
594
  error: binding.ambiguous
563
- ? "项目绑定重试时目标项目无法唯一匹配"
564
- : "ZCode 项目绑定有限重试均未生效",
595
+ ? `项目绑定重试时目标项目无法唯一匹配:${ctx.projectPath};${await describeBinding()}`
596
+ : `ZCode 项目绑定有限重试均未生效:${ctx.projectPath};${await describeBinding()}`,
565
597
  endReason: "project_mismatch",
566
598
  needsUserKind: "setup_recovery",
567
599
  pendingQuestion: "项目绑定尚未确认,请在 ZCode 中确认目标项目后调用 continue_task。",
@@ -693,8 +725,8 @@ export async function runZcodeTask(args) {
693
725
  return result({
694
726
  hardFailure: true,
695
727
  error: finalBinding.ambiguous
696
- ? "ZCode 项目绑定回读不一致,且重试时目标项目无法唯一匹配"
697
- : "ZCode 项目绑定回读与 projectPath 不一致,有限幂等重试均失败",
728
+ ? `ZCode 项目绑定回读不一致,且重试时目标项目无法唯一匹配:${ctx.projectPath};${await describeBinding()}`
729
+ : `ZCode 项目绑定回读与 projectPath 不一致,有限幂等重试均失败:${ctx.projectPath};${await describeBinding()}`,
698
730
  endReason: "project_mismatch",
699
731
  needsUserKind: "setup_recovery",
700
732
  pendingQuestion: "项目绑定尚未确认,请在 ZCode 中确认目标项目后调用 continue_task。",
@@ -75,7 +75,13 @@ export const ZCODE_SELECTORS = {
75
75
  primary: '[data-testid^="workspace-item-"]',
76
76
  fallbacks: ['[class*="project-item"]', '[class*="workspace-item"]'],
77
77
  verifiedVersion: "3.11.x",
78
- note: "项目列表项",
78
+ note: "3.11.x 的项目列表项。ZCode 3.14.x 已删除该契约(app.asar 扫描 0 命中),改用 projectMenuItem。",
79
+ },
80
+ projectMenuItem: {
81
+ primary: '[role="menuitemcheckbox"]',
82
+ fallbacks: [],
83
+ verifiedVersion: "3.14.x",
84
+ note: "工作区下拉中的项目项:textContent 即项目显示名,aria-checked 表示是否为当前绑定。3.14.x 下 workspace-item-* 与 data-project-path 均已不存在,这是唯一可用的绑定证据来源;仅在项目菜单展开时有效。",
79
85
  },
80
86
  projectPath: {
81
87
  primary: "[data-project-path]",
@@ -4,4 +4,4 @@
4
4
  * 本文件由 scripts/sync-version.mjs 在每次 build 前重新生成。
5
5
  */
6
6
  // generated: 勿手改 —— 运行 `npm run build` 自动同步
7
- export const MCP_SERVER_VERSION = "0.7.3";
7
+ export const MCP_SERVER_VERSION = "0.7.4";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tianshu-mcp",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
4
4
  "description": "天枢 × AI-Agent 编排 MCP server —— 驱动 Codex、TraeWork、ZCode、Kimi Code、Qoder CN 与 Open Design 完成项目开发、验收、失败返修与再验收闭环。",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -169,6 +169,77 @@ async function uiState() {
169
169
  }
170
170
  }
171
171
 
172
+ /**
173
+ * issue #24 真机取证:ZCode 3.14.x 是否真的删除了项目绑定的两处 DOM 契约。
174
+ *
175
+ * 输出各契约的命中计数、触发器全属性、展开菜单后的项目项清单,以及修复后的适配器回读结果。
176
+ * **会展开一次工作区菜单**(只读采集,不选中任何项)。
177
+ */
178
+ async function domContracts() {
179
+ const endpoints = await cdpTargets();
180
+ const endpoint = endpoints.find((item) =>
181
+ item.targets?.some(
182
+ (target) =>
183
+ target.type === "page" && /z[ -]?code/i.test(`${target.title ?? ""} ${target.url ?? ""}`),
184
+ ),
185
+ );
186
+ if (!endpoint) return { ok: false, message: "没有通过产品标识核验的 ZCode CDP 页面" };
187
+ try {
188
+ const { ZcodeCdpClient } = await import("../dist/agents/zcode/cdp.js");
189
+ const client = new ZcodeCdpClient(endpoint.port, 10_000, {});
190
+ await client.connect();
191
+ try {
192
+ const counts = await client.evaluate(
193
+ `(function(){return {
194
+ dataProjectPath: document.querySelectorAll('[data-project-path]').length,
195
+ workspaceItems: document.querySelectorAll('[data-testid^="workspace-item-"]').length,
196
+ dataProjectName: document.querySelectorAll('[data-project-name]').length,
197
+ triggers: document.querySelectorAll('[data-testid="composer-workspace-trigger"]').length,
198
+ menuItemCheckbox: document.querySelectorAll('[role="menuitemcheckbox"]').length
199
+ }})()`,
200
+ );
201
+ const trigger = await client.evaluate(
202
+ `(function(){const e=document.querySelector('[data-testid="composer-workspace-trigger"]');if(!e)return null;const attrs={};for(const a of e.attributes)attrs[a.name]=a.value;return {text:(e.textContent||'').trim().slice(0,200),attrs}})()`,
203
+ );
204
+ // 项目身份只在菜单展开时可见:先收起残留菜单,再展开一次做只读采集。
205
+ await client.dismissMenus();
206
+ const opened = await client.clickProjectTriggerAndConfirm(Date.now() + 5_000);
207
+ const menuItems = opened.opened
208
+ ? await client.evaluate(
209
+ `(function(){return [...document.querySelectorAll('[role="menuitemcheckbox"]')].map(function(e){return {text:(e.textContent||'').trim().slice(0,200),checked:e.getAttribute('aria-checked'),testid:e.getAttribute('data-testid')||undefined};})})()`,
210
+ )
211
+ : [];
212
+ const binding = await client.workspaceBinding();
213
+ const projects = await client.projects();
214
+ await client.dismissMenus();
215
+ return {
216
+ ok: true,
217
+ port: endpoint.port,
218
+ counts,
219
+ trigger,
220
+ menuOpened: opened.opened,
221
+ menuItems,
222
+ binding,
223
+ projects,
224
+ verdict: {
225
+ // 两处旧契约都缺席 ⇒ 路径渠道不可用,适配器必须靠显示名完成绑定。
226
+ pathChannelAvailable: counts.dataProjectPath > 0 || counts.workspaceItems > 0,
227
+ nameChannelAvailable: binding.projectPath === "" && !!binding.projectName,
228
+ ambiguous: binding.ambiguous === true,
229
+ },
230
+ };
231
+ } finally {
232
+ client.disconnect();
233
+ }
234
+ } catch (e) {
235
+ return {
236
+ ok: false,
237
+ port: endpoint.port,
238
+ message: `绑定契约诊断失败:${e.message}。请先执行 npm run build。`,
239
+ };
240
+ }
241
+ }
242
+
172
243
  async function menuControls(kind) {
173
244
  const endpoints = await cdpTargets();
174
245
  const endpoint = endpoints.find((item) =>
@@ -273,6 +344,7 @@ else if (command === "process") json(processInfo());
273
344
  else if (command === "cdp") json(await cdpTargets());
274
345
  else if (command === "models") json(await modelState());
275
346
  else if (command === "permission") json(await permissionState());
347
+ else if (command === "dom-contracts") json(await domContracts());
276
348
  else if (command === "selectors")
277
349
  json({
278
350
  source: "src/agents/zcode/selectors.ts",