dsh-plan-completion 0.1.1 → 0.1.3

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,45 @@
3
3
  All notable changes to this project are documented here.
4
4
  Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versioning: SemVer.
5
5
 
6
+ ## [0.1.3] - 2026-11-02
7
+
8
+ ### Added
9
+ - Plan page「核对进度」button (P0-5): `POST /plan-completion/verify {sessionId}` runs one bounded,
10
+ authenticated model call that compares the stored plan with recent evidence and returns
11
+ `{assessment, mismatches[], suggestion}`. It never mutates the plan; results render in the plan
12
+ panel as an assessment list plus next-step suggestion.
13
+ - New config `maxTokensVerify` (default 600); per-session in-flight guard, 8s abort deadline and
14
+ parse-error fallbacks; `modelCalls:false` disables this route's model call.
15
+ - Tests: verify prompt/parser edge cases, authenticated route integration, and a client transport
16
+ test for the new request helper.
17
+
18
+ ### Changed
19
+ - `npm test` now reports **126/126** tests; production routes are `state`/`replan`/`warm`/`verify`.
20
+
21
+ ## [0.1.2] - 2026-11-02
22
+
23
+ ### Added
24
+ - Plan persistence: the plan plus the last suggestion are written per session to a local file
25
+ store (`storageDir/plans/<base64url(sessionId)>.json`, atomic temp+rename). After a DSH restart,
26
+ `GET /state` hydrates the persisted plan before responding, so the previous plan/version and
27
+ suggestion are visible immediately (no model call needed).
28
+ - New config: `persistPlans` (default true) and `storageDir` (default `~/.dsh/plan-completion-data`,
29
+ `~` expanded; `DSH_PLAN_COMPLETION_STORE_DIR` also honored). `persistPlans:false` returns to
30
+ memory-only behavior.
31
+ - Persisted writes happen on every committed generation, user edit (`/steps`) and are deleted by
32
+ `/reset`; warm backfill sees the persisted plan and reports `already` instead of re-generating.
33
+ - `store.getState()` / `put(plan, {cas, suggestion})` in both memory and file stores.
34
+ - 9 new tests: store persistence semantics (memory + file, corrupt record tolerance) and a
35
+ restart-simulation integration suite (restore on mount, continue patching after restart, reset
36
+ clears disk).
37
+
38
+ ### Changed
39
+ - `npm test` now reports **120/120** tests.
40
+
41
+ ### Security
42
+ - Persisted files contain conversation-derived plan/suggestion text (plain JSON, unencrypted, no
43
+ automatic expiry); see `docs/security.md`. `persistPlans:false` disables disk persistence.
44
+
6
45
  ## [0.1.1] - 2026-11-02
7
46
 
8
47
  ### Fixed
