zen-gitsync 2.16.51 → 2.17.1

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.
Files changed (119) hide show
  1. package/package.json +12 -2
  2. package/src/cli/ai/agent.js +2 -2
  3. package/src/cli/ai/images.js +5 -3
  4. package/src/cli/ai/modelSetup.js +1 -1
  5. package/src/cli/ai/sessionStore.js +4 -6
  6. package/src/config.js +178 -39
  7. package/src/gitCommit.js +8 -0
  8. package/src/ui/public/assets/AgentView-BsN9y4Kg.css +1 -0
  9. package/src/ui/public/assets/AgentView-YDATCFcA.js +4 -0
  10. package/src/ui/public/assets/AppVersionBadge-Bw4-tHkL.js +2 -0
  11. package/src/ui/public/assets/{AppVersionBadge-CJrDr7GV.css → AppVersionBadge-D5tJzJfa.css} +1 -1
  12. package/src/ui/public/assets/BranchSelector-BZTHIIaF.js +1 -0
  13. package/src/ui/public/assets/BranchSelector-T_rJm-JY.css +1 -0
  14. package/src/ui/public/assets/CommitForm-DKedPnu-.js +6 -0
  15. package/src/ui/public/assets/CommitForm-Oij8R6P-.css +1 -0
  16. package/src/ui/public/assets/CommonDialog-C_RjF9Mp.css +1 -0
  17. package/src/ui/public/assets/CommonDialog-OCekbVQM.js +1 -0
  18. package/src/ui/public/assets/EditorView-BgA-_8WV.css +1 -0
  19. package/src/ui/public/assets/EditorView-C5O3ihKq.js +1 -0
  20. package/src/ui/public/assets/FlowExecutionViewer-B5kh7VxS.css +1 -0
  21. package/src/ui/public/assets/FlowExecutionViewer-vIvDP419.js +1 -0
  22. package/src/ui/public/assets/FlowOrchestrationWorkspace-BAQDU9Jd.css +1 -0
  23. package/src/ui/public/assets/FlowOrchestrationWorkspace-SlVXZt60.js +21 -0
  24. package/src/ui/public/assets/LogList-DlPJsPy_.js +7 -0
  25. package/src/ui/public/assets/LogList-DlWwSZet.css +1 -0
  26. package/src/ui/public/assets/MindmapView-B1Go82pJ.js +1 -0
  27. package/src/ui/public/assets/MindmapView-CS2t30Ko.css +1 -0
  28. package/src/ui/public/assets/MonitorView-CphDOlQu.css +1 -0
  29. package/src/ui/public/assets/MonitorView-GQ34uXPs.js +1 -0
  30. package/src/ui/public/assets/ProjectStartupButton-0UNF9CeP.js +1 -0
  31. package/src/ui/public/assets/ProjectStartupButton-B7zvqRj_.css +1 -0
  32. package/src/ui/public/assets/RemoteManagerDialog-D51q3x7T.js +1 -0
  33. package/src/ui/public/assets/RemoteManagerDialog-bpwaVAvX.css +1 -0
  34. package/src/ui/public/assets/RemoteRepoCard-Bin9d3GZ.js +1 -0
  35. package/src/ui/public/assets/RemoteRepoCard-CDykPlPj.css +1 -0
  36. package/src/ui/public/assets/SourceMapView-DG_a_076.css +1 -0
  37. package/src/ui/public/assets/SourceMapView-OxlKvnEc.js +3 -0
  38. package/src/ui/public/assets/{SvgIcon-CDNsk4-Q.css → SvgIcon-BQ6bR7l0.css} +1 -1
  39. package/src/ui/public/assets/SvgIcon-BpKq_phu.js +1 -0
  40. package/src/ui/public/assets/UserInputNode-D2EDkZ_H.js +1 -0
  41. package/src/ui/public/assets/UserInputNode-DDtvv9Iq.css +1 -0
  42. package/src/ui/public/assets/WorkbenchView-DWMdGe8g.css +1 -0
  43. package/src/ui/public/assets/WorkbenchView-DyhIOjGD.js +13 -0
  44. package/src/ui/public/assets/{_plugin-vue_export-helper-N7dqbp6x.js → _plugin-vue_export-helper-BaSe6lmR.js} +3 -3
  45. package/src/ui/public/assets/configStore-CyAQ0a-U.js +1 -0
  46. package/src/ui/public/assets/element-plus-H7kW-grr.js +19 -0
  47. package/src/ui/public/assets/{flow-mindmap-wqtwJcDW.js → flow-mindmap-DEwSVurC.js} +12 -12
  48. package/src/ui/public/assets/index-BXdqTR9A.css +1 -0
  49. package/src/ui/public/assets/index-SjRTI8Of.js +46 -0
  50. package/src/ui/public/assets/office-docx-C67al1tG.js +31 -0
  51. package/src/ui/public/assets/{office-excel-5lGzd5Yy.js → office-excel-ffLec5k6.js} +1 -1
  52. package/src/ui/public/assets/office-pptx-vRGbnHIO.js +60 -0
  53. package/src/ui/public/assets/toolsStore-BcJP9HmK.js +1 -0
  54. package/src/ui/public/assets/{vendor-C6ntdXkR.js → vendor-Dwln8rZH.js} +39 -46
  55. package/src/ui/public/assets/vue-flow-BfBE1dKN.js +39 -0
  56. package/src/ui/public/index.html +13 -13
  57. package/src/ui/server/index.js +9 -0
  58. package/src/ui/server/routes/config.js +9 -6
  59. package/src/ui/server/routes/git/aiDiffSummary.js +2 -2
  60. package/src/ui/server/routes/gitOps.js +63 -7
  61. package/src/ui/server/routes/workbench/agentSessionStore.js +6 -9
  62. package/src/ui/server/routes/workbench/attachmentUtils.js +93 -0
  63. package/src/ui/server/routes/workbench/attachmentUtils.test.js +100 -0
  64. package/src/ui/server/routes/workbench/envContext.js +137 -0
  65. package/src/ui/server/routes/workbench/envContext.test.js +178 -0
  66. package/src/ui/server/routes/workbench/index.js +210 -8
  67. package/src/ui/server/routes/workbench/orchestratorStore.js +6 -0
  68. package/src/ui/server/routes/workbench/pathParity.test.js +73 -0
  69. package/src/ui/server/routes/workbench/projectRegistry.js +37 -16
  70. package/src/ui/server/routes/workbench/projectRegistry.test.js +62 -17
  71. package/src/ui/server/routes/workbench/shared.js +7 -2
  72. package/src/ui/server/routes/workbench/targetResolver.js +260 -0
  73. package/src/ui/server/routes/workbench/targetResolver.test.js +224 -0
  74. package/src/ui/server/routes/workbench/taskRunner.js +42 -0
  75. package/src/ui/server/utils/gitExitCode.js +116 -0
  76. package/src/ui/server/utils/gitExitCode.test.js +108 -0
  77. package/src/ui/server/utils/instanceRegistry.js +17 -6
  78. package/src/ui/public/assets/AgentView-8-xsOmE6.css +0 -1
  79. package/src/ui/public/assets/AgentView-DcTGw8-j.js +0 -4
  80. package/src/ui/public/assets/AppVersionBadge-CA5-POGA.js +0 -2
  81. package/src/ui/public/assets/BranchSelector-B0eLkAdv.css +0 -1
  82. package/src/ui/public/assets/BranchSelector-qn-iGHOI.js +0 -1
  83. package/src/ui/public/assets/CommitForm-CQ0K8Yy2.css +0 -1
  84. package/src/ui/public/assets/CommitForm-DHfyJ331.js +0 -6
  85. package/src/ui/public/assets/CommonDialog-DJBixJcI.js +0 -1
  86. package/src/ui/public/assets/CommonDialog-jP0E9mco.css +0 -1
  87. package/src/ui/public/assets/EditorView-BRWoa7--.js +0 -1
  88. package/src/ui/public/assets/EditorView-DZMLvQds.css +0 -1
  89. package/src/ui/public/assets/FlowExecutionViewer-YoPgld-R.js +0 -1
  90. package/src/ui/public/assets/FlowExecutionViewer-jNHI5zdm.css +0 -1
  91. package/src/ui/public/assets/FlowOrchestrationWorkspace-CYGp-PqS.css +0 -1
  92. package/src/ui/public/assets/FlowOrchestrationWorkspace-DyEjlCBW.js +0 -21
  93. package/src/ui/public/assets/LogList-BlJplIi1.css +0 -1
  94. package/src/ui/public/assets/LogList-_z4dIgvS.js +0 -7
  95. package/src/ui/public/assets/MindmapView-BQTqx842.css +0 -1
  96. package/src/ui/public/assets/MindmapView-BrkcAcDh.js +0 -2
  97. package/src/ui/public/assets/MonitorView-8TG_K4m3.js +0 -1
  98. package/src/ui/public/assets/MonitorView-DetlFFXq.css +0 -1
  99. package/src/ui/public/assets/ProjectStartupButton-BqLyrik7.js +0 -1
  100. package/src/ui/public/assets/ProjectStartupButton-QLPltCK5.css +0 -1
  101. package/src/ui/public/assets/RemoteManagerDialog-BauAeH2w.css +0 -1
  102. package/src/ui/public/assets/RemoteManagerDialog-LOVV6KrB.js +0 -4
  103. package/src/ui/public/assets/RemoteRepoCard-CG-jARTe.css +0 -1
  104. package/src/ui/public/assets/RemoteRepoCard-SQyKcXDG.js +0 -7
  105. package/src/ui/public/assets/SourceMapView-BNRjvgzH.css +0 -1
  106. package/src/ui/public/assets/SourceMapView-DckLynaL.js +0 -3
  107. package/src/ui/public/assets/SvgIcon-CvIX1KL7.js +0 -1
  108. package/src/ui/public/assets/UserInputNode-BVgQkB2l.css +0 -1
  109. package/src/ui/public/assets/UserInputNode-Bc124-BK.js +0 -1
  110. package/src/ui/public/assets/WorkbenchView-BtQkd9Oy.js +0 -37
  111. package/src/ui/public/assets/WorkbenchView-FdhmMI-b.css +0 -1
  112. package/src/ui/public/assets/configStore-NaVpBxPo.js +0 -1
  113. package/src/ui/public/assets/element-plus-Csu3CJKj.js +0 -39
  114. package/src/ui/public/assets/index-Ci3lbjk2.css +0 -1
  115. package/src/ui/public/assets/index-D9u0mCSi.js +0 -98
  116. package/src/ui/public/assets/office-docx-BKrAsewH.js +0 -32
  117. package/src/ui/public/assets/office-pptx-DKtkU_7r.js +0 -74
  118. package/src/ui/public/assets/toolsStore-CNDLLgUG.js +0 -1
  119. package/src/ui/public/assets/vue-flow-D7IxiTyP.js +0 -39
