dsh-workflow-map 0.3.2 → 0.3.4

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
@@ -3,6 +3,92 @@
3
3
  `dsh-workflow-map` 的所有重要变更记录在此。
4
4
  格式遵循 [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),版本号遵循 [SemVer](https://semver.org/)。
5
5
 
6
+ ## [0.3.4] - 2026-10-30
7
+
8
+ ### Changed
9
+
10
+ - **宿主要求改由 `engines.dsh` 声明**:`>=0.2.1-rc.1 <0.3.0-0`,覆盖 `0.2.1` 全系列(`rc.1` 至 `rc.5`)与 `0.2.1` 正式版。
11
+ 该区间由宿主在安装与启用期读取(`dsh-app-boot` 的 `evaluatePluginCompatibility()`),不满足即拒绝加载。
12
+ - **删除非标准 `dsh.host` 字段**:宿主包仍由 `peerDependencies` 声明(均为可选 peer),版本闸门只看 `engines.dsh`。
13
+ - **中英 README 增加宿主要求块**:逐版本核验方式与结论、区间写法说明,区间字符串与 `package.json` 的 `engines.dsh` 逐字符一致。
14
+
15
+ ### Added
16
+
17
+ - **`docs/host-compatibility.md`**:区间判据、206 包闭包比对、九个契约点的逐版本证据,以及 `0.2.1-rc.3`(实机运行)
18
+ 与 `0.2.1-rc.1` / `rc.2` / `rc.4` / `rc.5`(契约核验)的区别。
19
+
20
+ ### Fixed
21
+
22
+ - **计划行自带的步骤文件名不再被当成标题的一部分**。PAE 会把计划行渲染成 `1. 标题(限定语)(step-01-x.md)`,
23
+ 折叠时连行尾的文件名一起收进标题,于是这一步的标题比待办表里那一行多一个括号组:`foldTodos` 拿步骤键
24
+ 与表比对时判定「无变化」,视图里留下一条对话正文并不存在的分步(`audit-real` 约束 ①)。
25
+ 现在解析计划行时就剥掉行尾的 `.md` / `.json` 等文件名标注;计划标题改写时同步更新步骤键,
26
+ 旧键归档进 `aliasKeys`(与改名同一条路径),已挂在旧键上的节点随之迁移。
27
+ - **`docs/` 与两份 README 的口语化表述收敛为陈述句**:删去带日期的括号注、第一人称示例与破折号转折,
28
+ 内容不变。
29
+ - **浅色主题下分步卡与背景同色**。宿主在浅色主题里把 `--dsw-alias-bg-layer-1` 与 `-2` 都解析到
30
+ `--dsw-static-neutral-bluish-00`(即 `#fff`),与 `--dsw-alias-bg-base` 同一个值;分步卡当时只填 `bg-layer-2`
31
+ 且边线是 `transparent`,于是整块卡面在白底上不可见。现在卡面带自己的抬升墨色
32
+ (`--wf-lift` / `--wf-lift-2`,深色主题另行覆盖),边线改用 `--dsw-alias-border-l2`,分步、节点、
33
+ 单元三类卡面与悬停/选中态一致。
34
+ - **待办表是步骤生命周期的唯一依据**。`todo/write` 每次重写整张表,但视图同步把行“配对”到旧记录时
35
+ 沿用了那条记录的旧状态:表中写 `pending` 的行显示成 `done`/100%,表中写 `in_progress` 的行显示成
36
+ `pending`(同一会话里两种都出现过)。现在配对后一律按行本身的状态落值,未命名、从未开始、又不拥有
37
+ 任何节点的 `pending` 行会被丢弃,会话不再因为改写过表格而长出空卡。
38
+ - **非运行中的分步不再显示百分比**。`percentFor()` 在进入各条测算路径(自报 / 待办比值 / 基线)之前先判状态:
39
+ 只要不是 `running` 就不出数字。此前 74 步待办(5–99%)带着一个没人更新过的旧数字,看着像停滞。
40
+ - **回合结束时关闭该回合的运行步**。`foldTurnEnd` 以前只翻 `turnActive` 标志,运行步会一直是 `running`;
41
+ 现在按结束时刻补时长并结束该步,唯一的例外是待办表此刻仍标 `in_progress`(那一步确实跨回合继续做)。
42
+ - **检查点版本 3 → 4**:旧检查点里的步骤台账按旧映射生成(错误状态、残留空行、带有不应存在的百分比),
43
+ 按既有契约丢弃后从日志重建。
44
+ - **量测脚本不再依赖 `--dump-dom`**。Edge 156.0.4314.50 的启动器会在毫秒内以退出码 0 退出、把真正的浏览器进程
45
+ 甩成独立进程,于是 `--dump-dom` 写进一个没人持有的 stdout,落盘文件恒为 0 字节(`--version` 也无输出)。
46
+ 新增 `tools/cdp-dump.mjs`:自己拉起无头浏览器、从 `DevToolsActivePort` 拿调试端口、连页面 target、
47
+ `Page.enable` + `Runtime.evaluate(document.documentElement.outerHTML)` 取 DOM,失败必抛;
48
+ 10 个 `tools\measure-*.ps1` 全部改为调它,并新增 `tools\patch-measure-cdp.mjs` 留证改法。
49
+
50
+ ### Tests
51
+
52
+ - **离线自测 10 套 / 1533 条断言**全部通过,退出码全 0(0.3.3 为 1515 条)。本轮新增 13 条:`selftest-fold`
53
+ 的 `(r)` 用例 8 条(待办表改回 `pending` 必须降级、改写表后的 `pending` 行不得顶替运行步、
54
+ `in_progress` 行对应的记录即使原来是 `pending` 也要转为运行、未命名空行被丢弃、回合结束仍保留表中
55
+ `in_progress` 的步、非运行行不出数字……),`selftest-panel` 的浅色主题针脚 5 条(抬升变量、深色覆盖、
56
+ 卡面抬升、卡面边线取 token、悬停抬升)。
57
+ - **真实会话日志**:`check-real-injects` 折叠 147 个会话、识别出 19 条热注入记录、failures 0;
58
+ `audit-real` 折叠 148 个会话、629 个步骤,约束 ①(分步必须存在于待办表)与 ②(同一时刻只有一个
59
+ running 步)各 0 违规;`done` 步显示 <100% 的数量为 **0**,非运行步带数字的数量为 **0**(修前 74 条)。
60
+ - **浏览器量测 10 套 / 323 条断言**全部通过,退出码全 0(0.3.3 为 308 条):
61
+ `panel 21 · timing 20 · inject 43 · dock 14 · flow 20 · effects 28 · branches 15 · detail 50 · settings 56 · main 56`。
62
+ `main` 从 41 增至 56 的 15 条是本轮新增的主题对比检查:浅色帧的页面底色确实是宿主真值 `#fff`、
63
+ 两种主题下 `--wf-lift` 各自生效、单元卡边线 alpha 与抬升渐变俱在、14 px 颗星徽标靠自身颜色边线可见
64
+ (实测 light/dark 均为 `rgba(…,1)`)。旧的 `card` / `node`(遗留组件)不在单页上,按「不存在不记失败」处理,
65
+ 它们的边线与抬升由 `selftest-panel` 的样式表针脚守住。
66
+ - **`0.2.1-rc.5` 隔离实机**:`--dump-config` 退出码 0、stdout 1234 行、零告警;
67
+ `/dsh-workflow/state`、`/steer`、`/unsteer` 按本插件契约应答(`404 unknown session`、缺参 `400`)。
68
+ 记录与复跑命令见 `docs/host-compatibility.md` §3.1。
69
+
70
+ ## [0.3.3] - 2026-10-29
71
+
72
+ ### Fixed
73
+
74
+ - **编号清单的序号不再丢失,地图不再重复显示步骤**。模型把待办表从 7 行无编号换成 14 行带 `1. `–`14. ` 前缀后,`syncToTodos` 把序号从**已经剥掉前缀的标题**上再解析一次,
75
+ 于是 `planIndexOf` 永远返回 null;创建出的行丢了序号,而 `foldTodos` 按位置配对时又不回写序号,
76
+ 旧行继续带着上一份计划的 `planIndex`。`applyPlanToView` 随后按这个错序号把计划步骤 2/3/4 的标题和状态
77
+ 盖到别的行上。用户看到的是第 3–7 步各出现两次,跑动中的那一格也不是真正的当前步。
78
+ 修法:`planIndex` 从**原始 content** 解析;配对到旧行时把序号按当前表回写;槽位配对遇到序号冲突时让位给按序号匹配。
79
+ 复现判据:修前的视图里 14 行有 5 组标题重名,修后 `planIndex` 1–14 连续且无重名。
80
+ - **计划自带的确认点不再丢失**。`orchestrator.json` 里 `requiresConfirmation: true` 的步骤(该会话第 2/12/13 步)
81
+ 在地图上一个「需确认」标记都没有:`applyPlanToView` 的注释说宿主会合并这一半,但代码里没有这段合并。
82
+ 现在由 `applyPlanToView` 补上,标记的 `kind` 为 `plan`;已 done/failed 的步骤不带标记(与日志侧规则一致),
83
+ 重复应用同一份计划不会叠加。
84
+
85
+ ### Tests
86
+
87
+ - `tools/selftest-fold.mjs` 新增 8 条断言:`(o1)`/`(o2)` 编号清单换表后序号连续、标题不重名、
88
+ 行不继承邻居标题;`(p)` 计划确认点只落在该落的步骤、`kind` 为 `plan`、重复应用不重复、已完成步骤不带标记。
89
+ - 离线自测 **10 套 / 1515 条断言**全部通过(0.3.2 为 1507 条)。
90
+
91
+
6
92
  ## [0.3.1] - 2026-10-28
7
93
 
8
94
  ### Fixed
@@ -10,9 +96,9 @@
10
96
  - **`soft` 配色的同类残留**。0.3.0 已让 `brand` 配色下运行中卡片的外框跟随品牌色,
11
97
  但 `soft` 配色的规则仍只认单页改版之前的选择器(`.wf-step-item > .wf-step-card` / `.wf-node`),
12
98
  于是切到 `soft` 时运行中单元的外框仍是默认彩虹渐变、状态环仍停在品牌蓝、
13
- 卫星圆点与连线的流光也没换色。现在 `soft` 与 `brand` 一样同时命名现役选择器——
14
- `.wf-unit[data-status='running']>.wf-unit-center>.wf-unit-card::after`、`.wf-unit-ring`、
15
- `.wf-sat[data-live='true']::after` 与 `.wf-sat-row[data-live='true'] .wf-sat-line`——
99
+ 卫星圆点与连线的流光也没换色。现在 `soft` 与 `brand` 一样同时命名现役选择器
100
+ (`.wf-unit[data-status='running']>.wf-unit-center>.wf-unit-card::after`、`.wf-unit-ring`、
101
+ `.wf-sat[data-live='true']::after` 与 `.wf-sat-row[data-live='true'] .wf-sat-line`),
16
102
  三处一并跟随 `--wf-soft-1` / `--wf-soft-2`。
17
103
  - 对应的浏览器量测从「记录缺陷」改为「断言修复」:`settings.soft.keepsRainbowRing`
18
104
  换成 `settings.soft.frameMigrated` 与 `settings.soft.ringColor`,两者都用期望色
@@ -56,7 +142,7 @@
56
142
  收敛为唯一 running 行;进度上报也归属到待办表里真正 `in_progress` 的那一行。
57
143
  - **地图分步与对话里的待办表不一致**:折叠只增不删,重新规划或改写标题后旧行会残留。
58
144
  现在视图严格按最新待办表产出分步,表里已不存在的行与其节点一并移除。
59
- - **已完成的步显示「0 工具 0 文件」**:三个叠加的问题——按标题匹配导致改写文案后旧行变孤儿、
145
+ - **已完成的步显示「0 工具 0 文件」**:三个叠加的问题。按标题匹配导致改写文案后旧行变孤儿、
60
146
  新建行未写入步骤键、全局节点上限按「丢弃最老」执行而最老的恰属已完成步。
61
147
  现已改为就地改名并迁移节点、统一赋值步骤键、并从节点最多的步开始淘汰(单步永不被清空)。
62
148
  - **被拒绝的审批仍标「需确认」**:审批被拒时该行仍需人工处理,原实现把任何「已决议」都当成已结束。
@@ -66,7 +152,7 @@
66
152
 
67
153
  ### Changed
68
154
 
69
- - **面板布局改为单页**:原先的左右双列(分步列 + 流程列)合并为一个视图——串行主流程在中间,
155
+ - **面板布局改为单页**:原先的左右双列(分步列 + 流程列)合并为一个视图。串行主流程在中间,
70
156
  每步两侧以圆形接口表示该类工具/文件,点击后在右页展开详情。两列之间的跨列曲线随之移除。
71
157
  - **移除「预计剩余时间」**:进度条第二行只保留「N 分钟前估计」(这个数字有多旧),
72
158
  不再预测还需要多久。宿主侧相应的计算与字段一并移除。
package/README.en.md CHANGED
@@ -4,6 +4,22 @@ English · [**简体中文**](README.md)
4
4
 
5
5
  A DeepSeek Harness plugin that renders the current session as an interactive step-by-step workflow map.
6
6
 
7
+ > **Host requirement**
8
+ >
9
+ > - **DSH kernel**: `engines.dsh` = `>=0.2.1-rc.1 <0.3.0-0`, covering the whole `0.2.1` series (`0.2.1-rc.1` through `0.2.1-rc.5`) and the `0.2.1` release.
10
+ > - **Node.js**: `>=20` (`engines.node`).
11
+ > - Host packages are declared as `peerDependencies` (all optional peers, same range as `engines.dsh`); the host is not bundled. Hosts below `0.2.1-rc.1`, or at `0.3.0` and above, are outside the declared range.
12
+ > - Range note: `>=0.2.1-rc.1 <0.3.0-0` carries an explicit prerelease tag on the `0.2.1` tuple, which is what makes it match `0.2.1-rc.x`; a start of `>=0.1.0-rc.1` silently excludes every `0.2.1` prerelease. The upper bound `<0.3.0-0` sorts below every prerelease of `0.3.0`, so the whole `0.3` line (including `0.3.0-rc.1`) stays outside the range.
13
+
14
+ Per-version method and result:
15
+
16
+ | Host version | How it was checked | Result |
17
+ |---|---|---|
18
+ | `0.2.1-rc.3` | Run on a real host: plugin load, panel injection, view routes | Baseline |
19
+ | `0.2.1-rc.1` / `0.2.1-rc.2` / `0.2.1-rc.4` / `0.2.1-rc.5` | Cross-version contract check: closure metadata, per-file SHA-256, contract anchor windows | All nine contract points unchanged |
20
+
21
+ A contract check is not a real-host run: the last four versions were compared against published artifacts and contract anchors only, and never booted. Method and evidence: [`docs/host-compatibility.md`](docs/host-compatibility.md).
22
+
7
23
  Three entry points share one data source and one set of components: the sidebar panel item
8
24
  (`sidebar.panellist`), the main content area (`main`), and the session tab bar (`conversation.view`).
9
25
  The panel folds session events into a step sequence, showing each step's status, progress, tool calls
@@ -18,7 +34,7 @@ opened for a step (type list, step detail). Bottom: the persistent injection bar
18
34
  ![license](https://img.shields.io/badge/license-MIT-blue)
19
35
  ![platform](https://img.shields.io/badge/platform-DeepSeek%20Harness-brightgreen)
20
36
  ![node](https://img.shields.io/badge/node-%E2%89%A520-informational)
21
- ![version](https://img.shields.io/badge/version-0.3.1-success)
37
+ ![version](https://img.shields.io/badge/version-0.3.4-success)
22
38
 
23
39
  ---
24
40
 
@@ -63,8 +79,6 @@ encoding (animation and colour).
63
79
 
64
80
  ### 2.1 Hot injection (steer / unsteer)
65
81
 
66
- This section states the write semantics, state determination and failure semantics of the injection channel.
67
- All three are derived from session log events; the front end does not guess.
68
82
  **Write semantics**: `POST /dsh-workflow/steer` calls the host's `agent.steer(message)`; the message
69
83
  enters the session inbox queue `next-step` and is claimed by the next step **after the current turn
70
84
  ends**. The call does not trigger `cancel`, so the running turn is not interrupted.
@@ -94,9 +108,6 @@ the capability.
94
108
 
95
109
  ### 2.2 Progress estimation
96
110
 
97
- This section states the percentage priority order, the algorithm and cap of each source, and the definition
98
- of the staleness field. The rule is implemented by `percentFor` in `src/fold.mjs`.
99
-
100
111
  Each step's percentage is resolved by the following **priority** (`percentFor` in `src/fold.mjs`):
101
112
  | Priority | Source | Rule | Cap |
102
113
  |---|---|---|---|
@@ -163,7 +174,7 @@ can collapse the host's composer on that page; switching back unmounts the view
163
174
  ## 3. Installation
164
175
 
165
176
  > **Prerequisites for installing from the market**: two conditions must both hold. (1) `dsh-workflow-map`
166
- > is published on npm (currently `0.3.1`). (2) The `awesome-dsh-plugin` PR is merged **and** the market index
177
+ > is published on npm (currently `0.3.4`). (2) The `awesome-dsh-plugin` PR is merged **and** the market index
167
178
  > (`dsh-plugin-catalog`, served via jsDelivr) has refreshed and picked up the entry.
168
179
  > Once both hold, install from the **DSH market** (Plugins > Discover) by searching `workflow`, no terminal needed.
169
180
  > If it is not indexed yet, use the command line below (3.2) to install the same package; both routes are equivalent.
@@ -293,8 +304,8 @@ steps, their status, the percentage source, the node list and the report artifac
293
304
 
294
305
  ## 8. Compatibility and constraints
295
306
 
296
- - **DeepSeek Harness** 0.2.x or newer (relies on session projections and `webServer` route registration).
297
- - **Node.js** ≥ 20。
307
+ - **DeepSeek Harness**: the `engines.dsh` range `>=0.2.1-rc.1 <0.3.0-0` (relies on session projections and `webServer` route registration); `0.3.0` and newer are not supported.
308
+ - **Node.js**: `>=20`.
298
309
  - The three endpoints mount on the host's existing port; **no new listener** is opened.
299
310
  - The composer is rendered by the host and is not part of this plugin. The plugin neither replaces nor
300
311
  overlays it; it only yields space on the session-tab page through a stylesheet rule.
@@ -309,7 +320,7 @@ steps, their status, the percentage source, the node list and the report artifac
309
320
  # after editing src/client/*.js, rebuild the browser half
310
321
  node tools/build-client.mjs
311
322
 
312
- # offline self-tests (10 suites, 1507 assertions)
323
+ # offline self-tests (10 suites, 1533 assertions)
313
324
  node tools/selftest-fold.mjs # fold semantics
314
325
  node tools/selftest-host.mjs # registration, schema, projection wiring
315
326
  node tools/selftest-orchestrator.mjs # plan file reading and merging
@@ -326,7 +337,7 @@ node tools/check-real-injects.mjs # fold historical logs in bulk
326
337
  node tools/audit-real.mjs # four-constraint audit
327
338
  ```
328
339
 
329
- Browser measurements (needs a Chromium; the scripts auto-detect Chrome / Edge; 308 assertions):
340
+ Browser measurements (needs a Chromium; the scripts auto-detect Chrome / Edge; 323 assertions, light/dark theme contrast included):
330
341
 
331
342
  ```bash
332
343
  pwsh -File tools/measure-panel.ps1
@@ -417,7 +428,7 @@ baseline creeps up but is capped at 90 %. The second line ("estimated N minutes
417
428
  The todo table caps at 99 and the time baseline at 90; only a done/failed status pins 100 %. This keeps
418
429
  "nearly there" from being confused with "already there".
419
430
 
420
- **Does it upload my code?**
431
+ **Does it upload user code?**
421
432
  No. The plugin makes no network requests; it reads only the local session log and the `.pae` plan file.
422
433
 
423
434
  **Can I install only the UI and skip the host half?**
package/README.md CHANGED
@@ -4,7 +4,23 @@
4
4
 
5
5
  DeepSeek Harness 插件:把当前会话渲染为一张可交互的分步工作流地图。
6
6
 
7
- 三个入口共用同一份数据与同一套组件 —— 侧边栏面板项(`sidebar.panellist`)、主内容区(`main`)、
7
+ > **宿主适配要求(Host requirement)**
8
+ >
9
+ > - **DSH 内核**:`engines.dsh` = `>=0.2.1-rc.1 <0.3.0-0`,覆盖 `0.2.1` 全系列(`0.2.1-rc.1` 至 `0.2.1-rc.5`)与 `0.2.1` 正式版。
10
+ > - **Node.js**:`>=20`(`engines.node`)。
11
+ > - 宿主包以 `peerDependencies` 声明(均为可选 peer,区间与 `engines.dsh` 一致),不打包宿主;低于 `0.2.1-rc.1` 或 `0.3.0` 及以上的宿主不在声明范围内。
12
+ > - 区间写法:`>=0.2.1-rc.1 <0.3.0-0` 在 `0.2.1` 元组上带显式预发布标签,才能匹配 `0.2.1-rc.x`;起点若写 `>=0.1.0-rc.1`,`0.2.1` 的预发布版本会被静默排除。上界 `<0.3.0-0` 小于 `0.3.0` 的任何预发布版本,因此整条 `0.3` 线(含 `0.3.0-rc.1`)都在区间外。
13
+
14
+ 逐版本核验方式与结论:
15
+
16
+ | 宿主版本 | 核验方式 | 结论 |
17
+ |---|---|---|
18
+ | `0.2.1-rc.3` | 实机运行:插件加载、面板注入、视图路由 | 基线版本 |
19
+ | `0.2.1-rc.1` / `0.2.1-rc.2` / `0.2.1-rc.4` / `0.2.1-rc.5` | 跨版本契约核验:闭包元数据、逐文件 SHA-256、契约锚点窗口 | 九个契约点未变 |
20
+
21
+ 契约核验不等于实机运行:上表后四个版本只处理已发布的产物与契约锚点,未启动宿主。方法与证据见 [`docs/host-compatibility.md`](docs/host-compatibility.md)。
22
+
23
+ 三个入口共用同一份数据与同一套组件:侧边栏面板项(`sidebar.panellist`)、主内容区(`main`)、
8
24
  会话标签栏(`conversation.view`)。面板将会话事件折叠为分步序列,展示每步的状态、进度、
9
25
  工具调用与文件操作,并支持在会话运行中向其下一步注入提示词。
10
26
 
@@ -17,7 +33,7 @@ DeepSeek Harness 插件:把当前会话渲染为一张可交互的分步工作
17
33
  ![license](https://img.shields.io/badge/license-MIT-blue)
18
34
  ![platform](https://img.shields.io/badge/platform-DeepSeek%20Harness-brightgreen)
19
35
  ![node](https://img.shields.io/badge/node-%E2%89%A520-informational)
20
- ![version](https://img.shields.io/badge/version-0.3.1-success)
36
+ ![version](https://img.shields.io/badge/version-0.3.4-success)
21
37
 
22
38
  ---
23
39
 
@@ -60,7 +76,6 @@ DeepSeek Harness 插件:把当前会话渲染为一张可交互的分步工作
60
76
 
61
77
  ### 2.1 热注入(steer / unsteer)
62
78
 
63
- 本节给出注入通道的写入语义、状态判定与失败语义;三者均以会话日志事件为依据,前端不作推测。
64
79
  **写入语义**:`POST /dsh-workflow/steer` 调用宿主的 `agent.steer(message)`,消息进入会话收件箱的
65
80
  `next-step` 队列,在**当前轮次结束后**由下一步取用。调用不触发 `cancel`,因此不中断正在执行的轮次。
66
81
 
@@ -81,14 +96,12 @@ HTTP 200 仅表示消息已进入收件箱,**不等价于**会话已读取;
81
96
  事件区分。撤回的持久记录是 splice 的**位置**而非消息 id,因此折叠侧维护一份收件箱台账
82
97
  (按宿主 `inboxProjectionDefinition` 的 splice 语义逐条重放)才能定位被移除的具体行。
83
98
 
84
- 失败语义:文档型错误一律返回 JSON,不向 HTTP 层抛出异常。常见状态码 —— `400` 缺字段 /
99
+ 失败语义:文档型错误一律返回 JSON,不向 HTTP 层抛出异常。常见状态码:`400` 缺字段 /
85
100
  body 非 JSON,`404` 会话不存在,`409` 会话未在运行或消息已被取用,`413` 文本超 2000 字或
86
101
  body 超 64 KiB,`503` 宿主未暴露相应能力。
87
102
 
88
103
  ### 2.2 进度估算
89
104
 
90
- 本节给出百分比的取值优先级、各来源的算法与上限,以及陈旧程度字段的定义。
91
-
92
105
  每步的百分比按以下**优先级**取值(`src/fold.mjs` 的 `percentFor`):
93
106
  | 优先级 | 来源 | 计算规则 | 上限 |
94
107
  |---|---|---|---|
@@ -147,7 +160,7 @@ body 超 64 KiB,`503` 宿主未暴露相应能力。
147
160
 
148
161
  ## 3. 安装
149
162
 
150
- > **市场安装的前提**:该入口要求同时满足两个条件 ——(1)`dsh-workflow-map` 已发布到 npm(当前为 `0.3.1`);
163
+ > **市场安装的前提**:该入口要求同时满足两个条件。(1)`dsh-workflow-map` 已发布到 npm(当前为 `0.3.4`);
151
164
  > (2)`awesome-dsh-plugin` 的 PR 已合并,且市场索引(`dsh-plugin-catalog`,经 jsDelivr 分发)已刷新收录该条目。
152
165
  > 满足后在 **DSH 市场**(Plugins → Discover)搜索 `workflow` 即可安装,无需命令行;
153
166
  > 若尚未收录,使用下面的命令行方式(3.2)安装同一个包,两者效果一致。
@@ -275,7 +288,7 @@ curl -i "http://127.0.0.1:<GUI端口>/dsh-workflow/state?sessionId=<会话ID>"
275
288
 
276
289
  ## 8. 兼容性与约束
277
290
 
278
- - **DeepSeek Harness** 0.2.x 及以上(依赖会话投影与 `webServer` 路由注册)。
291
+ - **DeepSeek Harness**:`engines.dsh` 声明的区间 `>=0.2.1-rc.1 <0.3.0-0`(依赖会话投影与 `webServer` 路由注册);`0.3.0` 及以后不支持。
279
292
  - **Node.js** ≥ 20。
280
293
  - 三个端点挂载在宿主既有端口上,**不新开监听**。
281
294
  - 输入框(composer)由宿主渲染,不属于本插件。插件不替代、不覆盖该组件;仅在会话标签栏入口
@@ -290,7 +303,7 @@ curl -i "http://127.0.0.1:<GUI端口>/dsh-workflow/state?sessionId=<会话ID>"
290
303
  # 修改 src/client/*.js 后必须重新生成浏览器半
291
304
  node tools/build-client.mjs
292
305
 
293
- # 离线自测(10 套,共 1507 条断言)
306
+ # 离线自测(10 套,共 1533 条断言)
294
307
  node tools/selftest-fold.mjs # 折叠语义
295
308
  node tools/selftest-host.mjs # 注册、schema、投影接线
296
309
  node tools/selftest-orchestrator.mjs # 计划文件读取与合并
@@ -307,7 +320,7 @@ node tools/check-real-injects.mjs # 批量折叠历史日志
307
320
  node tools/audit-real.mjs # 四项约束审计
308
321
  ```
309
322
 
310
- 浏览器量测(需一份 Chromium,脚本自动探测 Chrome / Edge,共 308 条断言):
323
+ 浏览器量测(需一份 Chromium,脚本自动探测 Chrome / Edge,共 323 条断言,含浅色/深色主题对比检查):
311
324
 
312
325
  ```bash
313
326
  pwsh -File tools/measure-panel.ps1
@@ -396,7 +409,7 @@ dsh-workflow-map/
396
409
  待办表封顶 99、时间基线封顶 90;只有状态进入完成/失败才固定 100%。
397
410
  这是为了避免「看起来快好了」与「已经好了」混淆。
398
411
 
399
- **它会把我的代码上传吗?**
412
+ **会把用户代码上传吗?**
400
413
  不会。插件不发起任何网络请求;只读本地会话日志与 `.pae` 计划文件。
401
414
 
402
415
  **可以只换界面、不装宿主半吗?**
@@ -418,7 +431,7 @@ MIT。允许商用、修改与再分发,需保留版权声明。
418
431
  以下信息有助于定位问题:
419
432
 
420
433
  1. DSH 版本与插件版本(`package.json` 的 `version`);
421
- 2. `GET /dsh-workflow/state?sessionId=<id>` 的响应体 —— **其中包含任务标题与文件路径,
422
- 提交前请自行删减**;
434
+ 2. `GET /dsh-workflow/state?sessionId=<id>` 的响应体(**其中包含任务标题与文件路径,
435
+ 提交前请自行删减**);
423
436
  3. 出现问题的面板截图;
424
437
  4. 若与折叠有关,附上该会话日志中相关事件序列的片段。
package/client.js CHANGED
@@ -198,7 +198,15 @@ window.__ModuleLoader__.load({
198
198
  // the three branch hues live on the ROOT, so the dashed branches and their round
199
199
  // icons share one palette with the rest of the panel (and one place to change it).
200
200
  "--wf-chain-read:#5b8cff;--wf-chain-edit:#e0a13a;--wf-chain-tool:#8f7bea;" +
201
+ // `--dsw-alias-bg-layer-1` and `-2` carry the SAME value in the host's light theme (both
202
+ // neutral-bluish-00, i.e. #fff), so a card filled with layer-2 is invisible on the page. Every
203
+ // raised surface in the panel therefore paints one translucent lift over that fill. The lift goes
204
+ // towards ink on a light page and towards white on a dark one; the host marks dark by putting
205
+ // `data-ds-dark-theme` on <body>, so the light values are the defaults and the dark block below
206
+ // only wins when that attribute is present.
207
+ "--wf-lift:rgba(15,23,42,.045);--wf-lift-2:rgba(15,23,42,.085);" +
201
208
  "font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI','Microsoft YaHei',sans-serif;font-size:12px;line-height:1.5}",
209
+ "body[data-ds-dark-theme] .wf-root{--wf-lift:rgba(255,255,255,.035);--wf-lift-2:rgba(255,255,255,.07)}",
202
210
 
203
211
  /* card: fills the main area; the header and the settings card keep their own height,
204
212
  the two-column body takes the rest. No drag grips: the column has no free edges.
@@ -281,13 +289,14 @@ window.__ModuleLoader__.load({
281
289
  // `grid-area:1/1` is what makes the triangle and the card share the cell: without it the
282
290
  // auto-placement cursor would drop the card into row 2, below the triangle.
283
291
  ".wf-step-card{grid-area:1/1;display:flex;gap:8px;align-items:flex-start;width:100%;padding:7px 8px;border-radius:8px;text-align:left;" +
284
- "font:inherit;color:inherit;cursor:pointer;border:1px solid transparent;" +
285
- "background:var(--dsw-alias-bg-layer-2,#282d3a);border-left:3px solid transparent}",
292
+ "font:inherit;color:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.22));" +
293
+ "background:var(--dsw-alias-bg-layer-2,#282d3a);background-image:linear-gradient(var(--wf-lift),var(--wf-lift));" +
294
+ "border-left:3px solid transparent}",
286
295
  ".wf-step-card:hover{border-color:var(--dsw-alias-border-l2,rgba(255,255,255,.22));" +
287
- "background:var(--dsw-alias-bg-layer-1,#232733)}",
296
+ "background:var(--dsw-alias-bg-layer-1,#232733);background-image:linear-gradient(var(--wf-lift-2),var(--wf-lift-2))}",
288
297
  ".wf-step-card:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5b8cff);outline-offset:1px}",
289
298
  ".wf-step-card[data-selected='true']{border-color:var(--dsw-alias-brand-primary,#5b8cff);" +
290
- "background:var(--dsw-alias-bg-layer-1,#232733)}",
299
+ "background:var(--dsw-alias-bg-layer-1,#232733);background-image:linear-gradient(var(--wf-lift-2),var(--wf-lift-2))}",
291
300
  ".wf-step-card[data-active='true']{border-left-color:var(--dsw-alias-brand-primary,#5b8cff)}",
292
301
  ".wf-step-ring{flex:0 0 auto;width:18px;height:18px;margin-top:1px;border-radius:50%;" +
293
302
  "display:inline-flex;align-items:center;justify-content:center;font-size:11px;line-height:1;" +
@@ -441,7 +450,7 @@ window.__ModuleLoader__.load({
441
450
  ".wf-branch-icon{flex:0 0 auto;width:" + WF_CHAIN_ICON + "px;height:" + WF_CHAIN_ICON + "px;padding:0;" +
442
451
  "border-radius:50%;display:inline-flex;align-items:center;justify-content:center;" +
443
452
  "font:inherit;font-size:11px;line-height:1;cursor:pointer;position:relative;" +
444
- "background:var(--dsw-alias-bg-layer-2,#282d3a);color:var(--dsw-alias-label-secondary,#9aa3b2);" +
453
+ "background:var(--dsw-alias-bg-layer-2,#282d3a);background-image:linear-gradient(var(--wf-lift),var(--wf-lift));color:var(--dsw-alias-label-secondary,#9aa3b2);" +
445
454
  "border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.28))}",
446
455
  ".wf-branch-icon:hover{color:var(--dsw-alias-label-primary,#e9ecf3)}",
447
456
  ".wf-branch-icon:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5b8cff);outline-offset:2px}",
@@ -471,12 +480,14 @@ window.__ModuleLoader__.load({
471
480
  /* node card: icon · title · meta · status dot */
472
481
  ".wf-node{display:flex;gap:7px;align-items:flex-start;width:100%;padding:6px 8px;border-radius:8px;" +
473
482
  "text-align:left;font:inherit;color:inherit;cursor:pointer;" +
474
- "background:var(--dsw-alias-bg-layer-2,#282d3a);border:1px solid transparent;" +
483
+ "background:var(--dsw-alias-bg-layer-2,#282d3a);background-image:linear-gradient(var(--wf-lift),var(--wf-lift));" +
484
+ "border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.22));" +
475
485
  "border-left:3px solid var(--dsw-alias-border-l2,rgba(255,255,255,.2))}",
476
- ".wf-node:hover{border-color:var(--dsw-alias-border-l2,rgba(255,255,255,.24));background:var(--dsw-alias-bg-layer-1,#232733)}",
486
+ ".wf-node:hover{border-color:var(--dsw-alias-border-l2,rgba(255,255,255,.24));" +
487
+ "background:var(--dsw-alias-bg-layer-1,#232733);background-image:linear-gradient(var(--wf-lift-2),var(--wf-lift-2))}",
477
488
  ".wf-node:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5b8cff);outline-offset:1px}",
478
489
  ".wf-node[data-selected='true']{border-color:var(--dsw-alias-brand-primary,#5b8cff);" +
479
- "background:var(--dsw-alias-bg-layer-1,#232733)}",
490
+ "background:var(--dsw-alias-bg-layer-1,#232733);background-image:linear-gradient(var(--wf-lift-2),var(--wf-lift-2))}",
480
491
  ".wf-node[data-selected='true'] .wf-node-title{font-weight:600}",
481
492
  ".wf-node[data-kind='tool']{border-left-color:var(--dsw-alias-brand-primary,#5b8cff)}",
482
493
  ".wf-node[data-kind='report']{border-left-color:var(--dsw-alias-state-success-primary,#45c07a)}",
@@ -1053,11 +1064,12 @@ window.__ModuleLoader__.load({
1053
1064
 
1054
1065
  /* ---- the unit card ---- */
1055
1066
  ".wf-unit-card{display:grid;grid-template-columns:auto auto minmax(0,1fr);align-items:flex-start;gap:8px;" +
1056
- "width:100%;padding:10px 12px;border-radius:10px;text-align:left;border:1px solid transparent;" +
1057
- "background:var(--dsw-alias-bg-layer-2,#282d3a);color:inherit;font:inherit;cursor:pointer;" +
1067
+ "width:100%;padding:10px 12px;border-radius:10px;text-align:left;border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.22));" +
1068
+ "background:var(--dsw-alias-bg-layer-2,#282d3a);background-image:linear-gradient(var(--wf-lift),var(--wf-lift));" +
1069
+ "color:inherit;font:inherit;cursor:pointer;" +
1058
1070
  "border-left:3px solid transparent;box-sizing:border-box}",
1059
1071
  ".wf-unit-card:hover{border-color:var(--dsw-alias-border-l2,rgba(255,255,255,.22));" +
1060
- "background:var(--dsw-alias-bg-layer-1,#232733)}",
1072
+ "background:var(--dsw-alias-bg-layer-1,#232733);background-image:linear-gradient(var(--wf-lift-2),var(--wf-lift-2))}",
1061
1073
  ".wf-unit-card:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5b8cff);outline-offset:1px}",
1062
1074
  ".wf-unit-card[data-selected='true']{border-color:var(--dsw-alias-brand-primary,#5b8cff)}",
1063
1075
  ".wf-unit-card[data-active='true']{border-left-color:var(--dsw-alias-brand-primary,#5b8cff)}",
@@ -2653,7 +2665,7 @@ window.__ModuleLoader__.load({
2653
2665
  * "0 seconds ago" would be a fabricated number, which is exactly what this plugin
2654
2666
  * refuses to print.
2655
2667
  *
2656
- * (2026-10-26) · the projected REMAINING half is GONE by user request. It used to be shipped
2668
+ * The projected REMAINING half is no longer part of this rail. It used to be shipped
2657
2669
  * by the host as a derived millisecond field (median of the finished steps, hard-bounded) and
2658
2670
  * rendered as "remaining ~N minutes"; the host no longer computes it and this rail no longer renders it.
2659
2671
  * One line, one fact, no consumer-less field — and a payload that still carries that field (an
package/cordis.patch.yml CHANGED
@@ -1,8 +1,8 @@
1
1
  # dsh-workflow-map — bundle 挂载声明
2
2
  #
3
- # 本文件只做一件事:把这个包的宿主半插入 DSH 的插件行。
4
- # 浏览器半不需要单独声明 —— 它由 package.json 的 `dsh.client`(platform: web)指向
5
- # 本包的 ./client,所以这里没有 client 行。
3
+ # 作用:把这个包的宿主半插入 DSH 的插件行。
4
+ # 浏览器半不由本文件声明 —— package.json 的 `dsh.client`(platform: web)指向本包的
5
+ # ./client,因此这里没有 client 行。
6
6
  #
7
7
  # id 在整个 profile 的 patch 层里必须唯一。
8
8
 
@@ -23,8 +23,8 @@
23
23
  | `locale/` / `icon.svg` | 词典与图标 |
24
24
  | `tools/` | 自测、探针、浏览器量测(**不随 npm 包发布**,进 Git) |
25
25
 
26
- **发布前必改**:`package.json` 里三处 `YOUR_GITHUB_USERNAME`(`repository.url`、`homepage`、`bugs.url`)
27
- 替换成真实用户名。
26
+ **发布前核对**:`package.json` 里 `repository.url`、`homepage`、`bugs.url` 三处必须指向真实的 GitHub 用户名与仓库名;
27
+ 仓库改名或 fork 后要同步改这三处。
28
28
 
29
29
  ---
30
30
 
@@ -36,7 +36,7 @@
36
36
 
37
37
  在 <https://github.com/new> 建一个**空**仓库,仓库名与 `package.json` 的 `name` 一致。
38
38
 
39
- ⚠️ **不要**勾选 "Add a README / .gitignore / license"——本地已有,勾选会导致首次推送冲突。
39
+ ⚠️ **不要**勾选 "Add a README / .gitignore / license":本地已有,勾选会导致首次推送冲突。
40
40
 
41
41
  ### 2.2 初始化并推送
42
42
 
@@ -87,8 +87,8 @@ pnpm pack --dry-run
87
87
 
88
88
  - 第一行为 `package: dsh-workflow-map@<版本>`;
89
89
  - 含 `client.js`、`lib/`、`src/`、`index.js`、`cordis.patch.yml`、`locale/*.json`、`icon.svg`、
90
- `README.md`、`CHANGELOG.md`、`LICENSE`、`docs/OPEN-SOURCE-GUIDE.md`;
91
- - **不含** `tools/`、`evidence/`、`*.bak*`、任何个人文档。
90
+ `README.md`、`CHANGELOG.md`、`LICENSE`、`docs/OPEN-SOURCE-GUIDE.md`、`docs/host-compatibility.md`;
91
+ - **不含** `tools/`、`evidence/`、`*.bak*` 与本地草稿。
92
92
 
93
93
  `files[]` 是白名单:以**文件**(而非目录)列出,能避免整目录被连带发布。
94
94
 
@@ -168,7 +168,7 @@ node tools/probe-route.mjs # 真实 HTTP 路由
168
168
  node tools/probe-steer.mjs # 注入 / 撤回通道
169
169
  ```
170
170
 
171
- 合计 **1507 条离线断言**。也可一条命令:
171
+ 合计 **1533 条离线断言**。也可一条命令:
172
172
 
173
173
  ```bash
174
174
  npm test
@@ -196,7 +196,7 @@ pwsh -File tools/measure-settings.ps1 # 设置卡与导出
196
196
  pwsh -File tools/measure-main.ps1 # 单页几何与交互
197
197
  ```
198
198
 
199
- 合计 **308 条浏览器量测**。
199
+ 合计 **323 条浏览器量测**(0.3.4 起 `measure-main` 另含 15 条浅色/深色主题对比检查)。
200
200
 
201
201
  > `tools/retired/` 里是量「已移除界面」的历史脚本,**不属于验证链**,不期望通过。
202
202
 
@@ -248,7 +248,7 @@ dsh-workflow-map/
248
248
  ├─ client.js 生成物,禁止手改
249
249
  ├─ cordis.patch.yml 挂载声明
250
250
  ├─ locale/{zh,en}.json 词典
251
- ├─ docs/ 本文件
251
+ ├─ docs/ 发布指南与宿主兼容性核验
252
252
  ├─ tools/ 自测 · 探针 · 浏览器量测
253
253
  ├─ CHANGELOG.md
254
254
  ├─ LICENSE (MIT)
@@ -0,0 +1,320 @@
1
+ # 宿主兼容性核验(DSH `0.2.1-rc.1` – `0.2.1-rc.5`)
2
+
3
+ 本文件回答一个问题:本插件声明的宿主范围,在 `0.2.1` 全系列(`rc.1` 至 `rc.5`)里是否真的有依据。
4
+
5
+ 结论以 `package.json` 的 `engines.dsh` 为唯一声明口径;本文件记录该口径的判据、核验方法与逐条证据,供使用者自行复核。
6
+
7
+ ---
8
+
9
+ ## 一、声明
10
+
11
+ ```json
12
+ "engines": {
13
+ "node": ">=20",
14
+ "dsh": ">=0.2.1-rc.1 <0.3.0-0"
15
+ }
16
+ ```
17
+
18
+ 区间含义:
19
+
20
+ - 起点 `>=0.2.1-rc.1`:`0.2.1` 元组上的第一个预发布版本,带显式预发布标签。
21
+ - 上界 `<0.3.0-0`:排除 `0.3.0` 及其预发布(`0.3.0-0` 是 `0.3.0` 预发布的最小值),即不声称兼容下一 minor。
22
+
23
+ 本插件不使用 `dsh.host` 之类的自定义元数据字段:宿主包由 `peerDependencies` 声明(均为可选 peer),版本闸门由 `engines.dsh` 单独决定。
24
+
25
+ ---
26
+
27
+ ## 二、`engines.dsh` 由谁判定:两种判据
28
+
29
+ 同一个区间字符串会被两套判据读取,结论不完全相同,声明必须同时满足两者。
30
+
31
+ ### 2.1 DSH 自身的兼容性判定
32
+
33
+ `@deepseek-ai/dsh-app-boot` 的 `evaluatePluginCompatibility()` 是安装与启用期的判定入口:
34
+
35
+ ```js
36
+ // @deepseek-ai/dsh-app-boot lib/index.js:252-284(节选,省略参数校验与回落分支的具体实现)
37
+ function satisfies(runtimeVersion, range) {
38
+ return range.trim() !== "" && semver.satisfies(runtimeVersion, range, { includePrerelease: true });
39
+ }
40
+ function unmetRequirements(fields, runtimeVersion) {
41
+ const engines = Object.hasOwn(fields, "engines") ? objectOf$1(fields.engines, "Plugin manifest engines") : {};
42
+ if (Object.hasOwn(engines, "dsh")) {
43
+ const range = engines.dsh;
44
+ if (typeof range !== "string") throw new Error("Plugin manifest engines.dsh must be a string");
45
+ return satisfies(runtimeVersion, range) ? {} : { "engines.dsh": range };
46
+ }
47
+ // 没有 engines.dsh 时,回落到逐个 @deepseek-ai/dsh / @deepseek-ai/dsh-* peer 的 range 判定
48
+ }
49
+ ```
50
+
51
+ `@deepseek-ai/dsh-plugin-manager` 在三个位置调用它,未满足即抛 `incompatible-version`:
52
+
53
+ | 位置 | 场景 |
54
+ |---|---|
55
+ | `lib/types/index.js:368` | 列出 bundle 时逐个核对 |
56
+ | `lib/types/index.js:660` | 安装(`add`)前核对 |
57
+ | `lib/types/index.js:857` | 启用(`enable`)前核对 |
58
+
59
+ 同一包对外的 `requirements` 字段说明也把键名写死为 `engines.dsh`(`lib/typert.host.js:59`):
60
+
61
+ > "Only the requirements the running version does not satisfy, keyed `engines.dsh` or by DSH peer package name."
62
+
63
+ ### 2.2 npm / pnpm 自身的 engines 检查
64
+
65
+ npm 与 pnpm 用不带 `includePrerelease` 的默认 semver 语义检查 `engines`。
66
+
67
+ ### 2.3 两者对同一区间的判定(semver 7.8.5 实测)
68
+
69
+ | 运行时版本 | `>=0.2.1-rc.1 <0.3.0-0`(npm 默认) | 同区间(DSH 闸门,`includePrerelease`) | `>=0.1.0-rc.1 <0.3.0-0`(npm 默认) |
70
+ |---|---|---|---|
71
+ | `0.2.1-alpha.5` | 不通过 | 不通过 | 不通过 |
72
+ | `0.2.1-rc.1` | 通过 | 通过 | **不通过** |
73
+ | `0.2.1-rc.2` | 通过 | 通过 | **不通过** |
74
+ | `0.2.1-rc.3` | 通过 | 通过 | **不通过** |
75
+ | `0.2.1-rc.4` | 通过 | 通过 | **不通过** |
76
+ | `0.2.1-rc.5` | 通过 | 通过 | **不通过** |
77
+ | `0.2.1` | 通过 | 通过 | 通过 |
78
+ | `0.2.2-rc.1` | 不通过 | 通过 | 不通过 |
79
+ | `0.3.0-0` | 不通过 | 不通过 | 不通过 |
80
+ | `0.3.0` | 不通过 | 不通过 | 不通过 |
81
+
82
+ 由此得到两个写法上的硬要求:
83
+
84
+ 1. **起点必须落在 `0.2.1` 元组上。** 默认 semver 只允许「与区间内某个比较符同元组」的预发布版本参与匹配;写成 `>=0.1.0-rc.1`(元组 `0.1.0`)时,`0.2.1-rc.x` 全部被判为不匹配。所以在 npm/pnpm 的默认判据下,`>=0.1.0-rc.1 <0.3.0-0` 会静默排除全部 `0.2.1` 预发布版本。
85
+ 2. **上界取 `<0.3.0-0`。** 写成 `<0.3.0` 会在 DSH 闸门(`includePrerelease`)下把 `0.3.0-rc.x` 也算作通过,与「不声称兼容下一 minor」冲突。
86
+
87
+ DSH 闸门带 `includePrerelease`,因此 `0.2.2-rc.x` 这类后续 `0.2.x` 预发布也会通过。这是有意接受的结果:声明覆盖整个 `0.2.x` 预发布链,而不是逐版本列举。
88
+
89
+ ---
90
+
91
+ ## 三、逐版本核验表
92
+
93
+ | 版本 | 取样方式 | 结论 |
94
+ |---|---|---|
95
+ | `0.2.1-rc.1` | npm 闭包元数据(206 包)+ 9 个契约点的逐文件 SHA / 锚点窗口 | 契约面与 `rc.5` 一致;闭包比 `rc.3` 少 3 个包(见 §4.3) |
96
+ | `0.2.1-rc.2` | 同上 | 同上 |
97
+ | `0.2.1-rc.3` | 同上 + 核验机实机运行(插件加载、面板注入、视图路由) | 基线版本 |
98
+ | `0.2.1-rc.4` | npm 闭包元数据 + 5 个变动包逐文件 SHA + 契约锚点窗口 | 契约面无变化 |
99
+ | `0.2.1-rc.5` | npm 闭包元数据 + 逐文件比对 + **隔离 `DSH_HOME` 实机运行**(`--dump-config` 解析、web 宿主启动、`/dsh-workflow/*` 三种端点实测,见 §3.1) | 契约面无变化,且宿主半在 `rc.5` 上实机应答 |
100
+
101
+ `rc.4` 的结论来自与 `rc.3` 的逐文件比对,不是实机运行结论(见 §8)。`rc.5` 已补做隔离实机运行,记录见下节。
102
+
103
+ ### 3.1 `rc.5` 隔离实机运行记录(2026-10-30)
104
+
105
+ 核验机内核是 `rc.3`,因此不升级本机环境:在 `%TEMP%\wfmap-rc5-run\` 另起一套 `npm i @deepseek-ai/dsh@0.2.1-rc.5`,`DSH_HOME=%TEMP%\wfmap-rc5-run\home`(隔离),新 profile `wfmap5` 的 `dsh.profile.bundles` = `dsh-base` + `dsh-web-app` + `dsh-workflow-map`(后者以 `link:` 指向本仓库的工作副本,未改动源文件)。全程不写本机 `~/.dsh`、不动 `desktop` profile。
106
+
107
+ **A 级 —— `rc.5` 的 loader 解析本插件 manifest 与 patch:通过**
108
+
109
+ ```text
110
+ node <run>\node_modules\@deepseek-ai\dsh\lib\bin.js --profile wfmap5 --dump-config
111
+ # 退出码 0;stdout 1234 行;stderr 0 行
112
+ # 末尾三行即本插件层:
113
+ # # == dsh-workflow-map
114
+ # - id: workflow-map
115
+ # name: dsh-workflow-map
116
+ # stdout 全文无 unknown / invalid / unresolved / warn / error 类告警
117
+ ```
118
+
119
+ **B 级 —— `rc.5` 宿主上路由实机应答:通过**
120
+
121
+ 同一套 `rc.5` 起 web 宿主(`--profile wfmap5 --port 55999 --no-open --host 127.0.0.1`),逐端点实测(响应头均为 `content-type: application/json; charset=utf-8`,与 `src/route.js` 的契约一致):
122
+
123
+ | 请求 | 状态 | 响应体(原文) | 说明 |
124
+ |---|---|---|---|
125
+ | `GET /dsh-workflow/state?sessionId=none` | `404` | `{"ok":false,"error":"unknown session"}` | 前缀路由已挂上;`sessions` 服务在 `rc.5` 上可用(否则会是 503) |
126
+ | `GET /dsh-workflow/state`(无 `sessionId`) | `400` | `{"ok":false,"error":"sessionId is required"}` | 参数校验分支 |
127
+ | `POST /dsh-workflow/steer` | `404` | `{"ok":false,"error":"unknown session"}` | 热注入通道已挂上 |
128
+ | `POST /dsh-workflow/unsteer` | `404` | `{"ok":false,"error":"unknown session"}` | 撤回通道已挂上 |
129
+ | `GET /dsh-workflow/nope` | `404` | `{"ok":false,"error":"not found","path":"/dsh-workflow/state \| /dsh-workflow/steer \| /dsh-workflow/unsteer"}` | 前缀内部路由表,证明命中的是本插件的 handler,而非宿主兜底 404 |
130
+
131
+ 对照:同一宿主上 `GET /` 返回 `401 dsh web authentication required; reopen the URL printed by dsh web.`(`dsh-client-connection` 的页面鉴权闸门,`lib/index.js:576`),属宿主行为,不影响插件路由。
132
+
133
+ **两个会把人带偏的坑(实测)**
134
+
135
+ 1. **系统代理会把 `127.0.0.1` 也拦走。** 本机代理 `127.0.0.1:26561` 的 `ProxyOverride` 不含 `localhost`/`127.0.0.1`,于是 `Invoke-WebRequest http://127.0.0.1:55999/...` 全部返回 **404 且空体**——看着像「插件没挂」。改用裸 socket 或 `-NoProxy` 后立刻看到 `401`(页面)与本插件的 JSON(路由)。**结论:HTTP 404 不能单独作为「路由未注册」的证据。**
136
+ 2. **`--profile <name>` 后面不能再跟 `web`。** `dsh --profile wfmap5 web` 报 `too many arguments. Expected 0 arguments but got 1: web`(`web` 是 profile 名,不是子命令);正确写法是 `dsh --profile wfmap5 --port 55999 --no-open --host 127.0.0.1`。
137
+
138
+ **复跑命令**(升级到 `rc.5` 后任选一个临时目录执行,不动本机环境):
139
+
140
+ ```powershell
141
+ $run="$env:TEMP\wfmap-rc5-run"; $env:DSH_HOME="$run\home"
142
+ node "$run\node_modules\@deepseek-ai\dsh\lib\bin.js" --profile wfmap5 --port 55999 --no-open --host 127.0.0.1
143
+ # 另开窗口(-NoProxy 是关键,否则会被系统代理拦成假 404):
144
+ Invoke-WebRequest 'http://127.0.0.1:55999/dsh-workflow/state?sessionId=none' -SkipHttpErrorCheck -NoProxy
145
+ # 期望:404 + {"ok":false,"error":"unknown session"}
146
+ ```
147
+
148
+ ---
149
+
150
+ ## 四、闭包级别比对
151
+
152
+ ### 4.1 方法
153
+
154
+ 以 `@deepseek-ai/dsh-base` 与 `@deepseek-ai/dsh-web-app` 的依赖并集为闭包,共 **206 个包名**。对每个包从 npm registry 取 `dist.unpackedSize` 与 `dist.fileCount`,逐版本比较;任一版本组合不同即进入人工取证。
155
+
156
+ 用 registry 元数据做初筛、再用逐文件 SHA-256 取证,是因为元数据能覆盖全部 206 个包,而逐文件下载只对筛出的少数包做。
157
+
158
+ ### 4.2 差异分布
159
+
160
+ 206 个包中有 **24 个**在 `rc.1`–`rc.5` 之间存在差异,全部集中在三段边界上:
161
+
162
+ | 变化边界 | 包数 | 包 |
163
+ |---|---|---|
164
+ | `rc.1 → rc.2` | 5 | `dsh-web`、`dsh-web-search-deepseek`、`dsh-web-deepseek-api`、`dsh-client-ui-deepseek-model-label-1017`、`dsh-client-ui-settings-web-search` |
165
+ | `rc.1 → rc.2` 且 `rc.2 → rc.3` | 1 | `dsh-agent-preset` |
166
+ | `rc.2 → rc.3` | 13 | `dsh-llm-deepseek-api-key`、`dsh-api-session-controller`、`dsh-api-remotes`、`dsh-client-ui-entry-customization`、`dsh-client-ui-plugin-manager`、`dsh-client-ui-settings-general`、`dsh-client-ui-agent-preset`、`dsh-client-ui-sidebar-right`、`dsh-client-ui-workspace`、`dsh-client-ui-sidebar`、`dsh-tool-cordis`、`dsh-web-frontend`、`dsh-client-product-analytics` |
167
+ | `rc.2 → rc.3` 且 `rc.3 → rc.4` | 2 | `dsh-llm-deepseek-account`、`dsh-client-ui-conversation` |
168
+ | `rc.3 → rc.4` | 1 | `dsh-mcp-resources` |
169
+ | `rc.4 → rc.5` | 1 | `dsh-client-ui-chat` |
170
+ | 三个边界都有 | 1 | `dsh-cordis-client-runner` |
171
+
172
+ 其中 `dsh-client-ui-sidebar` 与 `dsh-client-ui-conversation` 属于本插件的契约面,其锚点级比对见 §5。
173
+
174
+ ### 4.3 版本缺席
175
+
176
+ | 包 | 缺席版本 | 说明 |
177
+ |---|---|---|
178
+ | `dsh-web-deepseek-api` | `rc.1` | 首次发布于 `rc.2` |
179
+ | `dsh-client-ui-deepseek-model-label-1017` | `rc.1` | 首次发布于 `rc.2` |
180
+ | `dsh-client-ui-entry-customization` | `rc.1`、`rc.2` | 首次发布于 `rc.3` |
181
+ | `cordis-plugin-timer`、`schemastery` | 全部 | 第三方依赖,不随 DSH 的 `rc` 编号发布 |
182
+
183
+ `rc.1` 的 profile 因此比 `rc.3` 少 3 个客户端包。这 3 个包都与本插件的契约面无关(模型标签角标、入口定制、API 通道包),缺失不影响本插件加载。
184
+
185
+ ### 4.4 `rc.3 → rc.5` 之间真正改动的 5 个包
186
+
187
+ | 包 | `rc.3` | `rc.4` | `rc.5` | 改动文件 | 是否触及本插件契约 |
188
+ |---|---|---|---|---|---|
189
+ | `dsh-llm-deepseek-account` | 35661 B / 10 | 35735 B / 10 | 35735 B / 10 | `lib/index.js`、三个 README、`package.json` | 否:`lib/index.js` 只改 1 行,日志级别 `console.info` → `console.error` |
190
+ | `dsh-mcp-resources` | 30771 B / 11 | 30844 B / 11 | 30844 B / 11 | 三个 README、`package.json` | 否:无代码改动 |
191
+ | `dsh-client-ui-chat` | 857882 B / 106 | 同 `rc.3` | 859933 B / 107 | `lib/client.js`、3 个 `*.d.ts`、新增 `reasoning-text.d.ts`、README、`package.json` | 否:改动集中在推理正文渲染(新增 `conversation.chat.reasoning.content` 槽、`ReasoningBodyOwnerProps.text` 拆成 `rawText` + 显示文本) |
192
+ | `dsh-client-ui-conversation` | 1130507 B / 89 | 1131117 B / 89 | 同 `rc.4` | `lib/client.js`、`locales.d.ts`、README、`package.json` | 否:92 个 bundle 分区中仅 `locales.js` 与 `skeleton/ConversationSession.js` 变化 |
193
+ | `dsh-cordis-client-runner` | 483933 B / 20 | 同 `rc.3` | 484021 B / 20 | `lib/client.js`、`package.json` | 否:12 行变化全部是槽文档文本与 `sources` 源行号(`slots.ts:441` → `:447`) |
194
+
195
+ `dsh-client-ui-conversation` 的改动按 bundle 内 `//#region` 分区逐一比对:`lib/types/client/locales.js`(词条增删)与 `lib/types/client/skeleton/ConversationSession.js`(输入框手势与文件选择绑定重构)。本插件依赖的槽注册、DOM 属性都不在这两个分区内。
196
+
197
+ ---
198
+
199
+ ## 五、契约面逐项核对
200
+
201
+ 本插件只依赖九个契约点。下表每个点都给出提供方、用法与 `rc.1` ↔ `rc.5` 的比对证据。
202
+
203
+ | 契约点 | 提供方 | 本插件用法 | `rc.1` ↔ `rc.5` 证据 | 结论 |
204
+ |---|---|---|---|---|
205
+ | `sidebar.panellist` 槽 | `dsh-client-ui-sidebar` | 注册左栏面板项(`kind: 'list'`) | 槽声明在 `rc.1` 与 `rc.3` 逐字节相同:`kind: 'list'; scope: 'root'; owner: SidebarPanelIconOwnerProps` | 未变 |
206
+ | `main` 槽 | `dsh-client-ui-layout` | 注册主内容面板(`kind: 'keyed'`) | 整包除 `package.json` 外逐文件 SHA-256 相同(`lib/client.js` = `686fb83c91c8ff8d…`) | 未变 |
207
+ | `conversation.view` 槽 | `dsh-client-ui-conversation` | 注册会话标签栏视图 | 4 处锚点窗口(`DefaultConversationViews` 的 `renderSlot`、`apply.js` 的 `entries` / `subscribe` / `children`)在 `rc.1`/`rc.3`/`rc.4`/`rc.5` 逐字节相同 | 未变 |
208
+ | 输入框座位 DOM | `dsh-client-ui-conversation` | 选中本插件视图时隐藏输入框 | `data-composer-seat`(1 处)、`data-conversation-content`(1 处)、`data-conversation-scroll`(3 处)的上下文窗口逐字节相同 | 未变 |
209
+ | `sessionProjections` 服务 | `dsh-session-projection` | 注册会话投影 | `lib/index.js` SHA-256 `7dc2b212819baf4f…` 相同 | 未变 |
210
+ | `sessions` 服务 | `dsh-session` | 按会话号取会话 | `lib/index.js` SHA-256 `2f5dd07cb5c744a2…` 相同 | 未变 |
211
+ | `webServer` 服务 | `dsh-host-webserver` | 注册面板数据路由 | `lib/index.js` SHA-256 `08907285802f0edd…` 相同 | 未变 |
212
+ | `agents` 服务 | `dsh-agent-loop` | 读运行中的 agent 状态 | `lib/index.js` SHA-256 `cf57e3ecd487ac5c…` 相同 | 未变 |
213
+ | 会话事件 / JSONL 格式 | `dsh-session-persistence-jsonl` | 折叠会话事件为步骤序列 | `lib/worker.cjs` SHA-256 `4b0f235e772792d7…` 相同 | 未变 |
214
+
215
+ 输入框座位的实测嵌套(`dsh-client-ui-conversation` `rc.5` 的 `lib/client.js`):
216
+
217
+ ```text
218
+ div[data-conversation-content]
219
+ └─ div[data-conversation-scroll]
220
+ ├─ Views ← 选中的 conversation.view 占据位
221
+ └─ div[data-composer-seat]
222
+ ```
223
+
224
+ 本插件依赖的 DOM 只有这一条父子链与两个 `data-*` 属性,不依赖 class 名或内部组件。
225
+
226
+ §4.2 表里其余 22 个变动包与本插件的九个契约点没有交集:不注册 `conversation.chat.*` 系列槽、不读设置页组件、不调用搜索工具、不依赖 API 网关包。
227
+
228
+ ---
229
+
230
+ ## 六、安装闸门生效性
231
+
232
+ 结论:`engines.dsh` 不是装饰性字段,安装与启用期都会被读取。
233
+
234
+ | 环节 | 证据 |
235
+ |---|---|
236
+ | 判定实现 | `dsh-app-boot` 的 `evaluatePluginCompatibility()` → `unmetRequirements()`(§2.1 引文) |
237
+ | 判定实现版本一致 | `dsh-app-boot` `rc.1` 与 `rc.5` 除 `package.json` 外逐文件 SHA-256 相同(`lib/index.js` = `5135179ac810e4f6…`) |
238
+ | 安装期调用 | `dsh-plugin-manager` 三处调用(§2.1 表),未满足抛 `incompatible-version`,并在 `requirements` 里给出 `engines.dsh` 键 |
239
+ | 调用方版本一致 | `dsh-plugin-manager` `rc.3` 与 `rc.5` 除 `package.json` 外逐文件 SHA-256 相同 |
240
+
241
+ 由此可推出两条可验证的后果:
242
+
243
+ 1. 在 `0.2.1-rc.1`–`0.2.1-rc.5` 任一内核上安装或启用,本插件的 `engines.dsh` 判定通过,不会有 `incompatible-version`。
244
+ 2. 在 `0.3.0` 及以后的内核上,`engines.dsh` 判定不通过(§2.3),安装或启用会被上述任一调用点拦下并给出明确的 `engines.dsh: ">=0.2.1-rc.1 <0.3.0-0"` 说明。这属于预期行为:不兼容时应显式失败,而不是勉强加载。
245
+
246
+ ---
247
+
248
+ ## 七、可复跑命令
249
+ 以下命令只需 Node.js 20 以上。前两条只需 npm registry 访问;第三条需要环境里能解析到 `semver`(`npm i semver`,或使用 DSH 安装目录自带的副本)。
250
+
251
+ **1) 闭包元数据对比(206 包 × 5 个版本)**
252
+
253
+ ```bash
254
+ node -e '
255
+ const { readFileSync } = require("node:fs");
256
+ const names = readFileSync("closure-names.txt", "utf8").split(/\r?\n/).filter(Boolean);
257
+ const V = ["0.2.1-rc.1","0.2.1-rc.2","0.2.1-rc.3","0.2.1-rc.4","0.2.1-rc.5"];
258
+ let i = 0; const rows = [];
259
+ (async () => {
260
+ await Promise.all(Array.from({ length: 8 }, async () => {
261
+ while (i < names.length) {
262
+ const name = names[i++];
263
+ const p = await (await fetch("https://registry.npmjs.org/" + name.replace("/", "%2f"))).json();
264
+ const pick = (v) => { const d = p.versions?.[v]?.dist; return d ? [d.unpackedSize, d.fileCount] : null; };
265
+ const row = V.map((v) => JSON.stringify(pick(v)));
266
+ if (new Set(row).size > 1) rows.push({ name, row });
267
+ }
268
+ }));
269
+ console.log(rows.length, JSON.stringify(rows, null, 1));
270
+ })();
271
+ '
272
+ ```
273
+
274
+ `closure-names.txt` 是 `dsh-base` 与 `dsh-web-app` 依赖并集的包名清单(每行一个)。换用别的清单会得到别的闭包,比较结果随之变化。
275
+
276
+ **2) 单包逐文件 SHA-256 比对**
277
+
278
+ ```bash
279
+ # 取两个版本的 tarball,解包后逐文件比对
280
+ npm pack @deepseek-ai/dsh-session-projection@0.2.1-rc.1
281
+ npm pack @deepseek-ai/dsh-session-projection@0.2.1-rc.5
282
+ tar -xzf deepseek-ai-dsh-session-projection-0.2.1-rc.1.tgz -C rc1
283
+ tar -xzf deepseek-ai-dsh-session-projection-0.2.1-rc.5.tgz -C rc5
284
+ diff -r rc1/package rc5/package # 期望:仅 package.json 不同
285
+ ```
286
+
287
+ **3) 区间判定矩阵**
288
+
289
+ ```bash
290
+ node -e '
291
+ const semver = require("semver");
292
+ const ranges = [">=0.2.1-rc.1 <0.3.0-0", ">=0.1.0-rc.1 <0.3.0-0"];
293
+ const versions = ["0.2.1-alpha.5","0.2.1-rc.1","0.2.1-rc.5","0.2.1","0.2.2-rc.1","0.3.0-0","0.3.0"];
294
+ for (const r of ranges) console.log(r, versions.map((v) => [v, semver.satisfies(v, r), semver.satisfies(v, r, { includePrerelease: true })]));
295
+ '
296
+ ```
297
+
298
+ ---
299
+
300
+ ## 八、限制声明
301
+
302
+ - **动态核验范围**:截至 2026-10-30,`0.2.1-rc.3` 是本机实机运行版本;`0.2.1-rc.5` 已在隔离 `DSH_HOME` 的临时区实机启动并实测路由应答(§3.1),但不覆盖「真实会话内的投影内容」——`rc.5` 上没有跑过带会话的完整回合,`/state` 只验到「按契约返回 `unknown session`」。`rc.4` 仍只做了静态比对(闭包元数据、逐文件 SHA-256、契约锚点窗口),未实机启动。
303
+ - **比对对象**:只比对 npm 上已发布的产物,不覆盖未发布的仓库提交。
304
+ - **闭包口径**:206 个包名按 `dsh-base` 与 `dsh-web-app` 的依赖并集确定;更换清单会改变闭包边界。
305
+ - **第三方依赖**:闭包内含 `cordis`、`schemastery`、`zod` 等第三方包,它们不随 DSH 的 `rc` 编号发布,本文件不对其版本承诺做任何声明。
306
+ - **上界外版本**:`0.3.0` 及以后未做任何核验,声明上也不覆盖。
307
+ - **判定语义**:§2.3 的矩阵来自 semver 7.8.5;换用其他实现时 `includePrerelease` 的细节可能不同。
308
+
309
+ ---
310
+
311
+ ## 九、复核记录
312
+
313
+ | 项 | 取值 |
314
+ |---|---|
315
+ | 复核日期 | 2026-10-30 |
316
+ | 数据来源 | `registry.npmjs.org` 发布产物 |
317
+ | semver 实现 | 7.8.5(`satisfies`,分别带与不带 `includePrerelease`) |
318
+ | 闭包规模 | 206 个包名,24 个存在版本间差异 |
319
+ | 逐文件比对包 | `dsh-app-boot`、`dsh-session`、`dsh-session-projection`、`dsh-host-webserver`、`dsh-agent-loop`、`dsh-session-persistence-jsonl`、`dsh-plugin-manager`、`dsh-client-ui-layout`、`dsh-client-ui-sidebar`、`dsh-client-ui-conversation`、`dsh-client-ui-chat`、`dsh-cordis-client-runner`、`dsh-llm-deepseek-account`、`dsh-mcp-resources` |
320
+ | 实机运行内核 | `0.2.1-rc.3`(本机内核);`0.2.1-rc.5`(隔离 `DSH_HOME` 临时区,仅路由与 loader 层) |
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "dsh-workflow-map",
3
- "version": "0.3.2",
4
- "description": "工作流地图插件(DeepSeek Harness bundle):在会话标签栏、左栏菜单与主内容区以同一份面板展示当前会话的分步总览、每步的工具/文件类型明细、唯一彩色流光进度条,并支持热注入(steer)与「需确认」标注。宿主半折叠会话事件并读取 PAE 计划,经本地 HTTP 视图供数据;浏览器半渲染面板。",
3
+ "version": "0.3.4",
4
+ "description": "把当前会话渲染成一张可交互的分步工作流地图:会话标签栏、左栏菜单与主内容区共用同一份面板,逐步显示状态、工具与文件类型明细、进度条,并支持热注入(steer)与「需确认」标注。宿主半折叠会话事件、读取按步执行(PAE)计划,经本地 HTTP 视图供数据;浏览器半渲染面板。",
5
5
  "private": false,
6
6
  "type": "module",
7
7
  "license": "MIT",
8
8
  "keywords": [
9
9
  "deepseek-harness",
10
+ "deepseek-harness-plugin",
10
11
  "dsh",
12
+ "dsh-0.2.1",
11
13
  "cordis",
12
14
  "plugin",
13
15
  "bundle",
@@ -24,7 +26,20 @@
24
26
  "url": "https://github.com/Joz739/dsh-workflow-map/issues"
25
27
  },
26
28
  "engines": {
27
- "node": ">=20"
29
+ "node": ">=20",
30
+ "dsh": ">=0.2.1-rc.1 <0.3.0-0"
31
+ },
32
+ "peerDependencies": {
33
+ "@deepseek-ai/dsh-agent-loop": ">=0.2.1-rc.1 <0.3.0-0",
34
+ "@deepseek-ai/dsh-llm": ">=0.2.1-rc.1 <0.3.0-0"
35
+ },
36
+ "peerDependenciesMeta": {
37
+ "@deepseek-ai/dsh-agent-loop": {
38
+ "optional": true
39
+ },
40
+ "@deepseek-ai/dsh-llm": {
41
+ "optional": true
42
+ }
28
43
  },
29
44
  "exports": {
30
45
  ".": "./index.js",
@@ -45,6 +60,7 @@
45
60
  "CHANGELOG.md",
46
61
  "LICENSE",
47
62
  "docs/OPEN-SOURCE-GUIDE.md",
63
+ "docs/host-compatibility.md",
48
64
  "docs/screenshot.png",
49
65
  "docs/screenshot-detail.png",
50
66
  "package.json"
@@ -24,7 +24,7 @@
24
24
  * "0 seconds ago" would be a fabricated number, which is exactly what this plugin
25
25
  * refuses to print.
26
26
  *
27
- * (2026-10-26) · the projected REMAINING half is GONE by user request. It used to be shipped
27
+ * The projected REMAINING half is no longer part of this rail. It used to be shipped
28
28
  * by the host as a derived millisecond field (median of the finished steps, hard-bounded) and
29
29
  * rendered as "remaining ~N minutes"; the host no longer computes it and this rail no longer renders it.
30
30
  * One line, one fact, no consumer-less field — and a payload that still carries that field (an
@@ -169,7 +169,15 @@ var WF_CSS = [
169
169
  // the three branch hues live on the ROOT, so the dashed branches and their round
170
170
  // icons share one palette with the rest of the panel (and one place to change it).
171
171
  "--wf-chain-read:#5b8cff;--wf-chain-edit:#e0a13a;--wf-chain-tool:#8f7bea;" +
172
+ // `--dsw-alias-bg-layer-1` and `-2` carry the SAME value in the host's light theme (both
173
+ // neutral-bluish-00, i.e. #fff), so a card filled with layer-2 is invisible on the page. Every
174
+ // raised surface in the panel therefore paints one translucent lift over that fill. The lift goes
175
+ // towards ink on a light page and towards white on a dark one; the host marks dark by putting
176
+ // `data-ds-dark-theme` on <body>, so the light values are the defaults and the dark block below
177
+ // only wins when that attribute is present.
178
+ "--wf-lift:rgba(15,23,42,.045);--wf-lift-2:rgba(15,23,42,.085);" +
172
179
  "font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI','Microsoft YaHei',sans-serif;font-size:12px;line-height:1.5}",
180
+ "body[data-ds-dark-theme] .wf-root{--wf-lift:rgba(255,255,255,.035);--wf-lift-2:rgba(255,255,255,.07)}",
173
181
 
174
182
  /* card: fills the main area; the header and the settings card keep their own height,
175
183
  the two-column body takes the rest. No drag grips: the column has no free edges.
@@ -252,13 +260,14 @@ var WF_CSS = [
252
260
  // `grid-area:1/1` is what makes the triangle and the card share the cell: without it the
253
261
  // auto-placement cursor would drop the card into row 2, below the triangle.
254
262
  ".wf-step-card{grid-area:1/1;display:flex;gap:8px;align-items:flex-start;width:100%;padding:7px 8px;border-radius:8px;text-align:left;" +
255
- "font:inherit;color:inherit;cursor:pointer;border:1px solid transparent;" +
256
- "background:var(--dsw-alias-bg-layer-2,#282d3a);border-left:3px solid transparent}",
263
+ "font:inherit;color:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.22));" +
264
+ "background:var(--dsw-alias-bg-layer-2,#282d3a);background-image:linear-gradient(var(--wf-lift),var(--wf-lift));" +
265
+ "border-left:3px solid transparent}",
257
266
  ".wf-step-card:hover{border-color:var(--dsw-alias-border-l2,rgba(255,255,255,.22));" +
258
- "background:var(--dsw-alias-bg-layer-1,#232733)}",
267
+ "background:var(--dsw-alias-bg-layer-1,#232733);background-image:linear-gradient(var(--wf-lift-2),var(--wf-lift-2))}",
259
268
  ".wf-step-card:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5b8cff);outline-offset:1px}",
260
269
  ".wf-step-card[data-selected='true']{border-color:var(--dsw-alias-brand-primary,#5b8cff);" +
261
- "background:var(--dsw-alias-bg-layer-1,#232733)}",
270
+ "background:var(--dsw-alias-bg-layer-1,#232733);background-image:linear-gradient(var(--wf-lift-2),var(--wf-lift-2))}",
262
271
  ".wf-step-card[data-active='true']{border-left-color:var(--dsw-alias-brand-primary,#5b8cff)}",
263
272
  ".wf-step-ring{flex:0 0 auto;width:18px;height:18px;margin-top:1px;border-radius:50%;" +
264
273
  "display:inline-flex;align-items:center;justify-content:center;font-size:11px;line-height:1;" +
@@ -412,7 +421,7 @@ var WF_CSS = [
412
421
  ".wf-branch-icon{flex:0 0 auto;width:" + WF_CHAIN_ICON + "px;height:" + WF_CHAIN_ICON + "px;padding:0;" +
413
422
  "border-radius:50%;display:inline-flex;align-items:center;justify-content:center;" +
414
423
  "font:inherit;font-size:11px;line-height:1;cursor:pointer;position:relative;" +
415
- "background:var(--dsw-alias-bg-layer-2,#282d3a);color:var(--dsw-alias-label-secondary,#9aa3b2);" +
424
+ "background:var(--dsw-alias-bg-layer-2,#282d3a);background-image:linear-gradient(var(--wf-lift),var(--wf-lift));color:var(--dsw-alias-label-secondary,#9aa3b2);" +
416
425
  "border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.28))}",
417
426
  ".wf-branch-icon:hover{color:var(--dsw-alias-label-primary,#e9ecf3)}",
418
427
  ".wf-branch-icon:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5b8cff);outline-offset:2px}",
@@ -442,12 +451,14 @@ var WF_CSS = [
442
451
  /* node card: icon · title · meta · status dot */
443
452
  ".wf-node{display:flex;gap:7px;align-items:flex-start;width:100%;padding:6px 8px;border-radius:8px;" +
444
453
  "text-align:left;font:inherit;color:inherit;cursor:pointer;" +
445
- "background:var(--dsw-alias-bg-layer-2,#282d3a);border:1px solid transparent;" +
454
+ "background:var(--dsw-alias-bg-layer-2,#282d3a);background-image:linear-gradient(var(--wf-lift),var(--wf-lift));" +
455
+ "border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.22));" +
446
456
  "border-left:3px solid var(--dsw-alias-border-l2,rgba(255,255,255,.2))}",
447
- ".wf-node:hover{border-color:var(--dsw-alias-border-l2,rgba(255,255,255,.24));background:var(--dsw-alias-bg-layer-1,#232733)}",
457
+ ".wf-node:hover{border-color:var(--dsw-alias-border-l2,rgba(255,255,255,.24));" +
458
+ "background:var(--dsw-alias-bg-layer-1,#232733);background-image:linear-gradient(var(--wf-lift-2),var(--wf-lift-2))}",
448
459
  ".wf-node:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5b8cff);outline-offset:1px}",
449
460
  ".wf-node[data-selected='true']{border-color:var(--dsw-alias-brand-primary,#5b8cff);" +
450
- "background:var(--dsw-alias-bg-layer-1,#232733)}",
461
+ "background:var(--dsw-alias-bg-layer-1,#232733);background-image:linear-gradient(var(--wf-lift-2),var(--wf-lift-2))}",
451
462
  ".wf-node[data-selected='true'] .wf-node-title{font-weight:600}",
452
463
  ".wf-node[data-kind='tool']{border-left-color:var(--dsw-alias-brand-primary,#5b8cff)}",
453
464
  ".wf-node[data-kind='report']{border-left-color:var(--dsw-alias-state-success-primary,#45c07a)}",
@@ -1024,11 +1035,12 @@ var WF_CSS_PAGE = [
1024
1035
 
1025
1036
  /* ---- the unit card ---- */
1026
1037
  ".wf-unit-card{display:grid;grid-template-columns:auto auto minmax(0,1fr);align-items:flex-start;gap:8px;" +
1027
- "width:100%;padding:10px 12px;border-radius:10px;text-align:left;border:1px solid transparent;" +
1028
- "background:var(--dsw-alias-bg-layer-2,#282d3a);color:inherit;font:inherit;cursor:pointer;" +
1038
+ "width:100%;padding:10px 12px;border-radius:10px;text-align:left;border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.22));" +
1039
+ "background:var(--dsw-alias-bg-layer-2,#282d3a);background-image:linear-gradient(var(--wf-lift),var(--wf-lift));" +
1040
+ "color:inherit;font:inherit;cursor:pointer;" +
1029
1041
  "border-left:3px solid transparent;box-sizing:border-box}",
1030
1042
  ".wf-unit-card:hover{border-color:var(--dsw-alias-border-l2,rgba(255,255,255,.22));" +
1031
- "background:var(--dsw-alias-bg-layer-1,#232733)}",
1043
+ "background:var(--dsw-alias-bg-layer-1,#232733);background-image:linear-gradient(var(--wf-lift-2),var(--wf-lift-2))}",
1032
1044
  ".wf-unit-card:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5b8cff);outline-offset:1px}",
1033
1045
  ".wf-unit-card[data-selected='true']{border-color:var(--dsw-alias-brand-primary,#5b8cff)}",
1034
1046
  ".wf-unit-card[data-active='true']{border-left-color:var(--dsw-alias-brand-primary,#5b8cff)}",
package/src/fold.mjs CHANGED
@@ -63,7 +63,12 @@
63
63
  // own `inboxProjectionDefinition` replays. A checkpoint written before this bump has no ledger,
64
64
  // so the registry is told to drop it and rebuild from the log (the same contract as v1→v2: a
65
65
  // missing field must never be silently answered with a wrong index).
66
- export const STATE_VERSION = 3;
66
+ //
67
+ // STATE_VERSION 4 : the step ledger now follows the todo table's own statuses (a `pending` row
68
+ // demotes a step that still carried `done`/`running`, and a step any table no longer names is
69
+ // dropped). Checkpoints written before the bump hold ledgers from the older mapping — wrong
70
+ // statuses and leftover rows — so the same contract applies: drop and rebuild from the log.
71
+ export const STATE_VERSION = 4;
67
72
 
68
73
  export const SCHEMA_VERSION = 1;
69
74
 
@@ -515,6 +520,13 @@ function percentFor(state, step, now) {
515
520
  const pinned = step.percent === null ? 100 : step.percent;
516
521
  return { percent: clamp(pinned, 0, 100), source: step.percentSource === "none" ? "closed" : step.percentSource };
517
522
  }
523
+ // Only the step that is actually running can be partly done. A pending row is future work and a
524
+ // skipped one never will be done, so NEITHER carries a number. The old code let the todo table's
525
+ // completion ratio paint a bar on every waiting row as well, which is where the wall of bars
526
+ // below 100 % in an already finished session came from.
527
+ if (step.status !== STEP_STATUS.running) {
528
+ return { percent: null, source: "none" };
529
+ }
518
530
  // Priority 2 when a self-report exists: the todo table's completion ratio is a measurement (no
519
531
  // clock, no extrapolation), so it determines the number whenever it has rows to report on.
520
532
  // The self-report can only lower the result, never raise it above the measured ratio.
@@ -752,12 +764,22 @@ function foldTodos(state, event, time) {
752
764
  // session (no PAE plan at all) still produces a step list.
753
765
  let steps = [...state.steps];
754
766
  let durations = state.durations;
767
+ // Every row that matched an existing step, and the status that row states. The table is the one
768
+ // authority on a step's lifecycle, so these are applied in a final pass instead of inline: a row
769
+ // can match a step that a LATER row also matches (slot reuse after the model rewrites the table),
770
+ // and an inline write could then be overwritten by a stale copy of the old status.
771
+ const authority = new Map();
772
+ // The ledger as it stood BEFORE this table. Slot binding has to read this snapshot: by the time a
773
+ // later row is examined, `steps` already holds the rows this very table created, so "the row in
774
+ // this position" would point at a row from the NEW table instead of the one being replaced.
775
+ const prior = state.steps;
755
776
 
756
777
  for (const item of todos) {
757
778
  if (item.content === "") continue;
758
779
  const planIndex = planIndexOf(item.content);
759
780
  let at = planIndex === null ? -1 : steps.findIndex((step) => step.planIndex === planIndex);
760
781
  if (at === -1) at = steps.findIndex((step) => normalizeTitle(step.title) === normalizeTitle(item.content));
782
+ const matchedByTitle = at !== -1;
761
783
  // REWORDED ROW at the source. The model often rewrites its own todo text between two
762
784
  // `todo/write` events (measured: "read-only inventory: read plugin sources..." -> "read-only inventory: sources/config..."). Matching on the
763
785
  // title alone then misses the row that already exists, a SECOND row is created for the new
@@ -765,8 +787,13 @@ function foldTodos(state, event, time) {
765
787
  // sync then drops. That was "only the running step shows tool types". The fix belongs HERE:
766
788
  // reword in place when the slot and lifecycle agree, so the row keeps its ledger.
767
789
  if (at === -1) {
768
- const slotRow = steps[steps.length > todos.indexOf(item) ? todos.indexOf(item) : -1];
769
- const sameSlot = slotRow && (slotRow.status === item.status || slotRow.status === STEP_STATUS.running);
790
+ const slotRow = prior[todos.indexOf(item)];
791
+ // A running step is never taken over by a row that is not running: that is how a pending row
792
+ // used to hijack the live step and leave the real one with no running row at all.
793
+ const sameSlot =
794
+ slotRow &&
795
+ (slotRow.status === item.status ||
796
+ (slotRow.status === STEP_STATUS.running && item.status === "in_progress"));
770
797
  if (sameSlot) at = steps.indexOf(slotRow);
771
798
  }
772
799
 
@@ -796,6 +823,12 @@ function foldTodos(state, event, time) {
796
823
  // its old key, so the nodes it already owns can follow the rename. Both the rename and the
797
824
  // lifecycle change are applied in one assignment, so neither can lose the other.
798
825
  let next = step;
826
+ // The row's ordinal follows the TABLE, not the row's history. A matched row that used to be
827
+ // unnumbered (or numbered differently) must take the ordinal this table now states, or the
828
+ // view keeps a stale planIndex and `applyPlanToView` patches the wrong plan step onto it.
829
+ // Measured 2026-10-29 on session 164e339f: leftover unnumbered rows kept planIndex 2 / 3 /
830
+ // 4... from an earlier PAE plan and the map rendered plan steps 3-7 twice.
831
+ if (planIndex !== null && next.planIndex !== planIndex) next = { ...next, planIndex };
799
832
  const shown = text(stripOrdinal(item.content) || item.content, TITLE_LIMIT);
800
833
  const shownKey = normalizeTitle(shown);
801
834
  if (shownKey !== "" && shownKey !== next.stepKey) {
@@ -815,8 +848,40 @@ function foldTodos(state, event, time) {
815
848
  } else if (next !== step) {
816
849
  steps[at] = next;
817
850
  }
851
+ authority.set(at, { status: item.status, byTitle: matchedByTitle });
818
852
  }
819
853
 
854
+ // The final pass: one status per step, exactly as the table now states it. `pending` is a real
855
+ // state — a row the model has not started — and a step that still carried `done` or `running`
856
+ // from the previous wording of the table has to be demoted, or the panel shows finished work the
857
+ // table does not claim (and a bar at 100 % next to an empty todo). Demotion is only safe when the
858
+ // row matched on its own title; a slot match after a reword could hit a different row's step.
859
+ for (const [at, claim] of authority) {
860
+ const step = steps[at];
861
+ if (!step) continue;
862
+ if (claim.status === "completed" && step.status !== STEP_STATUS.done) {
863
+ if (step.startedAt > 0) durations = [...durations, time - step.startedAt].filter((value) => value > 0);
864
+ steps[at] = closeStep(step, time);
865
+ } else if (claim.status === "in_progress" && step.status !== STEP_STATUS.running) {
866
+ steps[at] = { ...step, status: STEP_STATUS.running, endedAt: null, startedAt: step.startedAt || time };
867
+ } else if (claim.status === "pending" && step.status !== STEP_STATUS.pending) {
868
+ steps[at] = { ...step, status: STEP_STATUS.pending, endedAt: null, startedAt: 0, percent: null, percentSource: "none" };
869
+ }
870
+ }
871
+
872
+ // A row the table no longer names, that never started and owns no node, is the leftover of an
873
+ // earlier wording of the same list. Keeping it leaves a permanently empty card in the step
874
+ // column, which is how every rewritten table used to grow the panel by one or two rows.
875
+ const named = new Set(todos.map((item) => normalizeTitle(item.content)).filter((key) => key !== ""));
876
+ const nodeKeys = new Set(state.nodes.map((node) => node.stepKey));
877
+ const kept = steps.filter((step) => {
878
+ if (step.status !== STEP_STATUS.pending || step.startedAt > 0) return true;
879
+ const keys = [step.stepKey, ...(Array.isArray(step.aliasKeys) ? step.aliasKeys : [])].filter(Boolean);
880
+ if (keys.some((key) => nodeKeys.has(key))) return true;
881
+ return keys.some((key) => named.has(key));
882
+ });
883
+ if (kept.length !== steps.length) steps = kept;
884
+
820
885
 
821
886
  return { ...state, asOf: time, todos, steps: reindex(steps), durations };
822
887
  }
@@ -1134,7 +1199,14 @@ function parsePlanRows(blockText) {
1134
1199
  for (const raw of blockText.split("\n")) {
1135
1200
  const match = /^\s*(\d+)\.\s*(.+)$/.exec(raw);
1136
1201
  if (!match) continue;
1137
- rows.push({ planIndex: Number(match[1]), title: text(match[2], TITLE_LIMIT) });
1202
+ // PAE appends each row's own step FILE to the title ("1. title(…)(step-01-x.md)"). That
1203
+ // annotation is not title text: keeping it leaves `normalizeTitle` one parenthetical group short
1204
+ // of the todo row the model writes ("1. title(…)"), so a plan step can carry a title that no
1205
+ // row of the todo table has. Measured 2026-10-30 on session 1bb4baa0: the fold opened step 1 with
1206
+ // the annotated title while its `stepKey` still held the table's wording, `foldTodos` therefore
1207
+ // saw "no change" and the view kept a step the conversation does not list (audit-real ①).
1208
+ const title = match[2].replace(/\s*[((][^(())]*\.(?:md|markdown|mjs|cjs|js|json|ya?ml|txt)[))]\s*$/, "");
1209
+ rows.push({ planIndex: Number(match[1]), title: text(title, TITLE_LIMIT) });
1138
1210
  if (rows.length >= 80) break;
1139
1211
  }
1140
1212
  return rows;
@@ -1195,10 +1267,26 @@ function openPlanStep(state, time, seq, planIndex, title, blockText) {
1195
1267
  steps.push(created);
1196
1268
  } else {
1197
1269
  const step = steps[targetAt];
1270
+ // A plan title must not replace the row's identity key on its own: `stepKey` is what `foldTodos`
1271
+ // compares the live table against and what node attribution follows. Adopting a title without its
1272
+ // key left the pair inconsistent, so the next todo/write judged the row unchanged and a stale
1273
+ // title survived into the view (measured 2026-10-30, session 1bb4baa0). The old key is parked in
1274
+ // `aliasKeys` exactly as a rename does, so nodes already carrying it still migrate.
1275
+ const adopted = title || step.title;
1276
+ const adoptedKey = normalizeTitle(adopted);
1277
+ const renamed = adoptedKey !== "" && adoptedKey !== step.stepKey;
1278
+ const seenKeys = Array.isArray(step.aliasKeys) ? step.aliasKeys : [];
1198
1279
  steps[targetAt] = {
1199
1280
  ...step,
1200
1281
  planIndex,
1201
- title: title || step.title,
1282
+ title: adopted,
1283
+ ...(renamed
1284
+ ? {
1285
+ prevKey: step.stepKey,
1286
+ stepKey: adoptedKey,
1287
+ aliasKeys: [...new Set([...seenKeys, step.stepKey, adoptedKey])].filter((key) => key !== ""),
1288
+ }
1289
+ : {}),
1202
1290
  anchorSeq: step.anchorSeq === undefined ? anchorOf(state, planIndex, seq) : step.anchorSeq,
1203
1291
  status: STEP_STATUS.running,
1204
1292
  endedAt: null,
@@ -1261,7 +1349,20 @@ function foldTurnStart(state, _event, time) {
1261
1349
  }
1262
1350
 
1263
1351
  function foldTurnEnd(state, _event, time) {
1264
- return { ...state, asOf: time, turnActive: false };
1352
+ const next = { ...state, asOf: time, turnActive: false };
1353
+ // The turn is over, so the step it was running is over too. Leaving it `running` kept a live,
1354
+ // forever-partial bar on the last step of every finished session.
1355
+ // The one exception is a step the todo table still names as its `in_progress` row: that step
1356
+ // really does continue into the next turn, and closing it here would make the next turn reopen
1357
+ // a step that never stopped.
1358
+ const running = runningStep(next);
1359
+ if (!running) return next;
1360
+ const inProgress = inProgressStepIndex(next);
1361
+ if (isFiniteNumber(inProgress) && inProgress === running.index) return next;
1362
+ const durations =
1363
+ running.startedAt > 0 ? [...next.durations, time - running.startedAt].filter((value) => value > 0) : next.durations;
1364
+ const steps = next.steps.map((step) => (step.index === running.index ? closeStep(step, time) : step));
1365
+ return withPercents({ ...next, steps, durations }, time);
1265
1366
  }
1266
1367
 
1267
1368
  function foldReport(state, event, time) {
@@ -2341,9 +2442,27 @@ function syncToTodos(state) {
2341
2442
  const key = normalizeTitle(item.content);
2342
2443
  if (key === "" || seen.has(key)) continue;
2343
2444
  seen.add(key);
2344
- wanted.push({ key, title, status: item.status });
2445
+ // `planIndex` MUST be parsed from the RAW content, not from `title`: `stripOrdinal`
2446
+ // already removed the "3." prefix, so `planIndexOf(title)` always returned null and every
2447
+ // created row lost its ordinal. Measured 2026-10-29 on session 164e339f: a 14-row numbered
2448
+ // table produced rows whose planIndex fell back to POSITION, so row 9 ("9. n8n 主工作流…")
2449
+ // was stamped planIndex 2 and `applyPlanToView` then rewrote it with plan step 2's title and
2450
+ // status — the map showed steps 3–7 twice and the wrong step as running.
2451
+ wanted.push({ key, title, status: item.status, planIndex: planIndexOf(item.content) });
2345
2452
  }
2346
2453
  if (wanted.length === 0) return state;
2454
+ // The row's status is the ONE statement of a step's lifecycle: `todo/write` replaces the whole
2455
+ // table every time, so a matched row must not inherit the status of whatever record the matcher
2456
+ // bound it to. Without this pass a row the model has not started kept the `done` of an old record
2457
+ // (a card at 100 % next to an empty todo), while an `in_progress` row showed as `pending` because
2458
+ // the record it landed on was still pending — both seen in one session on 2026-10-30.
2459
+ const rowStatus = (step, row) => {
2460
+ if (row.status === "completed") return step.status === STEP_STATUS.done ? step : closeStep(step, state.asOf || 0);
2461
+ if (row.status === "in_progress") {
2462
+ return { ...step, status: STEP_STATUS.running, endedAt: null, startedAt: step.startedAt || state.asOf || 0 };
2463
+ }
2464
+ return { ...step, status: STEP_STATUS.pending, endedAt: null, startedAt: 0, percent: null, percentSource: "none" };
2465
+ };
2347
2466
 
2348
2467
  const byKey = new Map();
2349
2468
  for (const step of state.steps) {
@@ -2390,14 +2509,30 @@ function syncToTodos(state) {
2390
2509
  // (2) TEXT SHARE as the fallback for a re-numbered or re-ordered table.
2391
2510
  let candidate = null;
2392
2511
  const slot = wanted.indexOf(row);
2393
- const sameSlot = orphans.find((step) => !used.has(step.index) && step.index === slot);
2512
+ // An explicit ordinal OUTRANKS position. A numbered table states which plan step each row
2513
+ // IS; letting the slot win anyway pairs row 9 with whatever orphan happens to sit at
2514
+ // position 8, and the row inherits that orphan's planIndex. Measured 2026-10-29 on session
2515
+ // 164e339f: after a 7-row unnumbered table was replaced by a 14-row numbered one, row 9
2516
+ // took the leftover row 2 and the map rendered plan steps 3-7 twice.
2517
+ const ordinalConflict = (step) =>
2518
+ row.planIndex !== null && Number.isInteger(step.planIndex) && step.planIndex !== row.planIndex;
2519
+ const sameSlot = orphans.find(
2520
+ (step) => !used.has(step.index) && step.index === slot && !ordinalConflict(step),
2521
+ );
2394
2522
  if (sameSlot && (sameSlot.status === row.status || sameSlot.status === STEP_STATUS.running)) {
2395
2523
  candidate = sameSlot;
2396
2524
  }
2525
+ if (candidate === null && row.planIndex !== null) {
2526
+ const byOrdinal = orphans.find(
2527
+ (step) => !used.has(step.index) && Number.isInteger(step.planIndex) && step.planIndex === row.planIndex,
2528
+ );
2529
+ if (byOrdinal) candidate = byOrdinal;
2530
+ }
2397
2531
  if (candidate === null) {
2398
2532
  let bestShare = 0;
2399
2533
  for (const step of orphans) {
2400
2534
  if (used.has(step.index)) continue;
2535
+ if (ordinalConflict(step)) continue;
2401
2536
  const other = normalizeTitle(step.title);
2402
2537
  const score = commonPrefix(other, row.key);
2403
2538
  if (score < REWORD_PREFIX_MIN) continue;
@@ -2417,7 +2552,7 @@ function syncToTodos(state) {
2417
2552
  // reworded title - the two did not match, so the nodes were dropped as orphans in the view.
2418
2553
  const oldKey = candidate.stepKey || normalizeTitle(candidate.title);
2419
2554
  const newKey = normalizeTitle(row.title);
2420
- const nextStep = { ...candidate, title: text(row.title, TITLE_LIMIT), stepKey: newKey };
2555
+ const nextStep = rowStatus({ ...candidate, title: text(row.title, TITLE_LIMIT), stepKey: newKey }, row);
2421
2556
  reworded.set(oldKey, newKey);
2422
2557
  ordered.push(nextStep);
2423
2558
  origins.push(candidate.index);
@@ -2430,13 +2565,8 @@ function syncToTodos(state) {
2430
2565
  // node-remap nor the node attribution can ever find it.
2431
2566
  created.stepKey = row.key;
2432
2567
  created.startedAt = 0;
2433
- created.planIndex = planIndexOf(row.title) ?? undefined;
2434
- created.status = row.status === "completed" ? STEP_STATUS.done : STEP_STATUS.pending;
2435
- if (created.status === STEP_STATUS.done) {
2436
- created.percent = 100;
2437
- created.percentSource = "closed";
2438
- created.maxPercent = 100;
2439
- }
2568
+ created.planIndex = row.planIndex ?? undefined;
2569
+ Object.assign(created, rowStatus(created, row));
2440
2570
  ordered.push(created);
2441
2571
  origins.push(-1);
2442
2572
  }
@@ -300,6 +300,24 @@ export function applyPlanToView(view, plan) {
300
300
  }
301
301
  }
302
302
  }
303
+ // The plan's own `requiresConfirmation` is the OTHER half of the needs-confirmation flag:
304
+ // `buildView` derives the log half (an open approval, an unanswered question), and this
305
+ // adds the plan half. It belongs HERE because this is the only place that has both the
306
+ // normalised plan step and the view row it lands on. Measured 2026-10-29 on session
307
+ // 164e339f: plan steps 2 / 12 / 13 carry requiresConfirmation and the map showed none of
308
+ // them, while `pendingConfirmSteps` in fold.mjs only ever consulted the log.
309
+ //
310
+ // A finished step never carries the flag (the same rule `buildView` applies): a decision a
311
+ // done step was waiting on is history, not a request.
312
+ if (planStep.requiresConfirmation === true && step.status !== "done" && step.status !== "failed") {
313
+ const rows = Array.isArray(step.pendingConfirm) ? step.pendingConfirm : [];
314
+ if (!rows.some((row) => isRecord(row) && row.kind === "plan")) {
315
+ patch.pendingConfirm = [
316
+ ...rows,
317
+ { kind: "plan", label: planStep.title !== "" ? planStep.title : `第 ${planStep.index} 步`, at: 0 },
318
+ ];
319
+ }
320
+ }
303
321
  if (Object.keys(patch).length === 0) continue;
304
322
  steps[position] = { ...step, ...patch };
305
323
  changed = true;