tianshu-mcp 0.1.2 → 0.1.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/README.en.md CHANGED
@@ -29,7 +29,7 @@ Tianshu plays the role of the overall commander; this MCP server is the **schedu
29
29
  ```bash
30
30
  npm install
31
31
  npm run build # → dist/
32
- npm test # 53 tests: unit + stub-agent 3-playbook integration + protocol + cancel/timeout/baseline/params regression
32
+ npm test # 72 tests: unit + stub-agent 3-playbook integration + protocol + cancel/timeout/baseline/params/config regression
33
33
  ```
34
34
 
35
35
  Register as a Tianshu MCP server (local dev mode):
@@ -75,7 +75,7 @@ run_task(projectPath=D:/xxx/my-app, task=「…task brief…」, agentId=codex,
75
75
  - 8 tools, TaskManager state machine / queue / concurrency gate / cancel (kill tree) / event-stream persistence
76
76
  - Acceptance engine (git baseline & diff, default-set derivation, command runner, code analysis, report.md/json)
77
77
  - fix-loop auto rework + needs_attention; skill self-install
78
- - Stub-agent 3 playbooks (good / fix-on-first / never) integration tests + protocol tests — **53/53 green** (incl. R1–R5 cancel/timeout/baseline/params regressions)
78
+ - Stub-agent 3 playbooks (good / fix-on-first / never) integration tests + protocol tests — **72/72 green** (incl. R1–R5/S1–S6 cancel/timeout/baseline/params/config regressions)
79
79
  - **M2 — Real Codex CLI smoke + rework loop** ✅ (2026-09-07)
80
80
  - Real `codex exec` completed `run_task → query_task → verify_task` (see [m2-smoke-record.md](docs/m2-smoke-record.md))
81
81
  - Real **failure → rework_task → re-verify succeeded** loop ([m2-rework-record.md](docs/m2-rework-record.md), artifacts in `docs/m2-evidence/`)
@@ -86,18 +86,27 @@ run_task(projectPath=D:/xxx/my-app, task=「…task brief…」, agentId=codex,
86
86
  - npm name `tianshu-mcp` published: `tianshu-mcp@0.1.1` (`npm view` resolves; `npx -y tianshu-mcp` raises and connects 8 tools, see [dod7-release-record.md](docs/dod7-release-record.md))
87
87
  - Real Tianshu-session skill-trigger validation (DoD #8) needs a GUI session (skill self-installed and ready)
88
88
 
89
- ## Acceptance remediation (R1–R8, 2026-09-07)
89
+ ## Acceptance remediation (R1–R8, 2026-09-07; S1–S6, 2026-09-08)
90
90
 
91
- Per the acceptance-remediation plan, all P1/P2 findings are fixed with regression tests (total **53/53**):
91
+ Per the acceptance-remediation plans, all P1/P2 findings are fixed with regression tests (total **72/72**):
92
92
 
93
93
  - **R1** ✅ cancel/interrupt state persistence (`cancel_requested → cancelled`, cancelReason/finishedAt/errorType, restart-recoverable, idempotent, bounded shutdown)
94
- - **R2** ✅ call-level `taskTimeoutMs` wins (adapter no longer overrides); POSIX process-group SIGTERM→SIGKILL with exit polling; Windows `taskkill /T /F`; single kill-tree impl + abort-race guard
95
- - **R3** ✅ git baseline participates in diff (baseline.head boundary; agent commits don't lose changes; dirty-worktree hash attribution; `--untracked-files=all`)
96
- - **R4** ✅ params: `round=0` valid; manual verify allocates next round (no overwrite); `extraChecks` append + `checksMode=replace`; `optional` doesn't fail verdict; `baselineRef` validated
97
- - **R5** ✅ hardcoded paths removed (`{LOCALAPPDATA}` placeholders + platform defaults); mtime+size hot reload for config/profile/projects
98
- - **R6** ✅ CI matrix win/mac/linux × Node 20/22 all green (run 25); Release version consistency (tag=package=tarball, v0.1.1 draft OK); tarball content check
99
- - **R7** ✅ real Tianshu loop: host connect (DoD #6) + skill load & MCP tool call + task loop (DoD #8) + npm publish & npx raise (DoD #7) all verified
100
- - **R8** 🔄 docs synced (ZH/EN + dev-plan checklist); see [remediation-recheck report](.codex/review/2026-09-07-remediation-recheck.md)
94
+ - **R2** ✅ call-level `taskTimeoutMs` wins; POSIX process-group SIGTERM→SIGKILL; Windows `taskkill /T /F`; single kill-tree impl + abort-race guard
95
+ - **R3** ✅ git baseline participates in diff (baseline.head boundary; agent commits don't lose changes; dirty-worktree hash attribution)
96
+ - **R4** ✅ params: `round=0` valid; manual verify allocates next round; `extraChecks` append + `checksMode=replace`; `optional` semantics; `baselineRef` validated
97
+ - **R5** ✅ hardcoded paths removed (`{LOCALAPPDATA}` placeholders); content-stamp hot reload for config/profile/projects
98
+ - **R6** ✅ CI matrix win/mac/linux × Node 20/22 all green; Release version consistency; tarball content check
99
+ - **R7** ✅ real Tianshu serve session (skill load + MCP tool call + stub task loop); npm v0.1.2 published + npx raise connect
100
+ - **R8** ✅ docs synced (ZH/EN + dev-plan checklist); recheck report
101
+
102
+ Second-round remediation (per `.codex/plans/2026-09-08-second-remediation-plan.md`):
103
+ - **S1** ✅ no-reason cancel lands `cancelled` (independent `cancelRequestedAt`/`abortSource`, no reliance on optional reason)
104
+ - **S2** ✅ normal timeout → `failed(timeout)` + exactly one `timeout_killed` event, fixed order
105
+ - **S3** ✅ tracked pre-dirty net-diff attribution (unchanged staged/unstaged no longer reported as agent changes)
106
+ - **S4** ✅ `verify_task(taskId)` persists real-task metadata (`reportRound`/`verificationSource`/`latestVerificationVerdict`, keeps agentId); single-source MCP version (build-injected, live-tested = 0.1.2)
107
+ - **S5** ✅ config/profiles/projects last-known-good + sha256 invalidation (fixed corrupt-JSON-reset bug)
108
+ - **S6** ✅ CI/Release npm ci retry corrected; Vitest v3 upgrade (audit 0); plain-text status markers (emoji scan test)
109
+ - **S7/S8/S9** full real-loop evidence & new release pending final close-out (see `.codex/review/`)
101
110
 
102
111
  ## Recommended phrasing (for Tianshu)
103
112
 
package/README.md CHANGED
@@ -29,7 +29,7 @@ TypeScript · Node.js ≥ 20 · `@modelcontextprotocol/sdk`(stdio)
29
29
  ```bash
30
30
  npm install
31
31
  npm run build # → dist/
32
- npm test # 53 项测试:单元 + stub-agent 三剧本集成 + 协议 + 取消/超时/基线/参数回归
32
+ npm test # 72 项测试:单元 + stub-agent 三剧本集成 + 协议 + 取消/超时/基线/参数/配置回归
33
33
  ```
34
34
 
35
35
  配置为天枢 MCP server(本地开发模式):
@@ -76,7 +76,7 @@ run_task(projectPath=D:/xxx/my-app, task=「…任务书…」, agentId=codex, a
76
76
  - 8 工具、TaskManager 状态机/队列/并发闸/cancel(kill tree)/事件流落盘
77
77
  - 验收引擎(git 基线/diff、默认集推导、命令 runner、代码分析、report.md/json)
78
78
  - fix-loop 自动返修 + needs_attention;技能自检安装(已在本机真实 `~/.rivet/skills` 验证)
79
- - stub-agent 三剧本(good/fix-on-first/never)集成测试 + 协议测试,**53/53 绿**(含 R1–R5 取消/超时/基线/参数回归)
79
+ - stub-agent 三剧本(good/fix-on-first/never)集成测试 + 协议测试,**72/72 绿**(含 R1–R5/S1–S6 取消/超时/基线/参数/配置回归)
80
80
  - **M2 — 真实 Codex CLI 冒烟 + rework 闭环** ✅(2026-09-07)
81
81
  - 真实 `codex exec` 跑通 `run_task → query_task → verify_task`([m2-smoke-record.md](docs/m2-smoke-record.md))
82
82
  - 真实 **失败→rework_task→再验收 succeeded** 闭环([m2-rework-record.md](docs/m2-rework-record.md),物证 `docs/m2-evidence/`)
@@ -94,18 +94,25 @@ run_task(projectPath=D:/xxx/my-app, task=「…任务书…」, agentId=codex, a
94
94
  - **npm 已发布**:`tianshu-mcp@0.1.1`(`npm view` 可查,`npx -y tianshu-mcp` 拉起 8 工具连通,见 [dod7-release-record.md](docs/dod7-release-record.md))
95
95
  - GUI 聊天会话内实际调用工具(DoD #8 最后一环)需用户开天枢新会话(宿主连通与 8 工具注册已就位)
96
96
 
97
- ## 验收整改(R1–R8,2026-09-07)
98
-
99
- 按 [验收修复计划](.zcode/plans/tianshu-mcp-development-plan.md) 与 `.codex/plans/2026-09-07-acceptance-remediation-plan.md` 完成:
97
+ ## 验收整改(R1–R8,2026-09-07;S1–S6,2026-09-08)
100
98
 
101
99
  - **R1** ✅ 取消/中断状态机持久化:运行中取消 `cancel_requested → cancelled`(cancelReason/finishedAt/errorType 落盘)、重启可恢复、幂等、shutdown 有界等待。
102
100
  - **R2** ✅ 调用级 `taskTimeoutMs` 覆盖生效(adapter 不再用 profile 覆盖)、POSIX 进程组 SIGTERM→SIGKILL、Windows taskkill /T /F,kill-tree 单实现。
103
101
  - **R3** ✅ Git 基线参与差异计算:以 baseline.head 为边界,agent 提交不丢变更、脏工作区 hash 归因、porcelain 逐文件展开。
104
102
  - **R4** ✅ 参数语义:`get_task_report(round=0)` 合法、手动验收分配新轮次不覆盖、`extraChecks` 追加 + `checksMode=replace`、`optional` 不影响 verdict、`baselineRef` 校验。
105
- - **R5** ✅ 移除路径硬编码(`{LOCALAPPDATA}` 等占位符 + 平台标准候选),config/profile/projects mtime 热加载。
106
- - **R6** 🔄 CI 矩阵扩到 Windows/macOS/Linux × Node 20/22;Release 版本一致性校验;tarball 内容校验。
107
- - **R7** 🟡 天枢真实闭环:宿主连通(DoD #6)与技能安装已实测;GUI 会话内工具调用待用户开天枢新会话。
108
- - **R8** 🔄 文档同步 + 最终复验。
103
+ - **R5** ✅ 移除路径硬编码(`{LOCALAPPDATA}` 等占位符 + 平台标准候选),config/profile/projects 热加载。
104
+ - **R6** ✅ CI 矩阵 Windows/macOS/Linux × Node 20/22 全绿;Release 版本一致性校验;tarball 内容校验。
105
+ - **R7** ✅ 真实 Tianshu serve 会话实测(技能加载 + MCP 工具 + stub 任务闭环);npm v0.1.2 发布 + npx 拉起连通。
106
+ - **R8** ✅ 文档同步 + 复验报告。
107
+
108
+ 二次整改(按 `.codex/plans/2026-09-08-second-remediation-plan.md`):
109
+ - **S1** ✅ 无理由取消稳定落 `cancelled`(新增 `cancelRequestedAt`/`abortSource` 独立字段,不依赖可选 reason)。
110
+ - **S2** ✅ 普通超时统一落 `failed(timeout)` + 一次 `timeout_killed` 事件,顺序固定。
111
+ - **S3** ✅ tracked 预脏净差异归因:staged/unstaged 未变文件不再误报为 agent 变更。
112
+ - **S4** ✅ `verify_task(taskId)` 持久化更新原任务元数据(`reportRound`/`verificationSource`/`latestVerificationVerdict`,保留 agentId);MCP 版本单一来源(build 注入,服务端实测 version=0.1.2)。
113
+ - **S5** ✅ config/profiles/projects 热加载 last-known-good + 内容 sha256 失效检测(修复损坏 JSON 被当缺失重置的 bug)。
114
+ - **S6** ✅ CI/Release npm ci 重试修正(成功即停/3 次上限/attempt 计数);Vitest v3 升级(audit 0 漏洞);纯文本状态标记(emoji 扫描测试)。
115
+ - **S7/S8/S9** 真实完整证据与新版发布待最终闭环(详见 `.codex/review/`)。
109
116
 
110
117
  ## 推荐用法(给天枢的提示语)
111
118
 
@@ -453,6 +453,55 @@ export declare const ProjectRecordSchema: z.ZodObject<{
453
453
  defaultAgentId?: string | undefined;
454
454
  }>;
455
455
  export type ProjectRecord = z.infer<typeof ProjectRecordSchema>;
456
+ /** projects.json 整体文件 schema(S5:不再用对象强制类型转换) */
457
+ export declare const ProjectsFileSchema: z.ZodRecord<z.ZodString, z.ZodObject<{
458
+ path: z.ZodString;
459
+ displayPath: z.ZodOptional<z.ZodString>;
460
+ firstSeenAt: z.ZodString;
461
+ lastSeenAt: z.ZodString;
462
+ verify: z.ZodOptional<z.ZodArray<z.ZodObject<{
463
+ name: z.ZodString;
464
+ cmd: z.ZodUnion<[z.ZodString, z.ZodArray<z.ZodString, "many">]>;
465
+ timeoutMs: z.ZodOptional<z.ZodNumber>;
466
+ optional: z.ZodOptional<z.ZodBoolean>;
467
+ }, "strip", z.ZodTypeAny, {
468
+ name: string;
469
+ cmd: string | string[];
470
+ timeoutMs?: number | undefined;
471
+ optional?: boolean | undefined;
472
+ }, {
473
+ name: string;
474
+ cmd: string | string[];
475
+ timeoutMs?: number | undefined;
476
+ optional?: boolean | undefined;
477
+ }>, "many">>;
478
+ defaultAgentId: z.ZodOptional<z.ZodString>;
479
+ }, "strip", z.ZodTypeAny, {
480
+ path: string;
481
+ firstSeenAt: string;
482
+ lastSeenAt: string;
483
+ displayPath?: string | undefined;
484
+ verify?: {
485
+ name: string;
486
+ cmd: string | string[];
487
+ timeoutMs?: number | undefined;
488
+ optional?: boolean | undefined;
489
+ }[] | undefined;
490
+ defaultAgentId?: string | undefined;
491
+ }, {
492
+ path: string;
493
+ firstSeenAt: string;
494
+ lastSeenAt: string;
495
+ displayPath?: string | undefined;
496
+ verify?: {
497
+ name: string;
498
+ cmd: string | string[];
499
+ timeoutMs?: number | undefined;
500
+ optional?: boolean | undefined;
501
+ }[] | undefined;
502
+ defaultAgentId?: string | undefined;
503
+ }>>;
504
+ export type ProjectsFile = z.infer<typeof ProjectsFileSchema>;
456
505
  export declare const AcceptanceConfigSchema: z.ZodObject<{
457
506
  checks: z.ZodDefault<z.ZodArray<z.ZodObject<{
458
507
  name: z.ZodString;
@@ -111,6 +111,8 @@ export const ProjectRecordSchema = z.object({
111
111
  verify: z.array(AcceptanceCheckSchema).optional(), // 管理员补录的验收配置(可选)
112
112
  defaultAgentId: z.string().optional(),
113
113
  });
114
+ /** projects.json 整体文件 schema(S5:不再用对象强制类型转换) */
115
+ export const ProjectsFileSchema = z.record(z.string().min(1), ProjectRecordSchema);
114
116
  /* ---------------- 项目内 .tianshu-mcp/acceptance.json ---------------- */
115
117
  export const AcceptanceConfigSchema = z.object({
116
118
  checks: z.array(AcceptanceCheckSchema).default([]),
@@ -23,17 +23,21 @@ export declare class DataHome {
23
23
  taskDir(taskId: string): string;
24
24
  init(): Promise<void>;
25
25
  /**
26
- * 轻量失效签名:mtime(ms) + 文件大小。同一 mtime tick 内的内容变化(快速 FS/CI)也能被捕获。
27
- * 失败返回 "0:0"(每次重读)。
26
+ * 内容失效签名(S5):文件内容 sha256。能检测同一 mtime tick 内的同长度修改;
27
+ * 无法读文件时回退 "0"(每次重读)。
28
28
  */
29
29
  private fileStamp;
30
30
  loadConfig(): Promise<ServerConfig>;
31
+ /** 读 JSON 且区分"文件不存在"与"JSON 损坏" */
32
+ private readJsonWithExists;
31
33
  saveConfig(cfg: ServerConfig): Promise<void>;
32
- /** 用户级 profiles(数据目录)叠加内置 profiles,用户键覆盖内置;按 mtime 轻量热加载(R5) */
34
+ /** 用户级 profiles(数据目录)叠加内置 profiles,用户键覆盖内置;按内容 stamp 热加载(S5) */
33
35
  loadProfiles(): Promise<Record<string, AgentProfile>>;
34
36
  saveProfiles(profiles: Record<string, AgentProfile>): Promise<void>;
35
37
  /** 供 get_profiles / adapter resolve 使用:返回一份 profile(含内置合并) */
36
38
  getProfile(id: string): Promise<AgentProfile | undefined>;
39
+ /** 解析 projects.json:Zod 校验(S5),解析/校验失败保留上一有效值 */
40
+ private loadProjectsRaw;
37
41
  loadProjects(): Promise<Record<string, ProjectRecord>>;
38
42
  registerProject(p: string, defaultAgentId?: string): Promise<{
39
43
  hash: string;
@@ -5,8 +5,9 @@
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
7
  import fsp from "node:fs/promises";
8
- import { AgentProfilesFileSchema, ServerConfigSchema, } from "./schema.js";
9
- import { readJsonSafe, writeJsonAtomic, mkdirp, readDirSafe, exists } from "../util/fs.js";
8
+ import { AgentProfilesFileSchema, ProjectsFileSchema, ServerConfigSchema, } from "./schema.js";
9
+ import { writeJsonAtomic, mkdirp, readDirSafe, exists } from "../util/fs.js";
10
+ import { createHash } from "node:crypto";
10
11
  import { projectHash, normPath } from "../util/path.js";
11
12
  import { nowIso } from "../util/id.js";
12
13
  export function resolveDataHome() {
@@ -76,43 +77,63 @@ export class DataHome {
76
77
  }
77
78
  /* ---------- config ---------- */
78
79
  /**
79
- * 轻量失效签名:mtime(ms) + 文件大小。同一 mtime tick 内的内容变化(快速 FS/CI)也能被捕获。
80
- * 失败返回 "0:0"(每次重读)。
80
+ * 内容失效签名(S5):文件内容 sha256。能检测同一 mtime tick 内的同长度修改;
81
+ * 无法读文件时回退 "0"(每次重读)。
81
82
  */
82
83
  async fileStamp(p) {
83
84
  try {
84
- const st = await fsp.stat(p);
85
- return `${st.mtimeMs}:${st.size}`;
85
+ const buf = await fsp.readFile(p);
86
+ return createHash("sha256").update(buf).digest("hex");
86
87
  }
87
88
  catch {
88
- return "0:0";
89
+ return "0";
89
90
  }
90
91
  }
91
92
  async loadConfig() {
92
93
  const stamp = await this.fileStamp(this.configPath);
93
94
  if (this.configCache && this.configCacheStamp === stamp)
94
95
  return this.configCache;
95
- const raw = await readJsonSafe(this.configPath);
96
- let cfg = ServerConfigSchema.parse({});
97
- if (raw != null) {
98
- const r = ServerConfigSchema.safeParse(raw);
99
- if (r.success) {
100
- cfg = r.data;
101
- this.configCache = cfg;
96
+ const { raw, exists: fileExists } = await this.readJsonWithExists(this.configPath);
97
+ // 文件不存在 → 首次默认
98
+ if (!fileExists) {
99
+ const def = ServerConfigSchema.parse({});
100
+ if (!this.configCache) {
101
+ this.configCache = def;
102
102
  this.configCacheStamp = stamp;
103
103
  }
104
- else {
105
- // 解析失败保留上一有效配置(R5)
106
- this.logger.warn(`config.json 解析失败,保留上一有效配置: ${r.error.message}`);
107
- if (this.configCache)
108
- return this.configCache;
109
- }
104
+ return this.configCache;
110
105
  }
111
- else {
112
- this.configCache = cfg;
106
+ if (raw == null) {
107
+ // 文件存在但 JSON 损坏 → 保留上一有效配置(S5 last-known-good)
108
+ this.logger.warn(`config.json JSON 损坏,保留上一有效配置`);
109
+ if (this.configCache)
110
+ return this.configCache;
111
+ return ServerConfigSchema.parse({});
112
+ }
113
+ const r = ServerConfigSchema.safeParse(raw);
114
+ if (r.success) {
115
+ this.configCache = r.data;
113
116
  this.configCacheStamp = stamp;
117
+ return r.data;
118
+ }
119
+ // schema 校验失败 → 保留上一有效配置
120
+ this.logger.warn(`config.json schema 校验失败,保留上一有效配置: ${r.error.message}`);
121
+ return this.configCache ?? ServerConfigSchema.parse({});
122
+ }
123
+ /** 读 JSON 且区分"文件不存在"与"JSON 损坏" */
124
+ async readJsonWithExists(p) {
125
+ try {
126
+ const text = await fsp.readFile(p, "utf8");
127
+ try {
128
+ return { raw: JSON.parse(text), exists: true };
129
+ }
130
+ catch {
131
+ return { raw: null, exists: true };
132
+ }
133
+ }
134
+ catch {
135
+ return { raw: null, exists: false };
114
136
  }
115
- return this.configCache ?? cfg;
116
137
  }
117
138
  async saveConfig(cfg) {
118
139
  await writeJsonAtomic(this.configPath, cfg);
@@ -120,7 +141,7 @@ export class DataHome {
120
141
  this.configCacheStamp = await this.fileStamp(this.configPath);
121
142
  }
122
143
  /* ---------- agent-profiles ---------- */
123
- /** 用户级 profiles(数据目录)叠加内置 profiles,用户键覆盖内置;按 mtime 轻量热加载(R5) */
144
+ /** 用户级 profiles(数据目录)叠加内置 profiles,用户键覆盖内置;按内容 stamp 热加载(S5) */
124
145
  async loadProfiles() {
125
146
  const stamp = await this.fileStamp(this.profilesPath);
126
147
  if (this._profilesCache && this._profilesCacheStamp === stamp)
@@ -130,7 +151,16 @@ export class DataHome {
130
151
  for (const [k, v] of Object.entries(this.builtinProfiles)) {
131
152
  merged[k] = { ...v, id: k };
132
153
  }
133
- const raw = await readJsonSafe(this.profilesPath);
154
+ const { raw, exists: fileExists } = await this.readJsonWithExists(this.profilesPath);
155
+ if (fileExists && raw == null) {
156
+ // JSON 损坏 → 保留上一有效(若有);否则仅内置
157
+ this.logger.warn(`agent-profiles.json JSON 损坏,保留上一有效 profiles`);
158
+ if (this._profilesCache)
159
+ return this._profilesCache;
160
+ this._profilesCache = merged;
161
+ this._profilesCacheStamp = stamp;
162
+ return merged;
163
+ }
134
164
  if (raw != null) {
135
165
  const r = AgentProfilesFileSchema.safeParse(raw);
136
166
  if (r.success) {
@@ -139,7 +169,7 @@ export class DataHome {
139
169
  }
140
170
  }
141
171
  else {
142
- this.logger.warn(`agent-profiles.json 解析失败,保留上一有效 profiles: ${r.error.message}`);
172
+ this.logger.warn(`agent-profiles.json 校验失败,保留上一有效 profiles: ${r.error.message}`);
143
173
  if (this._profilesCache)
144
174
  return this._profilesCache;
145
175
  }
@@ -166,18 +196,29 @@ export class DataHome {
166
196
  return all[id];
167
197
  }
168
198
  /* ---------- projects 自动登记 ---------- */
199
+ /** 解析 projects.json:Zod 校验(S5),解析/校验失败保留上一有效值 */
200
+ async loadProjectsRaw() {
201
+ const { raw, exists: fileExists } = await this.readJsonWithExists(this.projectsPath);
202
+ if (!fileExists)
203
+ return { ok: true, map: {}, missing: true }; // 文件不存在 = 空(首次)
204
+ if (raw == null)
205
+ return { ok: false, map: {}, error: "JSON 损坏" };
206
+ const r = ProjectsFileSchema.safeParse(raw);
207
+ if (!r.success) {
208
+ return { ok: false, map: {}, error: r.error.message };
209
+ }
210
+ return { ok: true, map: r.data };
211
+ }
169
212
  async loadProjects() {
170
213
  const stamp = await this.fileStamp(this.projectsPath);
171
214
  if (this.projectsCache && this.projectsCacheStamp === stamp)
172
215
  return this.projectsCache;
173
- const raw = await readJsonSafe(this.projectsPath);
174
- const map = {};
175
- if (raw != null && typeof raw === "object") {
176
- for (const [hash, v] of Object.entries(raw)) {
177
- if (v && typeof v === "object") {
178
- map[hash] = v;
179
- }
180
- }
216
+ const { ok, map, error } = await this.loadProjectsRaw();
217
+ if (!ok) {
218
+ this.logger.warn(`projects.json 解析失败,保留上一有效 projects: ${error}`);
219
+ if (this.projectsCache)
220
+ return this.projectsCache;
221
+ return {}; // 首次即非法:给空 map(不写入覆盖磁盘)
181
222
  }
182
223
  this.projectsCache = map;
183
224
  this.projectsCacheStamp = stamp;
@@ -195,6 +236,7 @@ export class DataHome {
195
236
  projects[hash] = record;
196
237
  this.projectsCache = projects;
197
238
  await writeJsonAtomic(this.projectsPath, projects);
239
+ this.projectsCacheStamp = await this.fileStamp(this.projectsPath);
198
240
  return { hash, record };
199
241
  }
200
242
  async updateProjectVerify(hash, verify) {
@@ -205,6 +247,7 @@ export class DataHome {
205
247
  rec.verify = verify.map((v) => ({ name: v.name, cmd: v.cmd }));
206
248
  this.projectsCache = projects;
207
249
  await writeJsonAtomic(this.projectsPath, projects);
250
+ this.projectsCacheStamp = await this.fileStamp(this.projectsPath);
208
251
  }
209
252
  async projectByPath(p) {
210
253
  const norm = normPath(p);
@@ -30,9 +30,15 @@ export declare class TaskOrchestrator {
30
30
  run(): Promise<OrchestrateResult>;
31
31
  private aborted;
32
32
  /**
33
- * 取消/中断的终态落盘:cancel_requested → cancelled(持久化 errorType/cancelReason/finishedAt);
34
- * 无 cancel_requested(如 server 关闭或未显式取消的 abort)→ interrupted。
35
- * 由 orchestrator 在子进程真正关闭后调用,避免只返回游离 status。
33
+ * 统一超时终态(S2):落 failed(errorType=timeout) 并记录一次 timeout_killed 事件。
34
+ * 普通 runRes.timeout 与 abort guard 超时共用此路径,保证事件不重复、顺序固定。
35
+ */
36
+ private timeoutTerminal;
37
+ /**
38
+ * 取消/中断的终态落盘(S1):用独立 cancelRequestedAt/abortSource 判断来源,不依赖可选 reason。
39
+ * - 用户取消(cancelRequestedAt 已置位 / abortSource=user / 排队中)→ cancelled
40
+ * - server 关闭/EOF/未显式取消的中止 → interrupted
41
+ * - 超时 → failed(timeout) + timeout_killed(委托 timeoutTerminal)
36
42
  */
37
43
  private abortTerminal;
38
44
  private finish;
@@ -53,7 +53,8 @@ export class TaskOrchestrator {
53
53
  return this.finish("failed", "spawn", `agent 基础设施失败:${runRes.error ?? "未知"}(日志 ${runRes.logFile})`);
54
54
  }
55
55
  if (runRes.timeout) {
56
- return this.finish("failed", "timeout", `任务超时(${meta.taskTimeoutMs}ms),进程树已终止。日志 ${runRes.logFile}`);
56
+ // S2:统一超时终态 —— 落 failed(timeout) 并记录一次 timeout_killed
57
+ return this.timeoutTerminal(`任务超时(${meta.taskTimeoutMs}ms),进程树已终止。日志 ${runRes.logFile}`);
57
58
  }
58
59
  if (runRes.killed) {
59
60
  return this.abortTerminal();
@@ -105,31 +106,47 @@ export class TaskOrchestrator {
105
106
  return this.signal?.aborted ?? false;
106
107
  }
107
108
  /**
108
- * 取消/中断的终态落盘:cancel_requested → cancelled(持久化 errorType/cancelReason/finishedAt);
109
- * 无 cancel_requested(如 server 关闭或未显式取消的 abort)→ interrupted。
110
- * 由 orchestrator 在子进程真正关闭后调用,避免只返回游离 status。
109
+ * 统一超时终态(S2):落 failed(errorType=timeout) 并记录一次 timeout_killed 事件。
110
+ * 普通 runRes.timeout 与 abort guard 超时共用此路径,保证事件不重复、顺序固定。
111
111
  */
112
- async abortTerminal() {
112
+ async timeoutTerminal(reason) {
113
113
  if (this.done)
114
114
  return { status: this.meta.status, meta: this.meta, reason: this.meta.lastMessage };
115
115
  this.done = true;
116
116
  const meta = this.meta;
117
- if (meta.errorType === "timeout") {
118
- // 超时兜底 guard 已标 timeout → 落 failed(timeout),记录 timeout_killed 事件
119
- meta.lastMessage = meta.lastMessage || "任务超时,进程树已终止。";
120
- await this.deps.store.updateStatus(meta, "failed", meta.lastMessage);
121
- await this.deps.store.appendEvent(meta.taskId, "timeout_killed", "failed", meta.lastMessage).catch(() => { });
122
- return { status: "failed", meta, reason: meta.lastMessage, summary: meta.lastMessage };
117
+ meta.errorType = "timeout";
118
+ meta.abortSource = "timeout";
119
+ meta.lastMessage = reason;
120
+ // 事件顺序固定:先 failed 落盘(含 finishedAt),再记 timeout_killed
121
+ await this.deps.store.updateStatus(meta, "failed", reason);
122
+ await this.deps.store.appendEvent(meta.taskId, "timeout_killed", "failed", reason).catch(() => { });
123
+ return { status: "failed", meta, reason, summary: reason };
124
+ }
125
+ /**
126
+ * 取消/中断的终态落盘(S1):用独立 cancelRequestedAt/abortSource 判断来源,不依赖可选 reason。
127
+ * - 用户取消(cancelRequestedAt 已置位 / abortSource=user / 排队中)→ cancelled
128
+ * - server 关闭/EOF/未显式取消的中止 → interrupted
129
+ * - 超时 → failed(timeout) + timeout_killed(委托 timeoutTerminal)
130
+ */
131
+ async abortTerminal() {
132
+ if (this.done)
133
+ return { status: this.meta.status, meta: this.meta, reason: this.meta.lastMessage };
134
+ if (this.meta.abortSource === "timeout") {
135
+ return this.timeoutTerminal(this.meta.lastMessage || "任务超时,进程树已终止。");
123
136
  }
124
- const hadCancelRequest = Boolean(meta.cancelReason) || meta.status === "queued";
125
- const isCancelled = hadCancelRequest;
137
+ this.done = true;
138
+ const meta = this.meta;
139
+ // 用户取消判定:cancelRequestedAt 置位 或 显式 user abortSource 或 queued 阶段取消
140
+ const isCancelled = Boolean(meta.cancelRequestedAt) || meta.abortSource === "user" || meta.status === "queued";
126
141
  if (isCancelled) {
127
142
  meta.errorType = "cancelled";
143
+ meta.abortSource = "user";
128
144
  meta.lastMessage = meta.cancelReason ? `已取消:${meta.cancelReason}` : "已取消";
129
145
  await this.deps.store.updateStatus(meta, "cancelled", meta.lastMessage);
130
146
  }
131
147
  else {
132
148
  meta.errorType = "interrupted";
149
+ meta.abortSource = "shutdown";
133
150
  meta.lastMessage = "任务已中断(server 退出 / 父进程 EOF / 未显式取消的中止)。";
134
151
  await this.deps.store.updateStatus(meta, "interrupted", meta.lastMessage);
135
152
  }
@@ -26,7 +26,12 @@ export interface MetaBlockFields {
26
26
  message: string;
27
27
  errorType?: string;
28
28
  cancelReason?: string;
29
+ cancelRequestedAt?: string;
30
+ abortSource?: string;
29
31
  finishedAt?: string;
32
+ reportRound?: number;
33
+ verificationSource?: string;
34
+ latestVerificationVerdict?: string;
30
35
  }
31
36
  export type ToolResult = {
32
37
  content: {
@@ -25,7 +25,12 @@ export function metaFromTask(meta, extra) {
25
25
  message: meta.lastMessage ?? "",
26
26
  errorType: meta.errorType ?? undefined,
27
27
  cancelReason: meta.cancelReason,
28
+ cancelRequestedAt: meta.cancelRequestedAt,
29
+ abortSource: meta.abortSource,
28
30
  finishedAt: meta.finishedAt,
31
+ reportRound: meta.reportRound,
32
+ verificationSource: meta.verificationSource,
33
+ latestVerificationVerdict: meta.latestVerificationVerdict,
29
34
  ...extra,
30
35
  };
31
36
  }
@@ -126,9 +126,9 @@ function describeStatus(meta) {
126
126
  running: "运行中(agent 正在开发)",
127
127
  verify_start: "验收中(自动命令检查 + 代码分析)",
128
128
  fixing: "返修中(上一轮验收失败,agent 正在按反馈修改)",
129
- succeeded: "✅ 任务成功",
130
- failed: "❌ 任务失败",
131
- needs_attention: "⚠️ 需要人工介入(自动返修轮次已用尽或可修性存疑)",
129
+ succeeded: "[PASS] 任务成功",
130
+ failed: "[FAIL] 任务失败",
131
+ needs_attention: "[WARN] 需要人工介入(自动返修轮次已用尽或可修性存疑)",
132
132
  cancelled: "已取消",
133
133
  interrupted: "已中断(server 重启/退出)",
134
134
  };
@@ -249,8 +249,9 @@ function verifyTaskHandler(ctx) {
249
249
  });
250
250
  // round 分配:手动验收写入任务目录时不能覆盖已有 report-0.*,分配下一可用轮次
251
251
  const round = await nextReportRound(store, taskId);
252
+ const verifyTaskId = taskId ?? `vfy_${Date.now()}`;
252
253
  const req = {
253
- taskId: taskId ?? `vfy_${Date.now()}`,
254
+ taskId: verifyTaskId,
254
255
  projectPath,
255
256
  displayPath,
256
257
  taskText,
@@ -265,31 +266,57 @@ function verifyTaskHandler(ctx) {
265
266
  };
266
267
  const { report, passed } = await engine.runVerify(req);
267
268
  const head = passed
268
- ? `✅ 验收通过(手动验收):${report.checks.filter((c) => c.passed).length}/${report.checks.length} 项命令检查通过。`
269
- : `❌ 验收失败(手动验收):${report.checks.filter((c) => !c.passed && !c.skipped).length} 项检查未通过。`;
269
+ ? `[PASS] 手动验收通过(reportRound ${round}):${report.checks.filter((c) => c.passed).length}/${report.checks.length} 项检查通过。`
270
+ : `[FAIL] 手动验收失败(reportRound ${round}):${report.checks.filter((c) => !c.passed && !c.skipped).length} 项检查未通过。`;
270
271
  const changed = report.analysis.changedFiles.length + report.analysis.untrackedFiles.length;
271
- const meta = {
272
- taskId: req.taskId,
273
- status: passed ? "succeeded" : "failed",
274
- projectPath,
275
- displayPath,
276
- agentId: "manual-verify",
277
- task: taskText ?? "(手动验收)",
278
- autoVerify: true,
279
- autoFixRounds: 0,
280
- taskTimeoutMs: 0,
281
- round: 0,
282
- roundsUsed: 0,
283
- createdAt: report.startedAt,
284
- updatedAt: report.finishedAt,
285
- lastMessage: head,
286
- changedFiles: [...report.analysis.changedFiles, ...report.analysis.untrackedFiles],
287
- diffstat: `+${report.analysis.diffstat.totalAdd} -${report.analysis.diffstat.totalDel}`,
288
- reportMd: report.files.md,
289
- reportJson: report.files.json,
290
- };
291
- const detailLines = [`${head}`, `变更 ${changed} 个文件,diffstat ${meta.diffstat}。`, `报告:${report.files.md}`, `JSON:${report.files.json}`];
292
- return formatToolResult(detailLines.join("\n"), metaFromTask(meta));
272
+ const diffstat = `+${report.analysis.diffstat.totalAdd} -${report.analysis.diffstat.totalDel}`;
273
+ // S4:taskId 模式下更新并持久化原任务元数据(保留原 agentId,不改任务终态;新增单独验收结论字段)。
274
+ let resultMeta;
275
+ if (taskId) {
276
+ const real = await manager.getMeta(taskId);
277
+ if (!real)
278
+ return errorResult(`任务不存在: ${taskId}`);
279
+ real.reportRound = round;
280
+ real.verificationSource = "manual";
281
+ real.latestVerificationVerdict = passed ? "passed" : "failed";
282
+ real.reportMd = report.files.md;
283
+ real.reportJson = report.files.json;
284
+ real.lastMessage = head;
285
+ real.changedFiles = [...report.analysis.changedFiles, ...report.analysis.untrackedFiles];
286
+ real.diffstat = diffstat;
287
+ real.updatedAt = report.finishedAt;
288
+ await manager.persistMetaUpdate(real);
289
+ resultMeta = real;
290
+ }
291
+ else {
292
+ // 独立 projectPath 验收:创建并持久化独立 vfy 记录
293
+ resultMeta = {
294
+ taskId: verifyTaskId,
295
+ status: passed ? "succeeded" : "failed",
296
+ projectPath,
297
+ displayPath,
298
+ agentId: "manual-verify",
299
+ task: taskText ?? "(手动验收)",
300
+ autoVerify: true,
301
+ autoFixRounds: 0,
302
+ taskTimeoutMs: 0,
303
+ round: 0,
304
+ roundsUsed: 0,
305
+ reportRound: round,
306
+ verificationSource: "manual",
307
+ latestVerificationVerdict: passed ? "passed" : "failed",
308
+ createdAt: report.startedAt,
309
+ updatedAt: report.finishedAt,
310
+ lastMessage: head,
311
+ changedFiles: [...report.analysis.changedFiles, ...report.analysis.untrackedFiles],
312
+ diffstat,
313
+ reportMd: report.files.md,
314
+ reportJson: report.files.json,
315
+ };
316
+ await manager.persistMetaUpdate(resultMeta);
317
+ }
318
+ const detailLines = [`${head}`, `变更 ${changed} 个文件,diffstat ${resultMeta.diffstat}。`, `报告:${report.files.md}`, `JSON:${report.files.json}`];
319
+ return formatToolResult(detailLines.join("\n"), metaFromTask(resultMeta));
293
320
  };
294
321
  }
295
322
  function reworkTaskHandler(ctx) {
@@ -320,7 +347,7 @@ function getProfilesHandler(ctx) {
320
347
  const rows = [];
321
348
  for (const id of ids) {
322
349
  const r = await registry.resolve(id, true);
323
- const mark = r.ok ? "✅ 可用" : "❌ 不可用";
350
+ const mark = r.ok ? "[PASS] 可用" : "[FAIL] 不可用";
324
351
  rows.push(`${mark}\t${id}\t${r.displayName}\t${r.message}${r.discovered ? ` [探测来源: ${r.discovered.source}]` : ""}`);
325
352
  }
326
353
  const head = "Agent 适配与可执行探测结果(列: 可用 / agentId / 名称 / 说明)";
package/dist/server.js CHANGED
@@ -15,6 +15,7 @@ import { makeHandlers } from "./mcp/handlers.js";
15
15
  import { TOOL_DEFS } from "./mcp/tools.js";
16
16
  import { Logger as LoggerCtor } from "./util/log.js";
17
17
  import { skillSelfInstall } from "./util/skill-install.js";
18
+ import { MCP_SERVER_VERSION } from "./version.generated.js";
18
19
  export async function buildServer(opts = {}) {
19
20
  const home = opts.home ?? resolveDataHome();
20
21
  const logger = opts.logger ?? (await LoggerCtor.create(path.join(home, "logs")));
@@ -42,7 +43,7 @@ export async function buildServer(opts = {}) {
42
43
  defaultAutoVerify: true,
43
44
  defaultAutoFixRounds: 0,
44
45
  });
45
- const server = new McpServer({ name: "tianshu-mcp", version: "0.1.0" }, {
46
+ const server = new McpServer({ name: "tianshu-mcp", version: MCP_SERVER_VERSION }, {
46
47
  capabilities: { tools: {} },
47
48
  instructions: "tianshu-mcp:调度外部 AI-Agent(codex/zcode)完成 项目开发 → 验收 → 返修 闭环。工具返回文本 + ---tianshu-mcp-meta--- JSON 块。run_task 是异步的:先拿 taskId 再用 query_task 轮询。",
48
49
  });
@@ -43,6 +43,8 @@ export declare class TaskManager {
43
43
  get activeCount(): number;
44
44
  getMaxRunning(): number;
45
45
  getMeta(taskId: string): Promise<TaskMeta | null>;
46
+ /** S4:外部对终态任务元数据的更新(如手动 verify 更新报告指针/轮次)——写快照并同步内存 map */
47
+ persistMetaUpdate(meta: TaskMeta): Promise<void>;
46
48
  listTasks(filter?: {
47
49
  projectPath?: string;
48
50
  status?: string;
@@ -66,7 +68,7 @@ export declare class TaskManager {
66
68
  }>;
67
69
  /** server 退出:终止全部活动任务并标 interrupted(排队中任务也归档) */
68
70
  shutdownInterrupt(): Promise<void>;
69
- /** 有界等待子进程真正关闭后落 interrupted */
71
+ /** 有界等待子进程真正关闭后落 interrupted(server 关闭路径,不得误记为 cancelled) */
70
72
  private persistInterrupted;
71
73
  private enqueue;
72
74
  /** 该项目当前是否有 running 任务(每项目串行闸) */
@@ -30,6 +30,7 @@ export class TaskManager {
30
30
  for (const meta of legacy) {
31
31
  this.tasks.set(meta.taskId, meta);
32
32
  meta.errorType = "interrupted";
33
+ meta.abortSource = "shutdown";
33
34
  meta.lastMessage = "server 重启遗留(启动时归档,不续跑)。";
34
35
  await this.store.updateStatus(meta, "interrupted", "server 重启遗留归档");
35
36
  }
@@ -45,6 +46,11 @@ export class TaskManager {
45
46
  async getMeta(taskId) {
46
47
  return this.tasks.get(taskId) ?? (await this.store.readSnapshot(taskId));
47
48
  }
49
+ /** S4:外部对终态任务元数据的更新(如手动 verify 更新报告指针/轮次)——写快照并同步内存 map */
50
+ async persistMetaUpdate(meta) {
51
+ this.tasks.set(meta.taskId, meta);
52
+ await this.store.writeSnapshot(meta);
53
+ }
48
54
  async listTasks(filter) {
49
55
  let metas = await this.store.listTaskSnapshots();
50
56
  if (filter?.projectPath) {
@@ -123,8 +129,14 @@ export class TaskManager {
123
129
  }
124
130
  meta.status = "cancelled";
125
131
  meta.errorType = "cancelled";
132
+ meta.abortSource = "user";
133
+ meta.cancelRequestedAt = nowIso();
126
134
  meta.cancelReason = reason;
135
+ meta.finishedAt = meta.cancelRequestedAt;
136
+ meta.updatedAt = meta.cancelRequestedAt;
127
137
  meta.lastMessage = reason ? `已取消(排队中):${reason}` : "已取消(排队中)";
138
+ // 追加 cancel_requested 事件后再落 cancelled,保证事件流完整
139
+ await this.store.appendEvent(meta.taskId, "cancel_requested", "queued", reason ? `收到取消请求:${reason}` : "收到取消请求(无 reason)");
128
140
  await this.store.updateStatus(meta, "cancelled", meta.lastMessage);
129
141
  return { found: true };
130
142
  }
@@ -154,6 +166,7 @@ export class TaskManager {
154
166
  if (meta.status === "queued") {
155
167
  meta.status = "interrupted";
156
168
  meta.errorType = "interrupted";
169
+ meta.abortSource = "shutdown";
157
170
  meta.lastMessage = "server 退出,排队中任务已归档";
158
171
  await this.store.updateStatus(meta, "interrupted", meta.lastMessage).catch(() => { });
159
172
  void taskId;
@@ -163,7 +176,7 @@ export class TaskManager {
163
176
  this.runningCount = 0;
164
177
  this.queue.clear();
165
178
  }
166
- /** 有界等待子进程真正关闭后落 interrupted */
179
+ /** 有界等待子进程真正关闭后落 interrupted(server 关闭路径,不得误记为 cancelled) */
167
180
  async persistInterrupted(meta) {
168
181
  const deadline = Date.now() + 2000;
169
182
  for (;;) {
@@ -176,6 +189,7 @@ export class TaskManager {
176
189
  if (ACTIVE_STATUSES.includes(meta.status)) {
177
190
  meta.status = "interrupted";
178
191
  meta.errorType = "interrupted";
192
+ meta.abortSource = "shutdown";
179
193
  meta.lastMessage = "server 退出,进程已终止";
180
194
  await this.store.updateStatus(meta, "interrupted", meta.lastMessage).catch(() => { });
181
195
  }
@@ -248,6 +262,7 @@ export class TaskManager {
248
262
  if (m && ACTIVE_STATUSES.includes(m.status)) {
249
263
  this.logger.warn(`任务 ${meta.taskId} 超过任务级超时兜底(${meta.taskTimeoutMs}+${KILL_GRACE_MS}ms),标记 timeout 并 abort`);
250
264
  m.errorType = "timeout";
265
+ m.abortSource = "timeout";
251
266
  m.lastMessage = `任务超时兜底触发(${meta.taskTimeoutMs}ms + ${KILL_GRACE_MS}ms grace)。`;
252
267
  ac.abort();
253
268
  }
@@ -21,7 +21,7 @@ export declare class TaskStore {
21
21
  readSnapshot(taskId: string): Promise<TaskMeta | null>;
22
22
  updateStatus(meta: TaskMeta, status: TaskStatus, detail?: string, eventName?: TaskEvent["event"]): Promise<void>;
23
23
  addNote(meta: TaskMeta, detail: string): Promise<void>;
24
- /** cancel_requested 事件 + cancelReason 落盘(供后续终态判定 cancelled vs interrupted) */
24
+ /** cancel_requested 事件 + 独立取消意图字段落盘(S1:不依赖可选 reason 判断取消来源) */
25
25
  markCancelRequested(meta: TaskMeta, reason?: string): Promise<void>;
26
26
  saveReport(taskId: string, report: VerifyReport): Promise<void>;
27
27
  /** 启动扫描:找出 running/interrupted/queued 遗留,供归档 */
@@ -102,11 +102,13 @@ export class TaskStore {
102
102
  await this.appendEvent(meta.taskId, "note", meta.status, detail);
103
103
  await this.writeSnapshot(meta);
104
104
  }
105
- /** cancel_requested 事件 + cancelReason 落盘(供后续终态判定 cancelled vs interrupted) */
105
+ /** cancel_requested 事件 + 独立取消意图字段落盘(S1:不依赖可选 reason 判断取消来源) */
106
106
  async markCancelRequested(meta, reason) {
107
107
  meta.cancelReason = reason;
108
- meta.updatedAt = nowIso();
109
- await this.appendEvent(meta.taskId, "cancel_requested", meta.status, reason ? `收到取消请求:${reason}` : "收到取消请求");
108
+ meta.cancelRequestedAt = nowIso();
109
+ meta.abortSource = "user";
110
+ meta.updatedAt = meta.cancelRequestedAt;
111
+ await this.appendEvent(meta.taskId, "cancel_requested", meta.status, reason ? `收到取消请求:${reason}` : "收到取消请求(无 reason)");
110
112
  await this.writeSnapshot(meta);
111
113
  }
112
114
  /* ---------- 报告 ---------- */
@@ -98,8 +98,18 @@ export interface TaskMeta {
98
98
  reportMd?: string;
99
99
  reportJson?: string;
100
100
  cancelReason?: string;
101
+ /** 用户取消意图的独立结构化标记(S1):一旦用户调用 cancel_task 即置位,不依赖可选 reason 推断 */
102
+ cancelRequestedAt?: string;
103
+ /** 中断来源:user=用户取消;shutdown=server 关闭/EOF;timeout=任务超时;internal=内部错误 */
104
+ abortSource?: "user" | "shutdown" | "timeout" | "internal";
101
105
  /** rework_task 注入的追加指示(仅作用于下一轮 agent) */
102
106
  reworkFeedback?: string;
107
+ /** S4:最近一次验收的报告轮次(0-based,reportRound)——区别于 agent roundsUsed */
108
+ reportRound?: number;
109
+ /** S4:最近一次验收来源:run_task 自动 / verify_task 手动;不覆盖 agentId */
110
+ verificationSource?: "auto" | "manual";
111
+ /** S4:最近一次手动验收结论(不改变 agent 任务终态时单独记录) */
112
+ latestVerificationVerdict?: "passed" | "failed";
103
113
  }
104
114
  /** manager 记录任务所需最小信息(内存态),与 TaskMeta 解耦 */
105
115
  export interface TaskRecord {
@@ -30,8 +30,19 @@ export async function analyzeChanges(opts) {
30
30
  // 基线前已存在的脏文件集合
31
31
  const preChanged = new Set(baseline.preExistingChanged);
32
32
  const preUntracked = new Set(baseline.preExistingUntracked);
33
- // 相对基线 ref 的已跟踪差异(含 agent 提交前移 + 工作树)
34
- const { numstat, changedLinesPerFile } = await diffSinceBaseline(opts.projectPath, baseline.head);
33
+ // S3:排除"基线前脏且任务期间内容未变化"的 tracked 文件(避免把用户原有改动归因给 agent)。
34
+ // 对每个基线前脏文件,比较基线内容 hash 与当前工作树内容 hash;一致 = agent 未触碰 → 完全排除。
35
+ const unchangedPreDirty = new Set();
36
+ for (const f of [...preChanged]) {
37
+ const baseHash = baseDirtyHash(baseline, f);
38
+ if (!baseHash)
39
+ continue; // 无基线 hash(超大/删除等)不排除,交给 diff 判断
40
+ const nowHash = await fileHashQuick(path.join(opts.projectPath, f));
41
+ if (nowHash === baseHash)
42
+ unchangedPreDirty.add(f);
43
+ }
44
+ // 相对基线 ref 的已跟踪差异(含 agent 提交前移 + 工作树);排除未变化的预脏文件
45
+ const { numstat, changedLinesPerFile } = await diffSinceBaseline(opts.projectPath, baseline.head, unchangedPreDirty);
35
46
  const perFile = [];
36
47
  const trackedSeen = new Set();
37
48
  // 已跟踪变更:diffSinceBaseline 以 baseline.head 为边界(含 agent 提交前移),净增删为 0 的剔除。
@@ -47,7 +58,7 @@ export async function analyzeChanges(opts) {
47
58
  continue;
48
59
  const full = path.join(opts.projectPath, f);
49
60
  if (preUntracked.has(f)) {
50
- const baseHash = baseline.preUntrackedHashes[f];
61
+ const baseHash = baseDirtyHash(baseline, f);
51
62
  const nowHash = await fileHashQuick(full);
52
63
  if (baseHash && nowHash && baseHash === nowHash)
53
64
  continue; // 基线前已有且未变:非任务改动
@@ -79,7 +90,7 @@ export async function analyzeChanges(opts) {
79
90
  for (const f of parsed.untracked) {
80
91
  if (preUntracked.has(f)) {
81
92
  const full = path.join(opts.projectPath, f);
82
- const baseHash = baseline.preUntrackedHashes[f];
93
+ const baseHash = baseDirtyHash(baseline, f);
83
94
  const nowHash = await fileHashQuick(full);
84
95
  if (baseHash && nowHash && baseHash === nowHash)
85
96
  continue;
@@ -102,7 +113,7 @@ export async function analyzeChanges(opts) {
102
113
  return true;
103
114
  // 基线前已有:内容变了才作为本轮新增未跟踪
104
115
  const full = path.join(opts.projectPath, f);
105
- const b = baseline.preUntrackedHashes[f];
116
+ const b = baseDirtyHash(baseline, f);
106
117
  const n = fileHashSync(full);
107
118
  return !(b && n && b === n);
108
119
  });
@@ -159,6 +170,10 @@ async function fileHashQuick(p) {
159
170
  return null;
160
171
  }
161
172
  }
173
+ /** 基线脏文件内容 hash:优先新字段 preDirtyHashes,兼容旧快照回退 preUntrackedHashes */
174
+ function baseDirtyHash(baseline, f) {
175
+ return baseline.preDirtyHashes?.[f] ?? baseline.preUntrackedHashes?.[f];
176
+ }
162
177
  function fileHashSync(p) {
163
178
  try {
164
179
  const buf = fs.readFileSync(p);
@@ -6,7 +6,9 @@ export interface Baseline {
6
6
  /** 相对动工前 HEAD 的已跟踪变更(基线前已存在) */
7
7
  preExistingChanged: string[];
8
8
  preExistingUntracked: string[];
9
- /** 基线前未跟踪文件的内容 hash(便于区分 agent 是否改动) */
9
+ /** 基线前脏文件(已跟踪 staged/unstaged + 未跟踪)的内容 hash:区分基线已有内容与 agent 后续改动 */
10
+ preDirtyHashes: Record<string, string>;
11
+ /** 基线前未跟踪文件的内容 hash(兼容旧字段,逻辑并入 preDirtyHashes) */
10
12
  preUntrackedHashes: Record<string, string>;
11
13
  capturedAt: string;
12
14
  message: string;
@@ -40,9 +42,10 @@ export declare function gitRefExists(projectPath: string, ref: string): Promise<
40
42
  * 相对基线 ref 的工作树差异 numstat:
41
43
  * - 若当前 HEAD === baseline.head:`git diff baseline.head`(含 staged+unstaged)
42
44
  * - 若 agent 创建了新提交(HEAD 前移):`git diff baseline.head..HEAD` 拿到已提交变更,再叠加工作树 diff
45
+ * excludeUnchangedPreDirty:基线前脏文件集合(按内容 hash 判定未变的应被排除,避免把用户原有改动归因给 agent,S3)。
43
46
  * 返回已跟踪文件相对基线的全部变更。
44
47
  */
45
- export declare function diffSinceBaseline(projectPath: string, baseRef: string | null): Promise<{
48
+ export declare function diffSinceBaseline(projectPath: string, baseRef: string | null, excludeFiles?: ReadonlySet<string>): Promise<{
46
49
  numstat: NumstatRow[];
47
50
  changedLinesPerFile: Map<string, string[]>;
48
51
  }>;
@@ -16,6 +16,7 @@ export async function captureBaseline(projectPath) {
16
16
  dirtyFiles: [],
17
17
  preExistingChanged: [],
18
18
  preExistingUntracked: [],
19
+ preDirtyHashes: {},
19
20
  preUntrackedHashes: {},
20
21
  capturedAt: new Date().toISOString(),
21
22
  message: "非 git 仓库:变更清单/diffstat 不可用(验收仍执行命令检查)。",
@@ -26,12 +27,20 @@ export async function captureBaseline(projectPath) {
26
27
  const statusRes = await gitOk(projectPath, ["status", "--porcelain", "--untracked-files=all"]);
27
28
  const porcelain = statusRes.ok ? statusRes.stdout.split("\n").filter((s) => s.trim().length > 0) : [];
28
29
  const parsed = parsePorcelain(porcelain);
30
+ // 记录全部预脏文件(已跟踪 staged/unstaged + 未跟踪)的内容 hash
31
+ const dirtyPaths = [...parsed.changed, ...parsed.untracked];
29
32
  const hashes = {};
30
- for (const f of parsed.untracked) {
33
+ for (const f of dirtyPaths) {
31
34
  const h = await fileHash(path.join(projectPath, f));
32
35
  if (h)
33
36
  hashes[f] = h;
34
37
  }
38
+ const preUntrackedHashes = {};
39
+ for (const f of parsed.untracked) {
40
+ const h = await fileHash(path.join(projectPath, f));
41
+ if (h)
42
+ preUntrackedHashes[f] = h;
43
+ }
35
44
  return {
36
45
  isRepo: true,
37
46
  head,
@@ -39,7 +48,8 @@ export async function captureBaseline(projectPath) {
39
48
  dirtyFiles: porcelain,
40
49
  preExistingChanged: parsed.changed,
41
50
  preExistingUntracked: parsed.untracked,
42
- preUntrackedHashes: hashes,
51
+ preDirtyHashes: hashes,
52
+ preUntrackedHashes,
43
53
  capturedAt: new Date().toISOString(),
44
54
  message: head
45
55
  ? `基线 HEAD=${head},工作树${porcelain.length > 0 ? `脏(${porcelain.length} 项)` : "干净"}。`
@@ -117,14 +127,20 @@ export async function gitRefExists(projectPath, ref) {
117
127
  * 相对基线 ref 的工作树差异 numstat:
118
128
  * - 若当前 HEAD === baseline.head:`git diff baseline.head`(含 staged+unstaged)
119
129
  * - 若 agent 创建了新提交(HEAD 前移):`git diff baseline.head..HEAD` 拿到已提交变更,再叠加工作树 diff
130
+ * excludeUnchangedPreDirty:基线前脏文件集合(按内容 hash 判定未变的应被排除,避免把用户原有改动归因给 agent,S3)。
120
131
  * 返回已跟踪文件相对基线的全部变更。
121
132
  */
122
- export async function diffSinceBaseline(projectPath, baseRef) {
133
+ export async function diffSinceBaseline(projectPath, baseRef, excludeFiles) {
134
+ const skip = (f) => (excludeFiles ? excludeFiles.has(f) : false);
123
135
  if (!baseRef || !(await gitRefExists(projectPath, baseRef))) {
124
136
  // 基线 ref 无效/缺失:退回当前 HEAD(仅跟踪工作树 + 已提交到 HEAD 的变化)
125
- const n = await gitNumstat(projectPath, "HEAD");
126
- const lines = await gitChangedLines(projectPath, "HEAD");
127
- return { numstat: n, changedLinesPerFile: lines };
137
+ const allN = await gitNumstat(projectPath, "HEAD");
138
+ const n = allN.filter((r) => !skip(r.file));
139
+ const allLines = await gitChangedLines(projectPath, "HEAD");
140
+ for (const f of [...allLines.keys()])
141
+ if (skip(f))
142
+ allLines.delete(f);
143
+ return { numstat: n, changedLinesPerFile: allLines };
128
144
  }
129
145
  const headNow = (await gitOk(projectPath, ["rev-parse", "HEAD"])).stdout.trim();
130
146
  // 工作树中相对基线的变更 = (baseline→工作树),含 commit 前移部分
@@ -134,9 +150,13 @@ export async function diffSinceBaseline(projectPath, baseRef) {
134
150
  const worktree = parseNumstat(w.stdout);
135
151
  const byFile = new Map();
136
152
  for (const row of committed) {
153
+ if (skip(row.file))
154
+ continue;
137
155
  byFile.set(row.file, { ...row });
138
156
  }
139
157
  for (const row of worktree) {
158
+ if (skip(row.file))
159
+ continue;
140
160
  const prev = byFile.get(row.file);
141
161
  if (prev) {
142
162
  // 合并:同文件在基线→HEAD 与 HEAD→工作树都有改动
@@ -161,7 +181,7 @@ export async function diffSinceBaseline(projectPath, baseRef) {
161
181
  cur = fh[1] ?? null;
162
182
  continue;
163
183
  }
164
- if (line.startsWith("+") && !line.startsWith("+++") && cur) {
184
+ if (line.startsWith("+") && !line.startsWith("+++") && cur && !skip(cur)) {
165
185
  const arr = toMap.get(cur) ?? [];
166
186
  arr.push(line.slice(1));
167
187
  toMap.set(cur, arr);
@@ -20,7 +20,7 @@ export function reportToMd(report) {
20
20
  L.push(`- 项目: ${report.projectPath}`);
21
21
  L.push(`- 开始: ${report.startedAt}`);
22
22
  L.push(`- 结束: ${report.finishedAt}`);
23
- L.push(`- 结论: **${report.verdict === "passed" ? "通过 ✅" : "失败 ❌"}**`);
23
+ L.push(`- 结论: **[${report.verdict === "passed" ? "PASS" : "FAIL"}]**`);
24
24
  L.push("", "## 自动命令检查", "");
25
25
  if (report.checks.length === 0)
26
26
  L.push("(无可运行的检查项)", "");
@@ -29,7 +29,7 @@ export function reportToMd(report) {
29
29
  L.push(`- [${mark}] ${c.name} — \`${c.cmd}\`${c.durationMs >= 0 ? ` (${c.durationMs}ms)` : ""}${c.skipped && c.reason ? ` — ${c.reason}` : ""}`);
30
30
  if (!c.passed && !c.skipped) {
31
31
  if (c.timeout)
32
- L.push(` - ⚠️ 超时(${c.durationMs}ms)`);
32
+ L.push(` - [WARN] 超时(${c.durationMs}ms)`);
33
33
  L.push(` - 退出码: ${c.exitCode ?? "n/a"}`);
34
34
  if (c.outputTail) {
35
35
  L.push(" - 输出尾部:", "", " ```");
@@ -68,12 +68,12 @@ export function reportToMd(report) {
68
68
  if (sig.secretLike)
69
69
  L.push(`- 疑似密钥/令牌形态 ${sig.secretLike} 处`);
70
70
  for (const f of a.bigFileChanges)
71
- L.push(`- ⚠️ 超大单文件改动: ${f}`);
71
+ L.push(`- [WARN] 超大单文件改动: ${f}`);
72
72
  for (const w of a.warnings)
73
- L.push(`- ⚠️ ${w}`);
73
+ L.push(`- [WARN] ${w}`);
74
74
  }
75
75
  for (const n of a.notes)
76
- L.push(`- 💡 ${n}`);
76
+ L.push(`- [INFO] ${n}`);
77
77
  L.push("", "---", "", report.message, "");
78
78
  return L.join("\n");
79
79
  }
@@ -83,8 +83,8 @@ export function summarizeReport(report) {
83
83
  const fail = report.checks.filter((c) => !c.passed && !c.skipped);
84
84
  const skip = report.checks.filter((c) => c.skipped).length;
85
85
  const head = report.passed
86
- ? `✅ 验收通过(第 ${report.round} 轮):${passCount}/${report.checks.length} 项命令检查通过${skip ? `,${skip} 项跳过` : ""}。`
87
- : `❌ 验收失败(第 ${report.round} 轮):${fail.length} 项检查未通过${skip ? `,${skip} 项跳过` : ""}。`;
86
+ ? `[PASS] 验收通过(第 ${report.round} 轮):${passCount}/${report.checks.length} 项命令检查通过${skip ? `,${skip} 项跳过` : ""}。`
87
+ : `[FAIL] 验收失败(第 ${report.round} 轮):${fail.length} 项检查未通过${skip ? `,${skip} 项跳过` : ""}。`;
88
88
  const lines = [head, ""];
89
89
  for (const c of report.checks) {
90
90
  const st = c.skipped ? "SKIP" : c.passed ? "PASS" : "FAIL";
@@ -0,0 +1,6 @@
1
+ /**
2
+ * 版本单一来源(S4):由 build 前的脚本从 package.json 注入。
3
+ * 若直接源码运行(未注入),回退读 package.json;再失败给占位。
4
+ * 本文件由 scripts/sync-version.mjs 在每次 build 前重新生成。
5
+ */
6
+ export declare const MCP_SERVER_VERSION = "0.1.4";
@@ -0,0 +1,7 @@
1
+ /**
2
+ * 版本单一来源(S4):由 build 前的脚本从 package.json 注入。
3
+ * 若直接源码运行(未注入),回退读 package.json;再失败给占位。
4
+ * 本文件由 scripts/sync-version.mjs 在每次 build 前重新生成。
5
+ */
6
+ // generated: 勿手改 —— 运行 `npm run build` 自动同步
7
+ export const MCP_SERVER_VERSION = "0.1.4";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tianshu-mcp",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "天枢 × AI-Agent 编排 MCP server —— 由天枢调度本 server 去驱动外部 AI-Agent CLI(Codex/Zcode/TraeWork)完成 项目开发 → 验收 → 失败返修 → 再验收 的闭环。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,7 +22,8 @@
22
22
  "node": ">=20"
23
23
  },
24
24
  "scripts": {
25
- "build": "tsc -p tsconfig.build.json",
25
+ "sync-version": "node scripts/sync-version.mjs",
26
+ "build": "npm run sync-version && tsc -p tsconfig.build.json",
26
27
  "dev": "tsx src/index.ts",
27
28
  "start": "node dist/index.js",
28
29
  "test": "vitest run",
@@ -47,7 +48,8 @@
47
48
  "prettier": "^3.4.2",
48
49
  "tsx": "^4.19.2",
49
50
  "typescript": "^5.7.2",
50
- "vitest": "^2.1.8"
51
+ "vite": "^6.4.3",
52
+ "vitest": "^3.2.7"
51
53
  },
52
54
  "keywords": [
53
55
  "mcp",