dsh-plan-completion 0.1.2 → 0.1.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,37 @@
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.4] - 2026-11-02
7
+
8
+ ### Fixed
9
+ - Suggestion/Markdown cleaning no longer destroys identifiers, file names or globs: `transaction_id`,
10
+ `payment_test.py`, `C:\work\my_file.js` and `src/**/*.js` keep their underscores/stars. Only paired
11
+ emphasis markers (`**bold**`, `*italic*`, `_emphasis_`, `~~strike~~`) are stripped; inline code keeps
12
+ its content with backticks removed.
13
+ - Semantic shortening replaces the raw 40-character cut: prefer the last clause boundary, then a
14
+ whitespace boundary, then cut before an ASCII token; never leave a truncated identifier/path or a
15
+ dangling command prefix (`npm`, `npm te`), and drop dangling separators.
16
+ - New `test/text.test.js` covers the identifier/glob/command cases and the old guarantees
17
+ (single line, ≤40 chars, dangerous suggestions filtered).
18
+
19
+ ### Changed
20
+ - `npm test` now reports **132/132** tests.
21
+
22
+ ## [0.1.3] - 2026-11-02
23
+
24
+ ### Added
25
+ - Plan page「核对进度」button (P0-5): `POST /plan-completion/verify {sessionId}` runs one bounded,
26
+ authenticated model call that compares the stored plan with recent evidence and returns
27
+ `{assessment, mismatches[], suggestion}`. It never mutates the plan; results render in the plan
28
+ panel as an assessment list plus next-step suggestion.
29
+ - New config `maxTokensVerify` (default 600); per-session in-flight guard, 8s abort deadline and
30
+ parse-error fallbacks; `modelCalls:false` disables this route's model call.
31
+ - Tests: verify prompt/parser edge cases, authenticated route integration, and a client transport
32
+ test for the new request helper.
33
+
34
+ ### Changed
35
+ - `npm test` now reports **126/126** tests; production routes are `state`/`replan`/`warm`/`verify`.
36
+
6
37
  ## [0.1.2] - 2026-11-02
7
38
 
8
39
  ### Added
package/README.md CHANGED
@@ -60,15 +60,23 @@ 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
+
72
80
  ## 计划持久化(v0.1.2)
73
81
 
74
82
  - 计划 + 最近一条建议按会话持久化到本地文件(`storageDir/plans/<base64url(sessionId)>.json`,原子写);
