zen-gitsync 2.17.28 → 2.17.29

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 (76) hide show
  1. package/README.md +8 -6
  2. package/package.json +4 -1
  3. package/src/ui/public/assets/{AgentEngineSelector-D8iaDrev.js → AgentEngineSelector-Wl7dKYKL.js} +2 -2
  4. package/src/ui/public/assets/{AgentView-BaYMwfC4.css → AgentView-Bebv5xk6.css} +1 -1
  5. package/src/ui/public/assets/AgentView-CebQ3545.js +11 -0
  6. package/src/ui/public/assets/AppVersionBadge-jm8ejuZ-.js +2 -0
  7. package/src/ui/public/assets/{BranchSelector-CTrTouMm.js → BranchSelector-CcNILIbg.js} +1 -1
  8. package/src/ui/public/assets/CommitForm-1ocxo8Cb.js +6 -0
  9. package/src/ui/public/assets/{CommonDialog-Bwj--3EO.js → CommonDialog-DpFTO5cg.js} +1 -1
  10. package/src/ui/public/assets/EditorView-CtRHV1Sv.js +37 -0
  11. package/src/ui/public/assets/FileDiffViewer-DXJO-G55.js +15 -0
  12. package/src/ui/public/assets/{FlowExecutionViewer-awWx7pMb.js → FlowExecutionViewer-d4O5Y9Ca.js} +1 -1
  13. package/src/ui/public/assets/FlowOrchestrationWorkspace-CKLRMeaL.js +21 -0
  14. package/src/ui/public/assets/{IconButton-IoukQQts.js → IconButton-Dl1cdP_x.js} +1 -1
  15. package/src/ui/public/assets/{LogList-Ca0hE-S0.js → LogList-CdDRC_CO.js} +4 -4
  16. package/src/ui/public/assets/MindmapView-U84MWPHc.js +2 -0
  17. package/src/ui/public/assets/MonitorView-spOV0cP7.js +1 -0
  18. package/src/ui/public/assets/{ProjectStartupButton-C8OZPgjM.js → ProjectStartupButton-BbAbfFn2.js} +1 -1
  19. package/src/ui/public/assets/{RecentDirectoriesChat-DFGIm3ab.js → RecentDirectoriesChat-CxNjjgVG.js} +2 -2
  20. package/src/ui/public/assets/RemoteManagerDialog-BVm2-Hn4.js +4 -0
  21. package/src/ui/public/assets/{RemoteRepoCard-HV8oHZL_.js → RemoteRepoCard-UHK_hrDS.js} +2 -2
  22. package/src/ui/public/assets/{SourceMapView-ByGif9Z8.js → SourceMapView-PHMr0wt3.js} +2 -2
  23. package/src/ui/public/assets/{SvgIcon-Cv-Ewpj-.js → SvgIcon-Dk7WnZLB.js} +1 -1
  24. package/src/ui/public/assets/{TemplateManager-DBuPZE4y.js → TemplateManager-B3ZNTXnP.js} +1 -1
  25. package/src/ui/public/assets/UserInputNode-DeAD7H0z.js +1 -0
  26. package/src/ui/public/assets/WorkbenchView-DsZ2ONpW.js +143 -0
  27. package/src/ui/public/assets/WorkbenchView-kZ8DoH2l.css +1 -0
  28. package/src/ui/public/assets/{_plugin-vue_export-helper-BP5VbhV0.js → _plugin-vue_export-helper-BKeZSroN.js} +5 -5
  29. package/src/ui/public/assets/{agentConversations-CDIB8kgC.js → agentConversations-ChTc-LkQ.js} +5 -5
  30. package/src/ui/public/assets/{configStore-eQKYDKzd.js → configStore-fJaic3kE.js} +1 -1
  31. package/src/ui/public/assets/{css.worker-BDCmgL4U.js → css.worker-DFdt6_zu.js} +9 -9
  32. package/src/ui/public/assets/editor.worker-O-CH8JUO.js +26 -0
  33. package/src/ui/public/assets/element-plus-D0R8BMj4.js +48 -0
  34. package/src/ui/public/assets/{element-plus-C1xzgNSR.css → element-plus-Qexo5hiM.css} +1 -1
  35. package/src/ui/public/assets/{flow-mindmap-B3900Z4K.js → flow-mindmap-Doi70WWn.js} +1 -1
  36. package/src/ui/public/assets/gitStore-D9oF5nof.js +6 -0
  37. package/src/ui/public/assets/{html.worker-DG8hnSJR.js → html.worker-JUzsMP-j.js} +8 -8
  38. package/src/ui/public/assets/index-CKxygqdx.js +148 -0
  39. package/src/ui/public/assets/json.worker-B4dEhy0e.js +58 -0
  40. package/src/ui/public/assets/{monaco-BScz9v63.js → monaco-DkJuNcCr.js} +6 -6
  41. package/src/ui/public/assets/{office-docx-C8sYW8O1.js → office-docx-O0YXk5gA.js} +1 -1
  42. package/src/ui/public/assets/{office-excel-DpZn5My2.js → office-excel-CXD9VwgB.js} +1 -1
  43. package/src/ui/public/assets/{office-pptx-BnYBTwT7.js → office-pptx-CiF2OlTq.js} +3 -3
  44. package/src/ui/public/assets/previewSandbox-21uStCdd.js +9 -0
  45. package/src/ui/public/assets/{toolsStore-C4B7BL-O.js → toolsStore-C89takm3.js} +1 -1
  46. package/src/ui/public/assets/{ts.worker-DgFMIcEs.js → ts.worker-CmEKzkmF.js} +49 -49
  47. package/src/ui/public/assets/{useThemeObserver-CGbhdyNi.js → useThemeObserver-BvSA7RDh.js} +1 -1
  48. package/src/ui/public/assets/{vendor-CUTaFm6P.js → vendor-CCrjQw5C.js} +2 -2
  49. package/src/ui/public/assets/{vue-flow-CxnoC7Uq.js → vue-flow-5Aq2bwoG.js} +1 -1
  50. package/src/ui/public/index.html +18 -18
  51. package/src/ui/server/routes/fs.js +12 -2
  52. package/src/ui/server/routes/workbench/agentMarketplace.js +130 -23
  53. package/src/ui/server/routes/workbench/agentMarketplace.test.js +68 -1
  54. package/src/ui/server/routes/workbench/envContext.js +69 -29
  55. package/src/ui/server/routes/workbench/envContext.test.js +68 -0
  56. package/src/ui/server/routes/workbench/executorModels.test.js +326 -326
  57. package/src/ui/server/routes/workbench/index.js +10 -3
  58. package/src/ui/server/routes/workbench/taskRunner.js +11 -4
  59. package/src/ui/public/assets/AgentView-BQz9h_Qi.js +0 -10
  60. package/src/ui/public/assets/AppVersionBadge-Mx8QwTA1.js +0 -2
  61. package/src/ui/public/assets/CommitForm-DsfySGI2.js +0 -6
  62. package/src/ui/public/assets/EditorView-DiyJWwAk.js +0 -37
  63. package/src/ui/public/assets/FileDiffViewer-DFFyUPF2.js +0 -15
  64. package/src/ui/public/assets/FlowOrchestrationWorkspace-Brq0M2sZ.js +0 -21
  65. package/src/ui/public/assets/MindmapView-BO_PG-mg.js +0 -2
  66. package/src/ui/public/assets/MonitorView-BXlLpzYU.js +0 -1
  67. package/src/ui/public/assets/RemoteManagerDialog-DFtZmhn4.js +0 -4
  68. package/src/ui/public/assets/UserInputNode-CVKtLvHE.js +0 -1
  69. package/src/ui/public/assets/WorkbenchView-5K0toOqC.js +0 -143
  70. package/src/ui/public/assets/WorkbenchView-BA7mKIr4.css +0 -1
  71. package/src/ui/public/assets/editor.worker-GCPm6rMv.js +0 -26
  72. package/src/ui/public/assets/element-plus-D7LDHcAn.js +0 -48
  73. package/src/ui/public/assets/gitStore-8MMG-kBB.js +0 -6
  74. package/src/ui/public/assets/index-DBOS6HZF.js +0 -148
  75. package/src/ui/public/assets/json.worker-CxwlJRnL.js +0 -58
  76. package/src/ui/public/assets/previewSandbox-C-BRP5dF.js +0 -9
