dsh-workplan 1.5.1 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts CHANGED
@@ -45,6 +45,9 @@ window.__ModuleLoader__.load({
45
45
  "helpNobody": "还没开始",
46
46
  "helpDone": "都做完了",
47
47
  "helpNextDone": "没有下一步,这件事已完成",
48
+ "helpWaitActive": "等这一步做完,再继续下一项",
49
+ "helpNoPlan": "还没有安排任何工作",
50
+ "nowRemaining": "还剩 {n} 项",
48
51
  "helpHint": "负责团队 = 谁来做;具体工作 = 要完成的一件事;卡住了 = 需要人处理。",
49
52
  "handleBlocked": "先处理",
50
53
  "resumeReady": "对主 Agent 说「续跑这个计划」,会从受阻处继续(已完成的不重跑)",
@@ -207,6 +210,9 @@ window.__ModuleLoader__.load({
207
210
  "helpNobody": "Not started yet",
208
211
  "helpDone": "Everything is done",
209
212
  "helpNextDone": "Nothing next; this task is complete",
213
+ "helpWaitActive": "Wait for this step to finish, then continue",
214
+ "helpNoPlan": "No work has been scheduled yet",
215
+ "nowRemaining": "{n} item(s) left",
210
216
  "helpHint": "Team = who does it; work item = one thing to finish; stuck = a person needs to help.",
211
217
  "handleBlocked": "Fix first",
212
218
  "resumeReady": "Tell the main agent to resume this plan; it continues from the blocked step without re-running finished work",
@@ -1028,14 +1034,21 @@ window.__ModuleLoader__.load({
1028
1034
  }
1029
1035
 
1030
1036
  function workerText(session: any, t: any) {
1031
- const active = findEntry(session, 'in_progress')
1032
- if (active) {
1033
- return `${deptName(t, active.subtask.department)} · ${active.subtask.title}`
1034
- }
1037
+ /*
1038
+ * [1.5.2] 与 `planNow` 同序:卡住优先。
1039
+ *
1040
+ * 旧顺序是「正在做」优先,而卡片右上角的「现在」是「卡住了」优先,
1041
+ * 于是同一屏一边说「现在卡住了 T1」、一边说「正在做 T2」(T5 C12)。
1042
+ * 卡住需要人介入,两个位置都应先报它。
1043
+ */
1035
1044
  const blocked = findEntry(session, 'blocked')
1036
1045
  if (blocked) {
1037
1046
  return `${deptName(t, blocked.subtask.department)} · ${blocked.subtask.title}(${t('status.blocked')})`
1038
1047
  }
1048
+ const active = findEntry(session, 'in_progress')
1049
+ if (active) {
1050
+ return `${deptName(t, active.subtask.department)} · ${active.subtask.title}`
1051
+ }
1039
1052
  const stats = session.stats || {}
1040
1053
  if ((stats.total ?? 0) > 0 && (stats.done ?? 0) >= (stats.total ?? 0)) return t('helpDone')
1041
1054
  return t('helpNobody')
@@ -1061,11 +1074,22 @@ window.__ModuleLoader__.load({
1061
1074
  function nextText(session: any, t: any) {
1062
1075
  const blocked = findEntry(session, 'blocked')
1063
1076
  if (blocked) return `${t('handleBlocked')}:${deptName(t, blocked.subtask.department)} · ${blocked.subtask.title}${resumeHint(session, t)}`
1077
+ /*
1078
+ * [1.5.2] 「没有下一步」只允许出现在真正全部完成之后。
1079
+ *
1080
+ * 旧实现找不到 pending 就直接 return「没有下一步,这件事已完成」,于是
1081
+ * · 单任务计划刚进入 in_progress(环 0%)→ 同一张卡宣布「已完成」(T5 C2);
1082
+ * · 降级计划 0/0 → 一边「安排失败」一边「已完成」(T5 C11)。
1083
+ * 现在先按计数判终态,再区分「等当前这一步」「根本没有计划」。
1084
+ */
1085
+ const counts = planCounts(session.stats || {})
1086
+ if (counts.total > 0 && counts.done === counts.total) return t('helpDone')
1087
+ if (counts.total === 0) return t('helpNoPlan')
1064
1088
  const next = findEntry(session, 'pending') || allEntries(session).find((entry: any) => !entry.item)
1065
1089
  if (next) return `${deptName(t, next.subtask.department)} · ${next.subtask.title}`
1066
- const stats = session.stats || {}
1067
- if ((stats.total ?? 0) > 0 && (stats.done ?? 0) >= (stats.total ?? 0)) return t('helpDone')
1068
- return t('helpNextDone')
1090
+ const active = findEntry(session, 'in_progress')
1091
+ if (active) return `${t('helpWaitActive')}:${deptName(t, active.subtask.department)} · ${active.subtask.title}`
1092
+ return t('helpWaitActive')
1069
1093
  }
1070
1094
 
1071
1095
  function progressText(item: any, t: any) {
@@ -1339,15 +1363,19 @@ window.__ModuleLoader__.load({
1339
1363
  const done = items.filter(({ item }: any) => item?.status === 'done').length
1340
1364
  const blocked = items.filter(({ item }: any) => item?.status === 'blocked').length
1341
1365
  const inProgress = items.some(({ item }: any) => item?.status === 'in_progress')
1342
- const tone = blocked > 0 ? 'blocked' : done === items.length ? 'done' : inProgress ? 'active' : 'idle'
1366
+ // [1.5.2] 与 `planStageRows` 同一条规则:有 done 就是 active,不再只在
1367
+ // 有 in_progress 时才算「正在做」。旧规则让同一阶段在概览行显示「正在做」、
1368
+ // 在阶段卡显示「还没开始」(T5 C9)。
1369
+ const tone = blocked > 0 ? 'blocked' : items.length > 0 && done === items.length ? 'done' : inProgress || done > 0 ? 'active' : 'idle'
1343
1370
  // Same tone/counts as before; only the presentation moved into the
1344
1371
  // collapsed summary row (done/total + state pill), so the numbers keep one
1345
1372
  // source: this step's own items from `statuses`.
1346
1373
  const stateText = blocked > 0
1347
1374
  ? `${blocked} ${t('stats.blocked')}`
1348
- : done === items.length
1375
+ : items.length > 0 && done === items.length
1349
1376
  ? t('helpDone')
1350
- : inProgress ? t('phase.executing') : t('phase.pending')
1377
+ : inProgress ? t('phase.executing')
1378
+ : done > 0 ? fill(t('nowRemaining'), { n: items.length - done }) : t('phase.pending')
1351
1379
  return h('section', { className: `dsh-wp-stage dsh-wp-tone-${tone}` },
1352
1380
  h(Collapse, {
1353
1381
  className: 'dsh-wp-stage-collapse',
@@ -1463,6 +1491,18 @@ window.__ModuleLoader__.load({
1463
1491
  return Math.max(0, Math.round(number))
1464
1492
  }
1465
1493
 
1494
+ /*
1495
+ * [1.5.2] 计划级百分比的唯一算式。
1496
+ *
1497
+ * 1.5.1 及以前,同一页面上至少有三处各自 `Math.round(done / total * 100)`:
1498
+ * 卡片环(planCounts)、组头环(groupTotals)、按任务进度条(TaskBars / 总览柱状图)。
1499
+ * 三份实现口径相同只是巧合,任何一处单独改动都会让「同一张卡两个百分比」重现。
1500
+ * 现在只有这一个函数,其它地方一律调用它。
1501
+ */
1502
+ function planPercent(done: number, total: number): number | null {
1503
+ return total > 0 ? Math.round((done / total) * 100) : null
1504
+ }
1505
+
1466
1506
  // 与 groupTotals 同一套口径:先夹到 [0,total],再让「正在做/还没开始」
1467
1507
  // 吃掉剩余量,四段之和恒等于总数(构成条不会溢出或出现负宽度)。
1468
1508
  function planCounts(stats: any): PlanCounts {
@@ -1472,8 +1512,44 @@ window.__ModuleLoader__.load({
1472
1512
  const blocked = Math.min(planCount(source.blocked), total - done)
1473
1513
  const active = Math.min(planCount(source.inProgress), total - done - blocked)
1474
1514
  const pending = Math.max(0, total - done - blocked - active)
1475
- const percent = total > 0 ? Math.round((done / total) * 100) : null
1476
- return { total, done, active, blocked, pending, percent }
1515
+ return { total, done, active, blocked, pending, percent: planPercent(done, total) }
1516
+ }
1517
+
1518
+ /*
1519
+ * [1.5.2] 完成语义的第二道保险:状态文案必须服从计数事实。
1520
+ *
1521
+ * 后端已保证「工作项全部 done ⇒ phase=done」(`reconcilePhase`)。这里再兜一层,
1522
+ * 让任何历史载荷、旧缓存或第三方构造的 payload 都不可能渲染出「100% + 正在
1523
+ * 收尾」:计数齐全且全部完成时一律按「已完成」呈现;反过来,phase 说 done
1524
+ * 但计数里还有没做完的项时,按真实计数降级,避免「已完成下面挂着没做完的活」。
1525
+ *
1526
+ * 计数未知(total=0,例如已取消或尚未拆解)时不动原始 phase——此时没有事实
1527
+ * 可以覆盖它,硬改会把「进行中/已完成」的分组依据一起抹掉。
1528
+ * 「integrating」只在计数证明全部完成时升级为 done,否则保留原语义(回放旧会话)。
1529
+ */
1530
+ function planPhaseOf(rawPhase: any, counts: PlanCounts): string {
1531
+ const phase = String(rawPhase ?? 'pending')
1532
+ if (counts.total > 0) {
1533
+ if (counts.done === counts.total) return 'done'
1534
+ if (phase === 'done') {
1535
+ if (counts.blocked > 0) return 'blocked'
1536
+ if (counts.active > 0) return 'executing'
1537
+ return 'pending'
1538
+ }
1539
+ }
1540
+ return phase
1541
+ }
1542
+
1543
+ /*
1544
+ * [1.5.2] 加权总进度的展示口径:未全部完成时封顶 99。
1545
+ *
1546
+ * 服务端 `deriveAggregates` 已做同样封顶;这里再算一次是为了让旧载荷/手工
1547
+ * fixture 也守同一条不变量——「总体进度 100%」绝不能与「还有 N 项没完成」同屏。
1548
+ */
1549
+ function planWeightedPercent(stats: any, counts: PlanCounts): number {
1550
+ const weighted = clampPct(stats?.progress ?? 0)
1551
+ if (counts.total > 0 && counts.done === counts.total) return 100
1552
+ return Math.min(99, weighted)
1477
1553
  }
1478
1554
 
1479
1555
  function planStatusMap(statuses: any): Map<string, any> {
@@ -1555,6 +1631,16 @@ window.__ModuleLoader__.load({
1555
1631
  return { state: 'active', text: withTitle(t('stats.inProgress'), active?.title) }
1556
1632
  }
1557
1633
  if (counts.total > 0 && counts.done === counts.total) return { state: 'done', text: t('helpDone') }
1634
+ /*
1635
+ * [1.5.2] 部分完成不再落到「还没开始」。
1636
+ *
1637
+ * `done>0` 但没有 blocked、也没有 in_progress 时(预算把剩余项重置成
1638
+ * pending,或剩余项还没轮到),旧代码直接返回 `stats.pending`「还没开始」,
1639
+ * 与同一张卡上的「1/2 做好了」和 50% 直接冲突(T5 C5)。
1640
+ */
1641
+ if (counts.done > 0) {
1642
+ return { state: 'active', text: fill(t('nowRemaining'), { n: counts.total - counts.done }) }
1643
+ }
1558
1644
  return { state: 'pending', text: t('stats.pending') }
1559
1645
  }
1560
1646
 
@@ -1643,7 +1729,9 @@ window.__ModuleLoader__.load({
1643
1729
  if (rows.length < 1 || rows.length > 12) return null
1644
1730
  return h('div', { className: 'dsh-wp-stepper', 'aria-hidden': 'true', 'data-stages': String(rows.length) },
1645
1731
  rows.map((row) => {
1646
- const reach = row.total > 0 ? ((row.done + row.active + row.blocked) / row.total) * 100 : 0
1732
+ // [1.5.2] 只按 done 计。旧式把 blocked/active 也算「触达」,于是环 50%
1733
+ // 时阶段条已经全满(T5 C7)——同一张卡两个「完成度」。
1734
+ const reach = row.total > 0 ? (row.done / row.total) * 100 : 0
1647
1735
  const width = row.tone === 'pending' ? 0 : reach
1648
1736
  return h('span', {
1649
1737
  key: row.id,
@@ -1664,11 +1752,20 @@ window.__ModuleLoader__.load({
1664
1752
  }
1665
1753
 
1666
1754
  function PlanViz({ t, counts, now, rows }: { t: (key: string) => string; counts: PlanCounts; now: PlanNow; rows: PlanStageRow[] }) {
1755
+ /*
1756
+ * [1.5.2] data-state 也是「完成语义」的一个读者。
1757
+ *
1758
+ * 旧实现只看 percent:0% 的 pending 卡拿到 data-state="active",
1759
+ * 而 100% 的卡即使 phase 说 integrating 也是 "done"(T5 C13/C1)。
1760
+ * 现在与胶囊、分组、环共用计数:全 done 才 done,0 且有未完成才 idle。
1761
+ */
1667
1762
  const state = counts.percent === null
1668
1763
  ? 'idle'
1669
1764
  : counts.blocked > 0
1670
1765
  ? 'blocked'
1671
- : counts.percent >= 100 ? 'done' : 'active'
1766
+ : counts.total > 0 && counts.done === counts.total
1767
+ ? 'done'
1768
+ : counts.done > 0 || counts.active > 0 ? 'active' : 'idle'
1672
1769
  return h('div', {
1673
1770
  className: 'dsh-wp-planviz',
1674
1771
  'data-percent': counts.percent === null ? 'none' : String(counts.percent),
@@ -1708,7 +1805,7 @@ window.__ModuleLoader__.load({
1708
1805
  h('span', null, `${counts.done}/${counts.total} ${t('stats.done')}`),
1709
1806
  ),
1710
1807
  h('div', { className: 'dsh-wp-planoverview-stats' },
1711
- h('span', null, `${t('vizWeighted')} ${clampPct(stats?.progress ?? 0)}%`),
1808
+ h('span', null, `${t('vizWeighted')} ${planWeightedPercent(stats, counts)}%`),
1712
1809
  h('span', null, `${counts.active} ${t('stats.inProgress')} · ${counts.pending} ${t('stats.pending')}`),
1713
1810
  counts.blocked > 0 ? h('span', null, `${counts.blocked} ${t('stats.blocked')}`) : null,
1714
1811
  ),
@@ -1742,13 +1839,18 @@ window.__ModuleLoader__.load({
1742
1839
 
1743
1840
  function SessionCard({ session, t, open, onToggle }: any) {
1744
1841
  const domId = `dsh-wp-session-${session.sessionId}`
1745
- const phase = session.phase || 'pending'
1746
1842
  const stats = session.stats || {}
1747
- const tone = phaseTone(phase)
1748
- const departments = Array.isArray(session.departments) ? session.departments : []
1749
1843
  // 1.5.0:折叠卡摘要=图表(环 + 百分比 + 四段构成条 + 图例 + 阶段步进条);
1750
1844
  // 数字仍只来自 session.stats,与旧头部的口径完全一致,不引入第二套统计。
1751
1845
  const counts = planCounts(stats)
1846
+ /*
1847
+ * [1.5.2] 卡片的色调与胶囊文案也走统一对账:只要计数显示全部完成,
1848
+ * 胶囊就必须是「已完成」,视觉档位也必须是 done。截图里那张卡之所以
1849
+ * 「100% + 正在收尾 + 留在进行中组」,就是因为这三个读者各读各的字段。
1850
+ */
1851
+ const phase = planPhaseOf(session.phase || 'pending', counts)
1852
+ const tone = phaseTone(phase)
1853
+ const departments = Array.isArray(session.departments) ? session.departments : []
1752
1854
  const statuses = Array.isArray(session.statuses) ? session.statuses : []
1753
1855
  const stageRows = planStageRows(session.stages, statuses)
1754
1856
  const now = planNow(t, counts, planTaskEntries(session.stages, statuses))
@@ -1989,7 +2091,7 @@ window.__ModuleLoader__.load({
1989
2091
  total,
1990
2092
  done,
1991
2093
  open: total - done,
1992
- percent: total > 0 ? Math.round((done / total) * 100) : null,
2094
+ percent: planPercent(done, total),
1993
2095
  active: Math.max(0, active),
1994
2096
  pending: Math.max(0, pending),
1995
2097
  blocked: Math.max(0, blocked),
@@ -2209,10 +2311,11 @@ window.__ModuleLoader__.load({
2209
2311
  h('div', { className: 'dsh-wp-section-title' }, t('taskChart')),
2210
2312
  (Array.isArray(sessions) ? sessions : []).map((session: any) => {
2211
2313
  const stats = session.stats || {}
2212
- const total = Math.max(0, Number(stats.total) || 0)
2213
- const done = Math.max(0, Number(stats.done) || 0)
2214
- const percent = total > 0 ? Math.round((done / total) * 100) : 0
2215
- const tone = phaseTone(session.phase || 'pending')
2314
+ const counts = planCounts(stats)
2315
+ // 同一套计数与对账:柱状图的百分比、文案、色调都不得自成一套。
2316
+ const percent = counts.percent ?? 0
2317
+ const phase = planPhaseOf(session.phase || 'pending', counts)
2318
+ const tone = phaseTone(phase)
2216
2319
  return h('button', {
2217
2320
  key: session.sessionId,
2218
2321
  type: 'button',
@@ -2222,13 +2325,13 @@ window.__ModuleLoader__.load({
2222
2325
  },
2223
2326
  h('div', { className: 'dsh-wp-bar-head' },
2224
2327
  h('span', { className: 'dsh-wp-bar-title' }, session.title || t('panel')),
2225
- h('span', { className: 'dsh-wp-bar-value' }, `${done}/${total}`),
2328
+ h('span', { className: 'dsh-wp-bar-value' }, `${counts.done}/${counts.total}`),
2226
2329
  ),
2227
2330
  h('div', { className: 'dsh-wp-bar-track' },
2228
2331
  h('div', { className: `dsh-wp-bar-fill dsh-wp-bar-fill--${tone}`, style: { width: `${percent}%` } }),
2229
2332
  ),
2230
2333
  h('div', { className: 'dsh-wp-bar-meta' },
2231
- h('span', null, t(`phase.${session.phase || 'pending'}`)),
2334
+ h('span', null, t(`phase.${phase}`)),
2232
2335
  h('span', null, `${percent}%`),
2233
2336
  ),
2234
2337
  )
@@ -2282,8 +2385,16 @@ window.__ModuleLoader__.load({
2282
2385
  const live: any[] = []
2283
2386
  const finished: any[] = []
2284
2387
  const urgentPhases = ['blocked', 'degraded', 'paused']
2388
+ /*
2389
+ * [1.5.2] 分组也按对账后的 phase,而不是 payload 里的原始 phase。
2390
+ *
2391
+ * 截图那张卡之所以停在「进行中」组,就是因为这里只读 `session.phase`:
2392
+ * 14/14、100% 的计划带着 integrating 被塞进 live 组,组头于是出现
2393
+ * 「未完成 0 / 已完成 14(完成率 100%)」却仍标着「进行中」。
2394
+ * `planPhaseOf` 让分组、组头计数、卡片胶囊共用同一个结论。
2395
+ */
2285
2396
  for (const session of activeSessions) {
2286
- if (session.phase === 'done') finished.push(session)
2397
+ if (planPhaseOf(session.phase, planCounts(session.stats)) === 'done') finished.push(session)
2287
2398
  else live.push(session)
2288
2399
  }
2289
2400
  /*
@@ -2301,7 +2412,7 @@ window.__ModuleLoader__.load({
2301
2412
  planning: 4,
2302
2413
  pending: 5,
2303
2414
  }
2304
- const rank = (session: any) => phaseRank[session.phase] ?? 5
2415
+ const rank = (session: any) => phaseRank[planPhaseOf(session.phase, planCounts(session.stats))] ?? 5
2305
2416
  live.sort((a: any, b: any) => (rank(a) - rank(b))
2306
2417
  || ((Number(b.createdAt) || 0) - (Number(a.createdAt) || 0))
2307
2418
  || String(a.sessionId).localeCompare(String(b.sessionId)))
@@ -2330,12 +2441,11 @@ window.__ModuleLoader__.load({
2330
2441
  return { live, finished, urgent: live.filter((session: any) => urgentPhases.includes(session.phase)).length }
2331
2442
  }, [activeSessions])
2332
2443
  const liveBars = useMemo(() => groups.live.slice(0, 6).map((session: any) => {
2333
- const doneCount = session.stats?.done ?? 0
2334
- const totalCount = session.stats?.total ?? 0
2335
2444
  const raw = String(session.title || t('panel'))
2336
2445
  return {
2337
2446
  label: raw.length > 14 ? `${raw.slice(0, 14)}…` : raw,
2338
- value: totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0,
2447
+ // [1.5.2] 与卡片/组头同一个百分比函数,杜绝第二套口径。
2448
+ value: planPercent(planCount(session.stats?.done), planCount(session.stats?.total)) ?? 0,
2339
2449
  }
2340
2450
  }), [groups, t])
2341
2451
  return h('div', { className: 'dsh-wp' },
@@ -2389,12 +2499,19 @@ window.__ModuleLoader__.load({
2389
2499
  const active = ['planning', 'executing', 'blocked'].includes(session.phase)
2390
2500
  if (currentTurn > createdTurn && !active) return null
2391
2501
  }
2502
+ /*
2503
+ * [1.5.2] 会话内联卡(TurnCard)也走同一套对账。
2504
+ *
2505
+ * 面板卡片修好了、内联卡却仍按原始 phase 渲染的话,同一张计划会在
2506
+ * 「侧栏已完成 / 对话里正在收尾」之间再次自相矛盾。
2507
+ */
2392
2508
  const stats = session.stats || {}
2393
- const tone = phaseTone(session.phase || 'pending')
2509
+ const phase = planPhaseOf(session.phase || 'pending', planCounts(stats))
2510
+ const tone = phaseTone(phase)
2394
2511
  return h('div', { className: `dsh-wp-turn dsh-wp-tone-${tone}` },
2395
2512
  h('div', { className: 'dsh-wp-turn-title' },
2396
2513
  h('span', null, session.title || t('panel')),
2397
- h(Pill, { tone }, t(`phase.${session.phase || 'pending'}`)),
2514
+ h(Pill, { tone }, t(`phase.${phase}`)),
2398
2515
  ),
2399
2516
  h('div', { className: 'dsh-wp-muted', style: { marginTop: '6px' } },
2400
2517
  `${t('helpWho')}:${workerText(session, t)}`),
package/src/host.ts CHANGED
@@ -325,7 +325,7 @@ export function apply(ctx: any, config: WorkplanConfig = {}) {
325
325
 
326
326
  export const meta = {
327
327
  name,
328
- version: '1.5.1',
328
+ version: '1.5.2',
329
329
  promptOrder: PROMPT_ORDER,
330
330
  routes: ['/dsh-workplan/plans', '/dsh-workplan/vendor/chart.umd.js'],
331
331
  }
package/src/plan.ts CHANGED
@@ -1074,8 +1074,16 @@ export function applyStatus(statuses: any, subtaskId: any, patch: any) {
1074
1074
  if (patch.summary === undefined) next.summary = ''
1075
1075
  if (patch.error === undefined) next.error = null
1076
1076
  }
1077
- if (patch.status === 'in_progress' && next.startedAt === null) {
1078
- next.startedAt = Date.now()
1077
+ if (patch.status === 'in_progress') {
1078
+ if (next.startedAt === null) next.startedAt = Date.now()
1079
+ /*
1080
+ * [1.5.2] 「正在做」不允许自称 100%。
1081
+ *
1082
+ * 工具允许 `{status:'in_progress', progress:100}`,界面于是出现
1083
+ * 「正在做 + 进度 100%」,计划层再叠一个环 0% / 加权 100%(T5 C8)。
1084
+ * 完成只能由 status='done' 表达,progress 到 99 就是这一步的上限。
1085
+ */
1086
+ if (Number(next.progress) >= 100) next.progress = 99
1079
1087
  }
1080
1088
  if (patch.status === 'blocked' && next.finishedAt === null) {
1081
1089
  next.finishedAt = Date.now()
@@ -1272,6 +1280,16 @@ export function deriveAggregates(statuses: readonly StatusEntry[]): {
1272
1280
  }
1273
1281
 
1274
1282
  const total = statuses.length
1283
+ const allDone = total > 0 && done === total
1284
+ /*
1285
+ * [1.5.2] 加权进度在「未全部完成」时不得四舍五入成 100。
1286
+ *
1287
+ * 旧算法直接 `round(progressSum / total)`:11 个 done + 1 个 blocked@95 得到
1288
+ * 100,卡片上就出现「总体进度 100%」旁边还有「1 卡住了 / 11/12 做好了」——
1289
+ * 与截图同类的自相矛盾(T4 实测 A.5)。这里的封顶只作用于展示口径,不影响
1290
+ * 任何计数;全部完成时仍严格为 100,从而与 `done === total` 一一对应。
1291
+ */
1292
+ const raw = total === 0 ? 0 : Math.round(progressSum / total)
1275
1293
  return {
1276
1294
  stats: {
1277
1295
  total,
@@ -1279,7 +1297,7 @@ export function deriveAggregates(statuses: readonly StatusEntry[]): {
1279
1297
  blocked,
1280
1298
  inProgress,
1281
1299
  pending,
1282
- progress: total === 0 ? 0 : Math.round(progressSum / total),
1300
+ progress: allDone ? 100 : Math.min(99, raw),
1283
1301
  },
1284
1302
  departments: [...departments.values()],
1285
1303
  }
@@ -1309,13 +1327,18 @@ export function computeStats(statuses: any): Stats {
1309
1327
  }
1310
1328
 
1311
1329
  /**
1312
- * Invariant checker for tests and CI; production paths never call it because
1313
- * pure derivation is already self-consistent.
1330
+ * Invariant checker for tests and CI.
1314
1331
  *
1315
1332
  * Returns an empty array when every invariant holds. Each message names the
1316
1333
  * field and the observed value so it can be pasted straight into a report.
1334
+ *
1335
+ * [1.5.2] `phase` 是新增的可选入参:1.4.3 的 I1–I7 只校验计数内部自洽,
1336
+ * 完全不看 `phase` 与 `stats` 的关系,因此「14/14 且 progress=100 却写着
1337
+ * 正在收尾」这种**跨字段**矛盾一路畅通到界面。I8/I9 专门封住这一类:
1338
+ * 只要调用方把本次真正要下发的 phase 传进来,任何「进度与状态文案打架」
1339
+ * 的载荷都会在这里被判为违规,而不是等到用户看截图。
1317
1340
  */
1318
- export function aggregateInvariantViolations(snapshot: PlanSnapshot): string[] {
1341
+ export function aggregateInvariantViolations(snapshot: PlanSnapshot, phase?: any): string[] {
1319
1342
  const violations: string[] = []
1320
1343
  const { statuses, stats, departments } = snapshot
1321
1344
 
@@ -1371,17 +1394,96 @@ export function aggregateInvariantViolations(snapshot: PlanSnapshot): string[] {
1371
1394
  violations.push(`I7 各部门 inProgress 之和=${sums.inProgress} 与 stats.inProgress=${stats.inProgress} 不一致`)
1372
1395
  }
1373
1396
 
1397
+ /*
1398
+ * [1.5.2] I8/I9 —— 完成语义跨字段不变量(用户截图那条「100% + 正在收尾」)。
1399
+ *
1400
+ * I8:工作项全部 done ⇔ phase 必须是 done。这是一条双向断言:
1401
+ * - 全 done 却是 pending/planning/executing/integrating/paused/blocked/degraded
1402
+ * ⇒ 卡片会同时出现 100% 与进行中文案(本次事故);
1403
+ * - phase=done 却还有未完成项 ⇒ 反向矛盾(「已完成」下面挂着没做完的活)。
1404
+ * I9:加权进度与计数同口径:全 done ⇒ 100;未全 done ⇒ 不得为 100。
1405
+ * 旧算法 round(progressSum/total) 让 11 done + 1 blocked@95 算出 100。
1406
+ */
1407
+ if (stats.total > 0) {
1408
+ const allDone = stats.done === stats.total
1409
+ if (allDone && phase !== undefined && phase !== null && phase !== 'done') {
1410
+ violations.push(`I8 stats.done=${stats.done}/${stats.total} 已全部完成,但 phase=${String(phase)} 仍表示未完成`)
1411
+ }
1412
+ if (!allDone && phase === 'done') {
1413
+ violations.push(`I8 phase=done 但 stats.done=${stats.done}/${stats.total} 尚未全部完成`)
1414
+ }
1415
+ if (allDone && stats.progress !== 100) {
1416
+ violations.push(`I9 全部完成但 stats.progress=${stats.progress}`)
1417
+ }
1418
+ if (!allDone && stats.progress >= 100) {
1419
+ violations.push(`I9 未全部完成(${stats.done}/${stats.total})却显示 stats.progress=${stats.progress}`)
1420
+ }
1421
+ }
1422
+ /*
1423
+ * I10:单个工作项也不能自相矛盾——`in_progress` 意味着「还没做完」,
1424
+ * 它的进度必须 < 100(旧载荷里出现过「正在做 + 100%」,T5 C8)。
1425
+ */
1426
+ for (const item of statuses) {
1427
+ if (item?.status === 'in_progress' && Number(item?.progress) >= 100) {
1428
+ violations.push(`I10 子任务 ${String(item?.subtaskId)} 状态为 in_progress 但 progress=${item?.progress}`)
1429
+ }
1430
+ }
1431
+
1374
1432
  return violations
1375
1433
  }
1376
1434
 
1377
- export function planPhase(statuses: any, resultSeen = false) {
1435
+ /**
1436
+ * 计划级阶段,由状态列表唯一决定。
1437
+ *
1438
+ * [1.5.2] 修复「一边 100%、一边正在收尾」的根因。
1439
+ *
1440
+ * 旧实现的最后一步是 `return resultSeen ? 'done' : 'integrating'`:工作项全部
1441
+ * done 时仍要等「结果消息落盘」才晋升终态。问题在于 `resultSeen` 只是**持久化
1442
+ * 回放**才知道的副作用,内存路径(`updateStatus` / `serialisePlan`)拿不到它:
1443
+ *
1444
+ * - 主 Agent 用 `dsh_workplan_update` 补完最后几项时,phase 永远停在 integrating;
1445
+ * - 该路径不会再派发,也就永远不会写结果消息,于是 integrating 永久留存;
1446
+ * - 同期 `stats.done/total` 已是 14/14、百分比 100%,卡片就同时显示
1447
+ * 「100% · 都做完了」和「正在收尾」,并留在「进行中」组(用户截图)。
1448
+ *
1449
+ * 结论:完成语义只能有一个事实源——状态列表。工作项全部完成即计划完成,
1450
+ * 不需要也不允许等另一个副作用来「确认」。`resultSeen` 参数仅为兼容旧调用点保留,
1451
+ * 不再参与判定(旧的 integrating 窗口已由 dispatch 内的 `executing` 覆盖)。
1452
+ */
1453
+ export function planPhase(statuses: any, _resultSeen = false) {
1378
1454
  if (statuses.length === 0) return 'pending'
1379
- if (statuses.every((item: any) => item.status === 'done')) return resultSeen ? 'done' : 'integrating'
1455
+ if (statuses.every((item: any) => item.status === 'done')) return 'done'
1380
1456
  if (statuses.some((item: any) => item.status === 'blocked')) return 'blocked'
1381
1457
  if (statuses.some((item: any) => item.status === 'in_progress' || item.status === 'done')) return 'executing'
1382
1458
  return 'pending'
1383
1459
  }
1384
1460
 
1461
+ /**
1462
+ * 把「缓存的 phase」与「由状态列表派生的 phase」对账成唯一结果。
1463
+ *
1464
+ * phase 是缓存(dispatch/预算/取消会写它),statuses 是事实源。只要两者可能
1465
+ * 分叉,就必须在出口处对账,否则同一个会话会因读取路径不同而翻转(实时
1466
+ * integrating / 刷新后 done,T2 场景 B)。
1467
+ *
1468
+ * 规则只有四条,全部可由 `aggregateInvariantViolations` 断言:
1469
+ * 1. 工作项全部 done ⇒ 一定是 done(缓存不得把它拖回任何进行中状态);
1470
+ * 2. 缓存是 done/integrating 但工作项没全 done ⇒ 缓存过期,回退到派生值;
1471
+ * 3. pending/planning/executing 是「状态列表能直接回答」的轮次状态:一旦
1472
+ * 有工作项开始(派生值不再是 pending),缓存里的旧 pending/planning 必须
1473
+ * 让位;而派生值仍是 pending 时保留缓存(dispatch 刚开始、状态尚未落盘)。
1474
+ * 4. 其余情况保留缓存的轮次状态(paused/cancelled/blocked/degraded)——
1475
+ * 它们携带状态列表里没有的信息(预算暂停、用户取消)。
1476
+ */
1477
+ export function reconcilePhase(phase: any, statuses: any) {
1478
+ const derived = planPhase(statuses)
1479
+ if (derived === 'done') return 'done'
1480
+ if (phase === 'done' || phase === 'integrating') return derived
1481
+ if (phase === 'pending' || phase === 'planning' || phase === 'executing') {
1482
+ return derived === 'pending' ? phase : derived
1483
+ }
1484
+ return phase ?? derived
1485
+ }
1486
+
1385
1487
  export function nextReadyInStage(plan: any, statuses: any[], stageId: string) {
1386
1488
  const byId = new Map(statuses.map((item: any) => [item.subtaskId, item]))
1387
1489
  const stage = plan.stages.find((item: any) => item.id === stageId)