@@ -77,6 +85,12 @@ Client 侧关键 API(实测):`inputActions.captureInsertion()` 返回 `{st
77
85
  - 默认目录 `~/.dsh/plan-completion-data`;可用 `storageDir` 改到别处,`persistPlans: false` 退回纯内存;
78
86
  - 注意:存储为本地明文 JSON(含计划/建议文本,可能来自会话);不加密、不自动清理,不适合多用户共享机器。
79
87
 
88
+ ## 建议文本安全(v0.1.4)
89
+
90
+ - 去 Markdown 只剥离**成对**的行内标记;`transaction_id`、`payment_test.py`、`C:\a_b\file.js`、`src/**/*.js` 等标识符/路径/glob 原样保留;
91
+ - 超过 40 字时按分句/空白/ASCII token 边界语义化缩短,不再把 `npm test` 截成 `npm`/`npm te`,也不会切断标识符或路径;
92
+ - 危险命令过滤(rm -rf、format、Drop DB 等)不变;单行、≤40 字、可直接发送的约束不变。
93
+
80
94
  ## 模型路由与隐私
81
95
 
82
96
  - 仅使用宿主 `agentDefaultModel.currentSelection()` 当前默认的 provider/model,不自动改投其他已配置供应商;无可用路由时使用启发式。该默认值不一定是历史会话原先使用的模型。
@@ -100,8 +114,7 @@ Client 侧关键 API(实测):`inputActions.captureInsertion()` 返回 `{st
100
114
  modelCalls: true # false:本插件仅用启发式,不向模型发送内容
101
115
  persistPlans: true # false:计划只存内存
102
116
  storageDir: '~/.dsh/plan-completion-data'
103
- enableDebugRoutes: false # 本地调试时才打开
104
- # debugDir: '<本地日志目录>' # 可选;发布版留空
117
+ enableDebugRoutes: false # 本地调试时才打开 # debugDir: '<本地日志目录>' # 可选;发布版留空
105
118
  ```
106
119
 
107
120
  | 配置项 | 默认 | 作用 | 依据 |
@@ -112,6 +125,7 @@ Client 侧关键 API(实测):`inputActions.captureInsertion()` 返回 `{st
112
125
  | `warmBackfill` | true | 打开「插件从未观察过的旧会话」时,从持久化会话日志回填一次计划+建议 | P0-1;真机老会话回填 2.2s |
113
126
  | `persistPlans` | true | 是否把计划+最近建议持久化到本地文件(DSH 重启恢复) | v0.1.2 |
114
127
  | `storageDir` | `~/.dsh/plan-completion-data` | 计划文件目录;`~` 会展开为 home;也可用 `DSH_PLAN_COMPLETION_STORE_DIR` | v0.1.2 |
128
+ | `maxTokensVerify` | 600 | 「核对进度」单次模型调用的输出预算 | v0.1.3 |
115
129
  | `enableDebugRoutes` | false | 是否注册 `debug/diag/theme/experiment/autojob/steps/reset` 调试路由;仍须鉴权 | 发布版最小攻击面 |
116
130
  | `modelCalls` | true | false 时关闭本插件全部模型调用,使用启发式 | 隐私控制 |
117
131
  | `debugDir` | 空 | 调试日志/实验结果的落盘目录(也可用 `DSH_PLAN_COMPLETION_DEBUG_DIR`);空则不写文件 | 可移植性 |
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
  }
@@ -12,7 +12,7 @@
12
12
 
13
13
  ### P0(必须,决定可用性与发布)
14
14
 
15
- > 更新(v0.1.2):P0-1(挂载回填)、P0-2(Hero 常驻)、P0-3(计划持久化)、P0-4(路由鉴权)已交付;以下保留原始规划记录,未完成项为 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
  |---|---|---|---|---|---|
@@ -20,7 +20,7 @@
20
20
  | P0-2 | ✅ **真·常驻(hero / 新会话也有入口)** | `conversation.input.dock` 在无 Session 的 hero 页不渲染;删除全部消息后栏位消失 | 在 `shell.overlay` 增加一个随 composer 定位的轻量 `▤ 计划` 入口(hero 时显示;已有 dock 时隐藏避免重复);无计划时保留虚线提示胶囊(一期已做) | 新会话 hero、删除全部消息、旧会话、正常会话四种状态都有入口且不重叠 | ✅ 已交付(v0.1.1) |
21
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,8 +1,12 @@
1
1
  # GitHub 发布评估 · 会话计划补全插件(2026-11-02)
2
2
 
3
- > 历史文档:以下保留 v0.1.0 的交付/评估/计划记录。**v0.1.1 已发布**(鉴权、状态一致性、隐私边界修复),详见 [security.md](security.md) 与 [CHANGELOG](../CHANGELOG.md);旧记录中的“无鉴权”、原始诊断字段与独立评审不代表当前实现。
3
+ > 历史文档:以下保留 v0.1.0 的交付/评估/计划记录。**当前最新为 v0.1.3**(核对进度 + 本地计划持久化 + 鉴权/状态一致性/隐私边界修复),详见 [security.md](security.md) 与 [CHANGELOG](../CHANGELOG.md);旧记录中的“无鉴权”、原始诊断字段与独立评审不代表当前实现。
4
+ >
5
+ > v0.1.3 发布记录(2026-11-02):tag `v0.1.3` + [Release](https://github.com/Xumi0920/dsh-plan-completion/releases/tag/v0.1.3);GitHub Actions [run 41368609382](https://github.com/Xumi0920/dsh-plan-completion/actions/runs/41368609382)(Ubuntu/Windows × Node20/22 四 job 全绿,126/126 tests);npm `dsh-plan-completion@0.1.3`(31 files,shasum `11adee0fe9acff199a180be511ca8bc600afdf6f`);canary 验证 `/plan-completion/verify` 未认证 401、客户端鉴权请求正常。
4
6
  >
5
7
  > 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`)。
8
+ >
9
+ > 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 真实回合自动写入)。
6
10
 
7
11
  > **2026-11-02 晚更新:P0 清单已全部完成,状态从「不满足全 PASS」→「可按 GitHub 源码仓库发布」。**
8
12
  >
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/text.js CHANGED
@@ -49,12 +49,17 @@ export function oneLine(value) {
49
49
  }
50
50
 
51
51
  const MD_LINE_PREFIX = /^\s{0,3}(?:#{1,6}\s+|>\s*|[-*+]\s+|\d+[.)]\s+)/;
52
- const MD_INLINE = /(\*\*|__|\*|_|`)/g;
53
52
  const MD_LINK = /\[([^\]]*)\]\([^)]*\)/g;
