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/CHANGELOG.md CHANGED
@@ -1,3 +1,112 @@
1
+ ## 1.5.2 — 2026-10-29
2
+
3
+ **「完成语义」统一版**。用户截图圈出一张 `14/14` 全部做完、环形进度 `100%` 的计划卡,
4
+ 右侧状态胶囊却写「正在收尾」,而且这张卡仍留在「进行中」组。本次把**进度**与**状态**
5
+ 收敛到同一个事实源,并把状态矩阵里同一根因的 8 类矛盾一并修掉。
6
+
7
+ ### 事故与根因
8
+
9
+ 截图会话为 `session-c3852c31-f6cd-4fc5-8cff-d8a3932e9feb`。事件回放显示:
10
+
11
+ 1. 最后一次派发在预算用尽时结束,结果消息落盘为 `12/14 done、phase: paused`;
12
+ 2. 之后主 Agent 直接调用 `dsh_workplan_update`,把剩余 `T13`、`T14` 标为 done
13
+ (会话里两次工具调用,`seq 263` / `seq 269`),**没有再发生任何派发轮次**;
14
+ 3. `updateStatus` 用 `planPhase(statuses)` 重算 phase,而 `planPhase` 以
15
+ 「结果消息是否已落盘(`resultSeen`)」为终态闸门 → 全部完成后仍返回 `integrating`;
16
+ 4. 结果消息只在派发轮次末尾写入,走 update 工具的路径**永远不会**写它,
17
+ 于是 phase 永久停在 `integrating`,`stats.progress` 已经是 `100`;
18
+ 5. `serialisePlan` 又优先信任内存里的 `record.phase`,而回放 `reconstructFromSession`
19
+ 用 `resultSeen = true` 重算出 `done` —— 同一条记录「刷新前正在收尾、刷新后已完成」;
20
+ 6. 客户端还有三套独立的「是否完成」判断:环形进度看 `percent >= 100`、
21
+ 「都做完了」看 `done === total`、状态胶囊看 `phase`,三者可以各说各话。
22
+
23
+ 因此这不是文案问题,而是**完成事实有两个事实源**(计数 vs. 副作用闸门)。
24
+
25
+ ### 改动
26
+
27
+ - **`src/plan.ts`**
28
+ - `planPhase()`:所有子任务 `done` 时**恒返回 `done`**,不再看 `resultSeen`
29
+ (参数保留以兼容既有调用,标注为历史遗留);`integrating` 仅作为旧载荷的
30
+ 兼容取值存在。
31
+ - 新增 `reconcilePhase(phase, statuses)`:终态只由计数决定;`done` / `integrating`
32
+ 这类「由计数派生」的旧值一律重算;`paused` / `cancelled` / `blocked` / `degraded`
33
+ 保留各自的事实来源。
34
+ - `deriveAggregates()`:未全部完成时总进度**封顶 99**(`Math.min(99, raw)`),
35
+ 杜绝「11 done + 1 blocked@95 → 总体进度 100」。
36
+ - `applyStatus()`:`in_progress` 的 `progress` 夹紧到 `99`,「正在做」不允许自称 100%。
37
+ - `aggregateInvariantViolations(snapshot, phase?)`:新增
38
+ **I8**(`done === total` 必须 `phase === 'done'`,双向)、
39
+ **I9**(全部完成必须 100%,未全部完成不得 >= 100)、
40
+ **I10**(`in_progress` 不得 `progress >= 100`)。
41
+ - **`src/runtime.ts`**
42
+ - `serialisePlan()`:`phase` 改为 `reconcilePhase(record.phase ?? 派生值, statuses)`;
43
+ 载荷在存在不变量违背时附带 `violations` 数组,便于定位而不是静默显示。
44
+ - `updateStatus()`:改为 `reconcilePhase(record.phase, record.statuses)`
45
+ —— 截图矛盾的直接入口。
46
+ - 派发尾部:终态晋升不再依赖「结果消息写入」这一副作用,同样走 `reconcilePhase`。
47
+ - 回放 `reconstructFromSession()`:paused/cancelled 覆盖之后统一 `reconcilePhase`,
48
+ 保证**刷新前后同源**。
49
+ - `cancelPlan()`:计划已全部完成时改为 no-op(返回 `alreadyDone: true`),
50
+ 消除「100% + 已取消」。
51
+ - 预算到点分支:若此刻已经全部完成,记 `done` 而不是 `paused`(防御性,正常循环下不可达)。
52
+ - **`src/client.ts`**(自包含 bundle,不能 import `plan.ts`,故在客户端复刻同一语义)
53
+ - 三个唯一算式:`planPercent(done, total)`、`planPhaseOf(rawPhase, counts)`、
54
+ `planWeightedPercent(stats, counts)`;卡片环、徽章、分组、分组环、阶段条、
55
+ 会话内联卡、`rank`、`liveBars` 全部改走它们。
56
+ - 新增文案:`helpWaitActive`(等这一步做完,再继续下一项)、
57
+ `helpNoPlan`(还没有安排任何工作)、`nowRemaining`(还剩 {n} 项)。
58
+ - **测试**
59
+ - 新增 `tests/phase-percent-invariant.test.ts`(13 例):终态语义、计数封顶、
60
+ I8/I9 双向、update 补完最后一项、空更新不回退、取消已完成计划、
61
+ 客户端渲染旧载荷。
62
+ - 新增 `tests/completion-semantics.test.ts`(8 例):`in_progress@100` 夹紧、
63
+ I10、单任务执行中不再宣布完成、部分完成「还剩 N 项」、0/0 降级不说完成、
64
+ 阶段条只按 done 计宽度、阶段卡与「现在」同口径、0% 未开始不再标 active。
65
+ - 更新 `tests/plan.test.ts`、`tests/workplan-budget-pause.test.ts` 里把
66
+ 「全部完成后停在进行中」当期望值的断言;`tests/derived-counts.test.ts`
67
+ 的 blocked 用例期望改为 99。
68
+ - **版本**:`1.5.1 → 1.5.2`(patch,修正完成语义,不新增对外接口)。
69
+
70
+ ### 同类矛盾(来自 `docs/state-progress-matrix.md`)
71
+
72
+ | 编号 | 现象 | 本次 |
73
+ | --- | --- | --- |
74
+ | C1 | 100% + 「正在收尾」+ 留在「进行中」 | 已修 |
75
+ | C2 | 执行中却写「没有下一步,这件事已完成」 | 已修 |
76
+ | C5 | 部分完成却写「还没开始」 | 已修 |
77
+ | C7 | 阶段条把 blocked 算作已触达(环 50%、条 100%) | 已修 |
78
+ | C8 | 允许 `in_progress + 100%` | 已修 |
79
+ | C9 | 阶段行说「正在做」、阶段卡说「还没开始」 | 已修 |
80
+ | C11 | 0/0 降级计划也说「这件事已完成」 | 已修 |
81
+ | C12 | 同屏两个互斥的「现在」 | 已修 |
82
+ | C13 | 0% 未开始的卡标 `data-state="active"` | 已修 |
83
+ | C3 | 已取消的卡在前端被过滤,用户看不到(与 `dsh_workplan_cancel` 描述文案不一致) | 未改:既有设计,`tests/client-render.test.ts` 明确断言不渲染;记录待与工具描述统一 |
84
+ | C10 | stale pending 被序列化成 cancelled,而回放返回 `pending + stale:true` | 未改:记录为下一步 |
85
+ | C14 | `planning` 是死枚举 | 未改:记录为清理项 |
86
+
87
+ ### 验证
88
+
89
+ - `npm run build` → exit 0;`npm run check`(`tsc --noEmit`)→ exit 0。
90
+ - `npm test` → **120/120 通过**,0 fail(原 99 + 新增 13 + 8)。
91
+ - `node scripts/repro-phase-percent-integrating.mjs --expect-fixed` → 7/7 PASS,
92
+ `payload.phase = done`、`stats = { total: 2, done: 2, progress: 100 }`、
93
+ 徽章「已完成」、`live = done`、`replay = done`、无 `violations`。
94
+ (同一脚本不带参数运行现在会 3 条 FAIL —— 这是旧 bug 已不存在、修复生效的证据。)
95
+ - 版本一致性 7 处:`package.json`、`package-lock.json` 两处、`src/host.ts`、
96
+ `lib/host.js`、`dist/host.js`、本文件顶部,全部 `1.5.2`。
97
+ - 本次**未重启 DSH 服务**:`lib/*.js`(host 侧)在 DSH 进程下次启动时才加载;
98
+ `client.js` 随页面刷新生效。
99
+
100
+ ### 发布
101
+
102
+ - **未执行**。`git tag v1.5.2`、`npm publish` 属对外发布,需用户明确授权后再做。
103
+
104
+ ### 回滚
105
+
106
+ - 升级前四个文件的副本:`.backups/pre-1.5.2-20261029-125724/`
107
+ (`package.json`、`package-lock.json`、`CHANGELOG.md`、`src/host.ts`)。
108
+ - 回滚后必须重新 `npm run build`;运行中的 DSH 进程不会自动重载 `lib/host.js`。
109
+
1
110
  ## 1.5.1 — 2026-10-28