@@ -27,7 +27,7 @@
27
27
  //
28
28
  // 设计要点:
29
29
  // - 路径归一与前端 utils/path.ts 的 canonicalProjectPath 必须**逐字一致**
30
- // (盘符转大写 + Windows 斜杠归一);两边口径一旦分叉,
30
+ // (Windows 形式转小写 + 斜杠归一);两边口径一旦分叉,
31
31
  // 同一个目录会在两侧落到不同 key 上,表现为项目凭空分裂成两个。
32
32
  // - 任务落在哪一列由 deriveTaskColumn 统一推导,是纯函数、单测覆盖;
33
33
  // 前端不重复实现这套规则,避免两边口径漂移。
@@ -36,27 +36,41 @@
36
36
  import { probeDirectoryGitStates } from '../../utils/directoryGitState.js';
37
37
 
38
38
  /** 看板列,数组顺序即列顺序 */
39
- export const TASK_COLUMNS = ['todo', 'doing', 'review', 'done'];
39
+ export const TASK_COLUMNS = ['todo', 'doing', 'done'];
40
40
 
41
41
  /** 没有关联任何项目的任务归到这个 key(与前端 useWorkbenchProjectGroups 一致) */
42
42
  export const NO_PROJECT_KEY = '__no_project__';
43
43
 