54
53
  const WRAPPING_QUOTES = /^["'“”‘’「」『』]+|["'“”‘’「」『』]+$/g;
54
+ const CLAUSE_BOUNDARY = /[,。;!?、,;!?]/;
55
+ const ASCII_TOKEN_CHAR = /[A-Za-z0-9_./\\-]/;
55
56
 
56
57
  /**
57
58
  * 去掉常见 Markdown 痕迹,得到适合直接发送的纯文本。
59
+ *
60
+ * 只剥离「成对」的行内标记:`transaction_id`、`C:\a_b\file.js`、`src/**\/*.js`
61
+ * 这类标识符/路径/glob 里的 `_`、`*` 不再被误删;
62
+ * 反引号只去掉包裹符号,代码内容原样保留。
58
63
  * @param {unknown} value
59
64
  * @returns {string}
60
65
  */
@@ -66,18 +71,89 @@ export function stripMarkdown(value) {
66
71
  .map((line) => line.replace(MD_LINE_PREFIX, ''))
67
72
  .join(' ');
68
73
  text = text.replace(MD_LINK, '$1');
69
- text = text.replace(MD_INLINE, '');
74
+ // Protect glob/star patterns (src/**/*.js, *.py, **/) before emphasis stripping.
75
+ const stars = [];
76
+ text = text.replace(/\*\*\/|\*\/|\*\.[A-Za-z0-9_-]+/g, (match) => {
77
+ stars.push(match);
78
+ return `\u0000${stars.length - 1}\u0000`;
79
+ });
80
+ // Inline code: drop the backtick delimiters, keep the code text (underscores intact).
81
+ text = text.replace(/`([^`\n]+)`/g, '$1').replace(/`/g, '');
82
+ // Paired emphasis markers only. Never strip a lone _/* that belongs to an identifier,
83
+ // file name or glob (e.g. transaction_id, foo_bar, *.js).
84
+ text = text
85
+ .replace(/\*\*([^*\n]+)\*\*/g, '$1')
86
+ .replace(/__([^_\n]+)__/g, '$1')
87
+ .replace(/~~([^~\n]+)~~/g, '$1')
88
+ .replace(/\*([^*\n]+)\*/g, '$1')
89
+ .replace(/(^|[^\p{L}\p{N}_])_([^_\n]+)_(?=[^\p{L}\p{N}_]|$)/gu, '$1$2');
90
+ text = text.replace(/\u0000(\d+)\u0000/g, (_match, index) => stars[Number(index)] ?? '');
70
91
  return oneLine(text).replace(WRAPPING_QUOTES, '').trim();
71
92
  }
72
93
 
73
94
  /**
74
- * 建议文本规范化:单行 + 去 Markdown + 截断到 max 字。
95
+ * 语义化截短:不把标识符/路径/命令从中间切断。
96
+ *
97
+ * 依次尝试:句子/分句边界(,。;!?、,;!?)→ 空白边界 → 回退到 ASCII token 起点。
98
+ * 实在没有边界时才硬截断,保证返回长度 ≤ max。
99
+ * @param {unknown} value
100
+ * @param {number} [max]
101
+ * @returns {string}
102
+ */
103
+ export function shortenSuggestion(value, max = DEFAULTS.suggestionMaxChars) {
104
+ const text = oneLine(value);
105
+ const limit = Number.isFinite(max) ? Math.floor(max) : 0;
106
+ if (limit <= 0) return '';
107
+ const chars = Array.from(text);
108
+ if (chars.length <= limit) return text;
109
+
110
+ const prefix = chars.slice(0, limit).join('');
111
+ const minUseful = Math.max(1, Math.floor(limit * 0.4));
112
+
113
+ // 1) Prefer the last clause boundary so the returned text stays a complete clause.
114
+ for (let i = prefix.length - 1; i >= minUseful; i -= 1) {
115
+ if (!CLAUSE_BOUNDARY.test(chars[i])) continue;
116
+ // Keep sentence-ending punctuation; drop separators such as ,、;: so the
117
+ // returned suggestion still reads like a message the user can send directly.
118
+ const keep = /[。!?!?]/.test(chars[i]) ? 1 : 0;
119
+ const head = chars.slice(0, i + keep).join('').trim();
120
+ if (head) return head;
121
+ }
122
+ // 2) Whitespace boundary: never leaves a partially copied word after it.
123
+ for (let i = prefix.length - 1; i >= minUseful; i -= 1) {
124
+ if (/\s/.test(chars[i])) {
125
+ let head = chars.slice(0, i).join('').trim();
126
+ // If the cut leaves a lone ASCII command prefix ("…再重跑 npm") and the next
127
+ // character is another ASCII token, drop that dangling token too.
128
+ const next = chars[i + 1] ?? '';
129
+ const tail = head.match(/(?:^|\s)([A-Za-z0-9_./\\-]+)$/);
130
+ if (tail && ASCII_TOKEN_CHAR.test(next)) {
131
+ head = head.slice(0, head.length - tail[1].length).trim().replace(/[\s,。;、,;::-]+$/, '');
132
+ }
133
+ if (head) return head;
134
+ }
135
+ }
136
+ // 3) Do not split an ASCII identifier/path/command token: cut before the token.
137
+ let cut = limit;
138
+ while (cut > 0 && ASCII_TOKEN_CHAR.test(chars[cut - 1] ?? '') && ASCII_TOKEN_CHAR.test(chars[cut] ?? '')) {
139
+ cut -= 1;
140
+ }
141
+ if (cut > 0) {
142
+ const head = chars.slice(0, cut).join('').trim().replace(/[\s,。;、,;::-]+$/, '');
143
+ if (head) return head;
144
+ }
145
+ // 4) Single oversized token: hard clamp (still ≤ limit).
146
+ return prefix;
147
+ }
148
+
149
+ /**
150
+ * 建议文本规范化:单行 + 去 Markdown + 语义化截断到 max 字。
75
151
  * @param {unknown} value
76
152
  * @param {number} [max]
77
153
  * @returns {string}
78
154
  */
