zen-gitsync 2.17.16 → 2.17.18

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 (144) hide show
  1. package/README.md +2 -0
  2. package/package.json +1 -1
  3. package/src/paths.js +13 -0
  4. package/src/ui/public/assets/AgentView-AMFKSDeH.css +1 -0
  5. package/src/ui/public/assets/AgentView-DQAB0ypS.js +13 -0
  6. package/src/ui/public/assets/AppVersionBadge-B5VG1_Lu.js +2 -0
  7. package/src/ui/public/assets/AppVersionBadge-DDYQqoy7.css +1 -0
  8. package/src/ui/public/assets/BranchSelector-Cj6athRO.js +1 -0
  9. package/src/ui/public/assets/BranchSelector-F4ibldMN.css +1 -0
  10. package/src/ui/public/assets/CommitForm-4kI2csgs.css +1 -0
  11. package/src/ui/public/assets/CommitForm-a2tMsfF9.js +6 -0
  12. package/src/ui/public/assets/CommonDialog-I-GCqRMC.js +1 -0
  13. package/src/ui/public/assets/CommonDialog-w6EmBEXv.css +1 -0
  14. package/src/ui/public/assets/EditorView-DBqhjm84.css +1 -0
  15. package/src/ui/public/assets/EditorView-wTi53Nxc.js +16 -0
  16. package/src/ui/public/assets/FileDiffViewer-B7i4BmbH.js +15 -0
  17. package/src/ui/public/assets/FileDiffViewer-n5ea9qMe.css +1 -0
  18. package/src/ui/public/assets/{FlowExecutionViewer-Drw4hlJG.js → FlowExecutionViewer-xZjrf8e1.js} +1 -1
  19. package/src/ui/public/assets/FlowOrchestrationWorkspace-DwuSSxJ1.css +1 -0
  20. package/src/ui/public/assets/FlowOrchestrationWorkspace-R0QKmUpG.js +21 -0
  21. package/src/ui/public/assets/IconButton-B6eqvssJ.js +1 -0
  22. package/src/ui/public/assets/IconButton-RlXqKOeu.css +1 -0
  23. package/src/ui/public/assets/LogList-BPqurE9g.js +7 -0
  24. package/src/ui/public/assets/LogList-XspUhx-C.css +1 -0
  25. package/src/ui/public/assets/MindmapView-Diq2Cu4O.css +1 -0
  26. package/src/ui/public/assets/MindmapView-NdFNlkTF.js +2 -0
  27. package/src/ui/public/assets/MonitorView-DuSZa8a3.js +1 -0
  28. package/src/ui/public/assets/MonitorView-DwUwJXnY.css +1 -0
  29. package/src/ui/public/assets/ProjectStartupButton-BrfKVP-w.js +1 -0
  30. package/src/ui/public/assets/ProjectStartupButton-CB8IOg1E.css +1 -0
  31. package/src/ui/public/assets/RemoteManagerDialog-BI7fRsOQ.js +4 -0
  32. package/src/ui/public/assets/RemoteManagerDialog-D4WphMtO.css +1 -0
  33. package/src/ui/public/assets/RemoteRepoCard-B4E8Ud3F.js +7 -0
  34. package/src/ui/public/assets/RemoteRepoCard-o01IrbVz.css +1 -0
  35. package/src/ui/public/assets/SourceMapView-B0OzHjxP.js +3 -0
  36. package/src/ui/public/assets/SourceMapView-CfDydtsu.css +1 -0
  37. package/src/ui/public/assets/SvgIcon-B2h7B2yF.js +1 -0
  38. package/src/ui/public/assets/TemplateManager-DO7EPfj8.css +1 -0
  39. package/src/ui/public/assets/TemplateManager-mIWE3Qf8.js +1 -0
  40. package/src/ui/public/assets/UserInputNode-BafC-B2U.js +1 -0
  41. package/src/ui/public/assets/UserInputNode-DDNSarag.css +1 -0
  42. package/src/ui/public/assets/WorkbenchView-BU2kJCAA.css +1 -0
  43. package/src/ui/public/assets/WorkbenchView-BseGz8hJ.js +56 -0
  44. package/src/ui/public/assets/{_plugin-vue_export-helper-dg2VUnSQ.js → _plugin-vue_export-helper-Dvuzpv2Y.js} +7 -7
  45. package/src/ui/public/assets/agentConversations-BEKqkGcm.js +5 -0
  46. package/src/ui/public/assets/{configStore-DtjAc3w9.js → configStore-CX0FkQ94.js} +1 -1
  47. package/src/ui/public/assets/element-plus-C1xzgNSR.css +1 -0
  48. package/src/ui/public/assets/{element-plus-DxBPQwL7.js → element-plus-D7LDHcAn.js} +3 -3
  49. package/src/ui/public/assets/flow-mindmap-zXXF3qDh.js +139 -0
  50. package/src/ui/public/assets/gitStore-CMuWWr2d.js +6 -0
  51. package/src/ui/public/assets/index-CB2cIiHl.js +115 -0
  52. package/src/ui/public/assets/index-DIU5200G.css +1 -0
  53. package/src/ui/public/assets/{office-docx-CZXLkCf5.js → office-docx-C8sYW8O1.js} +5 -5
  54. package/src/ui/public/assets/{office-excel-CBKszjVA.js → office-excel-DpZn5My2.js} +1 -1
  55. package/src/ui/public/assets/{office-pptx-NeUSnN3k.js → office-pptx-BnYBTwT7.js} +1 -1
  56. package/src/ui/public/assets/previewSandbox-CxLaNg4X.css +1 -0
  57. package/src/ui/public/assets/previewSandbox-DxR4iN9P.js +0 -0
  58. package/src/ui/public/assets/toolsStore-DMCSd287.js +1 -0
  59. package/src/ui/public/assets/{useThemeObserver-E7Hk50l3.js → useThemeObserver-QpveGpnn.js} +1 -1
  60. package/src/ui/public/assets/{vendor-DnB9EJa3.css → vendor-C-GCxMMu.css} +1 -1
  61. package/src/ui/public/assets/{vendor-bdHhLiED.js → vendor-CLmPUj3K.js} +8 -8
  62. package/src/ui/public/assets/vue-flow-CxnoC7Uq.js +39 -0
  63. package/src/ui/public/fonts/jetbrains-mono-latin.woff2 +0 -0
  64. package/src/ui/public/fonts/plus-jakarta-sans-latin-ext.woff2 +0 -0
  65. package/src/ui/public/fonts/plus-jakarta-sans-latin-italic.woff2 +0 -0
  66. package/src/ui/public/fonts/plus-jakarta-sans-latin.woff2 +0 -0
  67. package/src/ui/public/index.html +28 -27
  68. package/src/ui/server/index.js +28 -2
  69. package/src/ui/server/routes/aiContext/collectors.js +360 -0
  70. package/src/ui/server/routes/aiContext/collectors.test.js +297 -0
  71. package/src/ui/server/routes/aiContext/index.js +444 -0
  72. package/src/ui/server/routes/aiContext/index.test.js +433 -0
  73. package/src/ui/server/routes/aiContext/jobRefresh.js +110 -0
  74. package/src/ui/server/routes/aiContext/jobRefresh.test.js +191 -0
  75. package/src/ui/server/routes/aiContext/render.js +366 -0
  76. package/src/ui/server/routes/aiContext/render.test.js +255 -0
  77. package/src/ui/server/routes/aiContext/wiring.js +80 -0
  78. package/src/ui/server/routes/fs.js +43 -0
  79. package/src/ui/server/routes/mindmap.js +36 -24
  80. package/src/ui/server/routes/monitor.js +44 -35
  81. package/src/ui/server/routes/remoteRepos.js +57 -48
  82. package/src/ui/server/routes/workbench/agentChat.js +94 -10
  83. package/src/ui/server/routes/workbench/agentChatContext.test.js +171 -0
  84. package/src/ui/server/routes/workbench/agentChatShared.test.js +33 -3
  85. package/src/ui/server/routes/workbench/agentEngines.js +77 -0
  86. package/src/ui/server/routes/workbench/agentEngines.test.js +65 -0
  87. package/src/ui/server/routes/workbench/agentRoutes.js +208 -48
  88. package/src/ui/server/routes/workbench/agentRoutes.test.js +160 -4
  89. package/src/ui/server/routes/workbench/agentSessionStore.js +3 -0
  90. package/src/ui/server/routes/workbench/envContext.js +61 -64
  91. package/src/ui/server/routes/workbench/envContext.test.js +133 -63
  92. package/src/ui/server/routes/workbench/index.js +78 -32
  93. package/src/ui/server/routes/workbench/runExternalTurn.js +279 -0
  94. package/src/ui/server/routes/workbench/runExternalTurn.test.js +275 -0
  95. package/src/ui/server/routes/workbench/shared.js +17 -0
  96. package/src/ui/server/utils/agentAttachments.js +169 -0
  97. package/src/ui/server/utils/agentAttachments.test.js +140 -0
  98. package/src/ui/public/assets/AgentView-DOyN97Ve.js +0 -5
  99. package/src/ui/public/assets/AgentView-DkM2KXuc.css +0 -1
  100. package/src/ui/public/assets/AppVersionBadge-CJrDr7GV.css +0 -1
  101. package/src/ui/public/assets/AppVersionBadge-oON3sAj4.js +0 -2
  102. package/src/ui/public/assets/BranchSelector-B0eLkAdv.css +0 -1
  103. package/src/ui/public/assets/BranchSelector-Cpj7eGqY.js +0 -1
  104. package/src/ui/public/assets/CommitForm-CQ0K8Yy2.css +0 -1
  105. package/src/ui/public/assets/CommitForm-D_ovYhVw.js +0 -6
  106. package/src/ui/public/assets/CommonDialog-ChZbqfRF.js +0 -1
  107. package/src/ui/public/assets/CommonDialog-jP0E9mco.css +0 -1
  108. package/src/ui/public/assets/EditorView-BPzpI0GV.js +0 -1
  109. package/src/ui/public/assets/EditorView-DZMLvQds.css +0 -1
  110. package/src/ui/public/assets/FileDiffViewer-B9EkuKdF.css +0 -1
  111. package/src/ui/public/assets/FileDiffViewer-B_qEKA9L.js +0 -15
  112. package/src/ui/public/assets/FlowOrchestrationWorkspace-C7yTD1gj.js +0 -21
  113. package/src/ui/public/assets/FlowOrchestrationWorkspace-CYGp-PqS.css +0 -1
  114. package/src/ui/public/assets/IconButton-DEARmMkZ.js +0 -1
  115. package/src/ui/public/assets/IconButton-tjkOjzkC.css +0 -1
  116. package/src/ui/public/assets/LogList-BA_gowxE.js +0 -7
  117. package/src/ui/public/assets/LogList-BlJplIi1.css +0 -1
  118. package/src/ui/public/assets/MindmapView-8X6z245q.css +0 -1
  119. package/src/ui/public/assets/MindmapView-K-XcLLXr.js +0 -2
  120. package/src/ui/public/assets/MonitorView-C0RL5K9W.js +0 -1
  121. package/src/ui/public/assets/MonitorView-DetlFFXq.css +0 -1
  122. package/src/ui/public/assets/ProjectStartupButton-DC36_vvU.js +0 -1
  123. package/src/ui/public/assets/ProjectStartupButton-QLPltCK5.css +0 -1
  124. package/src/ui/public/assets/RemoteManagerDialog-BauAeH2w.css +0 -1
  125. package/src/ui/public/assets/RemoteManagerDialog-Bkqja660.js +0 -4
  126. package/src/ui/public/assets/RemoteRepoCard-Bsm7cj_H.js +0 -7
  127. package/src/ui/public/assets/RemoteRepoCard-CG-jARTe.css +0 -1
  128. package/src/ui/public/assets/SourceMapView-CN4Hkyp4.css +0 -1
  129. package/src/ui/public/assets/SourceMapView-Y5NKdIk6.js +0 -3
  130. package/src/ui/public/assets/SvgIcon-D_mFHXP4.js +0 -1
  131. package/src/ui/public/assets/TemplateManager-BzKVXZhy.js +0 -1
  132. package/src/ui/public/assets/TemplateManager-DMuG4i3G.css +0 -1
  133. package/src/ui/public/assets/UserInputNode-BPLK4aYx.js +0 -1
  134. package/src/ui/public/assets/UserInputNode-BVgQkB2l.css +0 -1
  135. package/src/ui/public/assets/WorkbenchView-B1BJhWHi.css +0 -1
  136. package/src/ui/public/assets/WorkbenchView-xbGdiVMQ.js +0 -56
  137. package/src/ui/public/assets/element-plus-Cwwq-Gje.css +0 -1
  138. package/src/ui/public/assets/flow-mindmap-BqFb1WO5.js +0 -139
  139. package/src/ui/public/assets/gitStore-BZo9_zyy.js +0 -6
  140. package/src/ui/public/assets/index-CHuCYLWm.js +0 -111
  141. package/src/ui/public/assets/index-CmbatbSR.css +0 -1
  142. package/src/ui/public/assets/previewSandbox-BTTTXooN.js +0 -0
  143. package/src/ui/public/assets/previewSandbox-CgdwPcuW.css +0 -1
  144. package/src/ui/public/assets/vue-flow-fLUQ0_kE.js +0 -39