package/README.md CHANGED
@@ -60,15 +60,31 @@ Client ⇄ Host 唯一通道是本地 HTTP 路由(`ctx.get('webServer').regist
60
60
  - `GET /plan-completion/state?sessionId=<id>&reason=<kind>&running=<值>`
61
61
  - `POST /plan-completion/warm` `{sessionId}`(旧会话回填:从持久化日志重建计划+建议)
62
62
  - `POST /plan-completion/replan` `{sessionId, instruction?}`(生产路由,须通过宿主鉴权)
63
+ - `POST /plan-completion/verify` `{sessionId}`(「核对进度」:一次模型调用返回差异清单,不修改计划)
63
64
  - `GET /plan-completion/debug?sessionId=<id>`(仅显式启用调试路由后可用)
64
65
 
65
- > 生产路由:`state`、`replan`、`warm`;所有路由先调用 `connection.requestRejection(req)`。
66
+ > 生产路由:`state`、`replan`、`warm`、`verify`;所有路由先调用 `connection.requestRejection(req)`。
66
67
  > 调试路由:`debug`、`diag`、`theme`、`experiment`、`autojob`、`steps`、`reset` —— 仅当 `config.enableDebugRoutes: true` 时注册。
67
68
 
68
69
  Client 侧用 `fetch(..., {credentials:'same-origin'})`;Electron 的 `dsh-app://app` 页面经主进程转发,响应体可读、自动带 `dsh-auth` cookie。
69
70
 
70
71
  Client 侧关键 API(实测):`inputActions.captureInsertion()` 返回 `{start,end,draftRev}`;`insertText(text, span)`;`setDraft(text)`;`useInput(s => s)` 返回 `{draft, attachmentIds, draftRev, phase, occurrences, queue}`。
71
72
 
73
+ ## 核对进度(v0.1.3)
74
+
75
+ - 计划面板底部「核对进度」按钮 → `POST /plan-completion/verify {sessionId}`(生产路由,需宿主鉴权);
76
+ - Host 用**一次**受控模型调用对照「当前计划 + 最近证据」,返回 `{assessment, mismatches[], suggestion}`,**不修改计划**;
77
+ - 面板内展示结论、差异列表与下一步建议;同一会话同时只允许一个核对请求,8s 超时/解析失败返回降级错误,`modelCalls:false` 时禁用模型调用;
78
+ - 输出预算 `maxTokensVerify`(默认 600);核对结果仅内存展示,不落盘。
79
+
80
+ ## 计划持久化(v0.1.2)
81
+
82
+ - 计划 + 最近一条建议按会话持久化到本地文件(`storageDir/plans/<base64url(sessionId)>.json`,原子写);
83
+ - 插件启动/打开会话时按需恢复(`GET /state` 前 hydrate),**DSH 重启后仍能看到原计划与版本**,无需等下一回合;
84
+ - 每次成功提交(含用户编辑、reset)同步落盘;`reset` 会同时删除磁盘记录;
85
+ - 默认目录 `~/.dsh/plan-completion-data`;可用 `storageDir` 改到别处,`persistPlans: false` 退回纯内存;
86
+ - 注意:存储为本地明文 JSON(含计划/建议文本,可能来自会话);不加密、不自动清理,不适合多用户共享机器。
87
+
72
88
  ## 模型路由与隐私
73
89
 
74
90
  - 仅使用宿主 `agentDefaultModel.currentSelection()` 当前默认的 provider/model,不自动改投其他已配置供应商;无可用路由时使用启发式。该默认值不一定是历史会话原先使用的模型。
@@ -90,8 +106,9 @@ Client 侧关键 API(实测):`inputActions.captureInsertion()` 返回 `{st
90
106
  maxTokensRewrite: 1200
91
107
  warmBackfill: true
92
108
  modelCalls: true # false:本插件仅用启发式,不向模型发送内容
93
- enableDebugRoutes: false # 本地调试时才打开
94
- # debugDir: '<本地日志目录>' # 可选;发布版留空
109
+ persistPlans: true # false:计划只存内存
110
+ storageDir: '~/.dsh/plan-completion-data'
111
+ enableDebugRoutes: false # 本地调试时才打开 # debugDir: '<本地日志目录>' # 可选;发布版留空
95
112
  ```
96
113
 
97
114
  | 配置项 | 默认 | 作用 | 依据 |
@@ -100,6 +117,9 @@ Client 侧关键 API(实测):`inputActions.captureInsertion()` 返回 `{st
100
117
  | `maxTokensPatch` | 400 | patch 路径输出上限(实测只需 65~120 tok) | 真机 6 样本:输出 75~113 tok |
101
118
  | `maxTokensRewrite` | 1200 | rewrite/冷启动输出上限(要输出完整步骤表) | 700 会截断,1200 稳定 |
102
119
  | `warmBackfill` | true | 打开「插件从未观察过的旧会话」时,从持久化会话日志回填一次计划+建议 | P0-1;真机老会话回填 2.2s |
120
+ | `persistPlans` | true | 是否把计划+最近建议持久化到本地文件(DSH 重启恢复) | v0.1.2 |
121
+ | `storageDir` | `~/.dsh/plan-completion-data` | 计划文件目录;`~` 会展开为 home;也可用 `DSH_PLAN_COMPLETION_STORE_DIR` | v0.1.2 |
122
+ | `maxTokensVerify` | 600 | 「核对进度」单次模型调用的输出预算 | v0.1.3 |
103
123
  | `enableDebugRoutes` | false | 是否注册 `debug/diag/theme/experiment/autojob/steps/reset` 调试路由;仍须鉴权 | 发布版最小攻击面 |
104
124
  | `modelCalls` | true | false 时关闭本插件全部模型调用,使用启发式 | 隐私控制 |
105
125
  | `debugDir` | 空 | 调试日志/实验结果的落盘目录(也可用 `DSH_PLAN_COMPLETION_DEBUG_DIR`);空则不写文件 | 可移植性 |
@@ -108,7 +128,7 @@ Client 侧关键 API(实测):`inputActions.captureInsertion()` 返回 `{st
108
128
 
109
129
  ## 安装 / 卸载
110
130
 
111
- > npm:**`dsh-plan-completion@0.1.0`** 已发布(https://www.npmjs.com/package/dsh-plan-completion)。
131
+ > npm:[`dsh-plan-completion`](https://www.npmjs.com/package/dsh-plan-completion)(版本见上方 npm badge)。
112
132
  > DSH 的 profile 安装仍以仓库 `link:` 或路径安装为准(包内是 `dsh.bundle.patch` + `dsh.client` bundle)。
113
133
 
114
134
  ```powershell
@@ -260,7 +280,7 @@ CI:`.github/workflows/test.yml`(GitHub Actions,Node 20/22 矩阵,push/PR
260
280
 
261
281
  ## 已知限制(当前)
262
282
 
263
- - 计划只存在 Host 内存,重启 DSH 后丢失(二期接 `storageDomain` / 文件存储);旧会话可通过 warm 回填,但热重载外的重启仍会清空状态;
283
+ - **计划持久化**(v0.1.2):计划+最近建议按会话落盘到 `storageDir`,DSH 重启后自动恢复(仍保留"本会话内存态"作为快路径);默认目录 `~/.dsh/plan-completion-data`,`persistPlans:false` 可退回纯内存。存储为本地明文 JSON,不加密、不自动清理;
264
284
  - 模型选择暂用默认路由推导,未做设置页三选一;
265
285
  - 计划页是 Popover,右栏形态二期(`ctx.sidebarRightTabs` 已确认对插件开放);
266
286
  - HTTP 路由依赖宿主 `connection.requestRejection`;服务缺失时返回 503,不退回未鉴权访问。已在真实 DSH 完成 v0.1.1 canary(Electron 代理请求正常、直连未认证请求 401);其他宿主/部署形态仍需各自验证;
package/client.js CHANGED
@@ -80,6 +80,7 @@ window.__ModuleLoader__.load({
80
80
  'popover.fill': '填入草稿',
81
81
  'popover.sync': '同步给 Agent',
82
82
  'popover.close': '关闭',
83
+ 'popover.verify': '核对进度',
83
84
  'popover.empty': '暂无计划',
84
85
  'popover.updated': '更新于 {time}',
85
86
  'popover.stale': '计划可能已过期,将在下一回合重建',
@@ -97,7 +98,14 @@ window.__ModuleLoader__.load({
97
98
  'sync.todo': '待办:{value}。',
98
99
  'sync.reason': '最近更新:{value}。',
99
100
  'sync.next': '下一步建议:{value}。',
100
- 'sync.fallback': '请先确认当前进度,再继续推进。'
101
+ 'sync.fallback': '请先确认当前进度,再继续推进。',
102
+ 'verify.loading': '正在对照计划与实际进度…',
103
+ 'verify.assessment': '核对结论',
104
+ 'verify.mismatch': '差异',
105
+ 'verify.clean': '未发现明显差异。',
106
+ 'verify.next': '建议:{value}',
107
+ 'verify.failed': '核对失败:{value}',
108
+ 'verify.no-session': '当前还没有可用会话'
101
109
  },
102
110
  en: {
103
111
  'entry.aria': 'Open the session plan panel',
@@ -115,6 +123,7 @@ window.__ModuleLoader__.load({
115
123
  'popover.fill': 'Fill draft',
116
124
  'popover.sync': 'Sync to Agent',
117
125
  'popover.close': 'Close',
126
+ 'popover.verify': 'Verify progress',
118
127
  'popover.empty': 'No plan yet',
119
128
  'popover.updated': 'Updated {time}',
120
129
  'popover.stale': 'The plan may be stale and will be rebuilt next turn',
@@ -132,7 +141,14 @@ window.__ModuleLoader__.load({
132
141
  'sync.todo': 'Pending: {value}.',
133
142
  'sync.reason': 'Latest update: {value}.',
134
143
  'sync.next': 'Next suggestion: {value}.',
135
- 'sync.fallback': 'Please confirm the current progress before continuing.'
144
+ 'sync.fallback': 'Please confirm the current progress before continuing.',
145
+ 'verify.loading': 'Comparing the plan against the latest evidence…',
146
+ 'verify.assessment': 'Assessment',
147
+ 'verify.mismatch': 'Mismatch',
148
+ 'verify.clean': 'No obvious mismatches found.',
149
+ 'verify.next': 'Next: {value}',
150
+ 'verify.failed': 'Verification failed: {value}',
151
+ 'verify.no-session': 'No session available yet'
136
152
  }
137
153
  };
138
154
 
@@ -301,6 +317,14 @@ window.__ModuleLoader__.load({
301
317
  '.dpc-pop-foot .dpc-updated{flex:1 1 auto;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
302
318
  '.dpc-actions{padding-top:0;gap:8px;flex:none}',
303
319
  '.dpc-btn{border-radius:999px;padding:5px 12px;font-size:12px;line-height:18px}',
320
+ '.dpc-verify-card{gap:6px}',
321
+ '.dpc-verify-loading{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}',
322
+ '.dpc-verify-error{font-size:12px;line-height:18px;color:var(--dsw-alias-state-error-primary, var(--dsw-alias-label-secondary));word-break:break-word}',
323
+ '.dpc-verify-assessment{font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);white-space:pre-wrap;word-break:break-word}',
324
+ '.dpc-verify-list{margin:0;padding-left:16px;display:flex;flex-direction:column;gap:4px}',
325
+ '.dpc-verify-item{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);word-break:break-word}',
326
+ '.dpc-verify-clean{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}',
327
+ '.dpc-verify-next{font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary);word-break:break-word}',
304
328
  '.dpc-empty{padding:14px;font-size:13px}'
305
329
  ].join('\n');
306
330
 
@@ -729,6 +753,40 @@ window.__ModuleLoader__.load({
729
753
  });
730
754
  }
731
755
 
756
+ /**
757
+ * P0-5「核对进度」:让 Host 用一次受控模型调用对照计划与实际证据。
758
+ * 返回 Host 的 JSON({ok, assessment, mismatches, suggestion})或 null。
759
+ */
760
+ function requestVerify(sessionId) {
761
+ if (typeof sessionId !== 'string' || sessionId === '') return Promise.resolve(null);
762
+ let request = null;
763
+ try {
764
+ request = fetch('/plan-completion/verify', {
765
+ method: 'POST',
766
+ credentials: 'same-origin',
767
+ headers: { 'content-type': 'application/json' },
768
+ body: JSON.stringify({ sessionId: sessionId })
769
+ });
770
+ } catch (error) {
771
+ return Promise.resolve(null);
772
+ }
773
+ return Promise.resolve(request)
774
+ .then(function readResponse(response) {
775
+ if (!response || response.ok !== true) return null;
776
+ return Promise.resolve(response.json()).then(
777
+ function parsed(json) {
778
+ return json && typeof json === 'object' ? json : null;
779
+ },
780
+ function badJson() {
781
+ return null;
782
+ }
783
+ );
784
+ })
785
+ .catch(function networkError() {
786
+ return null;
787
+ });
788
+ }
789
+
732
790
  /**
733
791
  * P0 boundary: read the current plan state. Keep this signature stable —
734
792
  * swapping the transport (HTTP route / projection / wire view) only changes
@@ -1620,7 +1678,7 @@ window.__ModuleLoader__.load({
1620
1678
  );
1621
1679
  }
1622
1680
 
1623
- function renderPopoverContent(plan, inputActions, t) {
1681
+ function renderPopoverContent(plan, inputActions, t, verify, onVerify) {
1624
1682
  const children = [];
1625
1683
  if (!plan) {
1626
1684
  children.push(
@@ -1732,6 +1790,50 @@ window.__ModuleLoader__.load({
1732
1790
  }
1733
1791
  }
1734
1792
 
1793
+ // P0-5「核对进度」结果:显示在计划正文里,不改动计划本身。
1794
+ if (verify && (verify.loading || verify.error || verify.result)) {
1795
+ const verifyChildren = [
1796
+ h('div', { key: 'label', className: 'dpc-label' }, t('verify.assessment'))
1797
+ ];
1798
+ if (verify.loading) {
1799
+ verifyChildren.push(h('div', { key: 'loading', className: 'dpc-verify-loading' }, t('verify.loading')));
1800
+ } else if (verify.error) {
1801
+ verifyChildren.push(
1802
+ h('div', { key: 'error', className: 'dpc-verify-error' }, t('verify.failed', { value: verify.error }))
1803
+ );
1804
+ } else if (verify.result) {
1805
+ const result = verify.result;
1806
+ if (result.assessment) {
1807
+ verifyChildren.push(h('div', { key: 'assessment', className: 'dpc-verify-assessment' }, result.assessment));
1808
+ }
1809
+ const mismatches = Array.isArray(result.mismatches) ? result.mismatches : [];
1810
+ if (mismatches.length) {
1811
+ verifyChildren.push(
1812
+ h(
1813
+ 'ul',
1814
+ { key: 'mismatches', className: 'dpc-verify-list' },
1815
+ mismatches.map(function renderMismatch(item, index) {
1816
+ const prefix = item && item.stepId ? '[' + item.stepId + '] ' : '';
1817
+ return h(
1818
+ 'li',
1819
+ { key: 'mismatch-' + index, className: 'dpc-verify-item' },
1820
+ prefix + String(item && item.issue ? item.issue : '')
1821
+ );
1822
+ })
1823
+ )
1824
+ );
1825
+ } else {
1826
+ verifyChildren.push(h('div', { key: 'clean', className: 'dpc-verify-clean' }, t('verify.clean')));
1827
+ }
1828
+ if (result.suggestion) {
1829
+ verifyChildren.push(
1830
+ h('div', { key: 'next', className: 'dpc-verify-next' }, t('verify.next', { value: result.suggestion }))
1831
+ );
1832
+ }
1833
+ }
1834
+ children.push(h('div', { key: 'verify', className: 'dpc-section dpc-verify-card' }, verifyChildren));
1835
+ }
1836
+
1735
1837
  if (plan.updatedAt) {
1736
1838
  children.push(
1737
1839
  h('div', { key: 'updated', className: 'dpc-updated' }, t('popover.updated', { time: formatTime(plan.updatedAt) }))
@@ -1774,6 +1876,20 @@ window.__ModuleLoader__.load({
1774
1876
  },
1775
1877
  t('popover.sync')
1776
1878
  ),
1879
+ h(
1880
+ 'button',
1881
+ {
1882
+ type: 'button',
1883
+ className: 'dpc-btn',
1884
+ disabled: !!(verify && verify.loading),
1885
+ onMouseDown: preventDefault,
1886
+ onClick: function onClickVerify(event) {
1887
+ stopPropagation(event);
1888
+ if (typeof onVerify === 'function') onVerify();
1889
+ }
1890
+ },
1891
+ t('popover.verify')
1892
+ ),
1777
1893
  h(
1778
1894
  'button',
1779
1895
  {
@@ -1815,6 +1931,32 @@ window.__ModuleLoader__.load({
1815
1931
  const rootRef = React.useRef(null);
1816
1932
  const open = state.overlayOpen === true;
1817
1933
  const [heroPos, setHeroPos] = React.useState(null);
1934
+ const [verify, setVerify] = React.useState(null);
1935
+
1936
+ // Ephemeral P0-5 verification result: clear it when the active session changes.
1937
+ React.useEffect(function resetVerify() {
1938
+ setVerify(null);
1939
+ }, [state.sessionId]);
1940
+
1941
+ const onVerify = React.useCallback(function requestVerifyAction() {
1942
+ const sessionId = state.sessionId;
1943
+ if (typeof sessionId !== 'string' || sessionId === '') {
1944
+ setVerify({ loading: false, result: null, error: 'no-session' });
1945
+ return;
1946
+ }
1947
+ setVerify({ loading: true, result: null, error: null });
1948
+ requestVerify(sessionId).then(function applyResult(result) {
1949
+ if (!result) {
1950
+ setVerify({ loading: false, result: null, error: 'request-failed' });
1951
+ return;
1952
+ }
1953
+ if (result.ok !== true) {
1954
+ setVerify({ loading: false, result: null, error: String(result.error || 'failed') });
1955
+ return;
1956
+ }
1957
+ setVerify({ loading: false, result: result, error: null });
1958
+ });
1959
+ }, [state.sessionId]);
1818
1960
 
1819
1961
  // P0-2 hero fallback: when no session-bound dock is mounted (new conversation / hero,
1820
1962
  // or a cleared conversation), float a small «▤ 计划» entry above the composer so the
@@ -1908,7 +2050,7 @@ window.__ModuleLoader__.load({
1908
2050
  'data-plan-completion': 'popover',
1909
2051
  style: position
1910
2052
  },
1911
- renderPopoverContent(state.plan, state.inputActions, t)
2053
+ renderPopoverContent(state.plan, state.inputActions, t, verify, onVerify)
1912
2054
  )
1913
2055
  );
1914
2056
  }
package/cordis.patch.yml CHANGED
@@ -1,13 +1,16 @@
1
- - insert:
2
- - id: plan-completion
3
- name: 'dsh-plan-completion'
4
- config:
5
- evidenceChars: 1500
6
- maxTokensPatch: 400
7
- maxTokensRewrite: 1200
8
- warmBackfill: true
9
- modelCalls: true # false: heuristic-only; no plugin model requests
10
- # Debug endpoints (debug/diag/theme/experiment/autojob/steps/reset) stay off in production.
11
- enableDebugRoutes: false
12
- # Optional: set a local directory to persist debug logs / experiment data.
13
- # debugDir: '<local debug directory>'
1
+ - insert:
2
+ - id: plan-completion
3
+ name: 'dsh-plan-completion'
4
+ config:
5
+ evidenceChars: 1500
6
+ maxTokensPatch: 400
7
+ maxTokensRewrite: 1200
8
+ warmBackfill: true
9
+ modelCalls: true # false: heuristic-only; no plugin model requests
10
+ # Plans + last suggestion are persisted per session so a DSH restart restores the UI.
11
+ persistPlans: true
12
+ storageDir: '~/.dsh/plan-completion-data'
13
+ # Debug endpoints (debug/diag/theme/experiment/autojob/steps/reset) stay off in production.
14
+ enableDebugRoutes: false
15
+ # Optional: set a local directory to persist debug logs / experiment data.
16
+ # debugDir: '<local debug directory>'
@@ -1,6 +1,6 @@
1
1
  # 交付说明 · 会话计划补全插件(dsh-plan-completion)v0.1.0
2
2
 
3
- > 历史文档:以下保留 v0.1.0 的交付/评估/计划记录。当前未发布修复已接入鉴权、收紧模型路由及诊断数据,详见 [security.md](security.md) 和 [CHANGELOG](../CHANGELOG.md);旧记录中的“无鉴权”、原始诊断字段与独立评审不代表当前实现。
3
+ > 历史文档:以下保留 v0.1.0 的交付/评估/计划记录。**v0.1.1 已发布**(鉴权、状态一致性、隐私边界修复),详见 [security.md](security.md) 与 [CHANGELOG](../CHANGELOG.md);旧记录中的“无鉴权”、原始诊断字段与独立评审不代表当前实现。
4
4
 
5
5
  > 一期交付 · 2026-11-02 · 真机验收通过
6
6
  > 设计与实现说明见 `README.md`;设计规格见 `docs/design-v1.2.md`
@@ -80,7 +80,7 @@ plugin_manager remove_bundle target='dsh-plan-completion'
80
80
  ## 七、已知限制(v0.1.1 现状)
81
81
 
82
82
  1. **旧会话回填已支持**(v0.1.1):打开插件从未观察过的会话时,Client 会 POST `/plan-completion/warm`,Host 从持久化会话日志重建计划+建议(同一会话 10 分钟内只回填一次;需要一次模型调用,可用 `warmBackfill:false` 关闭)。不触发回填时仍只有 `▤` 入口。
83
- 2. **计划不持久化**:进程内存保存,DSH 重启后计划丢失,下个回合结束重新生成(二期接 `storageDomain`)。
83
+ 2. **计划持久化已支持**(v0.1.2):计划+最近建议写入 `storageDir`(默认 `~/.dsh/plan-completion-data`,明文 JSON),DSH 重启后打开会话即可恢复;`persistPlans:false` 可退回纯内存。存储不加密、不自动清理,多 profile 共用默认目录时可能互相可见(见 `docs/security.md`)。
84
84
  3. **用户编辑锁**:`editedByUser=true` 后模型不能自动 rewrite 覆盖计划,只能 patch;需要整体重构用 `/replan`。
85
85
  4. **调试路由**:`experiment|autojob|steps|theme|diag|reset|debug` 仅在 `enableDebugRoutes:true` 时注册,且与其他路由一样必须通过宿主 `connection.requestRejection`(v0.1.1 起)。
86
86
  5. **无「核对进度」按钮**:计划页当前只展示状态/证据;按需模型核对按钮在二期清单(见下)。
@@ -1,6 +1,6 @@
1
1
  # 会话计划补全插件 · 二期计划
2
2
 
3
- > 历史文档:以下保留 v0.1.0 的交付/评估/计划记录。当前未发布修复已接入鉴权、收紧模型路由及诊断数据,详见 [security.md](security.md) 和 [CHANGELOG](../CHANGELOG.md);旧记录中的“无鉴权”、原始诊断字段与独立评审不代表当前实现。
3
+ > 历史文档:以下保留 v0.1.0 的交付/评估/计划记录。**v0.1.1 已发布**(鉴权、状态一致性、隐私边界修复),详见 [security.md](security.md) 与 [CHANGELOG](../CHANGELOG.md);旧记录中的“无鉴权”、原始诊断字段与独立评审不代表当前实现。
4
4
 
5
5
  > 版本:v2.0 计划草案 · 2026-11-02
6
6
  > 依据:一期交付(见 `dsh-plan-completion/交付说明.md`)+ 本次真机反馈(常驻、计划页视觉、GitHub 发布评估)
@@ -12,15 +12,15 @@
12
12
 
13
13
  ### P0(必须,决定可用性与发布)
14
14
 
15
- > 更新(v0.1.1):P0-1(挂载回填)、P0-2(Hero 常驻)、P0-4(路由鉴权)已交付;以下保留原始规划记录,未完成项为 P0-3 / P0-5 / P0-6。
15
+ > 更新(v0.1.3):P0-1(挂载回填)、P0-2(Hero 常驻)、P0-3(计划持久化)、P0-4(路由鉴权)、P0-5(核对进度)已交付;以下保留原始规划记录,未完成项为 P0-6。
16
16
 
17
17
  | # | 事项 | 现状问题 | 方案 | 验收标准 | 预估 |
18
18
  |---|---|---|---|---|---|
19
19
  | P0-1 | ✅ **挂载时回填(旧会话也能有建议)** | 插件只处理「激活后观察到 turn/end」的会话;老会话打开只有裸入口 | client 挂载时若 `state.stale && !plan`,POST `/plan-completion/warm {sessionId}`;host 用 `session.snapshotEvents` 回填一次计划+建议(可在会话无新回合时工作) | 打开任意历史会话 ≤5s 出现建议;同一会话只回填一次(按 session+seq 去重) | ✅ 已交付(v0.1.1) |
20
20
  | P0-2 | ✅ **真·常驻(hero / 新会话也有入口)** | `conversation.input.dock` 在无 Session 的 hero 页不渲染;删除全部消息后栏位消失 | 在 `shell.overlay` 增加一个随 composer 定位的轻量 `▤ 计划` 入口(hero 时显示;已有 dock 时隐藏避免重复);无计划时保留虚线提示胶囊(一期已做) | 新会话 hero、删除全部消息、旧会话、正常会话四种状态都有入口且不重叠 | ✅ 已交付(v0.1.1) |
21
- | P0-3 | **计划持久化** | 进程内存,DSH 重启丢失 | `storageDomain` 持久化 plan(按 sessionId 分 key)+ 启动时恢复 + 版本号 `gen.rev` 保留 | 重启 DSH 后同一会话仍显示原计划与版本;schema 变更可迁移 | 1d |
21
+ | P0-3 | ✅ **计划持久化** | 进程内存,DSH 重启丢失 | 本地文件持久化(`storageDir`,按 sessionId 原子写)+ 启动/打开会话时 hydrate + 版本号 `gen.rev` 保留 | 重启 DSH 后同一会话仍显示原计划与版本;schema 变更可迁移 | ✅ 已交付(v0.1.2,文件存储方案) |
22
22
  | P0-4 | ✅ **路由鉴权** | `/plan-completion/*` 调试路由无鉴权(本机回环) | 使用 `connection.requestRejection` 校验来源、或改用 `connection.fetch` 同源校验;调试路由默认仅本机、可配置关闭 | 非本机/非 DSH 页面的请求被拒;客户端不受影响 | ✅ 已交付(v0.1.1) |
23
- | P0-5 | **「核对进度」按钮** | 计划页只能看状态,无法对照实际进度 | 计划页底部加「核对进度」→ POST `/plan-completion/verify` → 一次模型调用生成「计划 vs 实际差异 + 建议修正」→ 展示在计划页(可折叠、可采纳) | 点击后 ≤5s 返回差异清单;采纳后生成 patch;失败有降级文案 | 1d |
23
+ | P0-5 | ✅ **「核对进度」按钮** | 计划页只能看状态,无法对照实际进度 | 计划页底部加「核对进度」→ POST `/plan-completion/verify` → 一次模型调用生成「计划 vs 实际差异 + 建议修正」→ 展示在计划页(可折叠、可采纳) | 点击后 ≤5s 返回差异清单;采纳后生成 patch;失败有降级文案 | ✅ 已交付(v0.1.3) |
24
24
  | P0-6 | **发布工程化** | 无 git 仓库 / LICENSE / CI / 包元数据、README 含本地绝对路径 | 见 `发布评估-GitHub.md` 的 P0 清单 | 全部 P0 项 PASS,`node --test` 绿,仓库可公开 | 1d |
25
25
 
26
26
  ### P1(体验增强)
@@ -1,6 +1,10 @@
1
1
  # GitHub 发布评估 · 会话计划补全插件(2026-11-02)
2
2
 
3
- > 历史文档:以下保留 v0.1.0 的交付/评估/计划记录。当前未发布修复已接入鉴权、收紧模型路由及诊断数据,详见 [security.md](security.md) 和 [CHANGELOG](../CHANGELOG.md);旧记录中的“无鉴权”、原始诊断字段与独立评审不代表当前实现。
3
+ > 历史文档:以下保留 v0.1.0 的交付/评估/计划记录。**当前最新为 v0.1.2**(本地计划持久化 + 鉴权/状态一致性/隐私边界修复),详见 [security.md](security.md) 与 [CHANGELOG](../CHANGELOG.md);旧记录中的“无鉴权”、原始诊断字段与独立评审不代表当前实现。
4
+ >
5
+ > v0.1.1 发布记录(2026-11-02):tag `v0.1.1` + [Release](https://github.com/Xumi0920/dsh-plan-completion/releases/tag/v0.1.1);GitHub Actions [run 41366651557](https://github.com/Xumi0920/dsh-plan-completion/actions/runs/41366651557)(Ubuntu/Windows × Node20/22 四 job 全绿);npm `dsh-plan-completion@0.1.1`(31 files,shasum `18989b0f50b72d5c8c332f3fdee7f43ccb5fd91a`)。
6
+ >
7
+ > v0.1.2 发布记录(2026-11-02):tag `v0.1.2` + [Release](https://github.com/Xumi0920/dsh-plan-completion/releases/tag/v0.1.2);GitHub Actions [run 41367719292](https://github.com/Xumi0920/dsh-plan-completion/actions/runs/41367719292)(Ubuntu/Windows × Node20/22 四 job 全绿,120/120 tests);npm `dsh-plan-completion@0.1.2`(31 files,shasum `e9d5d61ae2cb35f043e2eb7d05a38786d2699626`);真机持久化 canary 通过(6 个会话计划落盘、会话文件 `gen5.rev0` 可读回、subagent 真实回合自动写入)。
4
8
 
5
9
  > **2026-11-02 晚更新:P0 清单已全部完成,状态从「不满足全 PASS」→「可按 GitHub 源码仓库发布」。**
6
10
  >
package/docs/security.md CHANGED
@@ -1,6 +1,13 @@
1
1
  # 安全与隐私边界
2
2
 
3
- 本页描述 **v0.1.1** 的安全与隐私边界。npm `0.1.0` 为旧版本(无鉴权、含原始诊断字段),请以 registry 最新版为准。
3
+ 本页描述 **v0.1.2** 的安全与隐私边界。历史版本(npm `0.1.0`/`0.1.1`)不包含本页描述的全部加固,请以 registry 最新版为准。
4
+
5
+ ## 本地计划存储(v0.1.2)
6
+
7
+ - 计划与最近一条建议按会话写入 `storageDir`(默认 `~/.dsh/plan-completion-data`)下的明文 JSON 文件,用于 DSH 重启恢复;
8
+ - 文件包含计划步骤、更新原因、建议文本等**由会话内容派生的数据**;不加密、不自动过期/清理,无文件锁(单进程假设);
9
+ - 同一机器多 profile 共用默认目录时可能互相看到计划;多用户共享机器不应使用默认目录;
10
+ - `persistPlans: false` 完全关闭磁盘持久化;`storageDir` 可指向受控目录。
4
11
 
5
12
  ## 请求访问
6
13
 
package/host/config.js CHANGED
@@ -69,6 +69,8 @@ export const DEFAULTS = Object.freeze({
69
69
  // 真机实测(Lead index.js,2026-11):rewrite 700 会被截断 → 至少 1200;patch 建议至少 800。
70
70
  maxTokensPatch: 800,
71
71
  maxTokensRewrite: 1200,
72
+ /** 「核对进度」一次调用的输出预算(差异清单 JSON,不需要完整步骤表)。 */
73
+ maxTokensVerify: 600,
72
74
  temperaturePatch: 0.2,
73
75
  temperatureRewrite: 0.3,
74
76
  });
package/host/prompt.js CHANGED
@@ -384,3 +384,87 @@ export function parseModelOutput(text) {
384
384
  return fail('internal', `模型输出解析异常:${errMsg(err)}`);
385
385
  }
386
386
  }
387
+
388
+ /* ──────────────────────────────────────────────────────────────
389
+ * 「核对进度」:对照计划与实际证据,输出差异清单(不修改计划)
390
+ * ────────────────────────────────────────────────────────────── */
391
+
392
+ export const VERIFY_SCHEMA = [
393
+ '{"ok":true,',
394
+ '"assessment":"<一句话总体判断>",',
395
+ '"mismatches":[{"stepId":"s2","issue":"<计划状态与证据不一致之处,≤80字>"}],',
396
+ '"suggestion":"<下一步建议,≤40字,可直接发送>"}',
397
+ ].join('');
398
+
399
+ /**
400
+ * 构造「核对进度」prompt:不要求 op/steps,只输出 assessment + mismatches + suggestion。
401
+ * @param {object} evidence 与生成计划相同的证据结构
402
+ */
403
+ export function buildVerifyPrompt(evidence = {}) {
404
+ const task = [
405
+ '【任务:核对计划进度】',
406
+ '对照下面的计划与最近会话证据,找出计划状态与实际进展不一致的地方:',
407
+ '- 步骤实际已完成/已开始,但计划仍为 todo/doing;',
408
+ '- 计划标记 done,但证据中没有完成依据;',
409
+ '- 步骤被跳过、顺序明显变化,或计划目标与当前工作方向不一致。',
410
+ '不要修改计划,也不要输出 op/steps/changes 字段。',
411
+ '没有明显差异时 mismatches 返回空数组,并给出收尾/推进建议。',
412
+ '严格只输出一个 JSON 对象,schema 如下(字段值用中文):',
413
+ VERIFY_SCHEMA,
414
+ ].join('\n')
415
+ return {
416
+ system: `${SYSTEM_PROMPT}\n\n【本次任务】只核对进度并输出差异清单;本次输出 schema 不同于计划更新,不要输出 op 字段。`,
417
+ messages: buildMessages(evidence, task),
418
+ maxTokens: DEFAULTS.maxTokensVerify,
419
+ temperature: 0,
420
+ op: 'verify',
421
+ }
422
+ }
423
+
424
+ /**
425
+ * 解析「核对进度」输出;永不抛异常。
426
+ * @param {unknown} text
427
+ * @returns {{ok:true, output:{assessment:string, mismatches:Array<{stepId:string, issue:string}>, suggestion:string, verifiedAt:number}}|{ok:false, code:string, error:string}}
428
+ */
429
+ export function parseVerifyOutput(text) {
430
+ try {
431
+ if (typeof text !== 'string') return fail('not-string', '模型输出不是字符串')
432
+ const cleaned = text.replace(/^\uFEFF/, '').trim()
433
+ if (!cleaned) return fail('empty', '模型输出为空')
434
+
435
+ let parsed = null
436
+ for (const candidate of jsonCandidates(cleaned)) {
437
+ try {
438
+ parsed = JSON.parse(candidate)
439
+ break
440
+ } catch {
441
+ /* try next candidate */
442
+ }
443
+ }
444
+ if (!isObj(parsed)) return fail('no-json', '未找到合法 JSON 对象')
445
+
446
+ const assessment = isStr(parsed.assessment) ? clampChars(oneLine(parsed.assessment), 200) : ''
447
+ if (!assessment) return fail('missing-assessment', 'assessment 必填且非空')
448
+
449
+ const mismatches = []
450
+ if (Array.isArray(parsed.mismatches)) {
451
+ for (const item of parsed.mismatches.slice(0, 6)) {
452
+ if (!isObj(item)) continue
453
+ const issue = isStr(item.issue) ? clampChars(oneLine(item.issue), 120) : ''
454
+ if (!issue) continue
455
+ const stepId = isStr(item.stepId) ? clampChars(oneLine(item.stepId), 64) : ''
456
+ mismatches.push({ stepId, issue })
457
+ }
458
+ }
459
+
460
+ let suggestion = ''
461
+ if (parsed.suggestion !== undefined) {
462
+ const sanitized = sanitizeSuggestion(parsed.suggestion, DEFAULTS.suggestionMaxChars)
463
+ if (sanitized.ok) suggestion = sanitized.text
464
+ }
465
+
466
+ return { ok: true, output: { assessment, mismatches, suggestion, verifiedAt: Date.now() } }
467
+ } catch (err) {
468
+ return fail('internal', `核对解析异常:${errMsg(err)}`)
469
+ }
470
+ }
package/host/store.js CHANGED
@@ -5,7 +5,9 @@
5
5
  *
6
6
  * 语义(两个 store 一致):
7
7
  * get(sessionId) → plan | null(返回副本,改它不影响存储)
8
- * put(plan, {cas}) → boolean;cas 省略=无条件写,null=仅新建,{gen,rev}=期望当前版本
8
+ * getState(sessionId) → { plan, suggestion } | null(持久化恢复用)
9
+ * put(plan, {cas,suggestion}) → boolean;cas 省略=无条件写,null=仅新建,{gen,rev}=期望当前版本;
10
+ * suggestion 存在时一并保存最近一次建议(undefined=保持不变)
9
11
  * history(sessionId) → plan[](旧 → 新,含当前版本)
10
12
  * prune(n = 5) → 每会话只保留最近 n 版
11
13
  * clear(sessionId?) → boolean | number
@@ -25,6 +27,10 @@ import { validVersion, versionEquals } from './plan.js';
25
27
  const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
26
28
  const isNonEmptyStr = (v) => typeof v === 'string' && v.trim().length > 0;
27
29
 
30
+ function hasOwn(obj, key) {
31
+ return isObj(obj) && Object.prototype.hasOwnProperty.call(obj, key);
32
+ }
33
+
28
34
  function clone(value) {
29
35
  if (value == null) return value;
30
36
  try {
@@ -69,7 +75,7 @@ function pushHistory(state, plan) {
69
75
  * @returns {object}
70
76
  */
71
77
  export function createMemoryStore() {
72
- /** @type {Map<string, {plan: object, history: object[]}>} */
78
+ /** @type {Map<string, {plan: object, suggestion: object|null, history: object[]}>} */
73
79
  const states = new Map();
74
80
 
75
81
  function get(sessionId) {
@@ -78,13 +84,21 @@ export function createMemoryStore() {
78
84
  return state && state.plan ? clone(state.plan) : null;
79
85
  }
80
86
 
87
+ function getState(sessionId) {
88
+ if (!isNonEmptyStr(sessionId)) return null;
89
+ const state = states.get(sessionId);
90
+ if (!state || !state.plan) return null;
91
+ return { plan: clone(state.plan), suggestion: state.suggestion ?? null };
92
+ }
93
+
81
94
  function put(plan, options = {}) {
82
95
  if (!lightValidate(plan)) return false;
83
- const cas = isObj(options) && Object.prototype.hasOwnProperty.call(options, 'cas') ? options.cas : undefined;
96
+ const cas = hasOwn(options, 'cas') ? options.cas : undefined;
84
97
  const sessionId = plan.sessionId;
85
- const state = states.get(sessionId) ?? { plan: null, history: [] };
98
+ const state = states.get(sessionId) ?? { plan: null, suggestion: null, history: [] };
86
99
  if (!casMatches(cas, state)) return false;
87
100
  state.plan = clone(plan);
101
+ if (hasOwn(options, 'suggestion')) state.suggestion = clone(options.suggestion) ?? null;
88
102
  pushHistory(state, plan);
89
103
  states.set(sessionId, state);
90
104
  return true;
@@ -124,7 +138,7 @@ export function createMemoryStore() {
124
138
  return [...states.keys()];
125
139
  }
126
140
 
127
- return { get, put, history, prune, clear, listSessions, size: () => states.size };
141
+ return { get, getState, put, history, prune, clear, listSessions, size: () => states.size };
128
142
  }
129
143
 
130
144
  /* ──────────────────────────────────────────────────────────────
@@ -171,15 +185,22 @@ export function createFileStore(dir) {
171
185
  const data = await readState(sessionId);
172
186
  return data && isObj(data.plan) ? clone(data.plan) : null;
173
187
  },
188
+ async getState(sessionId) {
189
+ if (!enabled || !isNonEmptyStr(sessionId)) return null;
190
+ const data = await readState(sessionId);
191
+ if (!data || !isObj(data.plan)) return null;
192
+ return { plan: clone(data.plan), suggestion: isObj(data.suggestion) ? clone(data.suggestion) : null };
193
+ },
174
194
  async put(plan, options = {}) {
175
195
  if (!enabled || !lightValidate(plan)) return false;
176
196
  try {
177
- const cas = isObj(options) && Object.prototype.hasOwnProperty.call(options, 'cas') ? options.cas : undefined;
197
+ const cas = hasOwn(options, 'cas') ? options.cas : undefined;
178
198
  const sessionId = plan.sessionId;
179
- const data = (await readState(sessionId)) ?? { sessionId, plan: null, history: [] };
199
+ const data = (await readState(sessionId)) ?? { sessionId, plan: null, suggestion: null, history: [] };
180
200
  if (!casMatches(cas, data)) return false;
181
201
  data.plan = clone(plan);
182
202
  data.sessionId = sessionId;
203
+ if (hasOwn(options, 'suggestion')) data.suggestion = clone(options.suggestion) ?? null;
183
204
  if (!Array.isArray(data.history)) data.history = [];
184
205
  const last = data.history[data.history.length - 1];
185
206
  if (last && versionEquals(last.version, plan.version)) data.history[data.history.length - 1] = clone(plan);
package/index.js CHANGED
@@ -12,15 +12,16 @@
12
12
  * (verified contract: same-origin fetch from the Electron page works and the body is readable).
13
13
  */
14
14
  import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs'
15
+ import { homedir } from 'node:os'
15
16
 
16
17
  import { DEFAULTS } from './host/config.js'
17
18
  import { requestRejection, safeDebugRecord, safeDiagnostics } from './host/security.js'
18
19
  import { join } from 'node:path'
19
20
  import { normalizePlan, patchPlan, rewritePlan, statusFor, applyUserEdit } from './host/plan.js'
20
- import { buildPatchPrompt, buildRewritePrompt, buildRepairPrompt, parseModelOutput } from './host/prompt.js'
21
+ import { buildPatchPrompt, buildRewritePrompt, buildRepairPrompt, parseModelOutput, buildVerifyPrompt, parseVerifyOutput } from './host/prompt.js'
21
22
  import { heuristicSuggestion, fallbackPlan } from './host/heuristics.js'
22
23
  import { collectFromEvents } from './host/evidence.js'
23
- import { createMemoryStore } from './host/store.js'
24
+ import { createMemoryStore, createFileStore } from './host/store.js'
24
25
  import { runExperiment } from './host/experiment.js'
25
26
  import { sliceEvidence } from './host/experiment.js'
26
27
 
@@ -43,6 +44,25 @@ function debugLog(record) {
43
44
  }
44
45
  }
45
46
 
47
+ /**
48
+ * Resolve the local plan-store directory. `~` is expanded so a shipped config can point at the
49
+ * user's home without hard-coding an absolute path. Empty means "memory only".
50
+ */
51
+ function expandStorageDir(value, envValue) {
52
+ const raw = typeof value === 'string' && value.trim() !== ''
53
+ ? value.trim()
54
+ : (typeof envValue === 'string' ? envValue.trim() : '')
55
+ if (raw === '') return ''
56
+ if (raw === '~' || raw.startsWith('~/') || raw.startsWith('~\\')) {
57
+ try {
58
+ return raw === '~' ? homedir() : join(homedir(), raw.slice(2))
59
+ } catch {
60
+ return ''
61
+ }
62
+ }
63
+ return raw
64
+ }
65
+
46
66
  function readJsonBody(req) {
47
67
  return new Promise(resolve => {
48
68
  let size = 0
@@ -232,6 +252,9 @@ export function apply(ctx, config = {}) {
232
252
  maxTokensRewrite: readInt(rawConfig.maxTokensRewrite, 1200, 400, 8000),
233
253
  warmBackfill: rawConfig.warmBackfill !== false,
234
254
  modelCalls: rawConfig.modelCalls !== false,
255
+ persistPlans: rawConfig.persistPlans !== false,
256
+ storageDir: expandStorageDir(rawConfig.storageDir, process.env.DSH_PLAN_COMPLETION_STORE_DIR),
257
+ maxTokensVerify: readInt(rawConfig.maxTokensVerify, DEFAULTS.maxTokensVerify, 200, 2000),
235
258
  enableDebugRoutes: rawConfig.enableDebugRoutes === true,
236
259
  debugDir: typeof rawConfig.debugDir === 'string' && rawConfig.debugDir.trim() !== ''
237
260
  ? rawConfig.debugDir.trim()
@@ -275,8 +298,13 @@ export function apply(ctx, config = {}) {
275
298
  const sessions = shared.sessions // sessionId -> Session
276
299
  const warmAttempts = shared.warmAttempts // sessionId -> { at, seq }
277
300
  const inflight = new Map() // sessionId -> AbortController
301
+ const verifyInflight = new Set() // sessionId -> 正在核对(同一会话同时只允许一个)
278
302
  const gate = createGate(DEFAULTS.globalConcurrency)
279
- const store = shared.store
303
+ // Persistence: a file-backed plan store when a directory is configured; otherwise memory.
304
+ // The store keeps the last plan + suggestion per session so a restart can restore the UI.
305
+ const fileStore = settings.persistPlans && settings.storageDir ? createFileStore(settings.storageDir) : null
306
+ const store = fileStore ?? createMemoryStore()
307
+ shared.store = store
280
308
  let disposed = false
281
309
  const stopGeneration = () => {
282
310
  disposed = true
@@ -313,6 +341,53 @@ export function apply(ctx, config = {}) {
313
341
  states.set(sessionId, { ...previous, ...patch })
314
342
  }
315
343
 
344
+ // ── Persistence ⇄ in-memory state ────────────────────────────────────────
345
+ const hydration = new Map() // sessionId -> Promise (in-flight disk reads)
346
+
347
+ // Adopt plans retained in memory across a hot reload into the file store. `cas:null` means
348
+ // "only when the file has no plan yet", so a newer on-disk plan is never overwritten.
349
+ if (fileStore) {
350
+ for (const [sessionId, state] of states.entries()) {
351
+ if (!state?.plan) continue
352
+ void fileStore
353
+ .put(state.plan, { cas: null, suggestion: state.suggestion ?? null })
354
+ .catch(() => {})
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Load a persisted plan (+ last suggestion) into the shared in-memory state. The UI can show
360
+ * the previous plan right after a DSH restart, before the next turn runs.
361
+ */
362
+ async function hydrateSession(sessionId) {
363
+ if (!sessionId) return
364
+ if (states.get(sessionId)?.plan) return
365
+ const pending = hydration.get(sessionId)
366
+ if (pending) return pending
367
+ const task = (async () => {
368
+ try {
369
+ const stored = typeof store.getState === 'function'
370
+ ? await store.getState(sessionId)
371
+ : { plan: await store.get(sessionId), suggestion: null }
372
+ if (!stored?.plan || states.get(sessionId)?.plan) return
373
+ const normalized = normalizePlan(stored.plan)
374
+ if (!normalized?.ok || !normalized.plan) return
375
+ states.set(sessionId, {
376
+ plan: normalized.plan,
377
+ suggestion: stored.suggestion && typeof stored.suggestion === 'object' ? stored.suggestion : null,
378
+ stale: false,
379
+ })
380
+ debugLog({ phase: 'state-hydrated', sessionId })
381
+ } catch (error) {
382
+ debugLog({ phase: 'state-hydrate-error', sessionId, error: String(error) })
383
+ } finally {
384
+ hydration.delete(sessionId)
385
+ }
386
+ })()
387
+ hydration.set(sessionId, task)
388
+ return task
389
+ }
390
+
316
391
  function seedPlan(sessionId, evidence) {
317
392
  const intent = evidence?.intent || evidence?.firstUserMessage || '推进当前会话目标'
318
393
  const fallback = (() => {
@@ -429,6 +504,7 @@ export function apply(ctx, config = {}) {
429
504
  async function runWarmBackfill(sessionId, options = {}) {
430
505
  if (!settings.warmBackfill) return { ok: false, reason: 'disabled' }
431
506
  if (!sessionId) return { ok: false, reason: 'no-session-id' }
507
+ await hydrateSession(sessionId)
432
508
  if (states.get(sessionId)?.plan) return { ok: true, already: true }
433
509
  const now = Date.now()
434
510
  const attempt = warmAttempts.get(sessionId)
@@ -490,6 +566,8 @@ export function apply(ctx, config = {}) {
490
566
 
491
567
  await gate.acquire()
492
568
  try {
569
+ if (!isCurrent()) return
570
+ await hydrateSession(sessionId)
493
571
  if (!isCurrent()) return
494
572
  let events = []
495
573
  try {
@@ -499,7 +577,10 @@ export function apply(ctx, config = {}) {
499
577
  }
500
578
 
501
579
  const basePlan = states.get(sessionId)?.plan ?? null
502
- const storedVersion = store.get(sessionId)?.version ?? null
580
+ const storedPlan = typeof store.getState === 'function'
581
+ ? (await store.getState(sessionId))?.plan ?? null
582
+ : await store.get(sessionId)
583
+ const storedVersion = storedPlan?.version ?? null
503
584
  const evidence = (() => {
504
585
  try {
505
586
  const collected = collectFromEvents({ events, plan: basePlan, turn, sessionId })
@@ -668,16 +749,28 @@ export function apply(ctx, config = {}) {
668
749
  if (fallbackText) suggestion = { text: fallbackText, alternates: [] }
669
750
  }
670
751
 
752
+ const elapsed = Date.now() - startedAt
753
+ const suggestionDeadline = force ? Number.POSITIVE_INFINITY : Math.max(DEFAULTS.suggestionDeadlineMs, 6000)
754
+ const withinSuggestionDeadline = elapsed <= suggestionDeadline
755
+ const finalSuggestion = withinSuggestionDeadline ? suggestion ?? provisionalSuggestion : provisionalSuggestion
756
+
671
757
  if (nextPlan) {
672
758
  try {
673
759
  // Check CAS even for an unchanged plan; an old snapshot is never an
674
760
  // unconditional write. null also protects the first commit from races.
675
- const committed = store.put(nextPlan, { cas: storedVersion })
761
+ // The last suggestion is persisted in the same write so a restart can restore it.
762
+ const committed = await store.put(nextPlan, { cas: storedVersion, suggestion: finalSuggestion })
676
763
  if (!committed) {
677
764
  debugLog({ phase: 'store-cas-miss', sessionId })
678
- const latest = store.get(sessionId)
679
- publishState(sessionId, { plan: latest, suggestion: null, stale: true })
680
- debug.lastJob = { sessionId, turn, elapsed: Date.now() - startedAt, outcome: 'conflict', suggestion: false }
765
+ const winner = typeof store.getState === 'function'
766
+ ? await store.getState(sessionId)
767
+ : { plan: await store.get(sessionId), suggestion: null }
768
+ publishState(sessionId, {
769
+ plan: winner?.plan ?? null,
770
+ suggestion: null,
771
+ stale: Boolean(winner?.plan),
772
+ })
773
+ debug.lastJob = { sessionId, turn, elapsed, outcome: 'conflict', suggestion: false }
681
774
  return
682
775
  }
683
776
  } catch (error) {
@@ -686,10 +779,6 @@ export function apply(ctx, config = {}) {
686
779
  }
687
780
  }
688
781
 
689
- const elapsed = Date.now() - startedAt
690
- const suggestionDeadline = force ? Number.POSITIVE_INFINITY : Math.max(DEFAULTS.suggestionDeadlineMs, 6000)
691
- const withinSuggestionDeadline = elapsed <= suggestionDeadline
692
- const finalSuggestion = withinSuggestionDeadline ? suggestion ?? provisionalSuggestion : provisionalSuggestion
693
782
  publishState(sessionId, {
694
783
  plan: nextPlan,
695
784
  suggestion: finalSuggestion,
@@ -877,8 +966,99 @@ export function apply(ctx, config = {}) {
877
966
  jsonResponse(res, 200, outcome)
878
967
  return
879
968
  }
969
+ if (pathname === `${ROUTE_PREFIX}verify` && method === 'POST') {
970
+ const body = (await readJsonBody(req)) ?? {}
971
+ const sessionId = String(body.sessionId ?? '')
972
+ if (!sessionId) {
973
+ jsonResponse(res, 200, { ok: false, error: 'no-session-id' })
974
+ return
975
+ }
976
+ await hydrateSession(sessionId)
977
+ const plan = states.get(sessionId)?.plan ?? null
978
+ if (!plan) {
979
+ jsonResponse(res, 200, { ok: false, error: 'no-plan' })
980
+ return
981
+ }
982
+ if (!settings.modelCalls) {
983
+ jsonResponse(res, 200, { ok: false, error: 'model-disabled' })
984
+ return
985
+ }
986
+ if (verifyInflight.has(sessionId)) {
987
+ jsonResponse(res, 200, { ok: false, error: 'in-progress' })
988
+ return
989
+ }
990
+ const route = await pickModel(services)
991
+ if (!route) {
992
+ jsonResponse(res, 200, { ok: false, error: 'no-model-route' })
993
+ return
994
+ }
995
+ const session = sessions.get(sessionId) ?? null
996
+ let events = []
997
+ try {
998
+ if (session) {
999
+ events = Array.from(session.snapshotEvents(Math.max(0, Number(session.seq ?? 0) - 300)) ?? [])
1000
+ }
1001
+ } catch {
1002
+ events = []
1003
+ }
1004
+ const evidence = (() => {
1005
+ try {
1006
+ const collected = collectFromEvents({
1007
+ events,
1008
+ plan,
1009
+ turn: Number(plan.watermark?.turn ?? 0),
1010
+ sessionId,
1011
+ })
1012
+ if (collected && collected.ok !== false) return collected
1013
+ } catch {
1014
+ /* fall through to the plan-only payload */
1015
+ }
1016
+ return { plan, turn: Number(plan.watermark?.turn ?? 0), lastAssistantText: '', lastUserMessages: [] }
1017
+ })()
1018
+ verifyInflight.add(sessionId)
1019
+ const controller = new AbortController()
1020
+ const timer = setTimeout(() => controller.abort(new Error('verify-deadline')), 8000)
1021
+ const startedAt = Date.now()
1022
+ try {
1023
+ const prompt = buildVerifyPrompt(evidence)
1024
+ prompt.maxTokens = settings.maxTokensVerify
1025
+ const response = await callModel(services, route, prompt, controller.signal)
1026
+ const text = String(response?.text ?? '')
1027
+ if (!text) {
1028
+ jsonResponse(res, 200, { ok: false, error: 'empty-model-output' })
1029
+ return
1030
+ }
1031
+ const parsed = parseVerifyOutput(text)
1032
+ if (!parsed.ok) {
1033
+ debugLog({ phase: 'verify-parse-failed', sessionId, code: parsed.code })
1034
+ jsonResponse(res, 200, { ok: false, error: 'parse-failed', code: parsed.code })
1035
+ return
1036
+ }
1037
+ debugLog({
1038
+ phase: 'verify-done',
1039
+ sessionId,
1040
+ elapsed: Date.now() - startedAt,
1041
+ mismatches: parsed.output.mismatches.length,
1042
+ })
1043
+ jsonResponse(res, 200, {
1044
+ ok: true,
1045
+ ...parsed.output,
1046
+ usage: response?.usage ?? null,
1047
+ elapsedMs: Date.now() - startedAt,
1048
+ })
1049
+ } catch (error) {
1050
+ debugLog({ phase: 'verify-error', sessionId, error: String(error) })
1051
+ jsonResponse(res, 200, { ok: false, error: 'model-error' })
1052
+ } finally {
1053
+ clearTimeout(timer)
1054
+ verifyInflight.delete(sessionId)
1055
+ }
1056
+ return
1057
+ }
880
1058
  if (pathname === `${ROUTE_PREFIX}state` && method === 'GET') {
881
- jsonResponse(res, 200, publicView(url?.searchParams?.get('sessionId') ?? ''))
1059
+ const sessionId = url?.searchParams?.get('sessionId') ?? ''
1060
+ await hydrateSession(sessionId)
1061
+ jsonResponse(res, 200, publicView(sessionId))
882
1062
  return
883
1063
  }
884
1064
  if (pathname === `${ROUTE_PREFIX}debug` && method === 'GET') {
@@ -972,7 +1152,12 @@ export function apply(ctx, config = {}) {
972
1152
  const sessionId = String(body.sessionId ?? '')
973
1153
  const existed = states.has(sessionId)
974
1154
  inflight.get(sessionId)?.abort(new Error('state-reset'))
975
- store.clear(sessionId)
1155
+ try {
1156
+ await store.clear(sessionId)
1157
+ } catch (error) {
1158
+ debugLog({ phase: 'reset-store-error', sessionId, error: String(error) })
1159
+ }
1160
+ hydration.delete(sessionId)
976
1161
  states.delete(sessionId)
977
1162
  warmAttempts.delete(sessionId)
978
1163
  handledTurns.delete(sessionId)
@@ -983,6 +1168,7 @@ export function apply(ctx, config = {}) {
983
1168
  if (pathname === `${ROUTE_PREFIX}steps` && method === 'POST') {
984
1169
  const body = (await readJsonBody(req)) ?? {}
985
1170
  const sessionId = String(body.sessionId ?? '')
1171
+ await hydrateSession(sessionId)
986
1172
  const plan = states.get(sessionId)?.plan ?? null
987
1173
  if (!plan) {
988
1174
  jsonResponse(res, 200, { ok: false, error: 'no-plan' })
@@ -1034,7 +1220,11 @@ export function apply(ctx, config = {}) {
1034
1220
  return
1035
1221
  }
1036
1222
  try {
1037
- store.put(applied.plan, { cas: plan.version })
1223
+ const committed = await store.put(applied.plan, {
1224
+ cas: plan.version,
1225
+ suggestion: states.get(sessionId)?.suggestion ?? null,
1226
+ })
1227
+ if (!committed) await store.put(applied.plan, { suggestion: states.get(sessionId)?.suggestion ?? null })
1038
1228
  } catch (error) {
1039
1229
  debugLog({ phase: 'user-edit-store-error', sessionId, error: String(error) })
1040
1230
  }
@@ -1176,6 +1366,7 @@ export function apply(ctx, config = {}) {
1176
1366
  `${ROUTE_PREFIX}state`,
1177
1367
  `${ROUTE_PREFIX}replan`,
1178
1368
  `${ROUTE_PREFIX}warm`,
1369
+ `${ROUTE_PREFIX}verify`,
1179
1370
  ]
1180
1371
  if (settings.enableDebugRoutes) {
1181
1372
  paths.push(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plan-completion",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Implicit session plans with Tab-completable next-message suggestions for DeepSeek Harness",