@@ -37,7 +37,7 @@ process.env.HOME = sandboxHome;
37
37
  delete process.env.HOMEDRIVE;
38
38
  delete process.env.HOMEPATH;
39
39
 
40
- const { registerAgentMarketplaceRoutes } = await import('./agentMarketplace.js');
40
+ const { registerAgentMarketplaceRoutes, pickSkillDir, collectSkillDirs } = await import('./agentMarketplace.js');
41
41
  const { AI_SKILLS_DIR, AI_MCP_FILE } = await import('../../../../paths.js');
42
42
 
43
43
  // ── 测试脚手架:把注册的 handler 抓出来直接调 ──────────────────
@@ -336,3 +336,70 @@ test('install: 项目不存在时拒绝,不会把 .claude/skills 建到奇怪的
336
336
  assert.equal(status, 400);
337
337
  assert.match(payload.error, /项目目录不存在/);
338
338
  });
339
+
340
+ // ── 集合型仓库"装哪一个" ────────────────────────────────────────
341
+ // 踩过的坑:Leonxlnx/taste-skill 的 skills/ 下第一个是 brandkit,
342
+ // 旧逻辑"按目录顺序撞见第一个含 SKILL.md 的就装" → 点 taste-skill 装成 brandkit。
343
+ // 卡片 id 仍是 github-Leonxlnx-taste-skill 所以显示"已安装",
344
+ // 已安装列表里却是 brandkit 这个名字,用户根本对不上,只能怀疑没装成功。
345
+
346
+ test('pickSkillDir: 候选里挑与条目同名的那个,不取目录顺序第一个', () => {
347
+ const candidates = [
348
+ { dir: 'T/skills/brandkit', rel: 'skills/brandkit' },
349
+ { dir: 'T/skills/taste-skill', rel: 'skills/taste-skill' },
350
+ { dir: 'T/skills/taste-skill-v1', rel: 'skills/taste-skill-v1' },
351
+ ];
352
+ assert.equal(pickSkillDir(candidates, { skillName: 'taste-skill' }).rel, 'skills/taste-skill');
353
+ });
354
+
355
+ test('pickSkillDir: 条目给了子目录时以子目录为准,名字对得上也不用', () => {
356
+ const candidates = [
357
+ { dir: 'T/skills/taste', rel: 'skills/taste' },
358
+ { dir: 'T/skills/taste-skill', rel: 'skills/taste-skill' },
359
+ ];
360
+ assert.equal(pickSkillDir(candidates, { subpath: 'skills/taste', skillName: 'taste-skill' }).rel, 'skills/taste');
361
+ });
362
+
363
+ test('pickSkillDir: 名字对不上且候选并列时返回 null(报错,而不是猜一个)', () => {
364
+ const candidates = [
365
+ { dir: 'T/alpha', rel: 'alpha' },
366
+ { dir: 'T/beta', rel: 'beta' },
367
+ ];
368
+ assert.equal(pickSkillDir(candidates, { skillName: 'whatever' }), null);
369
+ });
370
+
371
+ test('pickSkillDir: 短目录名不会被裸 includes 误判成变体', () => {
372
+ const only = [{ dir: 'T/a', rel: 'a' }];
373
+ // wantName='whatever' 包含子串 'a',裸 includes 会把它当 rank 3 命中
374
+ assert.equal(pickSkillDir([...only, { dir: 'T/b', rel: 'b' }], { skillName: 'whatever' }), null);
375
+ assert.equal(pickSkillDir(only, { skillName: 'whatever' }).rel, 'a');
376
+ });
377
+
378
+ test('pickSkillDir: 仓库根就一份 SKILL.md 时优先于没名字的深层目录', () => {
379
+ const candidates = [
380
+ { dir: 'T/sub/other', rel: 'sub/other' },
381
+ { dir: 'T', rel: '' },
382
+ ];
383
+ assert.equal(pickSkillDir(candidates, { skillName: 'anything' }).rel, '');
384
+ });
385
+
386
+ test('pickSkillDir: 没有候选时返回 null(调用方报"仓库里没有 SKILL.md")', () => {
387
+ assert.equal(pickSkillDir([], { skillName: 'x' }), null);
388
+ });
389
+
390
+ test('collectSkillDirs: 收两层以内的 SKILL.md,跳过 .git 与点目录', async () => {
391
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-mp-collect-'));
392
+ const write = async rel => {
393
+ await fs.mkdir(path.join(root, rel), { recursive: true });
394
+ await fs.writeFile(path.join(root, rel, 'SKILL.md'), `---\nname: ${path.basename(rel)}\n---\n`, 'utf8');
395
+ };
396
+ await write('skills/brandkit');
397
+ await write('skills/deep/nested/too-deep');
398
+ await write('.claude-plugin');
399
+ await fs.mkdir(path.join(root, '.git'), { recursive: true });
400
+ await fs.writeFile(path.join(root, '.git', 'SKILL.md'), 'not a skill', 'utf8');
401
+ await fs.writeFile(path.join(root, 'README.md'), '# x', 'utf8');
402
+
403
+ const rels = (await collectSkillDirs(root)).map(item => item.rel).sort();
404
+ assert.deepEqual(rels, ['skills/brandkit']);
405
+ });
@@ -39,6 +39,25 @@
39
39
  // 不经过前端 $t(),也不会显示在界面上。反过来说,它也不该跟着界面语言变 ——