@@ -12,33 +12,37 @@
12
12
  // See the License for the specific language governing permissions and
13
13
  // limitations under the License.
14
14
  //
15
- // 执行时注入给 Agent 的「运行环境上下文」。
15
+ // 执行时注入给 Agent 的「运行环境上下文」——**多项目编排台视角**。
16
16
  //
17
17
  // 解决什么问题:从主 Agent 调度台发出去的指令,最终 prompt 只是用户原文
18
18
  // + 附件清单(拼装见 promptParts.js),cwd 之外的项目、看板进度、历史指令
19
19
  // 一概不在里面。于是用户问"我的项目有哪些",Agent 只能就着当前目录猜 ——
20
20
  // 而数据其实全在本机,只是没人告诉它路径。
21
21
  //
22
- // 这里做三件事,缺一不可:
23
- // 1. 给**摘要** —— 项目清单 + 各列任务计数(顺着看板口径,一眼看到进度);
24
- // 2. 给**真相源文件路径** —— 要细节自己去读;
25
- // 3. 给**用户偏好** —— 克隆仓库优先 SSH。这一条不是"运行环境"的事实,而是工作台任务的
26
- // Agent 是外部 CLI(claude / opencode),系统提示词不归我们写 —— 这里是唯一能把偏好
27
- // 递进去的地方。少这一句,任务里克隆仓库就会退回 https,弹凭据窗口把任务停住。
28
- // 刻意不把 tasks.json 全文塞进 prompt:几十条任务、每条还挂着执行输出,
29
- // 既烧 token,又会把用户真正要办的那句话淹掉。
22
+ // ── 与 aiContext 的关系(两条视图、一个口径)────────────────────────────
23
+ //
24
+ // 数据**不在这里取**。项目清单 / 各列计数 / 合计 / 真相源路径全部来自
25
+ // aiContext 的 `tasks` 板块(collectors.js 的 collectWorkbenchTasks),本文件
26
+ // 只负责把它渲染成"编排台视角"该有的样子:
27
+ // · 标出**当前任务属于哪个项目**(`← 当前`)—— 派发出来的任务知道自己是谁,
28
+ // 这一点在七个板块那份视图里没有;
29
+ // · 带上**用户偏好**(克隆优先 SSH);
30
+ // · 真相源清单里多一个 orchestrator.json(调度台指令流水)。
31
+ //
32
+ // 为什么不各自算一遍:以前这里自己读 tasks.json + 最近目录 + 任务分列,与
33
+ // aiContext 的 tasks 板块是**同一份统计的第二份实现**。两份实现走同一天开始分叉:
34
+ // 改一处漏一处不会报错,只会让 Agent 在编排台里和在智能体页里看到**不同的项目数**。
35
+ // 现在只有 collectWorkbenchTasks 一份。
30
36
  //
