weaver-work-cli 0.1.36 → 0.1.39

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 (169) hide show
  1. package/README.md +4 -2
  2. package/dist/chunks/{archive-XBVH7JU7.js → archive-ENZYBBME.js} +559 -68
  3. package/dist/chunks/{archive-oa-MZM3EKFQ.js → archive-oa-YWLEC5V4.js} +4 -3
  4. package/dist/chunks/{asset-4XO7UOUW.js → asset-OOV6DWPA.js} +8 -7
  5. package/dist/chunks/asset-idle-RE632MY5.js +670 -0
  6. package/dist/chunks/{attachment-content-VXSWANAC.js → attachment-content-RWN2FCLL.js} +3 -3
  7. package/dist/chunks/{blog-PGZKOPSC.js → blog-PEPOJAL7.js} +5 -4
  8. package/dist/chunks/{caizhilian-BH4ZVGUN.js → caizhilian-WYIQDVPJ.js} +5 -4
  9. package/dist/chunks/{calendar-MXEMTIO4.js → calendar-CIARBAOX.js} +4 -3
  10. package/dist/chunks/{chunk-NCOVUAZK.js → chunk-36DIFZAH.js} +4 -3
  11. package/dist/chunks/{chunk-I6QGIBDM.js → chunk-BJGXMFGD.js} +4 -3
  12. package/dist/chunks/{chunk-TC6MZAO3.js → chunk-CQHGNCK2.js} +52 -2
  13. package/dist/chunks/{chunk-WXFPWCXG.js → chunk-EINRJHQ3.js} +3 -3
  14. package/dist/chunks/{chunk-JUXVYKII.js → chunk-EZGFHJFW.js} +201 -19
  15. package/dist/chunks/{chunk-VPUTVEMC.js → chunk-F2EQXUEE.js} +436 -290
  16. package/dist/chunks/{chunk-JYKABYCV.js → chunk-GVPJZXJ2.js} +3 -3
  17. package/dist/chunks/{chunk-6LLJFKJE.js → chunk-HJYVHXUQ.js} +1 -1
  18. package/dist/chunks/{chunk-T72N74ZT.js → chunk-I2HB737U.js} +5 -4
  19. package/dist/chunks/{chunk-VPXOPQJI.js → chunk-K3RUEEOL.js} +4 -2
  20. package/dist/chunks/{chunk-HXJQI5NM.js → chunk-KTCB5R5H.js} +3 -2
  21. package/dist/chunks/{chunk-XMGSYD6S.js → chunk-L4N7B7ZD.js} +4 -4
  22. package/dist/chunks/{chunk-QYGO5EE2.js → chunk-MUQUHKPG.js} +4 -3
  23. package/dist/chunks/{chunk-QHFANTX7.js → chunk-NLK2RZKG.js} +1348 -90
  24. package/dist/chunks/{chunk-F746NKNF.js → chunk-P2DAISYW.js} +1 -1
  25. package/dist/chunks/{chunk-BPE37FPB.js → chunk-T7JDJJ7H.js} +3 -3
  26. package/dist/chunks/{chunk-JNG42JKG.js → chunk-XXJOSZTQ.js} +6 -6
  27. package/dist/chunks/{chunk-5DQEEGCC.js → chunk-YS5DPN4T.js} +1 -1
  28. package/dist/chunks/{chunk-EG6ZU444.js → chunk-ZUX3P6BQ.js} +10 -5
  29. package/dist/chunks/{chunk-JCSN3JGM.js → chunk-ZXRNHZTE.js} +7 -6
  30. package/dist/chunks/{conflict-JAB2LJEZ.js → conflict-DRMDOL56.js} +2 -2
  31. package/dist/chunks/{contract-XI2EEIZU.js → contract-73MERZWN.js} +4 -3
  32. package/dist/chunks/{cowork-SCPGLBA4.js → cowork-HNGBPD7A.js} +4 -3
  33. package/dist/chunks/{ebuilder-form-EYLHNOTA.js → ebuilder-form-OY6DVNRK.js} +3 -3
  34. package/dist/chunks/{ehr-EAP77IMT.js → ehr-RAQNEQO7.js} +4 -3
  35. package/dist/chunks/{esb-2TGGXEE7.js → esb-6XV7C7YV.js} +4 -4
  36. package/dist/chunks/{fna-2X7ZE7BU.js → fna-XTSRB7G7.js} +4 -3
  37. package/dist/chunks/{host-Q4LZGCRU.js → host-3F5UM5GN.js} +3 -3
  38. package/dist/chunks/{host-XCLDDXKQ.js → host-A7J6GDUP.js} +3 -3
  39. package/dist/chunks/{host-I4NRK4MX.js → host-VGPVP7YF.js} +3 -3
  40. package/dist/chunks/{host-C6EL2RKL.js → host-YAQAPYNP.js} +3 -3
  41. package/dist/chunks/{host-UEI5FGDM.js → host-YMBXWFWX.js} +3 -3
  42. package/dist/chunks/{hrm-YXERJM76.js → hrm-TLHKC545.js} +3 -3
  43. package/dist/chunks/{im-A4VZN2TQ.js → im-YOUCFMUK.js} +3 -3
  44. package/dist/chunks/{invoice-2Z6YPMIS.js → invoice-WNBS2B4C.js} +3 -3
  45. package/dist/chunks/{jiuchuanhui-AR5GMKZB.js → jiuchuanhui-XABWS5RN.js} +4 -3
  46. package/dist/chunks/{lazy-dispatch-QXW7P4X3.js → lazy-dispatch-NLZ5UQQI.js} +5 -5
  47. package/dist/chunks/{mail-7GZ6CPBJ.js → mail-LRYMBFNL.js} +4 -3
  48. package/dist/chunks/{meeting-3NMGA3MB.js → meeting-ESXL6MFI.js} +13 -12
  49. package/dist/chunks/{okr-BZ6XUOAG.js → okr-EOV65SSF.js} +4 -3
  50. package/dist/chunks/{plan-DR5CMUYP.js → plan-YFPVKW6Y.js} +4 -3
  51. package/dist/chunks/{project-6J63MK33.js → project-MDVZUYQW.js} +4 -3
  52. package/dist/chunks/{registry-VRTNC6UR.js → registry-QCLZEDAP.js} +8 -8
  53. package/dist/chunks/{registry-KDD7DHNK.js → registry-VT5Y2HLA.js} +4 -4
  54. package/dist/chunks/{registry-TKX2E7C7.js → registry-YX4CSEMD.js} +3 -3
  55. package/dist/chunks/{room-recommend-XCWRMB5P.js → room-recommend-L3MWH4JW.js} +4 -4
  56. package/dist/chunks/{schedule-IVBICP32.js → schedule-UASAVDVD.js} +4 -4
  57. package/dist/chunks/{workflow-D74NFK3C.js → workflow-KDGIUCNW.js} +9 -6
  58. package/dist/core/output.js +16 -6
  59. package/dist/index.js +33 -28
  60. package/dist/internal/e10/auth/messages.js +1 -0
  61. package/dist/internal/e10/auth-recovery.js +127 -7
  62. package/dist/internal/e10/client.js +16 -2
  63. package/dist/internal/e10/context.js +16 -3
  64. package/dist/internal/e10/request-runtime.js +106 -8
  65. package/dist/shortcuts/archive/host.js +5 -2
  66. package/dist/shortcuts/archive/manifest.js +1 -1
  67. package/dist/shortcuts/archive/operations/borrow-list.js +13 -1
  68. package/dist/shortcuts/archive/operations/flow-url.js +1 -1
  69. package/dist/shortcuts/archive/operations/prelib.js +472 -0
  70. package/dist/shortcuts/archive/operations/registry.js +2 -0
  71. package/dist/shortcuts/archive/operations/upload.js +1 -1
  72. package/dist/shortcuts/archive-oa/host.js +2 -1
  73. package/dist/shortcuts/asset/host.js +6 -5
  74. package/dist/shortcuts/asset-idle/errors.js +44 -0
  75. package/dist/shortcuts/asset-idle/host.js +161 -0
  76. package/dist/shortcuts/asset-idle/index.js +114 -0
  77. package/dist/shortcuts/asset-idle/manifest.js +159 -0
  78. package/dist/shortcuts/asset-idle/operations/idle.js +47 -0
  79. package/dist/shortcuts/asset-idle/operations/registry.js +33 -0
  80. package/dist/shortcuts/asset-idle/operations/shared.js +142 -0
  81. package/dist/shortcuts/asset-idle/operations/types.js +9 -0
  82. package/dist/shortcuts/asset-idle/operations.js +15 -0
  83. package/dist/shortcuts/blog/host.js +3 -2
  84. package/dist/shortcuts/caizhilian/host.js +3 -2
  85. package/dist/shortcuts/calendar/host.js +2 -1
  86. package/dist/shortcuts/contract/host.js +2 -1
  87. package/dist/shortcuts/cowork/host.js +2 -1
  88. package/dist/shortcuts/ebuilder-form/host.js +3 -1
  89. package/dist/shortcuts/ehr/host.js +2 -1
  90. package/dist/shortcuts/esb/host.js +2 -1
  91. package/dist/shortcuts/fna/host.js +2 -1
  92. package/dist/shortcuts/hrm/host.js +2 -1
  93. package/dist/shortcuts/im/host.js +3 -2
  94. package/dist/shortcuts/index.js +5 -0
  95. package/dist/shortcuts/invoice/host.js +5 -4
  96. package/dist/shortcuts/jiuchuanhui/host.js +2 -1
  97. package/dist/shortcuts/mail/host.js +2 -1
  98. package/dist/shortcuts/meeting/host.js +3 -2
  99. package/dist/shortcuts/meeting/invitation/text.js +71 -0
  100. package/dist/shortcuts/meeting/operations/eb/create-flow.js +1073 -50
  101. package/dist/shortcuts/meeting/operations/eb/crm-search.js +478 -0
  102. package/dist/shortcuts/meeting/operations/eb/factory.js +60 -1
  103. package/dist/shortcuts/meeting/operations/eb/index.js +2 -0
  104. package/dist/shortcuts/meeting/operations/invitation.js +122 -9
  105. package/dist/shortcuts/okr/host.js +2 -1
  106. package/dist/shortcuts/plan/host.js +2 -1
  107. package/dist/shortcuts/project/host.js +2 -1
  108. package/dist/shortcuts/workflow/host.js +3 -0
  109. package/dist/shortcuts/workflow/index.js +1 -1
  110. package/dist/shortcuts/workflow/operations/form.js +44 -11
  111. package/dist/shortcuts/workflow/operations/todoSmartConvert/aigroupRules.js +77 -26
  112. package/dist/shortcuts/workflow/operations/todoSmartConvert/aigroupStore.js +6 -0
  113. package/dist/shortcuts/workflow/operations/todoSmartConvert/summaryCache.js +59 -117
  114. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoDetailCache.js +44 -7
  115. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoDetailFetcher.js +37 -29
  116. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoSmartConvert.collect.js +20 -6
  117. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoSmartConvert.detail.js +22 -18
  118. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoSmartConvert.groupSummary.js +34 -10
  119. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoSmartConvert.js +4 -2
  120. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoSmartConvert.smartGroup.js +9 -2
  121. package/dist/shortcuts/workflow/operations/todoSmartConvert/todoSmartConvert.summary.js +110 -20
  122. package/docs/_catalog.md +3 -0
  123. package/docs/asset-idle.md +45 -0
  124. package/docs/e10-auth.md +1 -1
  125. package/docs/todo-smart-convert/subgroup-rules.md +1 -1
  126. package/package.json +1 -1
  127. package/scripts/package-skill.mjs +2 -0
  128. package/skill-template/business-info.json +14 -1
  129. package/skill-template/domains/asset-idle.md +13 -0
  130. package/skill-template/domains/wenshuding.md +3 -0
  131. package/skills/weaver-e10-archive-oa/SKILL.md +1 -0
  132. package/skills/weaver-e10-archive-oa/references/my-borrow.md +49 -0
  133. package/skills/weaver-e10-asset-idle/SKILL.md +73 -0
  134. package/skills/weaver-e10-asset-idle/references/idle-list.md +62 -0
  135. package/skills/weaver-e10-asset-idle/references/idle-stats-and-charts.md +65 -0
  136. package/skills/weaver-e10-asset-idle/references/source-manifest.json +193 -0
  137. package/skills/weaver-e10-asset-idle/references/widget-style.md +205 -0
  138. package/skills/weaver-e10-caizhilian/SKILL.md +29 -11
  139. package/skills/weaver-e10-caizhilian/references/auth-and-install.md +12 -0
  140. package/skills/weaver-e10-caizhilian/references/files.md +1 -1
  141. package/skills/weaver-e10-caizhilian/references/setting-encrypt.md +1 -1
  142. package/skills/weaver-e10-caizhilian/references/source-manifest.json +45 -45
  143. package/skills/weaver-e10-cowork/SKILL.md +2 -0
  144. package/skills/weaver-e10-jucailin/SKILL.md +2 -0
  145. package/skills/weaver-e10-jucailin/references/recruit-read.md +2 -0
  146. package/skills/weaver-e10-jucailin/references/recruit-write.md +1 -1
  147. package/skills/weaver-e10-mail/SKILL.md +3 -0
  148. package/skills/weaver-e10-meeting/SKILL.md +250 -301
  149. package/skills/weaver-e10-meeting/references/meeting-eb.md +203 -0
  150. package/skills/weaver-e10-meeting/references/meeting-invitation.md +37 -5
  151. package/skills/weaver-e10-meeting.zip +0 -0
  152. package/skills/weaver-e10-okr/references/source-manifest.json +3 -3
  153. package/skills/weaver-e10-shared/SKILL.md +5 -5
  154. package/skills/weaver-e10-shared/references/e10-auth-and-session.md +3 -3
  155. package/skills/weaver-e10-wenshuding/SKILL.md +29 -6
  156. package/skills/weaver-e10-wenshuding/references/archive-borrow-list.md +14 -0
  157. package/skills/weaver-e10-wenshuding/references/archive-download.md +2 -0
  158. package/skills/weaver-e10-wenshuding/references/archive-flow.md +1 -1
  159. package/skills/weaver-e10-wenshuding/references/archive-prelib-arrange.md +76 -0
  160. package/skills/weaver-e10-wenshuding/references/archive-prelib-list.md +107 -0
  161. package/skills/weaver-e10-wenshuding/references/archive-prelib-open.md +59 -0
  162. package/skills/weaver-e10-wenshuding/references/archive-upload.md +4 -1
  163. package/skills/weaver-e10-wenshuding/references/source-manifest.json +108 -65
  164. package/skills/weaver-e10-workflow/SKILL.md +5 -2
  165. package/skills/weaver-e10-workflow/aigroup/default.json +154 -19
  166. package/skills/weaver-e10-workflow/references/todo-smart-convert.md +59 -27
  167. package/skills/weaver-e10-workflow/tools/todo-serve.cjs +113 -25
  168. package/skills/weaver-e10-workflow/tools/todo-summary.cjs +292 -42
  169. package/skills/weaver-e10-yimiaoban/references/source-manifest.json +3 -3