40
40
  // 中文指令配中文上下文,模型理解得更稳。
41
41
  //
42
+ // ── 为什么续聊轮要精简(`compact`)────────────────────────────────────────
43
+ //
44
+ // 续接一轮对话时,prompt = [环境块][记忆块][用户这一轮说的话]。而续接走的是 CLI 自己的
45
+ // 会话恢复(claude `--resume` / opencode `--session` / codex `exec resume`),上一轮那份
46
+ // **完整**环境块已经在会话历史里 —— 整块再发一遍,信息上是纯重复。
47
+ //
48
+ // 实测(本机 jobs.json,2026-09-30):一条续接轮的 prompt 是
49
+ // 环境块 2118~2200 字符 + 记忆块 424 字符 + 用户原话 44 字符
50
+ // 也就是 98% 是重发的背景。代价有两处:一是每轮多烧约两千 token,而且它在**会话尾部**
51
+ // (不是前缀),命不中前缀缓存;二是 job.prompt 会被前端当成用户气泡渲染
52
+ // (useWorkbenchSimpleConversation.ts)—— 聊天记录里每续一轮就多一份一模一样的项目清单,
53
+ // 打开一条跑过多轮的任务,第一屏全是它(WorkbenchView.vue 里那条"钉到底部"的注释同源)。
54
+ //
55
+ // 但**不能整块砍掉**:块里有真正会变的东西(看板计数、正在执行几个、项目增减),
56
+ // 而且会话被压缩/裁剪之后那块背景可能真的不在了。所以续聊轮发的是这个"精简刷新版":
57
+ // 只留 当前项目 + 合计概览 + 真相源路径,项目清单与用户偏好交给前文
58
+ // (引导语里点名"克隆优先 SSH",免得那条行为约定被彻底忘掉)。
59
+ // 记忆块是同一套思路,见 taskRunner.js 的 `capture: !resumeSessionId`。
60
+ //
42
61
  // 纯函数:只吃入参、不读文件(取数在 workbench/index.js,数据在快照里)。