2
111
 
3
112
  **「先看这三句」与「分步进度」位置对换的补丁版**。用户以截图圈出展开态计划卡里的这两个板块,
@@ -19,14 +128,48 @@
19
128
  - `npm test` → **99/99 通过**(0 fail)。
20
129
  - 7 处版本一致性检查 → `[PASS] 7 处版本均为 1.5.1`(`package.json`、`package-lock.json` 两处、
21
130
  `src/host.ts`、`lib/host.js`、`dist/host.js`、`CHANGELOG.md` 顶部)。
22
- - 本次**未打 tag、未发布、未重启服务**;需要在运行实例里看到换序,须由主 Agent 重启 DSH
23
- (或重新加载 web profile)后再刷新页面。
131
+ - 提交 `fbe467b`(2026-10-28 22:27:53 +08:00)与 annotated tag `v1.5.1`
132
+ (taggerdate 2026-10-28 22:28:51 +08:00)已就绪并**已对外发布**(见下「发布」)。
133
+ - 本次**未重启 DSH 服务**;运行实例里的界面换序靠刷新页面触发(客户端 bundle 热重载)。
134
+
135
+ ### 发布
136
+
137
+ - 两个渠道、同一份制品:
138
+ - git:`https://github.com/abbaababa/dsh-workplan.git` 的 annotated tag `v1.5.1` → `fbe467b`;
139
+ - npm:公共 registry `https://registry.npmjs.org/dsh-workplan` 的 `1.5.1`(`dist-tags.latest`)。
140
+ - **状态(2026-10-28 22:46–22:50 +08:00 实测,发布已完成)**:
141
+ - `git push origin refs/heads/main` → `db74d44..fbe467b main -> main`(快进,exit 0);
142
+ - `git push origin refs/tags/v1.5.1` → `* [new tag] v1.5.1 -> v1.5.1`(exit 0);
143
+ - 远端复核(`git ls-remote`,独立网络查询):`refs/heads/main` = `fbe467b4a632…`,
144
+ `refs/tags/v1.5.1` = `120dc2cb…` → `^{}` = `fbe467b4a632…`,本地 `ahead/behind` = `0/0`;
145
+ - `npm publish --ignore-scripts`(在 `git worktree add --detach … v1.5.1` 的干净快照上执行)
146
+ → `+ dsh-workplan@1.5.1`(exit 0);
147
+ - `https://registry.npmjs.org/dsh-workplan/1.5.1` → **HTTP 200**,`time['1.5.1']` =
148
+ `2026-10-28T14:49:36.697Z`(22:49:36 +08:00),`dist-tags.latest` = `1.5.1`;
149
+ - 消费者视角:空白目录 `npm i dsh-workplan@1.5.1 --prefer-online` → 装到 1.5.1
150
+ (22 个文件,`client.js` 138 195 字节,`lib/host.js` 内 `version: '1.5.1'`)。
151
+ - **已发布制品的校验值**(registry 实测,与本地 `.tgz` 逐字节一致):
152
+ - npm `shasum`(SHA-1):`a6f35bd1b3d2630e3a2cf9e9555b58e51b20eaba`
153
+ - npm `integrity`(SHA-512 SRI):`sha512-AmpYMx55Y0ea2tAc14C4V7t/Pxrho+9nZXZqxz7JtdPYvFRyxS+wzEIUCfATRgq5cogT22lTSf0pEwUYOlgdqw==`
154
+ - 说明:同一份内容在原工作树打包时 `shasum` 为 `ecb39f0…`、在干净 `git worktree` 快照上为
155
+ `a6f35bd1…`,差异只来自 tar 记录的 mtime;**22 个文件的 blob 哈希与 tag `v1.5.1` 逐一相同**
156
+ (`checked 22 / mismatch 0`,另 44 个未随包发布的仓库文件按 `files` 白名单正确排除)。
157
+ 实际发布的是干净快照这一份,故以上两行为准。
158
+ - 发布顺序:先分支、后标签、再 npm,避免出现「远端有 tag 而 npm 没有」的半发布态。
159
+ - registry 有约 60–90 秒传播延迟(22:48:58、22:49:20 直连仍 404,22:49:41 转 200);
160
+ 该窗口内 `npm i` 需加 `--prefer-online` 绕过本地元数据缓存。
161
+ - 命令原文、失败分支、发布后验证与回滚:见 `docs/release-record-1.5.1-20261028.md`。
24
162
 