31
37
  // ⚠️ 文案为什么可以是中文硬编码(与 orchestratorStore 的「服务端不写死中文」不冲突):
32
38
  // 那一条约束针对的是**回给 UI 渲染的文案**,而这里是喂给模型的 prompt 内容,
33
39
  // 不经过前端 $t(),也不会显示在界面上。反过来说,它也不该跟着界面语言变 ——
34
40
  // 中文指令配中文上下文,模型理解得更稳。
35
41
  //
36
- // 纯函数:只吃入参、不读文件(文件读取在调用方 routes/workbench/index.js)。
42
+ // 纯函数:只吃入参、不读文件(取数在 workbench/index.js,数据在快照里)。
37
43
  // 这样单测不用碰真实用户数据 —— 与 promptParts.js 同一个理由。
38
- // 统计口径复用 projectRegistry 的 summarizeProjectTasks:「任务算哪一列」
39
- // 全局只有这一个实现,看板怎么分列,这里就怎么统计。
40
44
 
41
- import { summarizeProjectTasks, canonicalProjectPath, TASK_COLUMNS } from './projectRegistry.js';
45
+ import { canonicalProjectPath, TASK_COLUMNS } from './projectRegistry.js';
42
46
 
43
47
  /** 项目清单最多列几个:再多就该让 Agent 自己去读文件,而不是往 prompt 里堆 */