@@ -6,7 +6,7 @@
6
6
  * 避免每次现场拼脚本,也避免多跑一次 stage=group 进程(每次 CLI 启动约 6~7s)。
7
7
  *
8
8
  * 用法:
9
- * node todo-summary.cjs prep --limit 100 [--exclude 内部留言] [--days 365] [--out DIR]
9
+ * node todo-summary.cjs prep [--limit 500] [--exclude 内部留言] [--days 365 | --from A --to B] [--out DIR] # 用户没说时间段就都不传 = 不限时间取前 500 条
10
10
  * → 取数、按 workflowName 剔除指定流程、打印分组素材(模型据此做二次分组)
11
11
  * node todo-summary.cjs build --dir DIR --plan aiGroups.json
12
12
  * → 校验 1..count 全覆盖 → smartGroup;若还有待写摘要则打印,否则直接导出终态
@@ -17,8 +17,17 @@
17
17
  */
18
18
  const { execFileSync } = require('node:child_process');
19
19
  const fs = require('node:fs');
20
+ const os = require('node:os');
20
21
  const path = require('node:path');
21
22
 
23
+ // 缓存与 aigroup 规则都锚定「当前 skill」(2026-11-01 用户定稿):本脚本就住在
24
+ // <skillDir>/tools/ 下,把自身 skill 目录显式指给 CLI——生成与提取一律用当前 skill
25
+ // 的 cache/,不再走多安装发现排名(否则改名的副本会被旧安装的缓存抢走)。
26
+ // 用户已显式设置时不覆盖。
27
+ if (!process.env.WEAVER_WORK_CLI_SKILL_DIR) {
28
+ process.env.WEAVER_WORK_CLI_SKILL_DIR = path.resolve(__dirname, '..');
29
+ }
30
+
22
31
  /**
23
32
  * CLI 可执行入口(跨平台)。
24
33
  *
@@ -169,9 +178,10 @@ function groupStats(wfIdGroup) {
169
178
  /**
170
179
  * 导出最终结构。
171
180
  *
172
- * 交付形态(用户 2026-10-22 硬规则):**只在对话里输出 JSON + 耗时,不产物理文件**。
173
- * 所以默认走「stdout-only」:把最终结构的 JSON 打到 stdout 的 `[JSON_BEGIN]/[JSON_END]` 之间,
174
- * 由 Agent 原样贴进回复;只有显式传 `--file` 才写成 `待办汇总-*.json`(兼容旧用法/调试)。
181
+ * 交付形态:stdout 输出 `<browsertool>` 载荷(`[JSON_BEGIN]/[JSON_END]` 之间),
182
+ * 由 Agent 原样贴进回复;三键数据本体另存 `final.data.json`(交付文件复制它的,
183
+ * 命名 `<用户名>_<时间戳>.json` 由 Agent 侧按 references §6.1 拼写)。
184
+ * 旧 `--file`(写 `待办汇总-*.json` 外框归档)已于 2026-11-01 删除:文件名与内容都是旧口径,易误当交付物。
175
185
  *
176
186
  * 顶层键固定三个:`smartSummaryTitle` / `totalCount` / `wfIdGroup`(用户 2026-10-25 要求加
177
187
  * `totalCount` = 当前这批数据的总条数,与各大分组 `count` 之和一致)。
@@ -220,7 +230,7 @@ function buildTimingReport(state, result) {
220
230
  lines.push('- 「待写摘要为 0」时脚本会直接导出,不会白跑一次 finish。');
221
231
  lines.push('', '## 4. 复现', '');
222
232
  lines.push('```bash');
223
- lines.push('node todo-summary.cjs run --days 30 --limit 500 --out /tmp/t --file --timing-report');
233
+ lines.push('node todo-summary.cjs run --days 30 --limit 500 --out /tmp/t --timing-report');
224
234
  lines.push('```');
225
235
  return `${lines.join('\n')}\n`;
226
236
  }
@@ -311,7 +321,9 @@ function sleepSync(ms) {
311
321
  * - 快照按目录 mtime 计空闲,超时由服务端自己删;进程在所有快照过期且无请求后才退出。
312
322
  */