43
62
  // 这样单测不用碰真实用户数据 —— 与 promptParts.js 同一个理由。
44
63
 
@@ -70,6 +89,8 @@ const columnLabels = TASK_COLUMNS.map(k => COLUMN_LABELS[k] || k);
70
89
  * @param {object} [input.truthFiles] { tasksFile, jobsFile, orchestratorFile, configFile }
71
90
  * 路径口径的唯一来源见 workbench/shared.js 的 TRUTH_FILES
72
91
  * @param {number} [input.maxProjects] 只影响列出条数,不影响合计统计
92
+ * @param {boolean} [input.compact] 续聊轮的精简刷新版:只留 当前项目 + 合计概览 + 真相源,
93
+ * 项目清单与用户偏好交给会话前文(见文件头那一节)
73
94
  * @returns {string} 上下文块。**不返回 null** —— 见下面的注释。
74
95
  */
75
96
  export function buildEnvContextBlock({
@@ -77,6 +98,7 @@ export function buildEnvContextBlock({
77
98
  board = null,
78
99
  truthFiles = {},
79
100
  maxProjects = ENV_CONTEXT_MAX_PROJECTS,
101
+ compact = false,
80
102
  } = {}) {
81
103
  const list = Array.isArray(board?.projects) ? board.projects.filter(p => p && p.key) : [];
82
104
  const statsByKey = (board?.statsByKey && typeof board.statsByKey === 'object') ? board.statsByKey : {};
@@ -90,24 +112,33 @@ export function buildEnvContextBlock({
90
112
  const currentKey = canonicalProjectPath(currentProjectPath);
91
113
 
92
114
  const lines = [];
93
- lines.push('[运行环境 · 由 zen-gitsync 多项目编排台自动注入,不是用户输入的内容]');
115
+ // 首行前缀 `[运行环境` 是探针的锚点(scripts/verify-workbench-env-context.mjs
116
+ // 断言 prompt.startsWith('[运行环境')),精简版也照旧以它开头。
117
+ lines.push(compact
118
+ ? '[运行环境 · 由 zen-gitsync 多项目编排台自动注入,不是用户输入的内容 · 续聊轮精简刷新,完整项目清单与用户偏好见本会话前文]'
119
+ : '[运行环境 · 由 zen-gitsync 多项目编排台自动注入,不是用户输入的内容]');
94
120
  lines.push('');
95
121
  lines.push(`当前任务所在项目(也是你的工作目录): ${currentProjectPath || '(未指定)'}`);
96
122
  lines.push('');
97
123
 
98
124
  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} 个未列出(读下面的文件可以看全)`);
125
+ // 项目清单整段只在首轮发。它占这块的 90%(19 个项目 ≈ 1300 字符)却几乎不变,
126
+ // 续聊轮再发一遍等于把同一张表贴在对话里(见文件头)。
127
+ if (!compact) {
128
+ const limit = Number.isFinite(maxProjects) && maxProjects > 0 ? Math.floor(maxProjects) : ENV_CONTEXT_MAX_PROJECTS;
129
+ const shown = list.slice(0, limit);
130
+ lines.push(`项目清单(共 ${list.length} 个;格式: 名称 | 路径 | ${columnLabels.join('/')}):`);
131
+ for (const p of shown) {
132
+ const s = statsByKey[p.key] || {};
133
+ const mark = currentKey && p.key === currentKey ? ' ← 当前' : '';
134
+ lines.push(`- ${p.name || p.key} | ${p.path} | ${TASK_COLUMNS.map(k => s[k] || 0).join('/')}${mark}`);
135
+ }
136
+ if (list.length > shown.length) {
137
+ lines.push(`- …还有 ${list.length - shown.length} 个未列出(读下面的文件可以看全)`);
138
+ }
139
+ lines.push('');
109
140
  }
110
- lines.push('');
141
+ // 计数概览是这块里**真正会变**的部分,首轮与续聊轮都发
111
142
  lines.push(
112
143
  `看板任务概览:全部项目合计 ${total.total || 0} 条 —— ` +
113
144
  TASK_COLUMNS.map(k => `${COLUMN_LABELS[k]} ${total[k] || 0}`).join(' / ') +
@@ -122,27 +153,36 @@ export function buildEnvContextBlock({
122
153
  lines.push('');
123
154
  }
124
155
 
125
- // 用户偏好(见文件头)。放在"读文件"之前、"看板概览"之后 ——
126
- // 它是**行为**约定,与上面的事实清单分开;压尾的那句仍是"去读文件",
127
- // 那是整块上下文里最需要被记住的动作。
128
- lines.push(
129
- '用户偏好:克隆仓库 / 添加远端时**优先使用 SSH**(https 地址先换算成 ' +
130
- 'git@github.com:owner/repo.git,Gitee 同理 git@gitee.com:owner/repo.git —— ' +
131
- '走 https 会弹凭据窗口,把任务停在半路等用户输账号密码)。' +
132
- '只有 SSH 不可用(Permission denied (publickey) / Host key verification failed)' +
133
- '才退回 https,并说明这次走的是 https。',
134
- );
135
- lines.push('');
136
- lines.push('需要细节时直接读这些文件(本机绝对路径,你有读取权限,不必先问用户):');
156
+ if (compact) {
157
+ // 引导语替掉整段用户偏好,但**点名**那条行为约定:偏好本身不重述(前文有),
158
+ // 可"有这么一条"得让模型看得见,否则克隆仓库时它会退回 https。
159
+ lines.push('需要完整项目清单 / 进展细节时(含「克隆优先 SSH」偏好),直接读这些文件(本机绝对路径,你有读取权限,不必先问用户):');
160
+ } else {
161
+ // 用户偏好(见文件头)。放在"读文件"之前、"看板概览"之后 ——
162
+ // 它是**行为**约定,与上面的事实清单分开;压尾的那句仍是"去读文件",
163
+ // 那是整块上下文里最需要被记住的动作。
164
+ lines.push(
165
+ '用户偏好:克隆仓库 / 添加远端时**优先使用 SSH**(https 地址先换算成 ' +
166
+ 'git@github.com:owner/repo.git,Gitee 同理 git@gitee.com:owner/repo.git —— ' +
167
+ '走 https 会弹凭据窗口,把任务停在半路等用户输账号密码)。' +
168
+ '只有 SSH 不可用(Permission denied (publickey) / Host key verification failed)' +
169
+ '才退回 https,并说明这次走的是 https。',
170
+ );
171
+ lines.push('');
172
+ lines.push('需要细节时直接读这些文件(本机绝对路径,你有读取权限,不必先问用户):');
173
+ }
137
174
  if (truthFiles.tasksFile) lines.push(`- ${truthFiles.tasksFile} —— 全部任务全文(标题/描述/子任务状态/报错/projectPath)`);
138
175
  if (truthFiles.jobsFile) lines.push(`- ${truthFiles.jobsFile} —— 历次执行记录(状态与输出)`);
139
176
  if (truthFiles.orchestratorFile) lines.push(`- ${truthFiles.orchestratorFile} —— 用户在调度台发过的指令流水`);
140
177
  if (truthFiles.configFile) lines.push(`- ${truthFiles.configFile} —— 应用配置(projects / recentDirectories)`);
141
- lines.push('');
142
- lines.push(
143
- '用户问到"我有哪些项目""某件事进展如何"这类问题时,先读上面的文件再回答;' +
144
- '不要凭当前目录猜测,也不要回答"我看不到/无法访问"。',
145
- );
178
+ // 压尾那句"先读文件再回答"只在首轮讲:续聊轮上面那句引导语已经说了同一件事
179
+ if (!compact) {
180
+ lines.push('');
181
+ lines.push(
182
+ '用户问到"我有哪些项目""某件事进展如何"这类问题时,先读上面的文件再回答;' +
183
+ '不要凭当前目录猜测,也不要回答"我看不到/无法访问"。',
184
+ );
185
+ }
146
186
 
147
187
  return lines.join('\n');
148
188
  }
@@ -260,3 +260,71 @@ test('真相源清单来自唯一的 TRUTH_FILES(四个路径一个不落,
260
260
  assert.ok(block.includes(TRUTH_FILES.orchestratorFile));
261
261
  assert.match(block, /调度台发过的指令流水/);
262
262
  });
263
+
264
+ // ── 续聊轮精简版(compact)──────────────────────────────────────────────
265
+ //
266
+ // 续接走的是 CLI 自己的会话恢复(claude --resume / opencode --session / codex exec resume),
267
+ // 上一轮那份**完整**块已在会话历史里 —— 续聊轮再发一遍就是纯重复。实测一条续接轮 prompt:
268
+ // 环境块 2118~2200 字符 + 记忆块 424 字符 + 用户原话 44 字符(本机 jobs.json,2026-09-30)。
269
+ //
270
+ // 这组守四件事:
271
+ // 1. 项目清单真的不再发(它占这块 90%,且几乎不变);
272
+ // 2. 会变的那部分(合计概览、当前项目)还在 —— 精简不等于把刷新能力也砍了;
273
+ // 3. 真相源路径照给:会话被压缩后靠它重新够得着数据;
274
+ // 4. 首行锚点不变(scripts/verify-workbench-env-context.mjs 断言 startsWith('[运行环境'))。
275
+ //
276
+ // 数字口径一栏用"两种模式必须给出同一组计数"来守:精简是**裁剪**,不是另算一份。
277
+
278
+ const richBoard = () => {
279
+ // 项目数刻意贴近真实规模(用户本机 19 个):清单的"贵"是随项目数线性涨的,
280
+ // 只放两三个项目的夹具量不出"精简到底省了多少"(这条正是 compact 的存在理由)。
281
+ const recentDirs = ['D:\\ws\\zen-gitsync', 'D:\\ws\\article-generator'];
282
+ const tasks = [
283
+ { id: 't1', projectPath: 'D:\\ws\\zen-gitsync', type: 'simple' },
284
+ { id: 't2', projectPath: 'D:\\ws\\article-generator', type: 'simple' },
285
+ { id: 't3', projectPath: 'D:\\ws\\article-generator', type: 'simple' },
286
+ ];
287
+ for (let i = 0; i < 17; i += 1) recentDirs.push(`D:\\ws\\filler${i}`);
288
+ return boardOf({ recentDirs, tasks, jobs: [job('j1', 't1', 'done'), job('j2', 't2', 'running')] });
289
+ };
290
+
291
+ test('compact 不再发项目清单,但保留当前项目与合计概览', () => {
292
+ const board = richBoard();
293
+ const full = build({ currentProjectPath: 'D:\\ws\\zen-gitsync', board });
294
+ const compact = build({ currentProjectPath: 'D:\\ws\\zen-gitsync', board, compact: true });
295
+
296
+ // ① 清单行没了 —— 连"还有 N 个未列出"这种残余也不能留(模型会以为清单被截断)
297
+ assert.doesNotMatch(compact, /项目清单(共 \d+ 个/);
298
+ assert.doesNotMatch(compact, /^- .+ \| .+ \| \d+\/\d+\/\d+/m);
299
+ assert.doesNotMatch(compact, /还有 \d+ 个未列出/);
300
+ // 另一个项目(没在当前项目栏里出现过的)整体不该出现在精简块里
301
+ assert.ok(!compact.includes('D:\\ws\\article-generator'), '精简块不该再列其它项目');
302
+
303
+ // ② 会变的部分还在,而且数字与完整版**逐字相同**(同一次取数、两种裁剪)
304
+ const overview = full.match(/看板任务概览:全部项目合计 [^\n]+/)[0];
305
+ assert.ok(compact.includes(overview), `精简块应原样保留合计概览:\n${compact}`);
306
+ assert.match(compact, /全部项目合计 3 条/);
307
+ assert.match(compact, /正在执行 1 个/);
308
+ assert.match(compact, /当前任务所在项目(也是你的工作目录): D:\\ws\\zen-gitsync/);
309
+
310
+ // ③ 真相源路径照给(会话被压缩后靠它重新够得着数据)
311
+ for (const p of Object.values(TRUTH_FILES)) assert.ok(compact.includes(p), `缺少路径 ${p}`);
312
+ // 用户偏好不重述,但要点名"有这么一条" —— 少这一句,克隆仓库时会退回 https
313
+ assert.match(compact, /克隆优先 SSH/);
314
+
315
+ // ④ 首行锚点(探针依赖)与完整版同前缀
316
+ assert.ok(compact.startsWith('[运行环境'));
317
+ assert.ok(full.startsWith('[运行环境'));
318
+
319
+ // ⑤ 真省:精简块不到完整版的一半
320
+ assert.ok(compact.length * 2 < full.length, `精简块应显著更短: compact=${compact.length} full=${full.length}`);
321
+ });
322
+
323
+ test('compact 在取不到 board 时也照给真相源(且不返回空)', () => {
324
+ const compact = build({ currentProjectPath: 'D:\\ws\\a', board: null, compact: true });
325
+ assert.ok(compact, '精简版同样不返回 null');
326
+ for (const p of Object.values(TRUTH_FILES)) assert.ok(compact.includes(p), `缺少路径 ${p}`);
327
+ assert.match(compact, /项目清单:本次未能取到/);
328
+ // 没有清单时更不该出现"清单行"的残影
329
+ assert.doesNotMatch(compact, /^- .+ \| .+ \| \d+\/\d+\/\d+/m);
330
+ });