44
48
  export const ENV_CONTEXT_MAX_PROJECTS = 30;
@@ -60,45 +64,25 @@ const columnLabels = TASK_COLUMNS.map(k => COLUMN_LABELS[k] || k);
60
64
  *
61
65
  * @param {object} input
62
66
  * @param {string} input.currentProjectPath 本次任务的工作目录(= Agent 的 cwd)
63
- * @param {object[]} input.projects 项目条目(buildProjectEntries 的输出:{name, path, key})
64
- * @param {object[]} input.tasks tasks.json 里的全部任务
65
- * @param {object[]} input.jobs 执行记录(snapshotJobs() 的结果)
66
- * @param {string} input.tasksFile 真相源文件绝对路径(下面几个同理)
67
- * @param {string} input.jobsFile
68
- * @param {string} input.orchestratorFile
69
- * @param {string} input.configFile
67
+ * @param {object} [input.board] aiContext `tasks` 板块的结构化数据
68
+ * ({ projects, statsByKey, total, runningJobs })。
69
+ * 取不到时项目那一段整体省掉,但偏好与真相源照给。
70
+ * @param {object} [input.truthFiles] { tasksFile, jobsFile, orchestratorFile, configFile }
71
+ * 路径口径的唯一来源见 workbench/shared.js 的 TRUTH_FILES
70
72
  * @param {number} [input.maxProjects] 只影响列出条数,不影响合计统计
71
- * @returns {string|null} 上下文块;没有任何项目可列时返回 null(调用方据此跳过注入)
73
+ * @returns {string} 上下文块。**不返回 null** —— 见下面的注释。
72
74
  */