313
323
  const SERVE_ROOT = path.join(require('node:os').tmpdir(), 'weaver-work-cli-serve');
314
- const SERVE_VERSION = '3';
324
+ const SERVE_VERSION = '4';
325
+ /** v4 服务的发布密钥:ensureServeServer 从 current.json 读出,publishSnapshot 发布时回传。 */
326
+ let servePublishKey = '';
315
327
 
316
328
  /**
317
329
  * 同步探一次本地数据源的 `/health`:通就返回解析后的 JSON,不通返回 null。
@@ -362,6 +374,7 @@ function ensureServeServer(portOption) {
362
374
  // 先探活:既验「真有人在这端口上应答」,也验「应答的是不是我们这个版本的自己」。
363
375
  const health = probeHealth(cur.port);
364
376
  if (health && health.ok === true && String(health.version) === SERVE_VERSION) {
377
+ servePublishKey = String(cur.publishKey || '');
365
378
  return cur.port;
366
379
  }
367
380
  if (health && health.ok === true) {
@@ -390,7 +403,10 @@ function ensureServeServer(portOption) {
390
403
  if (fs.existsSync(registryFile)) {
391
404
  try {
392
405
  const info = JSON.parse(fs.readFileSync(registryFile, 'utf8'));
393
- if (info && info.version === SERVE_VERSION && info.port && info.pid === child.pid) return info.port;
406
+ if (info && info.version === SERVE_VERSION && info.port && info.pid === child.pid) {
407
+ servePublishKey = String(info.publishKey || '');
408
+ return info.port;
409
+ }
394
410
  } catch { /* 文件正在写入,稍后重试 */ }
395
411
  }
396
412
  sleepSync(120);