44
44
  /**
45
45
  * 项目路径归一。与前端 src/ui/client/src/utils/path.ts 的 canonicalProjectPath 同口径:
46
- * Windows 形式(带盘符)→ 盘符转大写 + 反斜杠归一;其余(POSIX)原样返回。
46
+ * Windows 形式(带盘符)→ 转小写 + 反斜杠归一;其余(POSIX)原样返回。
47
47
  *
48
48
  * 为什么必须归斜杠:常用目录允许用户手输,同一个目录会同时存在 `D:/ws/proj`
49
49
  * 与 `D:\ws\proj` 两种写法。文件系统不区分,字符串比较却会 ——
50
50
  * 于是项目列表里出现两个同名项目(实测 article-generator 就是如此)。
51
51
  * 归一是修一个看得见的重复项,不是顺手清理。
52
52
  *
53
- * 刻意**不**做去尾斜杠 / 全小写 / 展开 `..`:那会把比较规则变得依赖文件系统语义,
54
- * 而这里只承诺"同一目录的常见书写差异归一到同一个 key"。
53
+ * 为什么必须归**大小写**(2026-09-18 补,同一类问题的第二张脸):
54
+ * 只归斜杠治不了目录段的大小写。`recentDirectories` 存的是用户输入时的**原始写法**
55
+ * (`saveRecentDirectory` 只用 normalizeProjectPath 做去重比较,落盘仍写原串),
56
+ * 而任务的 `projectPath` 来自别处 —— 于是同一个目录会以 `c:\users\xuze3`
57
+ * (常用目录里手输的小写盘符)和 `C:\Users\xuze3`(任务带出来的)两种形态并存,
58
+ * 拿到两个 key、渲染成两条项目行。
59
+ *
60
+ * 关键在于**小写化必须做进 key 本身**,而不是在每处比较之前各自归一:key 会被拿去
61
+ * `stats.get(key)` 精确查表、比 `← 当前` 标记、比派发目标、比前端选中的项目 ——
62
+ * 只要有一处忘了先归一,同一目录就又会裂成两条。统一收在这里,调用方拿到的 key
63
+ * 天然可跨写法相等比较。(Windows 文件系统不区分大小写,小写化不会把两个真目录并成一个;
64
+ * 与之对应的 config.js 项目键 `normalizeProjectPath` 也是全小写的,方向一致。)
65
+ *
66
+ * 刻意**不**做去尾斜杠 / 展开 `..` / 解析符号链接:那会把比较规则变得依赖文件系统语义。
67
+ * 小写化是这条底线上唯一的例外,但它是**纯字符串**操作(只看 `X:` 这个形状,不看
68
+ * process.platform),没有引入任何 fs 依赖 —— 底线还在。
55
69
  */