73
75
  export function buildEnvContextBlock({
74
76
  currentProjectPath = '',
75
- projects = [],
76
- tasks = [],
77
- jobs = [],
78
- tasksFile = '',
79
- jobsFile = '',
80
- orchestratorFile = '',
81
- configFile = '',
77
+ board = null,
78
+ truthFiles = {},
82
79
  maxProjects = ENV_CONTEXT_MAX_PROJECTS,
83
80
  } = {}) {
84
- const list = Array.isArray(projects) ? projects.filter(p => p && p.key) : [];
85
- if (list.length === 0) return null;
86
-
87
- const stats = summarizeProjectTasks(
88
- Array.isArray(tasks) ? tasks : [],
89
- Array.isArray(jobs) ? jobs : [],
90
- );
81
+ const list = Array.isArray(board?.projects) ? board.projects.filter(p => p && p.key) : [];
82
+ const statsByKey = (board?.statsByKey && typeof board.statsByKey === 'object') ? board.statsByKey : {};
83
+ const total = board?.total || {};
84
+ const runningJobs = Number(board?.runningJobs) || 0;
91
85
 
92
- // 合计按全部任务算(含「未关联项目」那一桶),所以不能用 list 求和
93
- const total = { total: 0 };
94
- for (const key of TASK_COLUMNS) total[key] = 0;
95
- for (const s of stats.values()) {
96
- total.total += s.total;
97
- for (const key of TASK_COLUMNS) total[key] += s[key] || 0;
98
- }
99
-
100
- const limit = Number.isFinite(maxProjects) && maxProjects > 0 ? Math.floor(maxProjects) : ENV_CONTEXT_MAX_PROJECTS;
101
- const shown = list.slice(0, limit);
102
86
  // 当前项目的比对**必须归一化后再比**:条目 key 是 canonicalProjectPath 的结果
103
87
  // (Windows 形式小写 + 斜杠归一如 d:\ws\x),而调用方给的 currentProjectPath 是原始写法
104
88
  // (盘符/目录段大小写都不一定一致)。直接拿 path 跟 key 比会永远不等 —— 同款口径分叉
@@ -110,22 +94,35 @@ export function buildEnvContextBlock({
110
94
  lines.push('');
111
95
  lines.push(`当前任务所在项目(也是你的工作目录): ${currentProjectPath || '(未指定)'}`);
112
96
  lines.push('');
113
- lines.push(`项目清单(共 ${list.length} 个;格式: 名称 | 路径 | ${columnLabels.join('/')}):`);
114
- for (const p of shown) {
115
- const s = stats.get(p.key) || {};
116
- const mark = currentKey && p.key === currentKey ? ' ← 当前' : '';
117
- lines.push(`- ${p.name || p.key} | ${p.path} | ${TASK_COLUMNS.map(k => s[k] || 0).join('/')}${mark}`);
118
- }
119
- if (list.length > shown.length) {
120
- lines.push(`- …还有 ${list.length - shown.length} 个未列出(读下面的文件可以看全)`);
97
+
98
+ if (list.length > 0) {
99
+ const limit = Number.isFinite(maxProjects) && maxProjects > 0 ? Math.floor(maxProjects) : ENV_CONTEXT_MAX_PROJECTS;
100
+ const shown = list.slice(0, limit);
101
+ lines.push(`项目清单(共 ${list.length} 个;格式: 名称 | 路径 | ${columnLabels.join('/')}):`);
102
+ for (const p of shown) {
103
+ const s = statsByKey[p.key] || {};
104
+ const mark = currentKey && p.key === currentKey ? ' ← 当前' : '';
105
+ lines.push(`- ${p.name || p.key} | ${p.path} | ${TASK_COLUMNS.map(k => s[k] || 0).join('/')}${mark}`);
106
+ }
107
+ if (list.length > shown.length) {
108
+ lines.push(`- …还有 ${list.length - shown.length} 个未列出(读下面的文件可以看全)`);
109
+ }
110
+ lines.push('');
111
+ lines.push(
112
+ `看板任务概览:全部项目合计 ${total.total || 0} 条 —— ` +
113
+ TASK_COLUMNS.map(k => `${COLUMN_LABELS[k]} ${total[k] || 0}`).join(' / ') +
114
+ (runningJobs > 0 ? `,正在执行 ${runningJobs} 个` : ',当前无执行中的任务'),
115
+ );
116
+ lines.push('');
117
+ } else {
118
+ // 快照还没生成好(或 tasks 板块取数失败)。项目那一段省掉,**但偏好与真相源照给** ——
119
+ // 它们不需要取数,而且 SSH 那条少一次,任务就会在克隆仓库时退回 https、
120
+ // 弹出凭据窗口把任务停住(这正是文件头第 3 条要防的)。
121
+ lines.push('项目清单:本次未能取到(看板快照还没生成好)。需要时直接读下面的 tasks.json。');
122
+ lines.push('');
121
123
  }
122
- lines.push('');
123
- lines.push(
124
- `看板任务概览:全部项目合计 ${total.total} 条 —— ` +
125
- TASK_COLUMNS.map(k => `${COLUMN_LABELS[k]} ${total[k] || 0}`).join(' / '),
126
- );
127
- lines.push('');
128
- // 用户偏好(见文件头第 3 条)。放在"读文件"之前、"看板概览"之后 ——
124
+
125
+ // 用户偏好(见文件头)。放在"读文件"之前、"看板概览"之后 ——
129
126
  // 它是**行为**约定,与上面的事实清单分开;压尾的那句仍是"去读文件",
130
127
  // 那是整块上下文里最需要被记住的动作。
131
128
  lines.push(
@@ -137,10 +134,10 @@ export function buildEnvContextBlock({
137
134
  );
138
135
  lines.push('');
139
136
  lines.push('需要细节时直接读这些文件(本机绝对路径,你有读取权限,不必先问用户):');
140
- if (tasksFile) lines.push(`- ${tasksFile} —— 全部任务全文(标题/描述/子任务状态/报错/projectPath)`);
141
- if (jobsFile) lines.push(`- ${jobsFile} —— 历次执行记录(状态与输出)`);
142
- if (orchestratorFile) lines.push(`- ${orchestratorFile} —— 用户在调度台发过的指令流水`);
143
- if (configFile) lines.push(`- ${configFile} —— 应用配置(projects / recentDirectories)`);
137
+ if (truthFiles.tasksFile) lines.push(`- ${truthFiles.tasksFile} —— 全部任务全文(标题/描述/子任务状态/报错/projectPath)`);
138
+ if (truthFiles.jobsFile) lines.push(`- ${truthFiles.jobsFile} —— 历次执行记录(状态与输出)`);
139
+ if (truthFiles.orchestratorFile) lines.push(`- ${truthFiles.orchestratorFile} —— 用户在调度台发过的指令流水`);
140
+ if (truthFiles.configFile) lines.push(`- ${truthFiles.configFile} —— 应用配置(projects / recentDirectories)`);
144
141
  lines.push('');
145
142
  lines.push(
146
143
  '用户问到"我有哪些项目""某件事进展如何"这类问题时,先读上面的文件再回答;' +
@@ -14,23 +14,32 @@
14
14
  //
15
15
  // 运行环境上下文(envContext.js)纯函数单测。
16
16
  //
17
- // 这里守的是两件事:
17
+ // 这里守的是三件事:
18
18
  // 1. 注入出去的块里**真的**有项目名/路径/计数与真相源文件路径 ——
19
19
  // 否则"Agent 知道我有哪些项目"就是空话(旧行为:prompt 里只有用户原话);
20
- // 2. 计数口径与看板列一致(复用 summarizeProjectTasks,不另立一套)。
20
+ // 2. 计数口径与看板列一致 —— 而且**数据是从 aiContext 的 tasks 板块来的**
21
+ // (不再有第二份 buildProjectEntries + summarizeProjectTasks 实现);
22
+ // 3. 快照取不到时**降级但不丢关键信息**:项目清单可以没有,
23
+ // 但"克隆优先 SSH"这条偏好必须还在。
24
+ //
25
+ // ⚠️ board 一律用**真实的** collectWorkbenchTasks 产出,不手搓:
26
+ // 手搓的话,第 2 条就变成了"测试自己跟自己对",把口径分叉放走。
21
27
 
22
28
  import { test } from 'node:test';
23
29
  import assert from 'node:assert/strict';
24
30
 
25
31
  import { buildEnvContextBlock, ENV_CONTEXT_MAX_PROJECTS } from './envContext.js';
26
- import { canonicalProjectPath, TASK_COLUMNS } from './projectRegistry.js';
32
+ import { TASK_COLUMNS } from './projectRegistry.js';
33
+ import { TRUTH_FILES } from './shared.js';
34
+ import { collectWorkbenchTasks } from '../aiContext/collectors.js';
27
35
 
28
- const entry = (path) => ({
29
- path,
30
- key: canonicalProjectPath(path),
31
- name: path.split(/[\\/]/).filter(Boolean).pop(),
32
- source: 'recent',
33
- });
36
+ /** 用真实的 collector 造 board —— envContext 在生产里吃到的就是它 */
37
+ const boardOf = ({ tasks = [], jobs = [], recentDirs = [] } = {}) => {
38
+ const r = collectWorkbenchTasks({ tasks, jobs, recentDirs });
39
+ assert.equal(r.ok, true, 'collectWorkbenchTasks 应当成功');
40
+ assert.ok(r.data, 'tasks 板块必须带上结构化 data(envContext 的唯一数据来源)');
41
+ return r.data;
42
+ };
34
43
 
35
44
  const job = (id, taskId, status) => ({
36
45
  id, taskId, subId: `${taskId}__x`, title: '', status,
@@ -38,39 +47,41 @@ const job = (id, taskId, status) => ({
38
47
  endedAt: status === 'running' ? null : '2026-09-18T02:01:00.000Z',
39
48
  });
40
49
 
41
- const PATHS = {
42
- tasksFile: '/home/u/.zen-gitsync/tasks.json',
43
- jobsFile: '/home/u/.zen-gitsync/jobs.json',
44
- orchestratorFile: '/home/u/.zen-gitsync/orchestrator.json',
45
- configFile: '/home/u/.zen-gitsync/config.json',
46
- };
50
+ const build = (opts) => buildEnvContextBlock({ truthFiles: TRUTH_FILES, ...opts });
47
51
 
48
- test('没有任何项目时返回 null(不注入空块浪费 token)', () => {
49
- assert.equal(buildEnvContextBlock({}), null);
50
- assert.equal(buildEnvContextBlock(), null);
51
- assert.equal(buildEnvContextBlock({ projects: [] }), null);
52
- // 只有 key 缺项的脏条目也算没有项目
53
- assert.equal(buildEnvContextBlock({ projects: [{ name: 'x', path: '' }] }), null);
52
+ test('取不到 board 时**不返回 null**:偏好与真相源照给(项目清单整体省掉)', () => {
53
+ // 这一条是刻意的行为变更。老版本"没有项目就返回 null",但块里那两条**不需要取数**
54
+ // 的内容(克隆优先 SSH、真相源路径)恰恰是最不该丢的 —— SSH 那条少一次,
55
+ // 任务克隆仓库时就会退回 https、弹出凭据窗口把自己停在半路。
56
+ for (const board of [null, undefined, {}, { projects: [] }, { projects: [{ name: 'x' }] }]) {
57
+ const block = build({ currentProjectPath: 'D:\\ws\\a', board });
58
+ assert.ok(block, 'board 缺失也必须产出块');
59
+ assert.match(block, /优先使用 SSH/);
60
+ for (const p of Object.values(TRUTH_FILES)) assert.ok(block.includes(p), `缺少路径 ${p}`);
61
+ // 但项目那段不能硬编一个空清单(模型会把"没列出来"读成"没有项目")
62
+ assert.doesNotMatch(block, /项目清单(共 0 个/);
63
+ if (!board || !board.projects || board.projects.length === 0) {
64
+ assert.match(block, /项目清单:本次未能取到/);
65
+ }
66
+ }
54
67
  });
55
68
 
56
69
  test('项目清单带名称、路径与三列计数,并标出当前项目', () => {
57
- const projects = [entry('D:\\ws\\zen-gitsync'), entry('D:\\ws\\article-generator')];
58
- const tasks = [
59
- // 已完成:simple 任务跑过一次且 job 是 done
60
- { id: 't1', projectPath: 'D:\\ws\\zen-gitsync', type: 'simple' },
61
- // 进行中:有 job 处于 running
62
- { id: 't2', projectPath: 'D:\\ws\\article-generator', type: 'simple' },
63
- // 待处理:没子任务也没执行过
64
- { id: 't3', projectPath: 'D:\\ws\\article-generator', type: 'simple' },
65
- ];
66
- const jobs = [job('j1', 't1', 'done'), job('j2', 't2', 'running')];
67
-
68
- const block = buildEnvContextBlock({
69
- currentProjectPath: 'D:\\ws\\zen-gitsync',
70
- projects, tasks, jobs, ...PATHS,
70
+ const board = boardOf({
71
+ recentDirs: ['D:\\ws\\zen-gitsync', 'D:\\ws\\article-generator'],
72
+ tasks: [
73
+ // 已完成:simple 任务跑过一次且 job 是 done
74
+ { id: 't1', projectPath: 'D:\\ws\\zen-gitsync', type: 'simple' },
75
+ // 进行中:有 job 处于 running
76
+ { id: 't2', projectPath: 'D:\\ws\\article-generator', type: 'simple' },
77
+ // 待处理:没子任务也没执行过
78
+ { id: 't3', projectPath: 'D:\\ws\\article-generator', type: 'simple' },
79
+ ],
80
+ jobs: [job('j1', 't1', 'done'), job('j2', 't2', 'running')],
71
81
  });
72
82
 
73
- assert.ok(block, '应当产出上下文块');
83
+ const block = build({ currentProjectPath: 'D:\\ws\\zen-gitsync', board });
84
+
74
85
  // 项目名 + 路径都在
75
86
  assert.match(block, /zen-gitsync/);
76
87
  assert.match(block, /article-generator/);
@@ -78,10 +89,11 @@ test('项目清单带名称、路径与三列计数,并标出当前项目', ()
78
89
  // 三列计数:zen-gitsync 1 条已完成 → 0/0/1;article-generator 1 进行中 1 待处理 → 1/1/0
79
90
  assert.match(block, /- zen-gitsync \| D:\\ws\\zen-gitsync \| 0\/0\/1\s+← 当前/);
80
91
  assert.match(block, /- article-generator \| D:\\ws\\article-generator \| 1\/1\/0/);
81
- // 合计:3 条,各列各一条
92
+ // 合计:3 条,各列各一条;顺带带上"正在执行 1 个"
82
93
  assert.match(block, /全部项目合计 3 条 —— 待处理 1 \/ 进行中 1 \/ 已完成 1/);
94
+ assert.match(block, /正在执行 1 个/);
83
95
  // 真相源路径:这是"够得着"的关键,缺了 Agent 还是只能猜
84
- for (const p of Object.values(PATHS)) assert.ok(block.includes(p), `缺少路径 ${p}`);
96
+ for (const p of Object.values(TRUTH_FILES)) assert.ok(block.includes(p), `缺少路径 ${p}`);
85
97
  // 明确要求它去读文件,而不是回"我看不到"
86
98
  assert.match(block, /不要回答"我看不到\/无法访问"/);
87
99
  });
@@ -89,29 +101,31 @@ test('项目清单带名称、路径与三列计数,并标出当前项目', ()
89
101
  test('当前项目标记必须归一化后比较(大小写不同也算同一个)', () => {
90
102
  // 条目 key 是归一化过的(小写 + 反斜杠),而 cwd 常常是另一种写法 ——
91
103
  // 直接拿 path 跟 key 比会永远不相等,表现为"当前项目"标记丢失。
92
- const projects = [entry('C:\\Users\\xuze3'), entry('D:\\ws\\other')];
93
- const block = buildEnvContextBlock({
104
+ const board = boardOf({
105
+ recentDirs: ['C:\\Users\\xuze3', 'D:\\ws\\other'],
106
+ tasks: [],
107
+ jobs: [],
108
+ });
109
+ const block = build({
94
110
  // 不只盘符:目录段大小写也不一样(左栏重复项那个 bug 的同款写法差异)
95
111
  currentProjectPath: 'c:/users/XUZE3',
96
- projects, tasks: [], jobs: [], ...PATHS,
112
+ board,
97
113
  });
98
114
  assert.match(block, /- xuze3 \| C:\\Users\\xuze3 \| 0\/0\/0\s+← 当前/);
99
115
  assert.match(block, /- other \| D:\\ws\\other \| 0\/0\/0\n/);
100
116
  });
101
117
 
102
118
  test('项目数超过上限时只列前 N 个,但合计仍按全量算', () => {
103
- const projects = [];
104
- const tasks = [];
105
119
  const total = ENV_CONTEXT_MAX_PROJECTS + 5;
120
+ const recentDirs = [];
121
+ const tasks = [];
106
122
  for (let i = 0; i < total; i += 1) {
107
123
  const p = `D:\\ws\\p${i}`;
108
- projects.push(entry(p));
124
+ recentDirs.push(p);
109
125
  tasks.push({ id: `t${i}`, projectPath: p, type: 'simple' });
110
126
  }
111
127
 
112
- const block = buildEnvContextBlock({
113
- currentProjectPath: '', projects, tasks, jobs: [], ...PATHS,
114
- });
128
+ const block = build({ currentProjectPath: '', board: boardOf({ recentDirs, tasks }) });
115
129
 
116
130
  const listed = block.split('\n').filter(l => l.startsWith('- p') && l.includes('| D:'));
117
131
  assert.equal(listed.length, ENV_CONTEXT_MAX_PROJECTS);
@@ -122,21 +136,23 @@ test('项目数超过上限时只列前 N 个,但合计仍按全量算', () =>
122
136
  });
123
137
 
124
138
  test('maxProjects 可覆盖,且非法值回落到默认上限', () => {
125
- const projects = [entry('D:\\ws\\a'), entry('D:\\ws\\b'), entry('D:\\ws\\c')];
126
- const short = buildEnvContextBlock({ projects, tasks: [], jobs: [], maxProjects: 1, ...PATHS });
139
+ const board = boardOf({ recentDirs: ['D:\\ws\\a', 'D:\\ws\\b', 'D:\\ws\\c'], tasks: [] });
140
+ const short = build({ board, maxProjects: 1 });
127
141
  assert.equal(short.split('\n').filter(l => l.startsWith('- a') || l.startsWith('- b') || l.startsWith('- c')).length, 1);
128
142
 
129
- const bad = buildEnvContextBlock({ projects, tasks: [], jobs: [], maxProjects: 0, ...PATHS });
143
+ const bad = build({ board, maxProjects: 0 });
130
144
  assert.equal(bad.split('\n').filter(l => l.startsWith('- ') && l.includes('| D:')).length, 3);
131
145
  });
132
146
 
133
147
  test('没关联项目的任务不计入项目行,但计入合计', () => {
134
- const projects = [entry('D:\\ws\\a')];
135
- const tasks = [
136
- { id: 't1', projectPath: 'D:\\ws\\a', type: 'simple' },
137
- { id: 't2', projectPath: '', type: 'simple' },
138
- ];
139
- const block = buildEnvContextBlock({ projects, tasks, jobs: [], ...PATHS });
148
+ const board = boardOf({
149
+ recentDirs: ['D:\\ws\\a'],
150
+ tasks: [
151
+ { id: 't1', projectPath: 'D:\\ws\\a', type: 'simple' },
152
+ { id: 't2', projectPath: '', type: 'simple' },
153
+ ],
154
+ });
155
+ const block = build({ board });
140
156
  // 项目行只认 key 匹配得上的那条(t1)→ 待处理 1
141
157
  assert.match(block, /- a \| D:\\ws\\a \| 1\/0\/0/);
142
158
  // 而合计走 NO_PROJECT_KEY 那桶一起算 → 2 条;两者不相等正是这段断言要守的
@@ -151,12 +167,14 @@ test('没关联项目的任务不计入项目行,但计入合计', () => {
151
167
  // 只顺手改了最显眼的一行 → 另外三处照红。下面这条不比对具体数字,只守"段数 == 看板列数",
152
168
  // 下次增删列时它和上面几条会一起红,而不是让某一条孤零零地报一个对不上的字符串。
153
169
  test('项目行/合计的段数必须与 TASK_COLUMNS 一致,且每列都有中文标签', () => {
154
- const projects = [entry('D:\\ws\\a')];
155
- const tasks = [
156
- { id: 't1', projectPath: 'D:\\ws\\a', type: 'simple' },
157
- { id: 't2', projectPath: 'D:\\ws\\a', type: 'simple' },
158
- ];
159
- const block = buildEnvContextBlock({ projects, tasks, jobs: [], ...PATHS });
170
+ const board = boardOf({
171
+ recentDirs: ['D:\\ws\\a'],
172
+ tasks: [
173
+ { id: 't1', projectPath: 'D:\\ws\\a', type: 'simple' },
174
+ { id: 't2', projectPath: 'D:\\ws\\a', type: 'simple' },
175
+ ],
176
+ });
177
+ const block = build({ board });
160
178
 
161
179
  // 表头里的「待处理/进行中/已完成」:段数 = 列数,且不能有一列回落成 key 本身
162
180
  const headerLabels = block.match(/格式: 名称 \| 路径 \| ([^)]+)/)[1].split('/');
@@ -177,16 +195,68 @@ test('项目行/合计的段数必须与 TASK_COLUMNS 一致,且每列都有
177
195
  assert.equal(rowCounts[TASK_COLUMNS.indexOf('todo')], '2');
178
196
  });
179
197
 
198
+ // ── 口径同源:这一条守的是"别再长出第二份实现"────────────────────────
199
+ //
200
+ // 改动前 envContext 自己读 tasks.json、自己做 buildProjectEntries + summarizeProjectTasks,
201
+ // 与 aiContext 的 tasks 板块是同一口径的两个副本。两份实现分叉时**不会报错**,
202
+ // 只会让 Agent 在编排台里和智能体页里看到不同的项目数。这条断言把两份绑在一起:
203
+ // envContext 渲染出来的每个数字,都必须能在 collector 的摘要里找到。
204
+ test('口径同源:envContext 的数字必须与 tasks 板块的摘要逐项对上', () => {
205
+ const tasks = [
206
+ { id: 't1', projectPath: 'D:\\ws\\a', type: 'simple' },
207
+ { id: 't2', projectPath: 'D:\\ws\\a', type: 'simple' },
208
+ { id: 't3', projectPath: 'D:\\ws\\b', type: 'simple' },
209
+ { id: 't4', projectPath: '', type: 'simple' },
210
+ ];
211
+ const jobs = [job('j1', 't3', 'done')];
212
+ const section = collectWorkbenchTasks({ tasks, jobs, recentDirs: ['D:\\ws\\a', 'D:\\ws\\b'] });
213
+ const block = buildEnvContextBlock({
214
+ currentProjectPath: 'D:\\ws\\b',
215
+ board: section.data,
216
+ truthFiles: TRUTH_FILES,
217
+ });
218
+
219
+ // ① 合计那一句:collector 的 summary 里是「全部项目合计 4 条 —— 待处理 3 / 进行中 0 / 已完成 1」,
220
+ // envContext 必须复现出同样的数字(前缀文案相同,直接整句比对最省事)。
221
+ const totals = section.summary.match(/全部项目合计 \d+ 条 —— [^,]+/)[0];
222
+ assert.ok(block.includes(totals), `envContext 的合计与板块摘要不一致:\n${block}`);
223
+
224
+ // ② 项目数:摘要里的项目行数与 envContext 列出的行数一致
225
+ const collectorRows = section.lines.filter(l => /^- .+ \| .+ \| \d+\/\d+\/\d+$/.test(l));
226
+ const envRows = block.split('\n').filter(l => /^- .+ \| .+ \| \d+\/\d+\/\d+/.test(l));
227
+ assert.equal(envRows.length, collectorRows.length, '两份视图列出的项目数应一致');
228
+ assert.ok(collectorRows.length > 0, '这个用例应当至少有一个项目');
229
+
230
+ // ③ 结构化 data 自己也得自洽:statsByKey 的合计 == total
231
+ const sum = { total: 0 };
232
+ for (const k of TASK_COLUMNS) sum[k] = 0;
233
+ for (const s of Object.values(section.data.statsByKey)) {
234
+ sum.total += s.total || 0;
235
+ for (const k of TASK_COLUMNS) sum[k] += s[k] || 0;
236
+ }
237
+ assert.equal(sum.total, section.data.total.total);
238
+ for (const k of TASK_COLUMNS) assert.equal(sum[k], section.data.total[k]);
239
+ });
240
+
180
241
  // ── 用户偏好:克隆仓库优先 SSH ────────────────────────────────────────
181
242
  //
182
243
  // 这条只对**工作台任务**起作用(执行器是外部 CLI claude / opencode,系统提示词不归我们写,
183
244
  // envContext 是唯一能把偏好递进去的地方)。断言钉住两个平台的地址都写全 —— 用户实际踩的
184
245
  // 那个坑就是 Gitee 走 https 弹了凭据窗口;只留 GitHub 一条等于只覆盖一半场景。
185
246
  test('注入块带上「克隆优先 SSH」这条用户偏好', () => {
186
- const block = buildEnvContextBlock({ projects: [entry('D:\\ws\\a')], tasks: [], jobs: [], ...PATHS });
247
+ const block = build({ board: boardOf({ recentDirs: ['D:\\ws\\a'], tasks: [] }) });
187
248
  assert.match(block, /优先使用 SSH/);
188
249
  assert.match(block, /git@github\.com:owner\/repo\.git/);
189
250
  assert.match(block, /git@gitee\.com:owner\/repo\.git/);
190
251
  // 退回条件也得在:没写清"什么时候可以退回",模型会在 SSH 失败后来回重试
191
252
  assert.match(block, /Permission denied \(publickey\)/);
192
253
  });
254
+
255
+ test('真相源清单来自唯一的 TRUTH_FILES(四个路径一个不落,且不多写)', () => {
256
+ const block = build({ board: boardOf({ recentDirs: ['D:\\ws\\a'], tasks: [] }) });
257
+ const listed = block.split('\n').filter(l => l.startsWith('- ') && l.includes(' —— ') && l.includes('json'));
258
+ assert.equal(listed.length, Object.keys(TRUTH_FILES).length);
259
+ // 编排台指令流水必须在这份清单里 —— 它是 envContext 相对七个板块视图**多**出来的那一条
260
+ assert.ok(block.includes(TRUTH_FILES.orchestratorFile));
261
+ assert.match(block, /调度台发过的指令流水/);
262
+ });