@@ -405,6 +421,48 @@ function publishSnapshot(dir, portOption) {
405
421
  const port = ensureServeServer(portOption);
406
422
  if (!port) return null;
407
423
  const token = require('node:crypto').randomBytes(12).toString('hex');
424
+ const url = `http://127.0.0.1:${port}/s/${token}/todo.json`;
425
+ const body = fs.readFileSync(dataFile, 'utf8');
426
+
427
+ // v4 主通路:把 JSON 原文 POST 进服务内存(带发布密钥)。
428
+ // 服务端不再读磁盘 ⇒ 不受「常驻进程读权限被沙箱回收」的影响,链接跨命令/跨轮次稳定。
429
+ // POST 用独立的极短 node 子进程同步完成(与 probeHealth 同款手法:主进程 Atomics 阻塞时
430
+ // async 回调排不上队,只能用子进程);body 走 stdin 传给子进程,避免命令行长度问题。
431
+ const postCode = [
432
+ "const http=require('node:http');",
433
+ "const fs=require('node:fs');",
434
+ 'const key=process.argv[1];',
435
+ 'const port=Number(process.argv[2]);',
436
+ 'const token=process.argv[3];',
437
+ "const body=fs.readFileSync(0,'utf8');",
438
+ "const req=http.request({host:'127.0.0.1',port,path:'/s/'+token+'/todo.json',method:'POST',",
439
+ " headers:{'content-type':'application/json','x-publish-key':key,'content-length':Buffer.byteLength(body)},timeout:10000},",
440
+ ' (res)=>{let b="";res.setEncoding("utf8");',
441
+ ' res.on("data",(d)=>{b+=d;});',
442
+ ' res.on("end",()=>{process.stdout.write(b);process.exit(res.statusCode===200?0:3);});',
443
+ ' });',
444
+ "req.on('timeout',()=>{req.destroy();process.exit(2);});",
445
+ "req.on('error',()=>process.exit(2));",
446
+ 'req.end(body);',
447
+ ].join('');
448
+ try {
449
+ const result = execFileSync(
450
+ process.execPath,
451
+ ['-e', postCode, servePublishKey, String(port), token],
452
+ { input: body, encoding: 'utf8', timeout: 20000, maxBuffer: 16 * 1024 * 1024, env: cliEnv() },
453
+ );
454
+ const parsed = JSON.parse(result);
455
+ if (parsed && parsed.ok === true) {
456
+ try {
457
+ fs.writeFileSync(path.join(dir, 'serve.json'), JSON.stringify({
458
+ url, token, port, mode: 'memory', bytes: parsed.bytes,
459
+ }, null, 2));
460
+ } catch { /* 只是排错用的旁路文件 */ }
461
+ return url;
462
+ }
463
+ } catch { /* POST 失败 ⇒ 回落写文件方式(旧服务兼容) */ }
464
+
465
+ // 回落:老路径——写快照文件,由服务端读磁盘(v3 服务或 POST 失败时才走这里)。
408
466
  const snapDir = path.join(SERVE_ROOT, 'snapshots', token);
409
467
  fs.mkdirSync(snapDir, { recursive: true });
410
468
  // 先拷到临时名再拷成正式名:沙箱会拒绝对家目录做 rename(EPERM),copyFile 不受限;
@@ -413,16 +471,57 @@ function publishSnapshot(dir, portOption) {
413
471
  fs.copyFileSync(dataFile, tmp);
414
472
  fs.copyFileSync(tmp, path.join(snapDir, 'todo.json'));
415
473
  try { fs.unlinkSync(tmp); } catch { /* 残留也无害 */ }
416
- const url = `http://127.0.0.1:${port}/s/${token}/todo.json`;
417
474
  try {
418
- fs.writeFileSync(path.join(dir, 'serve.json'), JSON.stringify({ url, token, port, snapshotDir: snapDir }, null, 2));
475
+ fs.writeFileSync(path.join(dir, 'serve.json'), JSON.stringify({ url, token, port, mode: 'file' }, null, 2));
419
476
  } catch { /* 只是排错用的旁路文件 */ }
420
477
  return url;
421
478
  }
422
479
 
480
+ /**
481
+ * 交付文件名的用户名(2026-11-01 用户拍板):`auth whoami` 拿 employeeId →
482
+ * `hrm run hrm.employee.detail` 取 `username`(认证系统当前登录人,不是本机账号)。
483
+ * 结果缓存在 state.json——同一次流水线只查一次;查不到返回 null(调用方退回手工命名)。
484
+ */
485
+ function resolveDeliverUsername(state, dir) {
486
+ if (state.deliveryUsername) return state.deliveryUsername;
487
+ try {
488
+ const who = JSON.parse(runCli(['--json', 'auth', 'whoami'], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, env: cliEnv() }));
489
+ const employeeId = String((who.data || who).employeeId || '').trim();
490
+ if (!employeeId) return null;
491
+ const raw = runCli(['--json', 'hrm', 'run', 'hrm.employee.detail', '--input-json', JSON.stringify({ id: employeeId })], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, env: cliEnv() });
492
+ const detail = JSON.parse(raw);
493
+ const username = String((detail.data || {}).username || '').trim();
494
+ if (!username) return null;
495
+ state.deliveryUsername = username;
496
+ fs.writeFileSync(path.join(dir, 'state.json'), JSON.stringify(state));
497
+ return username;
498
+ } catch {
499
+ return null;
500
+ }
501
+ }
502
+
503
+ /** 规则文件缓存(同一进程只读一次)。 */
504
+ let aigroupRuleFileCache = null;
505
+ function loadAigroupRuleFile() {
506
+ if (aigroupRuleFileCache) return aigroupRuleFileCache;
507
+ try {
508
+ aigroupRuleFileCache = JSON.parse(fs.readFileSync(path.join(path.resolve(__dirname, '..'), 'aigroup', 'default.json'), 'utf8'));
509
+ } catch {
510
+ aigroupRuleFileCache = { rules: [] };
511
+ }
512
+ return aigroupRuleFileCache;
513
+ }
514
+
515
+ /** 组名 → 该规则的 summaryRequiredFields(needSummary:false 的组不校验)。 */
516
+ function requiredFieldsFor(title) {
517
+ const rules = loadAigroupRuleFile().rules || [];
518
+ const rule = rules.find((r) => (Array.isArray(r.workflowName) ? r.workflowName : [r.workflowName])
519
+ .some((name) => name && name !== '*' && (title.includes(String(name)) || String(name).includes(title))));
520
+ if (!rule || rule.needSummary === false) return [];
521
+ return Array.isArray(rule.summaryRequiredFields) ? rule.summaryRequiredFields.filter((f) => typeof f === 'string' && f.trim()) : [];
522
+ }
523
+
423
524
  function exportFinal(result, dir, state, kept, opts = {}) {
424
- const { limit, from, to } = state;
425
- const excluded = (state.exclude || []).join('/');
426
525
  const data = result.data;
427
526
  const groupTotal = (data.wfIdGroup || []).reduce((sum, g) => sum + (Number(g.count) || 0), 0);
428
527
  // CLI 已给 totalCount;缺了(旧版 CLI)就按各组 count 求和兜底,保证三个键恒定存在。
@@ -432,26 +531,89 @@ function exportFinal(result, dir, state, kept, opts = {}) {
432
531
  totalCount,
433
532
  wfIdGroup: data.wfIdGroup,
434
533
  };
435
- const stamp = new Date().toISOString().slice(0, 10);
436
- const scope = from && to
437
- ? `${from}~${to}`
438
- : limit
439
- ? (kept === limit ? `前${limit}条` : `前${limit}条-剩${kept}条`)
440
- : `全部${kept}条`;
441
- const stampPart = from && to ? '' : `-${stamp}`;
442
- const finalFile = path.join(dir, `待办汇总-${scope}${excluded ? `-排除${excluded}` : ''}${stampPart}.json`);
443
534
  // browsertool 载荷的 src 跟着当前认证环境走(用户 2026-10-27 要求不写死租户)。
444
535
  const baseUrl = resolveBaseUrl(state, dir);
445
536
  const stats = groupStats(data.wfIdGroup);
446
537
  const total = groupTotal;
538
+ // ── 交付硬闸门(2026-11-01 用户拍板):缺环节 / 静默降级出不了交付物,只有显式豁免才放行 ──
539
+ const allowEmpty = opts.allowEmptySummary === true;
540
+ const detailFailed = Number(opts.detailFailed) || 0;
541
+ const groupEmpty = Number(opts.groupEmpty) || 0;
542
+ if (!allowEmpty && stats.empty > 0) {
543
+ const emptyIds = [];
544
+ for (const group of data.wfIdGroup || []) {
545
+ for (const item of group.displayItems || []) {
546
+ for (const row of item.type === 'request' ? [item.requestData] : (item.requestDatas || [])) {
547
+ if (row && row.requestId && !String(row.summary || '').trim() && !emptyIds.includes(row.requestId)) emptyIds.push(row.requestId);
548
+ }
549
+ }
550
+ }
551
+ console.error('\n✖ 交付拦截:仍有 %d 条单条摘要为空(多为 --summaries 漏 id / 摘要缓存未回填)。缺失 requestId 前 10 个:%s%s',
552
+ stats.empty, emptyIds.slice(0, 10).join(', '), emptyIds.length > 10 ? ' …' : '');
553
+ console.error(' → 补全 --summaries 后重跑 finish,或加 --allow-empty-summary 显式豁免。');
554
+ process.exit(1);
555
+ }
556
+ if (!allowEmpty && groupEmpty > 0) {
557
+ console.error('\n✖ 交付拦截:仍有 %d 个组摘要为空(--group-summaries 漏了这些 groupId)。补全后重跑,或加 --allow-empty-summary 显式豁免。', groupEmpty);
558
+ process.exit(1);
559
+ }
560
+ if (!(opts.allowDetailFail === true) && detailFailed > 0) {
561
+ console.error('\n✖ 交付拦截:详情取数失败 %d 条——这些条目的摘要只能靠标题兜底,内容可能不完整(防"静默降级+单样本外推")。排查登录态/详情缓存后重跑,或加 --allow-detail-fail 显式豁免。', detailFailed);
562
+ process.exit(1);
563
+ }
564
+ // ⑤ 降级话术黑名单(2026-11-01):摘要正文出现"未在标题体现/详见流程"这类表述,
565
+ // 说明执行方把「自己没取到」写成了「业务对象没有」——按模板回查详情补齐后重写。
566
+ const DEGRADE_PHRASES = ['未在标题体现', '标题未体现', '标题里没有', '详见流程', '详见详情', '待确认'];
567
+ const degraded = [];
568
+ for (const group of data.wfIdGroup || []) {
569
+ for (const item of group.displayItems || []) {
570
+ for (const row of item.type === 'request' ? [item.requestData] : (item.requestDatas || [])) {
571
+ const text = String((row && row.summary) || '');
572
+ const hit = DEGRADE_PHRASES.find((phrase) => text.includes(phrase));
573
+ if (hit && row.requestId) degraded.push({ requestId: String(row.requestId), phrase: hit });
574
+ }
575
+ }
576
+ }
577
+ if (!allowEmpty && degraded.length > 0) {
578
+ console.error('\n✖ 交付拦截:%d 条摘要含降级话术(把取数失败写成业务信息缺失):%s%s',
579
+ degraded.length,
580
+ degraded.slice(0, 5).map((x) => `${x.requestId.slice(-6)}[${x.phrase}]`).join(' '),
581
+ degraded.length > 5 ? ' …' : '');
582
+ console.error(' → 按模板回查详情缓存或 workflow.detail 补齐要素后重写,或加 --allow-empty-summary 显式豁免。');
583
+ process.exit(1);
584
+ }
585
+ // ⑥ 要素级校验(2026-11-01 用户拍板 P2):规则声明了 summaryRequiredFields 的组,
586
+ // 每条摘要必须包含全部要素关键字,缺任一即拒绝导出("模板要素优先于字数偏好"的机器化)。
587
+ const missingFields = [];
588
+ for (const group of data.wfIdGroup || []) {
589
+ const required = requiredFieldsFor(String(group.title || ''));
590
+ if (!required.length) continue;
591
+ for (const item of group.displayItems || []) {
592
+ for (const row of item.type === 'request' ? [item.requestData] : (item.requestDatas || [])) {
593
+ if (!row || !row.requestId) continue;
594
+ const text = String(row.summary || '');
595
+ if (!text.trim()) continue; // 空摘要由闸门①负责
596
+ const missing = required.filter((keyword) => !text.includes(keyword));
597
+ if (missing.length > 0) missingFields.push({ requestId: String(row.requestId), missing });
598
+ }
599
+ }
600
+ }
601
+ if (!allowEmpty && missingFields.length > 0) {
602
+ console.error('\n✖ 交付拦截:%d 条摘要缺模板要素(要素级校验):%s%s',
603
+ missingFields.length,
604
+ missingFields.slice(0, 5).map((x) => `${x.requestId.slice(-6)}缺[${x.missing.join(',')}]`).join(' '),
605
+ missingFields.length > 5 ? ' …' : '');
606
+ console.error(' → 按模板回查详情补齐要素后重写,或加 --allow-empty-summary 显式豁免。');
607
+ process.exit(1);
608
+ }
447
609
  console.log('\n最终结构(共 %d 条 / %d 组|totalCount=%d):', total, data.wfIdGroup.length, totalCount);
448
610
  for (const row of stats.rows) {
449
611
  console.log(` ${row.title} | ${row.count} | ${row.display}${row.singles ? ` +单条${row.singles}` : ''}`);
450
612
  }
451
613
  // 叠加视图:优先组的条目同时留在工作流组里,所以 Σcount 会大于 totalCount(去重条数)。
452
- console.log('\n自检:totalCount=%d(去重)|Σcount=%d(含优先组叠加 %d)|汇总项覆盖 %d + 单条 %d | 空摘要 %d | 文件 %s',
614
+ console.log('\n自检:totalCount=%d(去重)|Σcount=%d(含优先组叠加 %d)|汇总项覆盖 %d + 单条 %d | 空摘要 %d | 三键文件 %s',
453
615
  totalCount, total, Math.max(0, total - totalCount), stats.covered, stats.singles, stats.empty,
454
- opts.writeFile ? finalFile : '(未生成,按交付规则只输出 <browsertool> 载荷)');
616
+ path.join(dir, 'final.data.json'));
455
617
  const meta = result.meta || {};
456
618
  const steps = (meta.timing && meta.timing.steps) || [];
457
619
  console.log('CLI 自报分段:%s', steps.map((s) => `${s.name}:${s.kind}=${fmt(s.durationMs)}`).join(' '));
@@ -496,6 +658,26 @@ function exportFinal(result, dir, state, kept, opts = {}) {
496
658
  lines.push(' ]', '}');
497
659
  // 供 todo-serve.cjs 读取的同构数据(只有三键,无外框):服务与载荷永远读同一份,不会出现两份数据。
498
660
  fs.writeFileSync(path.join(dir, 'final.data.json'), JSON.stringify(payload));
661
+ // 交付文件(2026-11-01 用户拍板):由**脚本**按「认证用户名_YYYYmmddHHMMSS.json」落到 ~/Downloads/,
662
+ // 执行方默认不展示该文件(正文只有组件块)——仅当用户明确要求输出 json 时才交付。
663
+ // 用户名 = auth whoami → hrm.employee.detail.username,state 缓存免重复查询;失败则不生成并在输出里说明。
664
+ let deliveredFile = null;
665
+ const username = resolveDeliverUsername(state, dir);
666
+ if (username) {
667
+ try {
668
+ const d = new Date();
669
+ const pad = (n) => String(n).padStart(2, '0');
670
+ const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
671
+ const downloads = path.join(os.homedir(), 'Downloads');
672
+ fs.mkdirSync(downloads, { recursive: true });
673
+ deliveredFile = path.join(downloads, `${username}_${stamp}.json`);
674
+ fs.writeFileSync(deliveredFile, JSON.stringify(payload));
675
+ } catch {
676
+ deliveredFile = null;
677
+ }
678
+ }
679
+ if (deliveredFile) console.log('已生成备用文件:%s(默认不展示;仅当用户明确要求输出 json 时再交付——交付口径以 references §6.1 顶部为准)', deliveredFile);
680
+ else console.log('备用文件未生成(用户名查询失败或 Downloads 不可写):如用户要求输出 json,把 %s 复制为 ~/Downloads/<用户名>_<YYYYmmddHHMMSS>.json 后交付', path.join(dir, 'final.data.json'));
499
681
  // 数据源模式:`--data-source <url>` 直接用给定地址;`--serve [端口]` 现起一个本地服务拿地址。
500
682
  let dataSourceUrl = opts.dataSource && opts.dataSource !== true ? String(opts.dataSource) : null;
501
683
  if (!dataSourceUrl && opts.serve) dataSourceUrl = publishSnapshot(dir, opts.serve);
@@ -505,9 +687,8 @@ function exportFinal(result, dir, state, kept, opts = {}) {
505
687
  console.log('\n数据源模式:params.dataSource = %s', dataSourceUrl);
506
688
  console.log(' 组件改为 GET 该地址(响应体就是 params.data 的三键对象);单进程多快照,快照空闲 30 分钟后自动清理');
507
689
  }
508
- // 用户 2026-10-27 定稿:交付 = 回复里内联 <browsertool> 整块,**不再生成 JSON 文件**。
509
- // `--file` 仅保留作调试(写一份纯载荷 JSON),交付流程不再使用它。
510
- if (opts.writeFile) fs.writeFileSync(finalFile, finalText);
690
+ // 交付 = 回复里内联 <browsertool> 整块 + 另存 final.data.json(Agent 复制它按 §6.1 命名交付)。
691
+ // 旧 `--file` 分支已删(2026-11-01):它写的 `待办汇总-*.json` 是外框归档,命名/内容均为旧口径,易误当交付物。
511
692
  const delivery = `<browsertool>\n${finalText.trimEnd()}\n</browsertool>`;
512
693
  console.log('\n[JSON_BEGIN]');
513
694
  console.log(delivery);
@@ -525,7 +706,6 @@ function exportFinal(result, dir, state, kept, opts = {}) {
525
706
  console.log('\n耗时摘要(仅 CLI 执行链路,不含模型往返):');
526
707
  console.log(conclusion);
527
708
  }
528
- return opts.writeFile ? finalFile : null;
529
709
  }
530
710
 
531
711
  /**
@@ -657,12 +837,44 @@ function cmdPrep(args) {
657
837
  fmt(collectMs), fmt(collectWallMs));
658
838
  console.log('输出目录:%s\n', dir);
659
839
  const focusKinds = focusCoversKinds(args.focus && args.focus !== true ? args.focus : '');
840
+ // 规则里 focus:false 的组**必须**由模型给 aiGroups(如 安全问题处理工单),prep 阶段就标出来,
841
+ // 不要等 build 报 ai_groups_required 才知道(2026-11-01 用户确认的规范缺陷)。
842
+ // 同时**回显该组的 groupingPrompt(组名词表/分法)与 requestSummaryPrompt(摘要模板)**——
843
+ // 这两样只存在于 aigroup/default.json,规则要求"生成 aiGroups 前必须先读它",
844
+ // 但 prep 原本不输出 ⇒ 执行方只能凭正文推断(自造组名/漏摘要要素的根因,2026-11-01 复盘定稿)。
845
+ const mustPlanRules = [];
846
+ try {
847
+ const ruleFile = JSON.parse(fs.readFileSync(path.join(path.resolve(__dirname, '..'), 'aigroup', 'default.json'), 'utf8'));
848
+ for (const rule of ruleFile.rules || []) {
849
+ if (rule.aiGroup && rule.aiGroup.groupRule && rule.aiGroup.groupRule.focus === false) {
850
+ const names = Array.isArray(rule.workflowName) ? rule.workflowName : [rule.workflowName];
851
+ for (const name of names) {
852
+ if (name && name !== '*') {
853
+ mustPlanRules.push({
854
+ name: String(name),
855
+ groupingPrompt: String((rule.aiGroup.groupRule || {}).groupingPrompt || ''),
856
+ requestSummaryPrompt: String(rule.requestSummaryPrompt || ''),
857
+ });
858
+ }
859
+ }
860
+ }
861
+ }
862
+ } catch { /* 规则文件读不到就不标注(build 仍会硬校验兜底) */ }
863
+ const matchRule = (title) => mustPlanRules.find((r) => title.includes(r.name) || r.name.includes(title));
864
+ const needsPlan = (title, count) => count >= 3 && (title.includes('内部留言') || Boolean(matchRule(title)));
660
865
  for (const group of planGroups) {
661
866
  // 只有「默认确定性类型 + 侧重点没覆盖本类型」才算无需分组;被覆盖时照常列出条目。
662
867
  const kind = kindOfTitle(group.title);
663
868
  const skip = group.groupId === 'priority' || group.count <= 1 || (kind !== '' && !focusKinds.has(kind));
664
- console.log(`=== ${group.groupId} | ${group.title} | ${group.count}${skip ? ' [无需分组]' : ''}`);
869
+ const rule = skip ? null : matchRule(group.title);
870
+ const marker = skip ? ' [无需分组]' : (rule || group.title.includes('内部留言') ? ' [必须给 plan]' : '');
871
+ console.log(`=== ${group.groupId} | ${group.title} | ${group.count}${marker}`);
665
872
  if (skip) continue;
873
+ // 回显规则内容:组名/分法只按 groupingPrompt 来(词表不得自造),摘要按 requestSummaryPrompt 写
874
+ if (rule) {
875
+ if (rule.groupingPrompt) console.log(` ▸ 分组口径(groupingPrompt,组名/分法只按它来,不得自造):${rule.groupingPrompt}`);
876
+ if (rule.requestSummaryPrompt) console.log(` ▸ 摘要模板(requestSummaryPrompt,本组单条摘要照它写):${rule.requestSummaryPrompt}`);
877
+ }
666
878
  for (const row of group.rows) {
667
879
  const hint = fieldHintById.get(row.requestId) || '';
668
880
  // 提示放在**同一行**:两行格式容易让人(和模型)回头去匹配标题,而标题里往往没有类型词。
@@ -670,7 +882,7 @@ function cmdPrep(args) {
670
882
  }
671
883
  }
672
884
  console.log('\n下一步:node %s build --dir %s', path.basename(__filename), dir);
673
- console.log('(只有内部留言组、或用户给了侧重点时才需要再补 --plan <aiGroups.json>;其余走确定性分组)');
885
+ console.log('(标了 [必须给 plan] 的组——内部留言、以及规则 focus:false 的组如 安全问题处理工单——build 时必须带 --plan <aiGroups.json>;组名/分法/摘要口径见各组下回显的规则内容;其余走确定性分组)');
674
886
  }
