tianshu-mcp 0.3.2 → 0.3.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/CHANGELOG.en.md CHANGED
@@ -19,9 +19,29 @@ Chinese version: [CHANGELOG.md](CHANGELOG.md)
19
19
  - Best-effort stop of a GUI-side pending session (via a temporary CDP connection) when cancelling
20
20
  a task in the `needs_user` state.
21
21
 
22
- ---
23
-
24
- ## [0.3.2] — 2026-09-12
22
+ ---
23
+
24
+ ## [0.3.3] — 2026-09-12
25
+
26
+ Fixes issues #4 / #7 by adapting the ZCode GUI driver to the 3.11.2 model-menu and project-binding semantics, while making acceptance fail closed for zero-test and zero-change outcomes.
27
+
28
+ ### Fixed
29
+
30
+ - ZCode provider selectors now support both `group-provider` and the 3.11.2 `group-family` prefix. Model selection tries the visible model first and only expands a provider/family group as a fallback; failures include visible text and `data-testid` candidates.
31
+ - New-project import dismisses the stale workspace menu before opening Add Project and uses a bounded three-round dismiss/click/verify loop, preventing the first click from being consumed as an outside click.
32
+ - Project binding prioritizes the composer menu's `menuitemcheckbox`; the legacy sidebar item remains a compatibility fallback. Display-name matching normalizes NFKC, whitespace, and case.
33
+ - Binding read-back combines the composer trigger text with the full-path fast path, rejects known unbound placeholders, and retries the idempotent bind operation for up to two rounds.
34
+ - Built-in ZCode and TraeWork discovery paths use `{PROGRAMFILES}` / `{PROGRAMFILES(X86)}`. Environment placeholders in custom profiles are expanded case-insensitively while unknown placeholders remain unchanged.
35
+ - A mandatory test check is failed when it exits with code 0 but its output reports zero executed tests.
36
+ - Git projects now require a change relative to the pre-work baseline by default. Pure question/analysis tasks can explicitly opt out with `"requireChanges": false` in `.tianshu-mcp/acceptance.json`.
37
+
38
+ ### Tests
39
+
40
+ - Regression coverage now models ZCode 3.11.2 flat family layouts, legacy provider fallback, testid diagnostics, swallowed project clicks, composer binding and read-back retries, plus zero-test/zero-change acceptance gates.
41
+
42
+ ---
43
+
44
+ ## [0.3.2] — 2026-09-12
25
45
 
26
46
  Fixes for issues #5 / #6: **the MCP task model was disconnected from the state of the turn inside
27
47
  the Codex GUI**. The former is the completion-detection deadlock that kept reporting `running`
@@ -494,7 +514,8 @@ project → pick model and reasoning level → send instructions → run detecti
494
514
 
495
515
  ---
496
516
 
497
- [Unreleased]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.3.2...HEAD
517
+ [Unreleased]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.3.3...HEAD
518
+ [0.3.3]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.3.2...v0.3.3
498
519
  [0.3.2]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.3.1...v0.3.2
499
520
  [0.3.1]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.3.0...v0.3.1
500
521
  [0.3.0]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.2.0...v0.3.0
package/CHANGELOG.md CHANGED
@@ -17,9 +17,29 @@
17
17
  - Codex GUI 的 macOS 真机验证(当前内置状态为 `research`)。
18
18
  - needs_user 状态下取消任务时经临时 CDP 连接尽力停止 GUI 内等待中的会话。
19
19
 
20
- ---
21
-
22
- ## [0.3.2] — 2026-09-12
20
+ ---
21
+
22
+ ## [0.3.3] — 2026-09-12
23
+
24
+ 修复 issue #4 / #7:适配 ZCode 3.11.2 的模型菜单与项目绑定语义,并将验收引擎收紧为 fail-closed,防止零用例、零变更任务假绿。
25
+
26
+ ### 修复
27
+
28
+ - ZCode 供应商分组选择器同时兼容 `group-provider` 与 3.11.2 `group-family`;模型流程改为直选优先、供应商分组展开兜底,并在失败信息中附带可见文本与 `data-testid` 候选。
29
+ - 添加新项目前收起残留工作区菜单,以最多三轮“收起—点击—验证”闭环消除首次点击被吞。
30
+ - 项目选择优先点击 composer 菜单的 `menuitemcheckbox`,旧版侧栏项仅作回退;项目名称按 NFKC、空白和大小写归一化。
31
+ - 项目绑定回读同时校验 composer 触发器文本与完整路径,未绑定占位词不再误判;绑定失败最多两轮幂等重试。
32
+ - ZCode/TraeWork 内置发现目录统一为 `{PROGRAMFILES}` / `{PROGRAMFILES(X86)}`;自定义 profile 的环境占位符按大小写不敏感方式展开,未知占位符保留原样。
33
+ - 非 optional 测试检查在退出码为 0 但输出显示零用例时改判失败。
34
+ - Git 项目默认要求相对动工前基线产生变更;纯问答/分析任务可在 `.tianshu-mcp/acceptance.json` 设置 `"requireChanges": false` 显式关闭。
35
+
36
+ ### 测试
37
+
38
+ - 回归覆盖 ZCode 3.11.2 family 平铺模型、旧版分组兜底、testid 诊断、项目点击被吞、composer 绑定与回读重试,以及零用例/零变更门禁。
39
+
40
+ ---
41
+
42
+ ## [0.3.2] — 2026-09-12
23
43
 
24
44
  修复 issue #5 / #6:**MCP 任务模型与 Codex GUI 内 turn 的状态脱节**。
25
45
  前者是"等用户确认时恒报 running"的完成判定死锁,后者是 `cancel_task` 只停 MCP 侧等待、
@@ -431,7 +451,8 @@ Codex 桌面端改为 **GUI 驱动**:新增 `codex-gui` adapter,通过 MSIX
431
451
 
432
452
  ---
433
453
 
434
- [未发布]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.3.2...HEAD
454
+ [未发布]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.3.3...HEAD
455
+ [0.3.3]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.3.2...v0.3.3
435
456
  [0.3.2]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.3.1...v0.3.2
436
457
  [0.3.1]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.3.0...v0.3.1
437
458
  [0.3.0]: https://github.com/lanlan0811/tianshu-mcp/compare/v0.2.0...v0.3.0