25
163
  ### 回滚
26
164
 
27
165
  - 升级前四个文件的副本:`.backups/pre-1.5.1-20261028-221343/`
28
166
  (`package.json`、`package-lock.json`、`CHANGELOG.md`、`src/host.ts`)。
29
167
  - 回滚后必须重新 `npm run build`;运行中的进程不会自动重载 `lib/host.js`。
168
+ - 已推送(远端已有 `fbe467b` 与 `v1.5.1`):`git push origin :refs/tags/v1.5.1` 删远端标签,
169
+ `git revert fbe467b` 回退提交(不要 `push --force` 改远端 `main` 历史)。
170
+ - 已发布到 npm:先用 `npm deprecate dsh-workplan@1.5.1 "回滚说明"` 引导用户;确需撤销时
171
+ `npm unpublish dsh-workplan@1.5.1 --force`——受 npm 官方 unpublish 政策约束(可撤销时间窗口、
172
+ 「有包依赖时不可撤销」),执行前核对 `npm unpublish --help` 与 npm 官方文档。
30
173
 
31
174
  ## 1.5.0 — 2026-10-28
32
175
 
package/client.js CHANGED
@@ -43,6 +43,9 @@ window.__ModuleLoader__.load({
43
43
  "helpNobody": "还没开始",
44
44
  "helpDone": "都做完了",
45
45
  "helpNextDone": "没有下一步,这件事已完成",
46
+ "helpWaitActive": "等这一步做完,再继续下一项",
47
+ "helpNoPlan": "还没有安排任何工作",
48
+ "nowRemaining": "还剩 {n} 项",
46
49
  "helpHint": "负责团队 = 谁来做;具体工作 = 要完成的一件事;卡住了 = 需要人处理。",
47
50
  "handleBlocked": "先处理",
48
51
  "resumeReady": "对主 Agent 说「续跑这个计划」,会从受阻处继续(已完成的不重跑)",
@@ -205,6 +208,9 @@ window.__ModuleLoader__.load({
205
208
  "helpNobody": "Not started yet",
206
209
  "helpDone": "Everything is done",
207
210
  "helpNextDone": "Nothing next; this task is complete",
211
+ "helpWaitActive": "Wait for this step to finish, then continue",
212
+ "helpNoPlan": "No work has been scheduled yet",
213
+ "nowRemaining": "{n} item(s) left",
208
214
  "helpHint": "Team = who does it; work item = one thing to finish; stuck = a person needs to help.",
209
215
  "handleBlocked": "Fix first",
210
216
  "resumeReady": "Tell the main agent to resume this plan; it continues from the blocked step without re-running finished work",
@@ -1026,14 +1032,21 @@ window.__ModuleLoader__.load({
1026
1032
  return text;
1027
1033
  }
1028
1034
  function workerText(session, t) {
1029
- const active = findEntry(session, 'in_progress');
1030
- if (active) {
1031
- return `${deptName(t, active.subtask.department)} · ${active.subtask.title}`;
1032
- }
1035
+ /*
1036
+ * [1.5.2] 与 `planNow` 同序:卡住优先。
1037
+ *
1038
+ * 旧顺序是「正在做」优先,而卡片右上角的「现在」是「卡住了」优先,
1039
+ * 于是同一屏一边说「现在卡住了 T1」、一边说「正在做 T2」(T5 C12)。
1040
+ * 卡住需要人介入,两个位置都应先报它。
1041
+ */
1033
1042
  const blocked = findEntry(session, 'blocked');
1034
1043
  if (blocked) {
1035
1044
  return `${deptName(t, blocked.subtask.department)} · ${blocked.subtask.title}(${t('status.blocked')})`;
1036
1045
  }
1046
+ const active = findEntry(session, 'in_progress');
1047
+ if (active) {
1048
+ return `${deptName(t, active.subtask.department)} · ${active.subtask.title}`;
1049
+ }
1037
1050
  const stats = session.stats || {};
1038
1051
  if ((stats.total ?? 0) > 0 && (stats.done ?? 0) >= (stats.total ?? 0))
1039
1052
  return t('helpDone');
@@ -1060,13 +1073,26 @@ window.__ModuleLoader__.load({
1060
1073
  const blocked = findEntry(session, 'blocked');
1061
1074
  if (blocked)
1062
1075
  return `${t('handleBlocked')}:${deptName(t, blocked.subtask.department)} · ${blocked.subtask.title}${resumeHint(session, t)}`;
1076
+ /*
1077
+ * [1.5.2] 「没有下一步」只允许出现在真正全部完成之后。
1078
+ *
1079
+ * 旧实现找不到 pending 就直接 return「没有下一步,这件事已完成」,于是
1080
+ * · 单任务计划刚进入 in_progress(环 0%)→ 同一张卡宣布「已完成」(T5 C2);
1081
+ * · 降级计划 0/0 → 一边「安排失败」一边「已完成」(T5 C11)。
1082
+ * 现在先按计数判终态,再区分「等当前这一步」「根本没有计划」。
1083
+ */
1084
+ const counts = planCounts(session.stats || {});
1085
+ if (counts.total > 0 && counts.done === counts.total)
1086
+ return t('helpDone');
1087
+ if (counts.total === 0)
1088
+ return t('helpNoPlan');
1063
1089
  const next = findEntry(session, 'pending') || allEntries(session).find((entry) => !entry.item);
1064
1090
  if (next)
1065
1091
  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))
1068
- return t('helpDone');
1069
- return t('helpNextDone');
1092
+ const active = findEntry(session, 'in_progress');
1093
+ if (active)
1094
+ return `${t('helpWaitActive')}:${deptName(t, active.subtask.department)} · ${active.subtask.title}`;
1095
+ return t('helpWaitActive');
1070
1096
  }
1071
1097
  function progressText(item, t) {
1072
1098
  const status = item?.status || 'pending';
@@ -1254,15 +1280,19 @@ window.__ModuleLoader__.load({
1254
1280
  const done = items.filter(({ item }) => item?.status === 'done').length;
1255
1281
  const blocked = items.filter(({ item }) => item?.status === 'blocked').length;
1256
1282
  const inProgress = items.some(({ item }) => item?.status === 'in_progress');
1257
- const tone = blocked > 0 ? 'blocked' : done === items.length ? 'done' : inProgress ? 'active' : 'idle';
1283
+ // [1.5.2] 与 `planStageRows` 同一条规则:有 done 就是 active,不再只在
1284
+ // 有 in_progress 时才算「正在做」。旧规则让同一阶段在概览行显示「正在做」、
1285
+ // 在阶段卡显示「还没开始」(T5 C9)。
1286
+ const tone = blocked > 0 ? 'blocked' : items.length > 0 && done === items.length ? 'done' : inProgress || done > 0 ? 'active' : 'idle';
1258
1287
  // Same tone/counts as before; only the presentation moved into the
1259
1288
  // collapsed summary row (done/total + state pill), so the numbers keep one
1260
1289
  // source: this step's own items from `statuses`.
1261
1290
  const stateText = blocked > 0
1262
1291
  ? `${blocked} ${t('stats.blocked')}`
1263
- : done === items.length
1292
+ : items.length > 0 && done === items.length
1264
1293
  ? t('helpDone')
1265
- : inProgress ? t('phase.executing') : t('phase.pending');
1294
+ : inProgress ? t('phase.executing')
1295
+ : done > 0 ? fill(t('nowRemaining'), { n: items.length - done }) : t('phase.pending');
1266
1296
  return h('section', { className: `dsh-wp-stage dsh-wp-tone-${tone}` }, h(Collapse, {
1267
1297
  className: 'dsh-wp-stage-collapse',
1268
1298
  panelClassName: 'dsh-wp-stage-body',
@@ -1290,6 +1320,17 @@ window.__ModuleLoader__.load({
1290
1320
  return 0;
1291
1321
  return Math.max(0, Math.round(number));
1292
1322
  }
1323
+ /*
1324
+ * [1.5.2] 计划级百分比的唯一算式。
1325
+ *
1326
+ * 1.5.1 及以前,同一页面上至少有三处各自 `Math.round(done / total * 100)`:
1327
+ * 卡片环(planCounts)、组头环(groupTotals)、按任务进度条(TaskBars / 总览柱状图)。
1328
+ * 三份实现口径相同只是巧合,任何一处单独改动都会让「同一张卡两个百分比」重现。
1329
+ * 现在只有这一个函数,其它地方一律调用它。
1330
+ */
1331
+ function planPercent(done, total) {
1332
+ return total > 0 ? Math.round((done / total) * 100) : null;
1333
+ }
1293
1334
  // 与 groupTotals 同一套口径:先夹到 [0,total],再让「正在做/还没开始」
1294
1335
  // 吃掉剩余量,四段之和恒等于总数(构成条不会溢出或出现负宽度)。
1295
1336
  function planCounts(stats) {
@@ -1299,8 +1340,46 @@ window.__ModuleLoader__.load({
1299
1340
  const blocked = Math.min(planCount(source.blocked), total - done);
1300
1341
  const active = Math.min(planCount(source.inProgress), total - done - blocked);
1301
1342
  const pending = Math.max(0, total - done - blocked - active);
1302
- const percent = total > 0 ? Math.round((done / total) * 100) : null;
1303
- return { total, done, active, blocked, pending, percent };
1343
+ return { total, done, active, blocked, pending, percent: planPercent(done, total) };
1344
+ }
1345
+ /*
1346
+ * [1.5.2] 完成语义的第二道保险:状态文案必须服从计数事实。
1347
+ *
1348
+ * 后端已保证「工作项全部 done ⇒ phase=done」(`reconcilePhase`)。这里再兜一层,
1349
+ * 让任何历史载荷、旧缓存或第三方构造的 payload 都不可能渲染出「100% + 正在
1350
+ * 收尾」:计数齐全且全部完成时一律按「已完成」呈现;反过来,phase 说 done
1351
+ * 但计数里还有没做完的项时,按真实计数降级,避免「已完成下面挂着没做完的活」。
1352
+ *
1353
+ * 计数未知(total=0,例如已取消或尚未拆解)时不动原始 phase——此时没有事实
1354
+ * 可以覆盖它,硬改会把「进行中/已完成」的分组依据一起抹掉。
1355
+ * 「integrating」只在计数证明全部完成时升级为 done,否则保留原语义(回放旧会话)。
1356
+ */
1357
+ function planPhaseOf(rawPhase, counts) {
1358
+ const phase = String(rawPhase ?? 'pending');
1359
+ if (counts.total > 0) {
1360
+ if (counts.done === counts.total)
1361
+ return 'done';
1362
+ if (phase === 'done') {
1363
+ if (counts.blocked > 0)
1364
+ return 'blocked';
1365
+ if (counts.active > 0)
1366
+ return 'executing';
1367
+ return 'pending';
1368
+ }
1369
+ }
1370
+ return phase;
1371
+ }
1372
+ /*
1373
+ * [1.5.2] 加权总进度的展示口径:未全部完成时封顶 99。
1374
+ *
1375
+ * 服务端 `deriveAggregates` 已做同样封顶;这里再算一次是为了让旧载荷/手工
1376
+ * fixture 也守同一条不变量——「总体进度 100%」绝不能与「还有 N 项没完成」同屏。
1377
+ */
1378
+ function planWeightedPercent(stats, counts) {
1379
+ const weighted = clampPct(stats?.progress ?? 0);
1380
+ if (counts.total > 0 && counts.done === counts.total)
1381
+ return 100;
1382
+ return Math.min(99, weighted);
1304
1383
  }
1305
1384
  function planStatusMap(statuses) {
1306
1385
  const byId = new Map();
@@ -1384,6 +1463,16 @@ window.__ModuleLoader__.load({
1384
1463
  }
1385
1464
  if (counts.total > 0 && counts.done === counts.total)
1386
1465
  return { state: 'done', text: t('helpDone') };
1466
+ /*
1467
+ * [1.5.2] 部分完成不再落到「还没开始」。
1468
+ *
1469
+ * `done>0` 但没有 blocked、也没有 in_progress 时(预算把剩余项重置成
1470
+ * pending,或剩余项还没轮到),旧代码直接返回 `stats.pending`「还没开始」,
1471
+ * 与同一张卡上的「1/2 做好了」和 50% 直接冲突(T5 C5)。
1472
+ */
1473
+ if (counts.done > 0) {
1474
+ return { state: 'active', text: fill(t('nowRemaining'), { n: counts.total - counts.done }) };
1475
+ }
1387
1476
  return { state: 'pending', text: t('stats.pending') };
1388
1477
  }
1389
1478
  function PlanRing({ percent }) {
@@ -1456,7 +1545,9 @@ window.__ModuleLoader__.load({
1456
1545
  if (rows.length < 1 || rows.length > 12)
1457
1546
  return null;
1458
1547
  return h('div', { className: 'dsh-wp-stepper', 'aria-hidden': 'true', 'data-stages': String(rows.length) }, rows.map((row) => {
1459
- const reach = row.total > 0 ? ((row.done + row.active + row.blocked) / row.total) * 100 : 0;
1548
+ // [1.5.2] 只按 done 计。旧式把 blocked/active 也算「触达」,于是环 50%
1549
+ // 时阶段条已经全满(T5 C7)——同一张卡两个「完成度」。
1550
+ const reach = row.total > 0 ? (row.done / row.total) * 100 : 0;
1460
1551
  const width = row.tone === 'pending' ? 0 : reach;
1461
1552
  return h('span', {
1462
1553
  key: row.id,
@@ -1473,11 +1564,20 @@ window.__ModuleLoader__.load({
1473
1564
  }));
1474
1565
  }
1475
1566
  function PlanViz({ t, counts, now, rows }) {
1567
+ /*
1568
+ * [1.5.2] data-state 也是「完成语义」的一个读者。
1569
+ *
1570
+ * 旧实现只看 percent:0% 的 pending 卡拿到 data-state="active",
1571
+ * 而 100% 的卡即使 phase 说 integrating 也是 "done"(T5 C13/C1)。
1572
+ * 现在与胶囊、分组、环共用计数:全 done 才 done,0 且有未完成才 idle。
1573
+ */
1476
1574
  const state = counts.percent === null
1477
1575
  ? 'idle'
1478
1576
  : counts.blocked > 0
1479
1577
  ? 'blocked'
1480
- : counts.percent >= 100 ? 'done' : 'active';
1578
+ : counts.total > 0 && counts.done === counts.total
1579
+ ? 'done'
1580
+ : counts.done > 0 || counts.active > 0 ? 'active' : 'idle';
1481
1581
  return h('div', {
1482
1582
  className: 'dsh-wp-planviz',
1483
1583
  'data-percent': counts.percent === null ? 'none' : String(counts.percent),
@@ -1496,7 +1596,7 @@ window.__ModuleLoader__.load({
1496
1596
  className: 'dsh-wp-planoverview',
1497
1597
  'data-plan-total': String(counts.total),
1498
1598
  'data-plan-done': String(counts.done),
1499
- }, h('div', { className: 'dsh-wp-planoverview-head' }, h('span', { className: 'dsh-wp-planoverview-title' }, t('vizStageTitle')), h('span', null, `${counts.done}/${counts.total} ${t('stats.done')}`)), h('div', { className: 'dsh-wp-planoverview-stats' }, h('span', null, `${t('vizWeighted')} ${clampPct(stats?.progress ?? 0)}%`), h('span', null, `${counts.active} ${t('stats.inProgress')} · ${counts.pending} ${t('stats.pending')}`), counts.blocked > 0 ? h('span', null, `${counts.blocked} ${t('stats.blocked')}`) : null), rows.length
1599
+ }, h('div', { className: 'dsh-wp-planoverview-head' }, h('span', { className: 'dsh-wp-planoverview-title' }, t('vizStageTitle')), h('span', null, `${counts.done}/${counts.total} ${t('stats.done')}`)), h('div', { className: 'dsh-wp-planoverview-stats' }, h('span', null, `${t('vizWeighted')} ${planWeightedPercent(stats, counts)}%`), h('span', null, `${counts.active} ${t('stats.inProgress')} · ${counts.pending} ${t('stats.pending')}`), counts.blocked > 0 ? h('span', null, `${counts.blocked} ${t('stats.blocked')}`) : null), rows.length
1500
1600
  ? h('div', { className: 'dsh-wp-planrows' }, rows.map((row) => {
1501
1601
  const width = row.total > 0 ? (row.done / row.total) * 100 : 0;
1502
1602
  return h('div', {
@@ -1514,13 +1614,18 @@ window.__ModuleLoader__.load({
1514
1614
  }
1515
1615
  function SessionCard({ session, t, open, onToggle }) {
1516
1616
  const domId = `dsh-wp-session-${session.sessionId}`;
1517
- const phase = session.phase || 'pending';
1518
1617
  const stats = session.stats || {};
1519
- const tone = phaseTone(phase);
1520
- const departments = Array.isArray(session.departments) ? session.departments : [];
1521
1618
  // 1.5.0:折叠卡摘要=图表(环 + 百分比 + 四段构成条 + 图例 + 阶段步进条);
1522
1619
  // 数字仍只来自 session.stats,与旧头部的口径完全一致,不引入第二套统计。
1523
1620
  const counts = planCounts(stats);
1621
+ /*
1622
+ * [1.5.2] 卡片的色调与胶囊文案也走统一对账:只要计数显示全部完成,
1623
+ * 胶囊就必须是「已完成」,视觉档位也必须是 done。截图里那张卡之所以
1624
+ * 「100% + 正在收尾 + 留在进行中组」,就是因为这三个读者各读各的字段。
1625
+ */
1626
+ const phase = planPhaseOf(session.phase || 'pending', counts);
1627
+ const tone = phaseTone(phase);
1628
+ const departments = Array.isArray(session.departments) ? session.departments : [];
1524
1629
  const statuses = Array.isArray(session.statuses) ? session.statuses : [];
1525
1630
  const stageRows = planStageRows(session.stages, statuses);
1526
1631
  const now = planNow(t, counts, planTaskEntries(session.stages, statuses));
@@ -1729,7 +1834,7 @@ window.__ModuleLoader__.load({
1729
1834
  total,
1730
1835
  done,
1731
1836
  open: total - done,
1732
- percent: total > 0 ? Math.round((done / total) * 100) : null,
1837
+ percent: planPercent(done, total),
1733
1838
  active: Math.max(0, active),
1734
1839
  pending: Math.max(0, pending),
1735
1840
  blocked: Math.max(0, blocked),
@@ -1919,10 +2024,11 @@ window.__ModuleLoader__.load({
1919
2024
  function TaskBars({ sessions, t, onSelect }) {
1920
2025
  return h('section', { className: 'dsh-wp-chart-card dsh-wp-task-bars' }, h('div', { className: 'dsh-wp-section-title' }, t('taskChart')), (Array.isArray(sessions) ? sessions : []).map((session) => {
1921
2026
  const stats = session.stats || {};
1922
- const total = Math.max(0, Number(stats.total) || 0);
1923
- const done = Math.max(0, Number(stats.done) || 0);
1924
- const percent = total > 0 ? Math.round((done / total) * 100) : 0;
1925
- const tone = phaseTone(session.phase || 'pending');
2027
+ const counts = planCounts(stats);
2028
+ // 同一套计数与对账:柱状图的百分比、文案、色调都不得自成一套。
2029
+ const percent = counts.percent ?? 0;
2030
+ const phase = planPhaseOf(session.phase || 'pending', counts);
2031
+ const tone = phaseTone(phase);
1926
2032
  return h('button', {
1927
2033
  key: session.sessionId,
1928
2034
  type: 'button',
@@ -1930,7 +2036,7 @@ window.__ModuleLoader__.load({
1930
2036
  'aria-label': `${t('jumpToTask')} ${session.title || t('panel')}`,
1931
2037
  onClick: () => { if (typeof onSelect === 'function')
1932
2038
  onSelect(session.sessionId); },
1933
- }, h('div', { className: 'dsh-wp-bar-head' }, h('span', { className: 'dsh-wp-bar-title' }, session.title || t('panel')), h('span', { className: 'dsh-wp-bar-value' }, `${done}/${total}`)), h('div', { className: 'dsh-wp-bar-track' }, h('div', { className: `dsh-wp-bar-fill dsh-wp-bar-fill--${tone}`, style: { width: `${percent}%` } })), h('div', { className: 'dsh-wp-bar-meta' }, h('span', null, t(`phase.${session.phase || 'pending'}`)), h('span', null, `${percent}%`)));
2039
+ }, h('div', { className: 'dsh-wp-bar-head' }, h('span', { className: 'dsh-wp-bar-title' }, session.title || t('panel')), h('span', { className: 'dsh-wp-bar-value' }, `${counts.done}/${counts.total}`)), h('div', { className: 'dsh-wp-bar-track' }, h('div', { className: `dsh-wp-bar-fill dsh-wp-bar-fill--${tone}`, style: { width: `${percent}%` } })), h('div', { className: 'dsh-wp-bar-meta' }, h('span', null, t(`phase.${phase}`)), h('span', null, `${percent}%`)));
1934
2040
  }));
1935
2041
  }
1936
2042
  function Dashboard({ t }) {
@@ -1980,8 +2086,16 @@ window.__ModuleLoader__.load({
1980
2086
  const live = [];
1981
2087
  const finished = [];
1982
2088
  const urgentPhases = ['blocked', 'degraded', 'paused'];
2089
+ /*
2090
+ * [1.5.2] 分组也按对账后的 phase,而不是 payload 里的原始 phase。
2091
+ *
2092
+ * 截图那张卡之所以停在「进行中」组,就是因为这里只读 `session.phase`:
2093
+ * 14/14、100% 的计划带着 integrating 被塞进 live 组,组头于是出现
2094
+ * 「未完成 0 / 已完成 14(完成率 100%)」却仍标着「进行中」。
2095
+ * `planPhaseOf` 让分组、组头计数、卡片胶囊共用同一个结论。
2096
+ */
1983
2097
  for (const session of activeSessions) {
1984
- if (session.phase === 'done')
2098
+ if (planPhaseOf(session.phase, planCounts(session.stats)) === 'done')
1985
2099
  finished.push(session);
1986
2100
  else
1987
2101
  live.push(session);
@@ -2001,7 +2115,7 @@ window.__ModuleLoader__.load({
2001
2115
  planning: 4,
2002
2116
  pending: 5,
2003
2117
  };
2004
- const rank = (session) => phaseRank[session.phase] ?? 5;
2118
+ const rank = (session) => phaseRank[planPhaseOf(session.phase, planCounts(session.stats))] ?? 5;
2005
2119
  live.sort((a, b) => (rank(a) - rank(b))
2006
2120
  || ((Number(b.createdAt) || 0) - (Number(a.createdAt) || 0))
2007
2121
  || String(a.sessionId).localeCompare(String(b.sessionId)));
@@ -2033,12 +2147,11 @@ window.__ModuleLoader__.load({
2033
2147
  return { live, finished, urgent: live.filter((session) => urgentPhases.includes(session.phase)).length };
2034
2148
  }, [activeSessions]);
2035
2149
  const liveBars = useMemo(() => groups.live.slice(0, 6).map((session) => {
2036
- const doneCount = session.stats?.done ?? 0;
2037
- const totalCount = session.stats?.total ?? 0;
2038
2150
  const raw = String(session.title || t('panel'));
2039
2151
  return {
2040
2152
  label: raw.length > 14 ? `${raw.slice(0, 14)}…` : raw,
2041
- value: totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0,
2153
+ // [1.5.2] 与卡片/组头同一个百分比函数,杜绝第二套口径。
2154
+ value: planPercent(planCount(session.stats?.done), planCount(session.stats?.total)) ?? 0,
2042
2155
  };
2043
2156
  }), [groups, t]);
2044
2157
  return h('div', { className: 'dsh-wp' }, h('header', { className: 'dsh-wp-header' }, h('div', null, h('h1', { className: 'dsh-wp-title' }, t('title')), h('p', { className: 'dsh-wp-subtitle' }, t('subtitle')), h('div', { className: 'dsh-wp-meta' }, updatedAt ? h('span', null, `${t('updatedAt')} ${formatTime(updatedAt)}`) : null, h('span', null, `${t('scopeAll')}:${activeSessions.length} ${t('sessionCount')},${t('taskBreakdown')} ${totals.total} ${t('subtaskCount')}`))), h('button', { type: 'button', className: 'dsh-wp-button', onClick: reload, disabled: loading }, t('refresh'))), error ? h(ErrorBanner, { message: error, onRetry: reload, t }) : null, activeSessions.length > 0 ? h('div', { className: 'dsh-wp-statbar', role: 'list' }, h('div', { className: 'dsh-wp-stat dsh-wp-stat--tasks', role: 'listitem' }, h('div', { className: 'dsh-wp-stat-value' }, activeSessions.length), h('div', { className: 'dsh-wp-stat-label' }, t('sessionCount'))), h('div', { className: 'dsh-wp-stat', role: 'listitem' }, h('div', { className: 'dsh-wp-stat-value' }, totals.total), h('div', { className: 'dsh-wp-stat-label' }, t('workItems'))), h('div', { className: 'dsh-wp-stat dsh-wp-stat--done', role: 'listitem' }, h('div', { className: 'dsh-wp-stat-value' }, totals.done), h('div', { className: 'dsh-wp-stat-label' }, t('stats.done'))), h('div', { className: 'dsh-wp-stat dsh-wp-stat--active', role: 'listitem' }, h('div', { className: 'dsh-wp-stat-value' }, totals.inProgress), h('div', { className: 'dsh-wp-stat-label' }, t('stats.inProgress'))), h('div', { className: 'dsh-wp-stat dsh-wp-stat--blocked', role: 'listitem' }, h('div', { className: 'dsh-wp-stat-value' }, totals.blocked), h('div', { className: 'dsh-wp-stat-label' }, t('stats.blocked')))) : null, activeSessions.length > 0 ? h('div', { className: 'dsh-wp-scope-note' }, t('allPlansHint')) : null, activeSessions.length === 0
@@ -2066,9 +2179,16 @@ window.__ModuleLoader__.load({
2066
2179
  if (currentTurn > createdTurn && !active)
2067
2180
  return null;
2068
2181
  }
2182
+ /*
2183
+ * [1.5.2] 会话内联卡(TurnCard)也走同一套对账。
2184
+ *
2185
+ * 面板卡片修好了、内联卡却仍按原始 phase 渲染的话,同一张计划会在
2186
+ * 「侧栏已完成 / 对话里正在收尾」之间再次自相矛盾。
2187
+ */
2069
2188
  const stats = session.stats || {};
2070
- const tone = phaseTone(session.phase || 'pending');
2071
- return h('div', { className: `dsh-wp-turn dsh-wp-tone-${tone}` }, h('div', { className: 'dsh-wp-turn-title' }, h('span', null, session.title || t('panel')), h(Pill, { tone }, t(`phase.${session.phase || 'pending'}`))), h('div', { className: 'dsh-wp-muted', style: { marginTop: '6px' } }, `${t('helpWho')}:${workerText(session, t)}`), h('div', { className: 'dsh-wp-muted', style: { marginTop: '2px' } }, `${t('helpNext')}:${nextText(session, t)}`), h('div', { className: 'dsh-wp-progress-meta' }, h('span', null, `${stats.done ?? 0}/${stats.total ?? 0} ${t('stats.done')} · ${stats.blocked ?? 0} ${t('stats.blocked')}`), h('span', { className: 'dsh-wp-muted' }, t('viewHint'))));
2189
+ const phase = planPhaseOf(session.phase || 'pending', planCounts(stats));
2190
+ const tone = phaseTone(phase);
2191
+ return h('div', { className: `dsh-wp-turn dsh-wp-tone-${tone}` }, h('div', { className: 'dsh-wp-turn-title' }, h('span', null, session.title || t('panel')), h(Pill, { tone }, t(`phase.${phase}`))), h('div', { className: 'dsh-wp-muted', style: { marginTop: '6px' } }, `${t('helpWho')}:${workerText(session, t)}`), h('div', { className: 'dsh-wp-muted', style: { marginTop: '2px' } }, `${t('helpNext')}:${nextText(session, t)}`), h('div', { className: 'dsh-wp-progress-meta' }, h('span', null, `${stats.done ?? 0}/${stats.total ?? 0} ${t('stats.done')} · ${stats.blocked ?? 0} ${t('stats.blocked')}`), h('span', { className: 'dsh-wp-muted' }, t('viewHint'))));
2072
2192
  }
2073
2193
  function contentText(block) {
2074
2194
  if (!block || !Array.isArray(block.content))
package/lib/host.js CHANGED
@@ -294,7 +294,7 @@ export function apply(ctx, config = {}) {
294
294
  }
295
295
  export const meta = {
296
296
  name,
297
- version: '1.5.1',
297
+ version: '1.5.2',
298
298
  promptOrder: PROMPT_ORDER,
299
299
  routes: ['/dsh-workplan/plans', '/dsh-workplan/vendor/chart.umd.js'],
300
300
  };