79
155
  export function clampSuggestion(value, max = DEFAULTS.suggestionMaxChars) {
80
- return clampChars(stripMarkdown(value), max);
156
+ return shortenSuggestion(stripMarkdown(value), max);
81
157
  }
82
158
 
83
159
  /**
package/index.js CHANGED
@@ -18,7 +18,7 @@ import { DEFAULTS } from './host/config.js'
18
18
  import { requestRejection, safeDebugRecord, safeDiagnostics } from './host/security.js'
19
19
  import { join } from 'node:path'
20
20
  import { normalizePlan, patchPlan, rewritePlan, statusFor, applyUserEdit } from './host/plan.js'
21
- import { buildPatchPrompt, buildRewritePrompt, buildRepairPrompt, parseModelOutput } from './host/prompt.js'
21
+ import { buildPatchPrompt, buildRewritePrompt, buildRepairPrompt, parseModelOutput, buildVerifyPrompt, parseVerifyOutput } from './host/prompt.js'
22
22
  import { heuristicSuggestion, fallbackPlan } from './host/heuristics.js'
23
23
  import { collectFromEvents } from './host/evidence.js'
24
24
  import { createMemoryStore, createFileStore } from './host/store.js'
@@ -254,6 +254,7 @@ export function apply(ctx, config = {}) {
254
254
  modelCalls: rawConfig.modelCalls !== false,
255
255
  persistPlans: rawConfig.persistPlans !== false,
256
256
  storageDir: expandStorageDir(rawConfig.storageDir, process.env.DSH_PLAN_COMPLETION_STORE_DIR),
257
+ maxTokensVerify: readInt(rawConfig.maxTokensVerify, DEFAULTS.maxTokensVerify, 200, 2000),
257
258
  enableDebugRoutes: rawConfig.enableDebugRoutes === true,
258
259
  debugDir: typeof rawConfig.debugDir === 'string' && rawConfig.debugDir.trim() !== ''
259
260
  ? rawConfig.debugDir.trim()
@@ -297,6 +298,7 @@ export function apply(ctx, config = {}) {
297
298
  const sessions = shared.sessions // sessionId -> Session
298
299
  const warmAttempts = shared.warmAttempts // sessionId -> { at, seq }
299
300
  const inflight = new Map() // sessionId -> AbortController
301
+ const verifyInflight = new Set() // sessionId -> 正在核对(同一会话同时只允许一个)
300
302
  const gate = createGate(DEFAULTS.globalConcurrency)
301
303
  // Persistence: a file-backed plan store when a directory is configured; otherwise memory.
302
304
  // The store keeps the last plan + suggestion per session so a restart can restore the UI.
@@ -964,6 +966,95 @@ export function apply(ctx, config = {}) {
964
966
  jsonResponse(res, 200, outcome)
965
967
  return
966
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
+ }
967
1058
  if (pathname === `${ROUTE_PREFIX}state` && method === 'GET') {
968
1059
  const sessionId = url?.searchParams?.get('sessionId') ?? ''
969
1060
  await hydrateSession(sessionId)
@@ -1275,6 +1366,7 @@ export function apply(ctx, config = {}) {
1275
1366
  `${ROUTE_PREFIX}state`,
1276
1367
  `${ROUTE_PREFIX}replan`,
1277
1368
  `${ROUTE_PREFIX}warm`,
1369
+ `${ROUTE_PREFIX}verify`,
1278
1370
  ]
1279
1371
  if (settings.enableDebugRoutes) {
1280
1372
  paths.push(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plan-completion",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Implicit session plans with Tab-completable next-message suggestions for DeepSeek Harness",