@@ -73,8 +73,8 @@ export const BUILTIN_PROFILES = {
73
73
  authNote: "复用本机 ZCode 登录态",
74
74
  executableDiscovery: {
75
75
  dirs: [
76
- "{ProgramFiles}/ZCode",
77
- "{ProgramFiles(x86)}/ZCode",
76
+ "{PROGRAMFILES}/ZCode",
77
+ "{PROGRAMFILES(X86)}/ZCode",
78
78
  "{LOCALAPPDATA}/Programs/ZCode",
79
79
  "{LOCALAPPDATA}/ZCode",
80
80
  "/Applications/ZCode.app/Contents/MacOS",
@@ -129,8 +129,8 @@ export const BUILTIN_PROFILES = {
129
129
  executableDiscovery: {
130
130
  dirs: [
131
131
  "D:/TRAE Work CN",
132
- "{ProgramFiles}/TRAE WORK CN",
133
- "{ProgramFiles(x86)}/TRAE WORK CN",
132
+ "{PROGRAMFILES}/TRAE WORK CN",
133
+ "{PROGRAMFILES(X86)}/TRAE WORK CN",
134
134
  "{LOCALAPPDATA}/Programs/TRAE WORK CN",
135
135
  "{LOCALAPPDATA}/TRAE WORK CN",
136
136
  "{APPDATA}/TRAE SOLO CN",
@@ -1,7 +1,7 @@
1
1
  import { CdpDisconnectedError, CdpUnavailableError } from "../traework/cdp/client.js";
2
2
  import { type ZcodeSelectorKey } from "./selectors.js";
3
3
  import type { ZcodePoll } from "./liveness.js";
4
- import type { ZcodeProjectItem } from "./project.js";
4
+ import { type ZcodeProjectItem } from "./project.js";
5
5
  export { CdpDisconnectedError, CdpUnavailableError };
6
6
  export declare function retryZcodeEvaluation<T>(operation: () => Promise<T>, sleep?: (ms: number) => Promise<void>): Promise<T>;
7
7
  export interface ZcodeSessionItem {
@@ -14,6 +14,12 @@ export interface ZcodeQuestionAnswerResult {
14
14
  available: string[];
15
15
  error?: string;
16
16
  }
17
+ export interface ZcodeClickExactResult {
18
+ clicked: boolean;
19
+ count: number;
20
+ available: string[];
21
+ testids?: string[];
22
+ }
17
23
  export declare class ZcodeCdpClient {
18
24
  private readonly selectors;
19
25
  private readonly inner;
@@ -27,11 +33,7 @@ export declare class ZcodeCdpClient {
27
33
  exists(key: ZcodeSelectorKey): Promise<boolean>;
28
34
  text(key: ZcodeSelectorKey): Promise<string>;
29
35
  click(key: ZcodeSelectorKey): Promise<boolean>;
30
- clickExact(key: ZcodeSelectorKey, value: string): Promise<{
31
- clicked: boolean;
32
- count: number;
33
- available: string[];
34
- }>;
36
+ clickExact(key: ZcodeSelectorKey, value: string): Promise<ZcodeClickExactResult>;
35
37
  selection(key: ZcodeSelectorKey): Promise<{
36
38
  display: string;
37
39
  internal: string;
@@ -39,6 +41,10 @@ export declare class ZcodeCdpClient {
39
41
  projects(): Promise<ZcodeProjectItem[]>;
40
42
  clickProject(id: string | undefined, projectPath: string | undefined): Promise<boolean>;
41
43
  boundProjectPath(): Promise<string>;
44
+ workspaceBinding(): Promise<{
45
+ triggerText: string;
46
+ projectPath: string;
47
+ }>;
42
48
  session(): Promise<{
43
49
  id?: string;
44
50
  title?: string;
@@ -1,5 +1,6 @@
1
1
  import { TraeworkCdpClient, CdpDisconnectedError, CdpUnavailableError, } from "../traework/cdp/client.js";
2
2
  import { candidateExpr } from "./selectors.js";
3
+ import { projectDisplayName } from "./project.js";
3
4
  import { normalizeZcodeModelSelection } from "./model.js";
4
5
  export { CdpDisconnectedError, CdpUnavailableError };
5
6
  export async function retryZcodeEvaluation(operation, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
@@ -90,11 +91,21 @@ export class ZcodeCdpClient {
90
91
  return true;
91
92
  }
92
93
  async clickExact(key, value) {
93
- const found = await this.evaluate(`(async function(){const norm=s=>(s||'').normalize('NFKC').trim().toLocaleLowerCase();const target=norm(${JSON.stringify(value)});const sels=${candidateExpr(key, this.selectors)};const visible=e=>{const r=e.getBoundingClientRect();if(!(r.width>0&&r.height>0&&r.bottom>0&&r.right>0&&r.top<innerHeight&&r.left<innerWidth))return false;const x=Math.max(0,Math.min(innerWidth-1,r.left+r.width/2)),y=Math.max(0,Math.min(innerHeight-1,r.top+r.height/2)),hit=document.elementFromPoint(x,y);return !!hit&&(hit===e||e.contains(hit))};const items=()=>{const out=[];for(const s of sels)for(const e of document.querySelectorAll(s))if(visible(e)&&!out.includes(e))out.push(e);return out};const leafTexts=e=>[...e.querySelectorAll('*')].filter(n=>n.children.length===0).map(n=>(n.textContent||'').trim()).filter(Boolean);const label=e=>(e.getAttribute('data-value')||e.getAttribute('data-model')||e.getAttribute('data-provider')||leafTexts(e)[0]||e.textContent||'').trim();let nodes=items();let scroller=nodes[0];while(scroller&&scroller!==document.body&&scroller.scrollHeight<=scroller.clientHeight)scroller=scroller.parentElement;const start=scroller?.scrollTop||0;if(scroller)scroller.scrollTop=0;const seen=new Map(),matches=new Map();for(let i=0;i<60;i++){await new Promise(r=>setTimeout(r,25));nodes=items();for(const e of nodes){const text=label(e);if(!text)continue;const id=e.getAttribute('data-id')||e.getAttribute('data-model-id')||e.getAttribute('data-value')||e.getAttribute('data-model')||e.getAttribute('data-provider')||e.getAttribute('data-testid')||text;seen.set(norm(id)+'|'+norm(text),text);if(norm(text)===target)matches.set(norm(id)+'|'+norm(text),{top:scroller?.scrollTop||0,text})}if(!scroller||scroller.scrollTop+scroller.clientHeight>=scroller.scrollHeight-1)break;const before=scroller.scrollTop;scroller.scrollTop=Math.min(scroller.scrollTop+Math.max(100,scroller.clientHeight*.8),scroller.scrollHeight);if(scroller.scrollTop===before)break}const matchesFound=[...matches.values()];if(matchesFound.length===1){if(scroller)scroller.scrollTop=matchesFound[0].top;await new Promise(r=>setTimeout(r,50));const exact=items().filter(e=>norm(label(e))===target);if(exact.length===1){exact[0].scrollIntoView({block:'center'});const r=exact[0].getBoundingClientRect();return {count:1,available:[...seen.values()],point:{x:r.left+r.width/2,y:r.top+r.height/2}}}}if(scroller)scroller.scrollTop=start;return {count:matchesFound.length,available:[...seen.values()]}})()`);
94
+ const found = await this.evaluate(`(async function(){const norm=s=>(s||'').normalize('NFKC').trim().toLocaleLowerCase();const target=norm(${JSON.stringify(value)});const sels=${candidateExpr(key, this.selectors)};const visible=e=>{const r=e.getBoundingClientRect();if(!(r.width>0&&r.height>0&&r.bottom>0&&r.right>0&&r.top<innerHeight&&r.left<innerWidth))return false;const x=Math.max(0,Math.min(innerWidth-1,r.left+r.width/2)),y=Math.max(0,Math.min(innerHeight-1,r.top+r.height/2)),hit=document.elementFromPoint(x,y);return !!hit&&(hit===e||e.contains(hit))};const items=()=>{const out=[];for(const s of sels)for(const e of document.querySelectorAll(s))if(visible(e)&&!out.includes(e))out.push(e);return out};const leafTexts=e=>[...e.querySelectorAll('*')].filter(n=>n.children.length===0).map(n=>(n.textContent||'').trim()).filter(Boolean);const label=e=>(e.getAttribute('data-value')||e.getAttribute('data-model')||e.getAttribute('data-provider')||leafTexts(e)[0]||e.textContent||'').trim();let nodes=items();let scroller=nodes[0];while(scroller&&scroller!==document.body&&scroller.scrollHeight<=scroller.clientHeight)scroller=scroller.parentElement;const start=scroller?.scrollTop||0;if(scroller)scroller.scrollTop=0;const seen=new Map(),seenTestids=new Map(),matches=new Map();for(let i=0;i<60;i++){await new Promise(r=>setTimeout(r,25));nodes=items();for(const e of nodes){const text=label(e);const testid=e.getAttribute('data-testid')||'';const id=e.getAttribute('data-id')||e.getAttribute('data-model-id')||e.getAttribute('data-value')||e.getAttribute('data-model')||e.getAttribute('data-provider')||testid||text;const key=norm(id)+'|'+norm(text);if(testid)seenTestids.set(key,testid);if(!text)continue;seen.set(key,text);if(norm(text)===target)matches.set(key,{top:scroller?.scrollTop||0,text})}if(!scroller||scroller.scrollTop+scroller.clientHeight>=scroller.scrollHeight-1)break;const before=scroller.scrollTop;scroller.scrollTop=Math.min(scroller.scrollTop+Math.max(100,scroller.clientHeight*.8),scroller.scrollHeight);if(scroller.scrollTop===before)break}const result={available:[...seen.values()],testids:[...seenTestids.values()]};const matchesFound=[...matches.values()];if(matchesFound.length===1){if(scroller)scroller.scrollTop=matchesFound[0].top;await new Promise(r=>setTimeout(r,50));const exact=items().filter(e=>norm(label(e))===target);if(exact.length===1){exact[0].scrollIntoView({block:'center'});const r=exact[0].getBoundingClientRect();return {count:1,...result,point:{x:r.left+r.width/2,y:r.top+r.height/2}}}}if(scroller)scroller.scrollTop=start;return {count:matchesFound.length,...result}})()`);
94
95
  if (!found.point)
95
- return { clicked: false, count: found.count, available: found.available };
96
+ return {
97
+ clicked: false,
98
+ count: found.count,
99
+ available: found.available,
100
+ testids: found.testids,
101
+ };
96
102
  await this.clickAt(found.point.x, found.point.y);
97
- return { clicked: true, count: found.count, available: found.available };
103
+ return {
104
+ clicked: true,
105
+ count: found.count,
106
+ available: found.available,
107
+ testids: found.testids,
108
+ };
98
109
  }
99
110
  async selection(key) {
100
111
  const raw = await this.evaluate(`(function(){for(const s of ${candidateExpr(key, this.selectors)})for(const e of document.querySelectorAll(s)){const r=e.getBoundingClientRect();if(!r.width||!r.height)continue;const data=e.closest('[data-model-id],[data-model],[data-value]')||e.querySelector('[data-model-id],[data-model],[data-value]')||e;return {display:(e.value||e.textContent||'').trim(),ariaLabel:(e.getAttribute('aria-label')||'').trim(),currentValue:(e.getAttribute('data-model-current-value')||'').trim(),legacyInternal:(data.getAttribute('data-model-id')||data.getAttribute('data-model')||data.getAttribute('data-value')||'').trim()}}return {display:''}})()`);
@@ -104,14 +115,18 @@ export class ZcodeCdpClient {
104
115
  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})()`);
105
116
  }
106
117
  async clickProject(id, projectPath) {
107
- const point = await this.evaluate(`(async function(){const matches=[];for(const s of ${candidateExpr("projectItem", this.selectors)})for(const e of document.querySelectorAll(s)){const testid=e.getAttribute('data-testid')||'';const id=e.getAttribute('data-project-id')||e.getAttribute('data-id')||testid;const p=e.getAttribute('data-project-path')||e.querySelector('[data-project-path]')?.getAttribute('data-project-path')||(testid.startsWith('workspace-item-')?testid.slice('workspace-item-'.length):'')||e.getAttribute('title')||'';if((${JSON.stringify(id ?? "")}&&id===${JSON.stringify(id ?? "")})||(${JSON.stringify(projectPath ?? "")}&&p===${JSON.stringify(projectPath ?? "")}))matches.push(e)}if(matches.length!==1)return null;matches[0].scrollIntoView({block:'center'});await new Promise(r=>setTimeout(r,50));const r=matches[0].getBoundingClientRect();return r.width&&r.height&&r.bottom>0&&r.right>0&&r.top<innerHeight&&r.left<innerWidth?{x:r.left+r.width/2,y:r.top+r.height/2}:null})()`);
118
+ const displayName = projectPath ? projectDisplayName(projectPath) : "";
119
+ const point = await this.evaluate(`(async function(){const norm=s=>(s||'').normalize('NFKC').trim().toLocaleLowerCase();const visible=e=>{const r=e.getBoundingClientRect();return r.width&&r.height&&r.bottom>0&&r.right>0&&r.top<innerHeight&&r.left<innerWidth};const targetName=norm(${JSON.stringify(displayName)});const triggers=[];for(const s of ${candidateExpr("projectTrigger", this.selectors)})for(const e of document.querySelectorAll(s))if(visible(e)&&!triggers.includes(e))triggers.push(e);const trigger=triggers.length===1?triggers[0]:null;let containers=[];const controlledId=trigger?.getAttribute('aria-controls')||'';if(controlledId){const controlled=document.getElementById(controlledId);if(controlled&&visible(controlled))containers.push(controlled)}if(trigger){let parent=trigger.parentElement;while(parent&&!containers.length){if(parent.querySelector('[role="menuitemcheckbox"]'))containers.push(parent);parent=parent.parentElement}}if(!containers.length)containers=[...document.querySelectorAll('[role="menu"]')].filter(e=>visible(e)&&e.querySelector('[role="menuitemcheckbox"]'));const checkboxMatches=[];for(const container of containers)for(const e of container.querySelectorAll('[role="menuitemcheckbox"]'))if(visible(e)&&norm(e.getAttribute('aria-label')||e.getAttribute('data-value')||e.textContent||'')===targetName&&!checkboxMatches.includes(e))checkboxMatches.push(e);let selected=checkboxMatches.length===1?checkboxMatches[0]:null;if(!selected){const matches=[];for(const s of ${candidateExpr("projectItem", this.selectors)})for(const e of document.querySelectorAll(s)){const testid=e.getAttribute('data-testid')||'';const itemId=e.getAttribute('data-project-id')||e.getAttribute('data-id')||testid;const p=e.getAttribute('data-project-path')||e.querySelector('[data-project-path]')?.getAttribute('data-project-path')||(testid.startsWith('workspace-item-')?testid.slice('workspace-item-'.length):'')||e.getAttribute('title')||'';if((${JSON.stringify(id ?? "")}&&itemId===${JSON.stringify(id ?? "")})||(${JSON.stringify(projectPath ?? "")}&&p===${JSON.stringify(projectPath ?? "")}))matches.push(e)}if(matches.length===1)selected=matches[0]}if(!selected)return null;selected.scrollIntoView({block:'center'});await new Promise(r=>setTimeout(r,50));const r=selected.getBoundingClientRect();return visible(selected)?{x:r.left+r.width/2,y:r.top+r.height/2}:null})()`);
108
120
  if (!point)
109
121
  return false;
110
122
  await this.clickAt(point.x, point.y);
111
123
  return true;
112
124
  }
113
125
  async boundProjectPath() {
114
- return ((await this.evaluate(`(function(){for(const s of ${candidateExpr("projectPath", this.selectors)})for(const e of document.querySelectorAll(s)){const p=e.getAttribute('data-project-path')||e.getAttribute('title')||(e.textContent||'').trim();if(p)return p}const trigger=document.querySelector('[data-testid="composer-workspace-trigger"]');const name=(trigger?.textContent||'').trim();if(name){const matches=[...document.querySelectorAll('[data-testid^="workspace-item-"]')].filter(e=>(e.textContent||'').trim()===name);if(matches.length===1)return (matches[0].getAttribute('data-testid')||'').slice('workspace-item-'.length)}return window.__ZCODE_PROJECT_PATH__||''})()`)) || "");
126
+ return (await this.workspaceBinding()).projectPath;
127
+ }
128
+ async workspaceBinding() {
129
+ return this.evaluate(`(function(){let projectPath='';for(const s of ${candidateExpr("projectPath", this.selectors)})for(const e of document.querySelectorAll(s)){const p=e.getAttribute('data-project-path')||e.getAttribute('title')||(e.textContent||'').trim();if(p){projectPath=p;break}}const triggers=[];for(const s of ${candidateExpr("projectTrigger", this.selectors)})for(const e of document.querySelectorAll(s)){const r=e.getBoundingClientRect();if(r.width&&r.height&&!triggers.includes(e))triggers.push(e)}const triggerText=triggers.length===1?(triggers[0].textContent||'').normalize('NFKC').trim():'';if(!projectPath&&triggerText){const matches=[...document.querySelectorAll('[data-testid^="workspace-item-"]')].filter(e=>(e.textContent||'').normalize('NFKC').trim()===triggerText);if(matches.length===1)projectPath=(matches[0].getAttribute('data-testid')||'').slice('workspace-item-'.length)}return {triggerText,projectPath:projectPath||window.__ZCODE_PROJECT_PATH__||''}})()`);
115
130
  }
116
131
  async session() {
117
132
  return this.evaluate(`(function(){const stable=id=>id&&id!=='draft'?id:undefined;const idOf=e=>{if(!e)return undefined;const explicit=stable(e.getAttribute('data-session-id'));if(explicit)return explicit;const testid=e.getAttribute('data-testid')||'';return testid.startsWith('task-item-')?stable(testid.slice('task-item-'.length)):undefined};const selectors=['[data-testid^="v4-session-pane"][data-session-id]','[data-session-id][aria-current=true]','[data-session-id][aria-selected=true]','[data-testid^="task-item-"][aria-current=true]','[data-testid^="task-item-"][aria-selected=true]','[data-testid^="task-item-"][data-state=active]','[data-testid^="task-item-"][data-state=selected]'];let active=null;for(const s of selectors){active=[...document.querySelectorAll(s)].find(e=>{const r=e.getBoundingClientRect();return r.width&&r.height})||null;if(active)break}const id=idOf(active)||stable(history.state?.sessionId)||stable(history.state?.taskId)||undefined;const item=id?[...document.querySelectorAll('[data-testid^="task-item-"]')].find(e=>(e.getAttribute('data-testid')||'').slice('task-item-'.length)===id):null;return {id,title:id?(item?.getAttribute('title')||item?.textContent||active?.getAttribute('title')||'').trim().slice(0,240)||undefined:undefined}})()`);
@@ -3,6 +3,10 @@ export interface ZcodeProjectItem {
3
3
  path?: string;
4
4
  id?: string;
5
5
  }
6
+ export declare const PROJECT_PLACEHOLDER_TEXTS: readonly ["选择项目", "select project", "select a project", "choose project"];
7
+ export declare function projectDisplayName(projectPath: string, platform?: NodeJS.Platform): string;
8
+ export declare function sameDisplayName(a: string, b: string): boolean;
9
+ export declare function isUnboundTriggerText(text: string): boolean;
6
10
  export declare function normalizeProjectPath(value: string, platform?: NodeJS.Platform): string;
7
11
  export declare function matchZcodeProject(items: ZcodeProjectItem[], target: string, platform?: NodeJS.Platform): {
8
12
  item?: ZcodeProjectItem;
@@ -1,4 +1,28 @@
1
1
  import path from "node:path";
2
+ export const PROJECT_PLACEHOLDER_TEXTS = [
3
+ "选择项目",
4
+ "select project",
5
+ "select a project",
6
+ "choose project",
7
+ ];
8
+ function normalizeDisplayName(value) {
9
+ return value.normalize("NFKC").trim().toLocaleLowerCase();
10
+ }
11
+ export function projectDisplayName(projectPath, platform = process.platform) {
12
+ const api = platform === "win32" ? path.win32 : path.posix;
13
+ return api.basename(projectPath).normalize("NFKC").trim();
14
+ }
15
+ export function sameDisplayName(a, b) {
16
+ return normalizeDisplayName(a) === normalizeDisplayName(b);
17
+ }
18
+ export function isUnboundTriggerText(text) {
19
+ const normalized = normalizeDisplayName(text);
20
+ const compact = normalized.replace(/\s+/g, "");
21
+ return PROJECT_PLACEHOLDER_TEXTS.some((placeholder) => {
22
+ const normalizedPlaceholder = normalizeDisplayName(placeholder);
23
+ return (normalized === normalizedPlaceholder || compact === normalizedPlaceholder.replace(/\s+/g, ""));
24
+ });
25
+ }
2
26
  export function normalizeProjectPath(value, platform = process.platform) {
3
27
  const api = platform === "win32" ? path.win32 : path.posix;
4
28
  let out = api.normalize(value).replace(/[\\/]+$/, "");
@@ -4,9 +4,9 @@ import { createHash } from "node:crypto";
4
4
  import { mkdirp } from "../../util/fs.js";
5
5
  import { parseZcodeModel, exactUiName } from "./model.js";
6
6
  import { validateTaskReferences } from "./references.js";
7
- import { matchZcodeProject, normalizeProjectPath } from "./project.js";
7
+ import { isUnboundTriggerText, matchZcodeProject, normalizeProjectPath, projectDisplayName, sameDisplayName, } from "./project.js";
8
8
  import { judgeZcodePoll } from "./liveness.js";
9
- import { ZcodeCdpClient, CdpDisconnectedError, CdpUnavailableError } from "./cdp.js";
9
+ import { ZcodeCdpClient, CdpDisconnectedError, CdpUnavailableError, } from "./cdp.js";
10
10
  import { ensureZcodeInstance, listZcodeProcesses } from "./instance.js";
11
11
  import { listOwnedDialogs, selectZcodeFolder } from "./dialog.js";
12
12
  const DEFAULT_DEPS = {
@@ -71,9 +71,14 @@ function prompt(ctx, refs) {
71
71
  return out;
72
72
  }
73
73
  async function waitBound(cdp, target, deps) {
74
+ const targetName = projectDisplayName(target);
74
75
  for (let i = 0; i < 30; i++) {
75
- const p = await cdp.boundProjectPath();
76
- if (p && normalizeProjectPath(p) === normalizeProjectPath(target))
76
+ const binding = await cdp.workspaceBinding();
77
+ if ((binding.projectPath &&
78
+ normalizeProjectPath(binding.projectPath) === normalizeProjectPath(target)) ||
79
+ (binding.triggerText &&
80
+ !isUnboundTriggerText(binding.triggerText) &&
81
+ sameDisplayName(binding.triggerText, targetName)))
77
82
  return true;
78
83
  await deps.sleep(300);
79
84
  }
@@ -107,9 +112,9 @@ async function connectStableZcode(ready, gui, deps, allowLoginPage = false) {
107
112
  // eslint-disable-next-line no-await-in-loop
108
113
  await deps.sleep(500);
109
114
  }
110
- throw (lastError instanceof Error
115
+ throw lastError instanceof Error
111
116
  ? lastError
112
- : new Error(`ZCode CDP 未在 ${gui.launchTimeoutMs}ms 内恢复`));
117
+ : new Error(`ZCode CDP 未在 ${gui.launchTimeoutMs}ms 内恢复`);
113
118
  }
114
119
  async function clickExactWhenReady(cdp, key, value, deps) {
115
120
  let last = { clicked: false, count: 0, available: [] };
@@ -216,6 +221,36 @@ export async function runZcodeTask(args) {
216
221
  let session = ctx.resume ? activeSession : {};
217
222
  let permission = ctx.resume?.permissionMode ?? gui.defaultPermissionMode ?? "完全访问";
218
223
  let answeredQuestion = false;
224
+ const ensureProjectBound = async (initialItem) => {
225
+ if (!initialItem && (await waitBound(cdp, ctx.projectPath, deps)))
226
+ return { bound: true, ambiguous: false };
227
+ let item = initialItem;
228
+ for (let round = 0; round < 2; round++) {
229
+ if (!item || round > 0) {
230
+ // eslint-disable-next-line no-await-in-loop
231
+ await cdp.dismissMenus();
232
+ // eslint-disable-next-line no-await-in-loop
233
+ if (!(await cdp.click("projectTrigger")))
234
+ continue;
235
+ // eslint-disable-next-line no-await-in-loop
236
+ await deps.sleep(300);
237
+ // eslint-disable-next-line no-await-in-loop
238
+ const matched = matchZcodeProject(await cdp.projects(), ctx.projectPath);
239
+ if (matched.ambiguous)
240
+ return { bound: false, ambiguous: true };
241
+ item = matched.item;
242
+ }
243
+ if (!item)
244
+ continue;
245
+ // eslint-disable-next-line no-await-in-loop
246
+ const clicked = await cdp.clickProject(item.id, item.path);
247
+ // eslint-disable-next-line no-await-in-loop
248
+ if (clicked && (await waitBound(cdp, ctx.projectPath, deps)))
249
+ return { bound: true, ambiguous: false };
250
+ item = undefined;
251
+ }
252
+ return { bound: false, ambiguous: false };
253
+ };
219
254
  if (ctx.resume?.kind === "continue" && ctx.resume.sendMessage) {
220
255
  const pending = await cdp.poll();
221
256
  if (pending.question) {
@@ -256,44 +291,34 @@ export async function runZcodeTask(args) {
256
291
  endReason: "project_ambiguous",
257
292
  });
258
293
  if (matched.item) {
259
- if (!(await cdp.clickProject(matched.item.id, matched.item.path)))
294
+ const binding = await ensureProjectBound(matched.item);
295
+ if (!binding.bound)
260
296
  return result({
261
297
  hardFailure: true,
262
- error: "精确项目项点击失败",
263
- endReason: "setup_failed",
298
+ error: binding.ambiguous
299
+ ? "项目绑定重试时目标项目无法唯一匹配"
300
+ : "ZCode 项目绑定两轮重试均未生效",
301
+ endReason: "project_mismatch",
264
302
  });
265
- if (!(await waitBound(cdp, ctx.projectPath, deps))) {
266
- // ZCode may ignore a menu click while its workspace list is still
267
- // animating. Retrying this pre-send, idempotent binding is safe.
268
- if (!(await cdp.click("projectTrigger")))
269
- return result({
270
- hardFailure: true,
271
- error: "项目首次绑定未生效,且无法重新打开项目列表",
272
- endReason: "project_mismatch",
273
- });
274
- await deps.sleep(300);
275
- const retried = matchZcodeProject(await cdp.projects(), ctx.projectPath);
276
- if (retried.ambiguous ||
277
- !retried.item ||
278
- !(await cdp.clickProject(retried.item.id, retried.item.path)))
279
- return result({
280
- hardFailure: true,
281
- error: "项目首次绑定未生效,重试时无法唯一选择目标项目",
282
- endReason: "project_mismatch",
283
- });
284
- }
285
303
  }
286
304
  else {
287
305
  const pids = deps.listProcesses().map((p) => p.pid);
288
306
  const before = await deps.listDialogs(pids);
289
- if (!(await cdp.click("addProject")))
290
- return result({
291
- hardFailure: true,
292
- error: "找不到 ZCode 添加项目入口",
293
- endReason: "setup_failed",
294
- });
295
- await deps.sleep(300);
296
- const folderOption = await clickAnyExactWhenReady(cdp, "chooseFolder", ["打开文件夹", "Open Folder"], deps);
307
+ let folderOption = {
308
+ clicked: false,
309
+ count: 0,
310
+ available: [],
311
+ };
312
+ for (let round = 0; round < 3 && !folderOption.clicked; round++) {
313
+ // ZCode 3.11.2 的工作区下拉会吞掉第一次 outside-click。每轮均先收起残留菜单再验证新菜单。
314
+ // eslint-disable-next-line no-await-in-loop
315
+ await cdp.dismissMenus();
316
+ // eslint-disable-next-line no-await-in-loop
317
+ if (!(await cdp.click("addProject")))
318
+ continue;
319
+ // eslint-disable-next-line no-await-in-loop
320
+ folderOption = await clickAnyExactWhenReady(cdp, "chooseFolder", ["打开文件夹", "Open Folder"], deps);
321
+ }
297
322
  if (!folderOption.clicked)
298
323
  return result({
299
324
  hardFailure: true,
@@ -326,10 +351,13 @@ export async function runZcodeTask(args) {
326
351
  cdp = await connectStableZcode(ready, gui, deps);
327
352
  logger.info("[zcode] 项目导入后的 ZCode CDP 页面已恢复");
328
353
  }
329
- if (!(await waitBound(cdp, ctx.projectPath, deps)))
354
+ const finalBinding = await ensureProjectBound();
355
+ if (!finalBinding.bound)
330
356
  return result({
331
357
  hardFailure: true,
332
- error: "ZCode 项目绑定回读与 projectPath 不一致",
358
+ error: finalBinding.ambiguous
359
+ ? "ZCode 项目绑定回读不一致,且重试时目标项目无法唯一匹配"
360
+ : "ZCode 项目绑定回读与 projectPath 不一致,两轮幂等重试均失败",
333
361
  endReason: "project_mismatch",
334
362
  });
335
363
  logger.info(`[zcode] 项目绑定回读通过:${ctx.projectPath}`);
@@ -345,19 +373,18 @@ export async function runZcodeTask(args) {
345
373
  endReason: "setup_failed",
346
374
  });
347
375
  await deps.sleep(250);
348
- const provider = await clickExactWhenReady(cdp, "providerOption", spec.provider, deps);
349
- if (!provider.clicked)
350
- return result({
351
- hardFailure: true,
352
- error: `供应商不存在或同名歧义:${spec.provider}(匹配 ${provider.count}${provider.available?.length ? `;可见候选=${provider.available.slice(0, 20).join("、")}` : ""})`,
353
- endReason: "model_unavailable",
354
- });
355
- await deps.sleep(250);
356
- const model = await clickExactWhenReady(cdp, "modelOption", spec.model, deps);
376
+ let model = await clickExactWhenReady(cdp, "modelOption", spec.model, deps);
377
+ let provider = { clicked: false, count: 0, available: [] };
378
+ if (!model.clicked) {
379
+ provider = await clickExactWhenReady(cdp, "providerOption", spec.provider, deps);
380
+ if (provider.clicked)
381
+ await deps.sleep(250);
382
+ model = await clickExactWhenReady(cdp, "modelOption", spec.model, deps);
383
+ }
357
384
  if (!model.clicked)
358
385
  return result({
359
386
  hardFailure: true,
360
- error: `模型不存在或同名歧义:${spec.provider}/${spec.model}(匹配 ${model.count}${model.available?.length ? `;可见候选=${model.available.slice(0, 20).join("、")}` : ""})`,
387
+ error: `模型不存在或同名歧义:${spec.provider}/${spec.model}(模型匹配 ${model.count}${model.available?.length ? `;模型候选=${model.available.slice(0, 20).join("、")}` : ""}${model.testids?.length ? `;模型 testid=${model.testids.slice(0, 20).join("、")}` : ""};供应商匹配 ${provider.count}${provider.available?.length ? `;供应商候选=${provider.available.slice(0, 20).join("、")}` : ""}${provider.testids?.length ? `;供应商 testid=${provider.testids.slice(0, 20).join("、")}` : ""})`,
361
388
  endReason: "model_unavailable",
362
389
  });
363
390
  await deps.sleep(300);
@@ -386,7 +413,7 @@ export async function runZcodeTask(args) {
386
413
  if (!perm.clicked)
387
414
  return result({
388
415
  hardFailure: true,
389
- error: `无法唯一选择权限模式:${permission}`,
416
+ error: `无法唯一选择权限模式:${permission}(匹配 ${perm.count}${perm.available?.length ? `;可见候选=${perm.available.slice(0, 20).join("、")}` : ""}${perm.testids?.length ? `;可见 testid=${perm.testids.slice(0, 20).join("、")}` : ""})`,
390
417
  endReason: "permission_unknown",
391
418
  });
392
419
  await deps.sleep(250);
@@ -431,7 +458,8 @@ export async function runZcodeTask(args) {
431
458
  let seenRunning = false;
432
459
  let markedSession = undefined;
433
460
  const confirmationAttempts = Math.ceil(Math.min(60_000, Math.max(5_000, ctx.taskTimeoutMs)) / 250);
434
- for (let i = 0; i < confirmationAttempts && !(seenMessage && (seenStateChange || seenRunning || markedSession)); i++) {
461
+ for (let i = 0; i < confirmationAttempts &&
462
+ !(seenMessage && (seenStateChange || seenRunning || markedSession)); i++) {
435
463
  // eslint-disable-next-line no-await-in-loop
436
464
  await deps.sleep(250);
437
465
  // eslint-disable-next-line no-await-in-loop
@@ -113,15 +113,18 @@ export const ZCODE_SELECTORS = {
113
113
  },
114
114
  providerOption: {
115
115
  primary: '[data-testid^="chat-model-select-group-provider:"]',
116
- fallbacks: ['[role="option"][data-provider]'],
116
+ fallbacks: [
117
+ '[data-testid^="chat-model-select-group-family:"]',
118
+ '[role="option"][data-provider]',
119
+ ],
117
120
  verifiedVersion: "3.11.x",
118
- note: "供应商候选",
121
+ note: "3.11.2 分组前缀漂移为 family;家族标签可能不可点,仅作兜底展开",
119
122
  },
120
123
  modelOption: {
121
124
  primary: '[data-testid^="chat-model-select-item-"][role="menuitemradio"]',
122
- fallbacks: ['[role="option"][data-model]'],
125
+ fallbacks: ['[data-testid^="chat-model-select-item-"]', '[role="option"][data-model]'],
123
126
  verifiedVersion: "3.11.x",
124
- note: "模型候选",
127
+ note: "3.11.2 模型平铺于家族标签下,testid 形如 chat-model-select-item-custom:…",
125
128
  },
126
129
  modelValue: {
127
130
  primary: '[data-testid="chat-model-select-trigger"]',
@@ -1237,6 +1237,7 @@ export declare const AcceptanceConfigSchema: z.ZodObject<{
1237
1237
  timeoutMs?: number | undefined;
1238
1238
  optional?: boolean | undefined;
1239
1239
  }>, "many">>;
1240
+ requireChanges: z.ZodDefault<z.ZodBoolean>;
1240
1241
  }, "strip", z.ZodTypeAny, {
1241
1242
  checks: {
1242
1243
  name: string;
@@ -1244,6 +1245,7 @@ export declare const AcceptanceConfigSchema: z.ZodObject<{
1244
1245
  timeoutMs?: number | undefined;
1245
1246
  optional?: boolean | undefined;
1246
1247
  }[];
1248
+ requireChanges: boolean;
1247
1249
  }, {
1248
1250
  checks?: {
1249
1251
  name: string;
@@ -1251,5 +1253,6 @@ export declare const AcceptanceConfigSchema: z.ZodObject<{
1251
1253
  timeoutMs?: number | undefined;
1252
1254
  optional?: boolean | undefined;
1253
1255
  }[] | undefined;
1256
+ requireChanges?: boolean | undefined;
1254
1257
  }>;
1255
1258
  export type AcceptanceConfig = z.infer<typeof AcceptanceConfigSchema>;
@@ -255,4 +255,5 @@ export const ProjectsFileSchema = z.record(z.string().min(1), ProjectRecordSchem
255
255
  /* ---------------- 项目内 .tianshu-mcp/acceptance.json ---------------- */
256
256
  export const AcceptanceConfigSchema = z.object({
257
257
  checks: z.array(AcceptanceCheckSchema).default([]),
258
+ requireChanges: z.boolean().default(true),
258
259
  });
package/dist/util/path.js CHANGED
@@ -56,11 +56,10 @@ export function expandEnvPath(tpl) {
56
56
  SYSTEMDRIVE: process.env.SYSTEMDRIVE ?? process.env.SystemDrive,
57
57
  XDG_DATA_HOME: process.env.XDG_DATA_HOME,
58
58
  };
59
- let out = tpl;
60
- for (const [k, v] of Object.entries(envMap)) {
61
- if (v)
62
- out = out.split(`{${k}}`).join(v);
63
- }
59
+ let out = tpl.replace(/\{([^{}]+)\}/g, (placeholder, key) => {
60
+ const value = envMap[key.toUpperCase()];
61
+ return value || placeholder;
62
+ });
64
63
  // Windows 下把模板里的正斜杠统一为平台分隔符(env 值本身已是平台分隔)
65
64
  if (process.platform === "win32")
66
65
  out = out.split("/").join("\\");
@@ -18,6 +18,7 @@ export interface VerifyRequest {
18
18
  store: TaskStore;
19
19
  logger: Logger;
20
20
  }
21
+ export declare function detectZeroTestCases(output: string): boolean;
21
22
  /** 从项目内容推导默认检查集(开发计划 §8.2) */
22
23
  export declare function deriveDefaultChecks(projectPath: string): Promise<{
23
24
  checks: AcceptanceCheckDef[];
@@ -12,6 +12,23 @@ import { analyzeChanges } from "./code-analysis.js";
12
12
  import { captureBaseline, gitDiffCheckSince } from "./git-baseline.js";
13
13
  import { nowIso } from "../util/id.js";
14
14
  const SCRIPT_NAMES = ["typecheck", "lint", "test", "build"];
15
+ const ZERO_TEST_CASE_PATTERNS = [
16
+ /#\s+tests\s+0\b/i,
17
+ /\bno tests (?:were )?found\b/i,
18
+ /\bno tests ran\b/i,
19
+ /\b0 tests (?:ran|executed|found)\b/i,
20
+ ];
21
+ export function detectZeroTestCases(output) {
22
+ return ZERO_TEST_CASE_PATTERNS.some((pattern) => pattern.test(output));
23
+ }
24
+ function isTestCheck(name, cmd) {
25
+ const normalizedName = name.toLocaleLowerCase();
26
+ const normalizedCmd = cmd.join(" ").toLocaleLowerCase();
27
+ return (normalizedName.includes("test") ||
28
+ /\bnode\s+--test\b/.test(normalizedCmd) ||
29
+ /\bnpm\s+run\s+test(?:\b|:)/.test(normalizedCmd) ||
30
+ /\bpytest\b|\bgo\s+test\b|\bcargo\s+test\b|\bvitest\b|\bjest\b|\bmocha\b/.test(normalizedCmd));
31
+ }
15
32
  /** 从项目内容推导默认检查集(开发计划 §8.2) */
16
33
  export async function deriveDefaultChecks(projectPath) {
17
34
  const checks = [];
@@ -64,12 +81,13 @@ export class AcceptanceEngine {
64
81
  const out = [];
65
82
  const mode = req.checksMode ?? "append";
66
83
  const hasExtra = !!req.extraChecks?.length;
84
+ const inProject = await this.readProjectAcceptance(req.projectPath);
85
+ const requireChanges = inProject?.requireChanges ?? true;
67
86
  // 先取基础集(项目/默认),除非 replace 模式只用 extraChecks
68
87
  if (!(mode === "replace" && hasExtra)) {
69
- const inProject = await this.readProjectAcceptance(req.projectPath);
70
88
  if (inProject) {
71
- notes.push(`使用项目内验收配置 <project>/.tianshu-mcp/acceptance.json(${inProject.length} 项)。`);
72
- out.push(...inProject);
89
+ notes.push(`使用项目内验收配置 <project>/.tianshu-mcp/acceptance.json(${inProject.checks.length} 项)。`);
90
+ out.push(...inProject.checks);
73
91
  }
74
92
  else if (req.projectVerify?.length) {
75
93
  notes.push(`使用 server 数据目录 projects.json 补录的验收配置(${req.projectVerify.length} 项)。`);
@@ -92,7 +110,7 @@ export class AcceptanceEngine {
92
110
  }
93
111
  out.push(...(req.extraChecks ?? []));
94
112
  }
95
- return { checks: out, notes };
113
+ return { checks: out, notes, requireChanges };
96
114
  }
97
115
  async readProjectAcceptance(projectPath) {
98
116
  const p = path.join(projectPath, ".tianshu-mcp", "acceptance.json");
@@ -104,7 +122,11 @@ export class AcceptanceEngine {
104
122
  this.logger.warn(`项目 ${projectPath} 的 acceptance.json 解析失败: ${r.error.message}`);
105
123
  return null;
106
124
  }
107
- return r.data.checks.map((c) => toAcceptanceDef(c));
125
+ const config = r.data;
126
+ return {
127
+ checks: config.checks.map((c) => toAcceptanceDef(c)),
128
+ requireChanges: config.requireChanges,
129
+ };
108
130
  }
109
131
  /** 执行一轮完整验收。返回 report + 是否 pass。 */
110
132
  async runVerify(req) {
@@ -114,8 +136,9 @@ export class AcceptanceEngine {
114
136
  const baseline = req.baseline ?? (await captureBaseline(req.projectPath));
115
137
  if (req.baseline)
116
138
  this.logger.debug(`使用 run_task 动工前基线(HEAD=${baseline.head ?? "n/a"})`);
117
- const { checks: rawChecks, notes } = await this.resolveChecks(req);
139
+ const { checks: rawChecks, notes, requireChanges } = await this.resolveChecks(req);
118
140
  const checks = [];
141
+ const zeroTestChecks = [];
119
142
  // 内置 git diff --check(相对动工前基线;非 git 仓库跳过并标注)
120
143
  if (baseline.isRepo) {
121
144
  const r = await gitDiffCheckSince(req.projectPath, baseline.head);
@@ -152,6 +175,16 @@ export class AcceptanceEngine {
152
175
  logFile: verifyLog,
153
176
  env: {},
154
177
  });
178
+ if (!c.optional &&
179
+ res.exitCode === 0 &&
180
+ res.passed &&
181
+ isTestCheck(c.name, argv) &&
182
+ detectZeroTestCases(res.outputTail)) {
183
+ res.passed = false;
184
+ res.outputTail = `${res.outputTail}\n[fail-closed] 命令退出码 0 但未执行任何测试用例`.slice(-4000);
185
+ res.reason = "fail-closed:命令退出码 0 但未执行任何测试用例";
186
+ zeroTestChecks.push(c.name);
187
+ }
155
188
  if (c.optional)
156
189
  res.optional = true;
157
190
  checks.push(res);
@@ -164,6 +197,30 @@ export class AcceptanceEngine {
164
197
  });
165
198
  for (const n of notes)
166
199
  analysis.notes.push(n);
200
+ const hasChanges = analysis.changedFiles.length > 0 ||
201
+ analysis.untrackedFiles.length > 0 ||
202
+ analysis.diffstat.totalAdd > 0 ||
203
+ analysis.diffstat.totalDel > 0;
204
+ if (baseline.isRepo && !hasChanges) {
205
+ if (requireChanges) {
206
+ checks.push({
207
+ name: "no-changes",
208
+ cmd: "requireChanges 门禁",
209
+ passed: false,
210
+ durationMs: 0,
211
+ exitCode: null,
212
+ outputTail: "fail-closed:相对动工前基线零文件变更",
213
+ timeout: false,
214
+ reason: "requireChanges 默认开启,但未检测到任务产出",
215
+ });
216
+ }
217
+ else {
218
+ analysis.notes.push("requireChanges=false:相对动工前基线零变更,仅提示不拦截。");
219
+ }
220
+ }
221
+ else if (!baseline.isRepo && requireChanges) {
222
+ analysis.notes.push("requireChanges=true,但项目不是 git 仓库,零变更门禁已跳过。");
223
+ }
167
224
  // optional:true 的失败只记 warning,不使本轮 verdict 失败(R4)
168
225
  const failed = checks.filter((c) => !c.passed && !c.skipped && !c.optional);
169
226
  const optFailed = checks.filter((c) => !c.passed && !c.skipped && c.optional);
@@ -173,6 +230,12 @@ export class AcceptanceEngine {
173
230
  if (failed.length) {
174
231
  summaryBits.push(`未通过检查: ${failed.map((c) => c.name).join(", ")}`);
175
232
  }
233
+ if (zeroTestChecks.length) {
234
+ summaryBits.push(`测试命令零用例: ${zeroTestChecks.join(", ")}`);
235
+ }
236
+ if (failed.some((check) => check.name === "no-changes")) {
237
+ summaryBits.push("相对动工前基线未检测到文件变更");
238
+ }
176
239
  if (optFailed.length) {
177
240
  summaryBits.push(`optional 检查未通过(不影响结论): ${optFailed.map((c) => c.name).join(", ")}`);
178
241
  }
@@ -3,4 +3,4 @@
3
3
  * 若直接源码运行(未注入),回退读 package.json;再失败给占位。
4
4
  * 本文件由 scripts/sync-version.mjs 在每次 build 前重新生成。
5
5
  */
6
- export declare const MCP_SERVER_VERSION = "0.3.2";
6
+ export declare const MCP_SERVER_VERSION = "0.3.3";
@@ -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.3.2";
7
+ export const MCP_SERVER_VERSION = "0.3.3";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tianshu-mcp",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "天枢 × AI-Agent 编排 MCP server —— 驱动 Codex CLI、TraeWork GUI 与 ZCode GUI 完成项目开发、验收、失败返修与再验收闭环。",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",