dsh-plan-completion 0.1.2 → 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 +15 -0
- package/README.md +11 -3
- package/client.js +146 -4
- package/docs/phase-2-plan.md +2 -2
- package/docs/publish-assessment.md +3 -1
- package/host/config.js +2 -0
- package/host/prompt.js +84 -0
- package/index.js +93 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,21 @@
|
|
|
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
|
+
|
|
6
21
|
## [0.1.2] - 2026-11-02
|
|
7
22
|
|
|
8
23
|
### 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`,原子写);
|
|
@@ -100,8 +108,7 @@ Client 侧关键 API(实测):`inputActions.captureInsertion()` 返回 `{st
|
|
|
100
108
|
modelCalls: true # false:本插件仅用启发式,不向模型发送内容
|
|
101
109
|
persistPlans: true # false:计划只存内存
|
|
102
110
|
storageDir: '~/.dsh/plan-completion-data'
|
|
103
|
-
enableDebugRoutes: false # 本地调试时才打开
|
|
104
|
-
# debugDir: '<本地日志目录>' # 可选;发布版留空
|
|
111
|
+
enableDebugRoutes: false # 本地调试时才打开 # debugDir: '<本地日志目录>' # 可选;发布版留空
|
|
105
112
|
```
|
|
106
113
|
|
|
107
114
|
| 配置项 | 默认 | 作用 | 依据 |
|
|
@@ -112,6 +119,7 @@ Client 侧关键 API(实测):`inputActions.captureInsertion()` 返回 `{st
|
|
|
112
119
|
| `warmBackfill` | true | 打开「插件从未观察过的旧会话」时,从持久化会话日志回填一次计划+建议 | P0-1;真机老会话回填 2.2s |
|
|
113
120
|
| `persistPlans` | true | 是否把计划+最近建议持久化到本地文件(DSH 重启恢复) | v0.1.2 |
|
|
114
121
|
| `storageDir` | `~/.dsh/plan-completion-data` | 计划文件目录;`~` 会展开为 home;也可用 `DSH_PLAN_COMPLETION_STORE_DIR` | v0.1.2 |
|
|
122
|
+
| `maxTokensVerify` | 600 | 「核对进度」单次模型调用的输出预算 | v0.1.3 |
|
|
115
123
|
| `enableDebugRoutes` | false | 是否注册 `debug/diag/theme/experiment/autojob/steps/reset` 调试路由;仍须鉴权 | 发布版最小攻击面 |
|
|
116
124
|
| `modelCalls` | true | false 时关闭本插件全部模型调用,使用启发式 | 隐私控制 |
|
|
117
125
|
| `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
|
}
|
package/docs/phase-2-plan.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
### P0(必须,决定可用性与发布)
|
|
14
14
|
|
|
15
|
-
> 更新(v0.1.
|
|
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;失败有降级文案 |
|
|
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,10 @@
|
|
|
1
1
|
# GitHub 发布评估 · 会话计划补全插件(2026-11-02)
|
|
2
2
|
|
|
3
|
-
> 历史文档:以下保留 v0.1.0
|
|
3
|
+
> 历史文档:以下保留 v0.1.0 的交付/评估/计划记录。**当前最新为 v0.1.2**(本地计划持久化 + 鉴权/状态一致性/隐私边界修复),详见 [security.md](security.md) 与 [CHANGELOG](../CHANGELOG.md);旧记录中的“无鉴权”、原始诊断字段与独立评审不代表当前实现。
|
|
4
4
|
>
|
|
5
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 真实回合自动写入)。
|
|
6
8
|
|
|
7
9
|
> **2026-11-02 晚更新:P0 清单已全部完成,状态从「不满足全 PASS」→「可按 GitHub 源码仓库发布」。**
|
|
8
10
|
>
|
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/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(
|