675
887
 
676
888
  function cmdBuild(args) {
@@ -680,7 +892,7 @@ function cmdBuild(args) {
680
892
  const kept = readJson(path.join(dir, 'items.json'));
681
893
  const planGroups = state.planGroups || [];
682
894
  const countById = new Map(planGroups.map((g) => [g.groupId, g.count]));
683
- // `--plan` 可选:其他类默认不按标题二次分组,很多批次根本不需要 AI 计划。
895
+ // `--plan`:确定性类型可不传;prep 标了 [必须给 plan] 的组(内部留言 / focus:false 组)不传会被 CLI 拒(ai_groups_required)。
684
896
  // 不传时直接走 CLI 确定性分组(等价于 aiGroups 未传)。
685
897
  let aiGroups;
686
898
  if (planFile) {
@@ -742,17 +954,39 @@ function cmdBuild(args) {
742
954
  fs.writeFileSync(path.join(dir, 'state.json'), JSON.stringify(state));
743
955
 
744
956
  if (pending.length === 0) {
745
- exportFinal(sg, dir, state, kept.length, { writeFile: args.file === true, timingReport: args['timing-report'] });
957
+ // 即使无待写摘要也必须走 finish:summary 阶段还负责满意度类(needSummary:false)的标题
958
+ // 填充与交付闸门——smartGroup 半成品直接导出会让这类条目空着(2026-11-01 闸门真机首跑抓到)。
959
+ cmdFinish({ ...args, dir });
746
960
  return;
747
961
  }
748
- const summaryTarget = pending[0] && pending[0].suggestedMaxChars ? pending[0].suggestedMaxChars : 50;
749
- console.log(`\n待写摘要(把结果写成 aiSummaries.json,元素 {requestId,summary},约 ${summaryTarget} 字):`);
750
- for (const item of pending) {
751
- console.log(`### ${item.requestId}`);
752
- console.log(`T: ${String(item.requestNameTitle || '').slice(0, 70)}`);
753
- console.log(`C: ${String(item.context || '').replace(/\s+/g, ' ').slice(0, 240)}`);
962
+ console.log(`\n待写摘要(把结果写成 aiSummaries.json,元素 {requestId,summary};每批的「口径」必须遵守):`);
963
+ for (const b of batches) {
964
+ const items = b.items || [];
965
+ if (items.length === 0) continue;
966
+ console.log(`## 批次 ${b.batchId}|${b.groupTitle}${b.subGroupTitle && b.subGroupTitle !== b.groupTitle ? ` → ${b.subGroupTitle}` : ''}|目标字数 ${b.suggestedMaxChars}`);
967
+ if (b.prompt) console.log(`口径:${b.prompt}`);
968
+ if (b.hint) console.log(`提示:${b.hint}`);
969
+ for (const item of items) {
970
+ console.log(`### ${item.requestId}`);
971
+ console.log(`T: ${String(item.requestNameTitle || '').slice(0, 160)}`);
972
+ // 素材**几乎不截断**(4000 字符保险丝):2026-11-01 实测 240 字截断把 e-todo 模板的
973
+ // 「预估开发总人天/需求概述」等尾部要素裁掉,模型只能靠标题凑数(要素缺失复盘定稿)。
974
+ console.log(`C: ${String(item.context || '').replace(/\s+/g, ' ').slice(0, 4000)}`);
975
+ }
754
976
  }
755
977
  console.log('\n下一步:node %s finish --dir %s --summaries <aiSummaries.json>', path.basename(__filename), dir);
978
+ // 组摘要(aiGroup.groupSummary.needAI=true 的汇总项):口径随批次下发,模型按它写 aiGroupSummaries
979
+ const gsBatches = sg.data.groupSummaryBatches || [];
980
+ if (gsBatches.length > 0) {
981
+ console.log('\n待写组摘要(写成 aiGroupSummaries.json,元素 {groupId,summary}):');
982
+ for (const b of gsBatches) {
983
+ console.log(`### 组摘要 ${b.groupId}|${b.parentTitle} → ${b.title}|${b.count} 条|目标字数 ${b.suggestedMaxChars}`);
984
+ if (b.prompt) console.log(`口径:${b.prompt}`);
985
+ if (b.hint) console.log(`提示:${b.hint}`);
986
+ if (b.stats && b.stats.tail) console.log(`tail:${b.stats.tail}`);
987
+ }
988
+ console.log('\nfinish 时加:--group-summaries <aiGroupSummaries.json>');
989
+ }
756
990
  }
757
991
 
758
992
  function cmdFinish(args) {
@@ -788,6 +1022,9 @@ function cmdFinish(args) {
788
1022
  fs.writeFileSync(path.join(dir, 'state.json'), JSON.stringify(state));
789
1023
  console.log('summary 内部 %s / 含进程启动 %s(回填 %d 条,其余走缓存预填)',
790
1024
  summaryInternal === null ? '-' : fmt(summaryInternal), fmt(summaryWallMs), aiSummaries.length);
1025
+ // 未回填的缺失 id 不在这里单列:summary 阶段的信封不带 summaryBatches/summaryStats,
1026
+ // 空 id 列表统一由 exportFinal 的闸门①从最终结构里就地提取(能直接给出 requestId)。
1027
+ // 未知 requestId 的 aiSummaries 由 CLI 侧硬校验拒绝(ai_summary_unknown_request,非零退出)。
791
1028
  let finalResult = result;
792
1029
  const groupBatches = result.data.groupSummaryBatches || [];
793
1030
  if (groupBatches.length > 0) {
@@ -819,6 +1056,10 @@ function cmdFinish(args) {
819
1056
  console.log('\n⚠ 还有 %d 个组摘要素材待模型生成(groupSummary 阶段):', groupBatches.length);
820
1057
  for (const batch of groupBatches) console.log(JSON.stringify(batch).slice(0, 400));
821
1058
  console.log(' → 写成 [{groupId,summary}] 后加 `--group-summaries <file>` 重跑本命令,可在同一次调用里回填');
1059
+ if (args['allow-empty-summary'] !== true) {
1060
+ console.error('✖ 交付拦截:%d 个组摘要未回填,导出会是空组摘要。补齐 --group-summaries 后重跑,或加 --allow-empty-summary 显式豁免。', groupBatches.length);
1061
+ process.exit(1);
1062
+ }
822
1063
  }
823
1064
  }
824
1065
  // 2026-10-28 用户 OA 页面实测通过:默认走本地数据源(params.dataSource),
@@ -827,10 +1068,15 @@ function cmdFinish(args) {
827
1068
  ? false
828
1069
  : (args.serve === undefined ? true : args.serve);
829
1070
  exportFinal(finalResult, dir, state, kept.length, {
830
- writeFile: args.file === true,
831
1071
  timingReport: args['timing-report'],
832
1072
  dataSource: args['data-source'],
833
1073
  serve: serveOption,
1074
+ // 交付硬闸门(2026-11-01):空摘要 / 详情取数失败都要显式豁免才能出文件。
1075
+ // 详情取数发生在 smartGroup 阶段 ⇒ failed 从 smartgroup.json 的 data.summaryStats 取。
1076
+ allowEmptySummary: args['allow-empty-summary'] === true,
1077
+ allowDetailFail: args['allow-detail-fail'] === true,
1078
+ detailFailed: ((sg.data || {}).summaryStats || {}).failed || 0,
1079
+ groupEmpty: (finalResult.meta && finalResult.meta.groupEmpty) || 0,
834
1080
  });
835
1081
  console.log('总耗时(脚本口径):%s', fmt(Date.now() - Date.parse(state.startedAt)));
836
1082
  }
@@ -849,21 +1095,24 @@ function cmdRun(args) {
849
1095
  : `/tmp/todo-summary-${new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)}`;
850
1096
  cmdPrep({ ...args, out: dir });
851
1097
  cmdBuild({ ...args, dir });
1098
+ // 无待写摘要时 cmdBuild 已经内部走完 cmdFinish(含满意度标题填充与交付闸门),这里直接收工
1099
+ if (fs.existsSync(path.join(dir, 'final.data.json'))) return;
852
1100
  const sg = readJson(path.join(dir, 'smartgroup.json'));
853
1101
  const pending = ((sg.data.summaryBatches || []).flatMap((batch) => batch.items || []));
854
1102
  if (pending.length > 0) {
855
1103
  console.log('\n⚠ 有 %d 条待写摘要:写成 aiSummaries.json 后跑 finish(**不用重跑** collect/smartGroup)', pending.length);
856
- console.log(' node %s finish --dir %s --summaries <aiSummaries.json> --file --timing-report', path.basename(__filename), dir);
1104
+ console.log(' node %s finish --dir %s --summaries <aiSummaries.json> --timing-report', path.basename(__filename), dir);
857
1105
  return;
858
1106
  }
859
- // 没有待写摘要 ⇒ 终态可以立刻出,省掉一次约 7s 的进程启动
1107
+ // build 停下但没导出(如组摘要待回填)⇒ 把 finish 跑完(它自己会按闸门决定放行/拦截)
860
1108
  cmdFinish({
861
1109
  dir,
862
- file: args.file === true,
863
1110
  'timing-report': args['timing-report'],
864
1111
  'group-summaries': args['group-summaries'],
865
1112
  serve: args.serve,
866
1113
  'no-serve': args['no-serve'],
1114
+ 'allow-empty-summary': args['allow-empty-summary'] === true,
1115
+ 'allow-detail-fail': args['allow-detail-fail'] === true,
867
1116
  });
868
1117
  }
869
1118
 
@@ -875,10 +1124,11 @@ else if (cmd === 'finish') cmdFinish(args);
875
1124
  else if (cmd === 'run') cmdRun(args);
876
1125
  else {
877
1126
  console.log('用法:node ' + path.basename(__filename) + ' <run|prep|build|finish> [options]');
878
- console.log(' run --days 30 --limit 500 [--out DIR] [--file] [--timing-report] # 一次跑完 prep+build(无待写摘要时连 finish 一起)');
1127
+ console.log(' run [--days N | --from A --to B] [--limit 500] [--out DIR] [--timing-report] # 一次跑完 prep+build;用户没说时间段就都不传 = 不限时间取前 500 条');
879
1128
  console.log(' prep --limit 100 [--exclude 内部留言] [--days 1460] [--sub-category 待处理] [--out DIR]');
880
- console.log(' build --dir DIR [--plan aiGroups.json] # 不传 plan = 直接走确定性分组');
881
- console.log(' finish --dir DIR [--summaries aiSummaries.json] [--group-summaries aiGroupSummaries.json] [--file] [--timing-report]');
1129
+ console.log(' build --dir DIR [--plan aiGroups.json] # prep 标注 [必须给 plan] 的组必须传;其余不传走确定性分组');
1130
+ console.log(' finish --dir DIR [--summaries aiSummaries.json] [--group-summaries aiGroupSummaries.json] [--timing-report]');
1131
+ console.log(' 交付闸门:空摘要 / 组摘要缺失 / 详情取数失败都会 exit 1 拒绝导出;显式豁免:--allow-empty-summary / --allow-detail-fail');
882
1132
  console.log(' 默认:载荷只带 params.dataSource(现起本地服务,空闲 30 分钟自动退出);--no-serve 退回内联 data');
883
1133
  console.log(' 提示:`--timing-report` 会在控制台打一段耗时摘要(阶段墙钟 / CLI 内部),**不产出 md**');
884
1134
  process.exit(2);
@@ -2270,7 +2270,7 @@
2270
2270
  }
2271
2271
  },
2272
2272
  "artifactBaseline": {
2273
- "aggregateSha256": "b12d789dc9cdfb03dd4d749ba305bc2a6f229d296c1a7cccbc5ba73b97e4bb77",
2273
+ "aggregateSha256": "4fcf441cb7e08c3c5e2de04c10144efd27c673788b28904786592c981d6b05e5",
2274
2274
  "files": [
2275
2275
  {
2276
2276
  "path": "docs/im.md",
@@ -2382,8 +2382,8 @@
2382
2382
  {
2383
2383
  "path": "src/shortcuts/im/host.ts",
2384
2384
  "role": "generated-cli",
2385
- "size": 24365,
2386
- "sha256": "e8a285adae2918a79e9fa038ca91cb5b1871206c7e51a7ba7708de6af3e6b97e"
2385
+ "size": 24373,
2386
+ "sha256": "6e219d2936358c6403e0b1eb6aafb5350cf43fbff75b599381a291008f683815"
2387
2387
  },
2388
2388
  {
2389
2389
  "path": "src/shortcuts/im/index.ts",