56
70
  export function canonicalProjectPath(p) {
57
71
  const s = String(p || '').trim();
58
72
  if (!/^[a-zA-Z]:/.test(s)) return s;
59
- return s.replace(/^([a-z])(?=:)/, (m) => m.toUpperCase()).replace(/\//g, '\\');
73
+ return s.replace(/\//g, '\\').toLowerCase();
60
74
  }
61
75
 
62
76
  /** 目录名(项目显示名),路径为空时返回空串而不是兜底文案(文案归前端 i18n 管) */
@@ -116,14 +130,14 @@ export function latestJob(jobsForTask) {
116
130
  * 1. 有 job 处于 running/pending,或子任务状态是 running → `doing`
117
131
  * 2. 有子任务且全部 done → `done`
118
132
  * 3. 有子任务但没全 done:
119
- * - 动过(有 done 的子任务 / 执行过 / 有 error 的子任务)→ `review`(跑过但没收尾,等你确认或继续)
133
+ * - 动过(有 done 的子任务 / 执行过 / 有 error 的子任务)→ `todo`
120
134
  * - 完全没动过 → `todo`
121
135
  * 4. 没子任务(简单任务,或还没拆分的复杂任务):
122
136
  * - 从没执行过 → `todo`
123
- * - 最近一条 job 是 done → `done`;其余终态(error/cancelled)→ `review`
137
+ * - 最近一条 job 是 done → `done`;其余终态(error/cancelled)→ `todo`
124
138
  *
125
- * 之所以不把 error 单列一列:列是"任务处于哪个阶段",error 是"这一轮执行失败了",
126
- * 失败的任务同样需要人看一眼,归到 `review`,具体错误在前端卡片上另有标记。
139
+ * error/cancelled 不再单列:列表示任务是否仍待处理,错误是这一轮执行的结果,
140
+ * 具体错误继续通过卡片标记和任务详情展示。
127
141
  */
128
142
  export function deriveTaskColumn(task, jobsForTask = []) {
129
143
  const subs = Array.isArray(task && task.subtasks) ? task.subtasks : [];
@@ -135,19 +149,18 @@ export function deriveTaskColumn(task, jobsForTask = []) {
135
149
  if (subs.length > 0) {
136
150
  const doneCount = subs.filter(s => s && s.status === 'done').length;
137
151
  if (doneCount === subs.length) return 'done';
138
- const touched = doneCount > 0 || jobs.length > 0 || subs.some(s => s && s.status === 'error');
139
- return touched ? 'review' : 'todo';
152
+ return 'todo';
140
153
  }
141
154
 
142
155
  if (jobs.length === 0) return 'todo';
143
156
  const last = latestJob(jobs);
144
- return last && last.status === 'done' ? 'done' : 'review';
157
+ return last && last.status === 'done' ? 'done' : 'todo';
145
158
  }
146
159
 
147
160
  /** 空统计,避免各处手写零值 */
148
161
  function emptyStats() {
149
162
  return {
150
- total: 0, todo: 0, doing: 0, review: 0, done: 0,
163
+ total: 0, todo: 0, doing: 0, done: 0,
151
164
  progress: 0, runningJobs: 0, errorSubtasks: 0, lastActiveAt: null,
152
165
  };
153
166
  }
@@ -203,7 +216,13 @@ export function summarizeProjectTasks(tasks, jobs) {
203
216
 
204
217
  /**
205
218
  * 合并最近目录与任务里的 projectPath,得到去重后的项目条目。
206
- * path 取"首次出现的写法"(先最近目录、后任务),前端可以直接拿它跟其它列表对齐。
219
+ * path 取"首次出现的写法"(先最近目录内部按原顺序、再任务),前端可以直接拿它跟其它列表对齐。
220
+ *
221
+ * ⚠️ "首次出现"要显式实现,不能靠 `map.set` 覆盖:常用目录里同一个目录
222
+ * 出现两种写法是常态(`c:\users\xuze3` 与 `C:\Users\xuze3`),
223
+ * 无脑 set 会让**最后一条**写法覆盖前面那条 —— 既违背上面这句约定
224
+ * (界面上的路径会随常用目录顺序来回跳),也会把先命中过的 fromTasks 抹掉,
225
+ * 让 `source` 从 'both' 退化成 'recent'。
207
226
  *
208
227
  * @param {{ recentDirs?: string[], tasks?: object[] }} input
209
228
  * @returns {Array<{ path: string, key: string, name: string, source: 'recent'|'task'|'both' }>}
@@ -213,7 +232,9 @@ export function buildProjectEntries({ recentDirs = [], tasks = [] } = {}) {
213
232
  for (const dir of recentDirs) {
214
233
  const key = canonicalProjectPath(dir);
215
234
  if (!key) continue;
216
- map.set(key, { path: String(dir).trim(), key, inRecent: true, fromTasks: false });
235
+ const hit = map.get(key);
236
+ if (hit) hit.inRecent = true; // 同一目录的第二种写法:只并入来源,不覆盖 path
237
+ else map.set(key, { path: String(dir).trim(), key, inRecent: true, fromTasks: false });
217
238
  }
218
239
  for (const t of tasks) {
219
240
  const key = canonicalProjectPath(t && t.projectPath);
@@ -40,15 +40,27 @@ const job = (id, taskId, status, startedAt, endedAt = null) => ({
40
40
 
41
41
  // ── 路径归一 ────────────────────────────────────────────────────────
42
42
 
43
- test('canonicalProjectPath: 盘符转大写 + Windows 斜杠归一(与前端同口径)', () => {
44
- assert.equal(canonicalProjectPath('e:\\workspace\\x'), 'E:\\workspace\\x');
45
- assert.equal(canonicalProjectPath(' D:/ws/y '), 'D:\\ws\\y');
43
+ test('canonicalProjectPath: Windows 形式转小写 + 斜杠归一(与前端同口径)', () => {
44
+ assert.equal(canonicalProjectPath('e:\\workspace\\x'), 'e:\\workspace\\x');
45
+ assert.equal(canonicalProjectPath('E:\\Workspace\\X'), 'e:\\workspace\\x');
46
+ assert.equal(canonicalProjectPath(' D:/ws/y '), 'd:\\ws\\y');
46
47
  // 同一目录的两种斜杠写法必须落到同一个 key,否则项目列表里出现重复项
47
48
  // (实测 article-generator 就同时存在于 D:/... 与 D:\... 两种常用目录里)
48
49
  assert.equal(canonicalProjectPath('D:/ws/y'), canonicalProjectPath('D:\\ws\\y'));
49
- // POSIX 路径原样返回,不能被改写成反斜杠
50
+ // 目录段大小写也必须归一:常用目录里手输的 c:\users\xuze3 与任务带出的
51
+ // C:\Users\xuze3 是同一个目录,旧口径只转盘符 → 左栏两条同名项目。
52
+ assert.equal(
53
+ canonicalProjectPath('C:\\Users\\xuze3'),
54
+ canonicalProjectPath('c:\\users\\xuze3'),
55
+ );
56
+ // 小写化是**纯字符串**规则,只看 `X:` 这个形状,不看 process.platform ——
57
+ // 所以 Windows 形态的断言在任何平台上都成立(POSIX 上跑测试也守得住)。
58
+ assert.equal(canonicalProjectPath('C:\\Users\\xuze3'), 'c:\\users\\xuze3');
59
+ // POSIX 路径原样返回,**不能**被小写化,也不能被改写成反斜杠:
60
+ // /home/Me/A 与 /home/me/a 是两个真目录,归一就是数据错误
50
61
  assert.equal(canonicalProjectPath('/home/me/proj'), '/home/me/proj');
51
62
  assert.equal(canonicalProjectPath('/home/me/proj/nested'), '/home/me/proj/nested');
63
+ assert.equal(canonicalProjectPath('/home/Me/A'), '/home/Me/A');
52
64
  assert.equal(canonicalProjectPath(''), '');
53
65
  assert.equal(canonicalProjectPath(null), '');
54
66
  });
@@ -84,16 +96,16 @@ test('deriveTaskColumn: 有 job 在跑或子任务 running 就是进行中', ()
84
96
 
85
97
  test('deriveTaskColumn: 子任务全部完成才算已完成', () => {
86
98
  assert.equal(deriveTaskColumn({ subtasks: [sub('s1', 'done'), sub('s2', 'done')] }, []), 'done');
87
- // 一半完成 → 跑过但没收尾,落在评审中
88
- assert.equal(deriveTaskColumn({ subtasks: [sub('s1', 'done'), sub('s2', 'todo')] }, []), 'review');
99
+ // 一半完成 → 仍待处理,错误与进度由卡片标记展示
100
+ assert.equal(deriveTaskColumn({ subtasks: [sub('s1', 'done'), sub('s2', 'todo')] }, []), 'todo');
89
101
  // 有子任务报错也算"需要人看一眼"
90
- assert.equal(deriveTaskColumn({ subtasks: [sub('s1', 'error'), sub('s2', 'todo')] }, []), 'review');
102
+ assert.equal(deriveTaskColumn({ subtasks: [sub('s1', 'error'), sub('s2', 'todo')] }, []), 'todo');
91
103
  });
92
104
 
93
105
  test('deriveTaskColumn: 无子任务的任务只能看执行记录', () => {
94
106
  assert.equal(deriveTaskColumn({ subtasks: [], type: 'simple' }, [job('j1', 't1', 'done', '2026-01-01T00:00:00Z')]), 'done');
95
- assert.equal(deriveTaskColumn({ subtasks: [], type: 'simple' }, [job('j1', 't1', 'cancelled', '2026-01-01T00:00:00Z')]), 'review');
96
- assert.equal(deriveTaskColumn({ subtasks: [], type: 'simple' }, [job('j1', 't1', 'error', '2026-01-01T00:00:00Z')]), 'review');
107
+ assert.equal(deriveTaskColumn({ subtasks: [], type: 'simple' }, [job('j1', 't1', 'cancelled', '2026-01-01T00:00:00Z')]), 'todo');
108
+ assert.equal(deriveTaskColumn({ subtasks: [], type: 'simple' }, [job('j1', 't1', 'error', '2026-01-01T00:00:00Z')]), 'todo');
97
109
  });
98
110
 
99
111
  test('latestJob 按 startedAt 取最新的一条', () => {
@@ -107,7 +119,7 @@ test('latestJob 按 startedAt 取最新的一条', () => {
107
119
 
108
120
  // ── 项目合并去重 ────────────────────────────────────────────────────
109
121
 
110
- test('buildProjectEntries: 最近目录与任务路径取并集去重,盘符大小写归一', () => {
122
+ test('buildProjectEntries: 最近目录与任务路径取并集去重,大小写与斜杠都归一', () => {
111
123
  const entries = buildProjectEntries({
112
124
  recentDirs: ['e:\\ws\\proj-a', 'D:\\ws\\proj-b'],
113
125
  tasks: [
@@ -118,10 +130,43 @@ test('buildProjectEntries: 最近目录与任务路径取并集去重,盘符
118
130
  });
119
131
  const byKey = new Map(entries.map(e => [e.key, e]));
120
132
  assert.equal(entries.length, 3);
121
- assert.equal(byKey.get('E:\\ws\\proj-a').source, 'both');
122
- assert.equal(byKey.get('D:\\ws\\proj-b').source, 'recent');
123
- assert.equal(byKey.get('D:\\ws\\proj-c').source, 'task');
124
- assert.equal(byKey.get('D:\\ws\\proj-c').name, 'proj-c');
133
+ assert.equal(byKey.get('e:\\ws\\proj-a').source, 'both');
134
+ assert.equal(byKey.get('d:\\ws\\proj-b').source, 'recent');
135
+ assert.equal(byKey.get('d:\\ws\\proj-c').source, 'task');
136
+ assert.equal(byKey.get('d:\\ws\\proj-c').name, 'proj-c');
137
+ // path 保留首次出现的原始写法(前端要拿它跟其它列表对齐、也要显示出来),
138
+ // 只有 key 被归一 —— 别顺手把 path 也小写了
139
+ assert.equal(byKey.get('d:\\ws\\proj-b').path, 'D:\\ws\\proj-b');
140
+ });
141
+
142
+ test('buildProjectEntries: 目录段大小写不同也是同一个项目(旧口径会裂成两条)', () => {
143
+ // 复现现场:常用目录里手输的小写盘符(saveRecentDirectory 落盘原始串)
144
+ // + 任务带出的 C:\Users\xuze3 —— 旧口径只转盘符,这里会得到两条 "xuze3"。
145
+ const entries = buildProjectEntries({
146
+ recentDirs: ['c:\\users\\xuze3'],
147
+ tasks: [{ id: 't1', projectPath: 'C:\\Users\\xuze3' }],
148
+ });
149
+ assert.equal(entries.length, 1, '同一目录的不同大小写写法不应分裂成两个项目');
150
+ assert.equal(entries[0].key, 'c:\\users\\xuze3');
151
+ assert.equal(entries[0].source, 'both', '应当被认成"既在常用目录、又有任务",而不是两个项目');
152
+ assert.equal(entries[0].name, 'xuze3');
153
+ // 项目行只认这个 key 的任务,key 分叉会连带把任务计数拆到两行上去
154
+ const stats = summarizeProjectTasks(
155
+ [{ id: 't1', projectPath: 'C:\\Users\\xuze3', subtasks: [] }],
156
+ [],
157
+ );
158
+ assert.equal(stats.get('c:\\users\\xuze3').total, 1);
159
+ assert.equal(stats.size, 1);
160
+
161
+ // 常用目录里同一个目录也可能历史累积出两种写法。此时只留**首次**那条:
162
+ // 否则界面上的路径会随常用目录的顺序跳变,`source` 也会从 both 退化掉。
163
+ const dup = buildProjectEntries({
164
+ recentDirs: ['C:\\Users\\xuze3', 'c:/users/xuze3'],
165
+ tasks: [{ id: 't1', projectPath: 'c:\\users\\xuze3' }],
166
+ });
167
+ assert.equal(dup.length, 1);
168
+ assert.equal(dup[0].path, 'C:\\Users\\xuze3', 'path 应取首次出现的写法');
169
+ assert.equal(dup[0].source, 'both', '重复的第二种写法不该抹掉已命中的来源');
125
170
  });
126
171
 
127
172
  test('summarizeProjectTasks: 按项目汇总列数量、进度、活跃执行与最后活跃时间', () => {
@@ -139,7 +184,7 @@ test('summarizeProjectTasks: 按项目汇总列数量、进度、活跃执行与
139
184
  ];
140
185
  const stats = summarizeProjectTasks(tasks, jobs);
141
186
 
142
- const a = stats.get('D:\\a');
187
+ const a = stats.get('d:\\a');
143
188
  assert.equal(a.total, 2);
144
189
  assert.equal(a.done, 1);
145
190
  assert.equal(a.doing, 1);
@@ -148,7 +193,7 @@ test('summarizeProjectTasks: 按项目汇总列数量、进度、活跃执行与
148
193
  // 最后活跃时间要把 job 的起止也算进来(任务体本身可能很久没改)
149
194
  assert.equal(a.lastActiveAt, '2026-01-04T00:00:00Z');
150
195
 
151
- const b = stats.get('D:\\b');
196
+ const b = stats.get('d:\\b');
152
197
  assert.equal(b.total, 1);
153
198
  assert.equal(b.todo, 1);
154
199
  assert.equal(b.progress, 0);
@@ -163,7 +208,7 @@ test('decorateTaskForBoard: 只回卡片需要的字段', () => {
163
208
  attachments: [{ id: 'at1' }],
164
209
  createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-02T00:00:00Z',
165
210
  }, []);
166
- assert.equal(card.column, 'review');
211
+ assert.equal(card.column, 'todo');
167
212
  assert.equal(card.type, 'complex');
168
213
  assert.equal(card.subtaskCount, 2);
169
214
  assert.equal(card.subtaskDoneCount, 1);
@@ -25,12 +25,17 @@ import fsp from 'fs/promises';
25
25
  import path from 'path';
26
26
  import os from 'os';
27
27
  import logger from '../../utils/logger.js';
28
+ import { DATA_DIR } from '../../../../paths.js';
28
29
 
29
30
  // ── 数据目录与持久化文件路径 ─────────────────────────────────
30
- // 全部存在用户主目录 ~/.zen-gitsync/ 下,跨项目共享
31
- export const DATA_DIR = path.join(os.homedir(), '.zen-gitsync');
31
+ // 全部存在用户主目录 ~/.zen-gitsync/ 下,跨项目共享。
32
+ // 目录本身定义在 src/paths.js(全部数据路径的唯一真相源),这里只在其下拼文件名。
33
+ export { DATA_DIR };
32
34
  export const PROMPTS_FILE = path.join(DATA_DIR, 'prompts.json');
33
35
  export const TASKS_FILE = path.join(DATA_DIR, 'tasks.json');
36
+ // 应用主配置(projects / recentDirectories / models …)。这里只用来把**路径**告诉 Agent,
37
+ // 让它需要时自己读 —— 服务端不在这个流程里解析配置内容。
38
+ export const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
34
39
  export const IMAGES_DIR = path.join(DATA_DIR, 'workbench-images');
35
40
  export const INSTRUCTION_FILE = path.join(DATA_DIR, 'ai-instruction.json');
36
41
  export const SUBTASK_INSTRUCTION_FILE = path.join(DATA_DIR, 'ai-subtask-instruction.json');
@@ -0,0 +1,260 @@
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ // 「这条指令该落到哪个项目」—— 全局唯一实现。
16
+ //
17
+ // 在调度台上敲一条指令时,用户不必先选项目:落到哪由这里决定。四级优先级,
18
+ // 从「最确定的意图」往下退:
19
+ //
20
+ // 1. explicit —— 调用方明确指定(用户在左栏选中了具体项目)。
21
+ // 显式意图永远最优先,不做任何"更聪明"的覆盖,否则用户会发现
22
+ // 「我明明选了它,却派到别处去了」。
23
+ // 2. mention —— 指令原文里点名的项目(名字或完整路径)。纯字符串匹配,
24
+ // 零延迟零成本,覆盖「给 article-generator 加个功能」这类绝大多数写法。
25
+ // 3. agent —— 交给模型判断。只有前两级都没命中才走这里(要等一次网络往返);
26
+ // 失败、超时、编了个不存在的路径 —— 一律静默降级,绝不阻塞派发。
27
+ // 4. default —— 都没命中,用调用方给的默认项目(应用当前项目)。
28
+ //
29
+ // 返回值里的 source 是给 UI 用的:让界面能如实说明「这个落点是怎么来的」。
30
+ // 判错了的时候,用户至少知道该怪谁 —— 是模型猜的,还是压根没识别出来。
31
+ //
32
+ // 纯逻辑 + 单次可注入的模型调用,单测不碰网络(见 targetResolver.test.js)。
33
+
34
+ import { canonicalProjectPath, projectName } from './projectRegistry.js';
35
+ import { callLlmJson } from './llmClient.js';
36
+
37
+ /** 落点来源。UI 按它选文案,服务端不写死任何面向界面的中文 */
38
+ export const TARGET_SOURCE = {
39
+ EXPLICIT: 'explicit',
40
+ MENTION: 'mention',
41
+ AGENT: 'agent',
42
+ DEFAULT: 'default',
43
+ };
44
+
45
+ export const TARGET_SOURCES = Object.values(TARGET_SOURCE);
46
+
47
+ /** 算作「词内字符」的集合:出现在 token 两侧就说明它只是某个长词的一部分 */
48
+ const WORD_CHAR = /[a-z0-9_-]/i;
49
+
50
+ /**
51
+ * 在 text 里找 token 的「成词」出现位置,找不到返回 -1。
52
+ *
53
+ * 为什么非要卡边界:项目名经常互为前缀 —— 清单里同时有 `claw-management`
54
+ * 与 `claw-management-api`、`claw-sdd` 与 `claw-sdd-project`。
55
+ * 光用 includes/indexOf,指令里写 `claw-management-api` 会先命中 `claw-management`,
56
+ * 改动就落到隔壁项目去了。
57
+ */
58
+ export function indexOfToken(text, token) {
59
+ const needle = String(token || '');
60
+ if (!needle) return -1;
61
+ const hay = String(text || '').toLowerCase();
62
+ const low = needle.toLowerCase();
63
+ let from = 0;
64
+ for (;;) {
65
+ const i = hay.indexOf(low, from);
66
+ if (i < 0) return -1;
67
+ const before = i > 0 ? hay[i - 1] : '';
68
+ const after = i + low.length < hay.length ? hay[i + low.length] : '';
69
+ if (!WORD_CHAR.test(before) && !WORD_CHAR.test(after)) return i;
70
+ from = i + 1;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * 规则匹配:指令原文里点名的项目。
76
+ *
77
+ * 优先级:完整路径(rank 3)> 项目名成词出现(rank 2)。
78
+ * 同级里取「匹配到的 token 更长」的那个 —— 更长即更具体,
79
+ * 用来压住 `claw-management` / `claw-management-api` 这类同族名。
80
+ *
81
+ * @param {string} text 指令原文
82
+ * @param {object[]} projects buildProjectEntries 的输出({name, path, key})
83
+ * @returns {{ project: object, matched: string } | null}
84
+ */
85
+ export function matchProjectMention(text, projects) {
86
+ const src = String(text || '');
87
+ if (!src) return null;
88
+ const list = (Array.isArray(projects) ? projects : []).filter(p => p && p.key && p.path);
89
+ if (!list.length) return null;
90
+
91
+ const lower = src.toLowerCase();
92
+ let best = null;
93
+
94
+ for (const p of list) {
95
+ const path = String(p.path || '').trim();
96
+ const name = String(p.name || projectName(path)).trim();
97
+
98
+ let matched = '';
99
+ let rank = 0;
100
+ if (path && lower.includes(path.toLowerCase())) {
101
+ // 完整路径是最强信号:肯把路径敲全的人不会是在打比方
102
+ matched = path;
103
+ rank = 3;
104
+ } else if (name && indexOfToken(lower, name) >= 0) {
105
+ matched = name;
106
+ rank = 2;
107
+ }
108
+ if (!matched) continue;
109
+
110
+ if (!best || rank > best.rank || (rank === best.rank && matched.length > best.matched.length)) {
111
+ best = { project: p, matched, rank };
112
+ }
113
+ }
114
+
115
+ return best ? { project: best.project, matched: best.matched } : null;
116
+ }
117
+
118
+ /**
119
+ * 让模型判断落点(第 3 级)。
120
+ *
121
+ * 只在规则匹配不到时调用。返回的路径**必须是清单里真实存在的那一条**:
122
+ * 模型编出来的路径一律当作"没判断出来"。宁可退到默认项目,
123
+ * 也不能凭空造一个不存在的工作目录 —— 那会让派发在下一步直接报「项目目录不存在」,
124
+ * 用户看到的是"派发失败",比"派到默认项目"难懂得多。
125
+ *
126
+ * @returns {{ project: object, reason: string } | null}
127
+ */
128
+ export async function pickProjectByAgent({
129
+ text,
130
+ projects,
131
+ model,
132
+ timeoutMs = 15000,
133
+ // 注入点:单测靠它替换掉真实网络调用(默认就是 callLlmJson)
134
+ callJson = callLlmJson,
135
+ } = {}) {
136
+ const list = (Array.isArray(projects) ? projects : []).filter(p => p && p.key && p.path);
137
+ if (!model || !list.length) return null;
138
+
139
+ const catalogue = list
140
+ .map((p, i) => `${i + 1}. ${p.name || projectName(p.path)} | ${p.path}`)
141
+ .join('\n');
142
+
143
+ const prompt = `你在「多项目编排台」里做一次路由判断:用户刚发了一条指令,请判断它应该在下面哪个项目里执行。
144
+
145
+ 可选项目(序号. 名称 | 绝对路径):
146
+ ${catalogue}
147
+
148
+ 指令原文:
149
+ """
150
+ ${String(text || '').slice(0, 2000)}
151
+ """
152
+
153
+ 判断规则:
154
+ 1. 指令直接提到某个项目名或路径 —— 选它。
155
+ 2. 指令描述的内容明显属于某个项目(例如"修一下文章生成器的导出"对应 article-generator,如果它在上面)—— 选它。
156
+ 3. 只要不能确定,就返回空字符串。**不要猜**:猜错的代价是把改动做到错误的仓库里,比退到默认项目严重得多。
157
+
158
+ 只返回 JSON:
159
+ {
160
+ "projectPath": "选中的项目绝对路径,必须是上面列出的路径原样复制;不确定就填空字符串",
161
+ "reason": "一句话说明依据"
162
+ }`;
163
+
164
+ const out = await callJson(model, prompt, { timeoutMs });
165
+ const picked = canonicalProjectPath(out && out.projectPath ? out.projectPath : '');
166
+ if (!picked) return null;
167
+ const hit = list.find(p => p.key === picked);
168
+ if (!hit) return null;
169
+ return { project: hit, reason: String((out && out.reason) || '').slice(0, 200) };
170
+ }
171
+
172
+ /**
173
+ * 解析指令的落点项目。
174
+ *
175
+ * @param {object} input
176
+ * @param {string} input.text 指令原文
177
+ * @param {object[]} input.projects 候选项目({name, path, key})
178
+ * @param {string} input.explicitPath 调用方显式指定的路径(空 = 没指定,走判断)
179
+ * @param {string} input.defaultPath 兜底路径(一般是应用当前项目)
180
+ * @param {function} [input.getModel] 懒取模型配置。**只有真的要走第 3 级时才会被调用** ——
181
+ * 常见情况(指令里点名了项目)不该为读一次 config 付成本
182
+ * @param {number} [input.agentTimeoutMs]
183
+ * @param {function} [input.onAgentError] 模型判断失败时的回调(调用方记日志用)。
184
+ * 不抛错:判断失败不该让整条指令派不出去
185
+ * @param {function} [input.pickByAgent] 注入点:单测替换第 3 级,避免真发网络请求
186
+ * @returns {Promise<{ path: string, name: string, source: string, matched: string, reason: string }>}
187
+ */
188
+ export async function resolveInstructionTarget({
189
+ text = '',
190
+ projects = [],
191
+ explicitPath = '',
192
+ defaultPath = '',
193
+ getModel = null,
194
+ agentTimeoutMs = 15000,
195
+ onAgentError = null,
196
+ pickByAgent = pickProjectByAgent,
197
+ } = {}) {
198
+ const list = (Array.isArray(projects) ? projects : [])
199
+ .filter(p => p && p.key && p.path)
200
+ .map(p => ({ ...p, name: p.name || projectName(p.path) }));
201
+ const byKey = new Map(list.map(p => [p.key, p]));
202
+
203
+ // ── 1. 显式指定 ──────────────────────────────────────────────────
204
+ const explicit = String(explicitPath || '').trim();
205
+ if (explicit) {
206
+ const hit = byKey.get(canonicalProjectPath(explicit));
207
+ return {
208
+ path: hit ? hit.path : explicit,
209
+ name: hit ? hit.name : projectName(explicit),
210
+ source: TARGET_SOURCE.EXPLICIT,
211
+ matched: '',
212
+ reason: '',
213
+ };
214
+ }
215
+
216
+ // ── 2. 指令原文里点名 ────────────────────────────────────────────
217
+ const mention = matchProjectMention(text, list);
218
+ if (mention) {
219
+ return {
220
+ path: mention.project.path,
221
+ name: mention.project.name,
222
+ source: TARGET_SOURCE.MENTION,
223
+ matched: mention.matched,
224
+ reason: '',
225
+ };
226
+ }
227
+
228
+ // ── 3. 模型判断 ──────────────────────────────────────────────────
229
+ if (typeof getModel === 'function') {
230
+ try {
231
+ const model = await getModel();
232
+ if (model) {
233
+ const picked = await pickByAgent({ text, projects: list, model, timeoutMs: agentTimeoutMs });
234
+ if (picked) {
235
+ return {
236
+ path: picked.project.path,
237
+ name: picked.project.name,
238
+ source: TARGET_SOURCE.AGENT,
239
+ matched: '',
240
+ reason: picked.reason,
241
+ };
242
+ }
243
+ }
244
+ } catch (err) {
245
+ if (typeof onAgentError === 'function') onAgentError(err);
246
+ }
247
+ }
248
+
249
+ // ── 4. 默认项目 ──────────────────────────────────────────────────
250
+ const fallback = String(defaultPath || '').trim();
251
+ const def = byKey.get(canonicalProjectPath(fallback));
252
+ const defPath = def ? def.path : fallback;
253
+ return {
254
+ path: defPath,
255
+ name: def ? def.name : projectName(defPath),
256
+ source: TARGET_SOURCE.DEFAULT,
257
+ matched: '',
258
+ reason: '',
259
+ };
260
+ }