@twinsearth/dsh-master 3.3.4 → 3.3.5

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.
Files changed (54) hide show
  1. package/README.md +1 -0
  2. package/client.js +83 -4
  3. package/cordis.patch.yml +2 -0
  4. package/dist-host/agents/pjm-agent.js +18 -2
  5. package/dist-host/agents/pm-agent.js +23 -1
  6. package/dist-host/agents/prompts.js +16 -6
  7. package/dist-host/channel/abort-registry.js +93 -0
  8. package/dist-host/channel/protocol.js +114 -0
  9. package/dist-host/channel/server.js +349 -0
  10. package/dist-host/channel/stream-state.js +343 -0
  11. package/dist-host/channel/ws.js +199 -0
  12. package/dist-host/dual/documents.js +36 -0
  13. package/dist-host/dual/orchestrator.js +214 -0
  14. package/dist-host/dual/schema.js +153 -0
  15. package/dist-host/engine/atomic-switch.js +159 -0
  16. package/dist-host/engine/health-probe.js +86 -0
  17. package/dist-host/engine/index.js +18 -0
  18. package/dist-host/engine/llm-stream.js +215 -0
  19. package/dist-host/engine/plugin-updater.js +392 -0
  20. package/dist-host/engine/safe-update.js +150 -0
  21. package/dist-host/index.js +114 -2
  22. package/dist-host/kernel.js +7 -7
  23. package/dist-host/plugins/cost-optimizer.js +3 -3
  24. package/dist-host/plugins/dual-manager.js +230 -6
  25. package/docs/AGENTS-PM-PJM.md +90 -6
  26. package/docs/CHANGELOG.md +58 -0
  27. package/docs/CHANNEL-WEBSOCKET.md +169 -0
  28. package/docs/DESIGN-command-center.md +147 -0
  29. package/docs/ENGINE-PRIMITIVES.md +215 -0
  30. package/docs/GAP-ANALYSIS-fang-session.md +75 -0
  31. package/docs/REVIEW-PM-PJM-3.3.4.md +32 -6
  32. package/docs/examples/command-center/README.md +167 -0
  33. package/package.json +3 -1
  34. package/src/agents/pjm-agent.ts +18 -2
  35. package/src/agents/pm-agent.ts +21 -1
  36. package/src/agents/prompts.ts +16 -6
  37. package/src/channel/abort-registry.ts +85 -0
  38. package/src/channel/protocol.ts +110 -0
  39. package/src/channel/server.ts +289 -0
  40. package/src/channel/stream-state.ts +349 -0
  41. package/src/channel/ws.ts +182 -0
  42. package/src/dual/documents.ts +35 -0
  43. package/src/dual/orchestrator.ts +223 -0
  44. package/src/dual/schema.ts +148 -1
  45. package/src/engine/atomic-switch.ts +146 -0
  46. package/src/engine/health-probe.ts +84 -0
  47. package/src/engine/index.ts +20 -0
  48. package/src/engine/llm-stream.ts +189 -0
  49. package/src/engine/plugin-updater.ts +359 -0
  50. package/src/engine/safe-update.ts +148 -0
  51. package/src/index.ts +105 -2
  52. package/src/kernel.ts +7 -7
  53. package/src/plugins/cost-optimizer.ts +3 -3
  54. package/src/plugins/dual-manager.ts +211 -6
@@ -0,0 +1,349 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+ // @ts-nocheck
4
+
5
+ /**
6
+ * 流式消息状态机(3.3.5)。
7
+ *
8
+ * 结构(3.3.5 性能版):**历史与当前流式分开**
9
+ * state.history —— 已定稿的消息数组,**引用稳定**:只有消息被定稿时才换新数组;
10
+ * state.active —— 当前正在流式的那条(或 null),每 chunk 只换这一个对象;
11
+ * state.messages —— 兼容视图(history + active),供旧代码/简单渲染使用。
12
+ *
13
+ * 为什么要拆:每 chunk 都让 UI `map` 一遍上百条历史是 O(N) 的 reconciliation 开销,
14
+ * `React.memo` 只能省 DOM,省不了上层遍历。消费方应当:
15
+ * * 历史列表订阅 `history`(引用没变就不重渲染);
16
+ * * 当前气泡只订阅 `active`(每次 chunk 只重渲染它)。
17
+ *
18
+ * 语义:
19
+ * * 幂等:重复 `stream_start` 不新建第二条;重复 `card_choice` 原地更新;
20
+ * * 容忍乱序:`stream_chunk` 早于 `stream_start` 只计数(orphan),不造数据;
21
+ * * 块级锁:`card_*` 到达时先定稿当前气泡,再插入卡片,并释放流式槽位;
22
+ * * 有界:历史条数、单条内容、选项数、快照大小都有上限。
23
+ */
24
+
25
+ export const STREAM_LIMITS = Object.freeze({
26
+ maxMessages: 200,
27
+ maxContentChars: 200_000,
28
+ maxOptions: 20,
29
+ maxSnapshotBytes: 256 * 1024,
30
+ })
31
+
32
+ export const MESSAGE_KINDS = Object.freeze(['text', 'choice', 'doc', 'status', 'error'])
33
+
34
+ function text(value) { return String(value ?? '') }
35
+
36
+ function emptyState() {
37
+ return {
38
+ messages: [],
39
+ history: [],
40
+ active: null,
41
+ activeStreamId: null,
42
+ capabilities: null,
43
+ snapshot: null,
44
+ orphanChunks: 0,
45
+ ignored: 0,
46
+ truncated: 0,
47
+ lastError: null,
48
+ }
49
+ }
50
+
51
+ function capHistory(history) {
52
+ return history.length > STREAM_LIMITS.maxMessages
53
+ ? history.slice(history.length - STREAM_LIMITS.maxMessages)
54
+ : history
55
+ }
56
+
57
+ function capContent(content, truncatedBefore) {
58
+ const value = text(content)
59
+ if (value.length <= STREAM_LIMITS.maxContentChars) return { content: value, truncated: truncatedBefore }
60
+ return { content: value.slice(0, STREAM_LIMITS.maxContentChars), truncated: truncatedBefore + 1 }
61
+ }
62
+
63
+ /** 当前正在流式的那条消息;没有则 null。 */
64
+ export function activeMessage(state) {
65
+ return state?.active ?? null
66
+ }
67
+
68
+ /** 是否有消息仍在流式输出(UI 据此切换“发送/停止”按钮)。 */
69
+ export function isGenerating(state) {
70
+ return Boolean(state?.active && state.active.streaming === true)
71
+ }
72
+
73
+ /**
74
+ * 光标微状态(UI 直接消费,不要在组件里重复推导):
75
+ * * `thinking` —— 收到 stream_start 但还没有第一个字:显示跳动点,不显示竖线;
76
+ * * `streaming` —— 正在吐字:竖线紧贴最后一个字符;
77
+ * * `interrupted` —— 最近一条消息是被中止的:红色闪烁两次后收起;
78
+ * * `idle` —— 没有正在生成的内容。
79
+ */
80
+ export function cursorState(state) {
81
+ if (state?.active && state.active.streaming === true) {
82
+ return text(state.active.content).length === 0 ? 'thinking' : 'streaming'
83
+ }
84
+ const history = Array.isArray(state?.history) ? state.history : []
85
+ const last = history.length > 0 ? history[history.length - 1] : null
86
+ if (last && last.interrupted === true) return 'interrupted'
87
+ return 'idle'
88
+ }
89
+
90
+ /**
91
+ * @param {{now?: () => number}} [options]
92
+ */
93
+ export function createChatStream(options = {}) {
94
+ const now = typeof options.now === 'function' ? options.now : () => Date.now()
95
+ let state = emptyState()
96
+ const listeners = new Set()
97
+
98
+ const notify = () => { for (const listener of [...listeners]) { try { listener(state) } catch { /* 订阅者异常不影响状态机 */ } } }
99
+
100
+ /** 由 history + active 构造完整 state(messages 为兼容视图)。 */
101
+ function bundle(next, history, active) {
102
+ const trimmed = capHistory(history)
103
+ const activeValue = active ?? null
104
+ const messages = activeValue ? [...trimmed, activeValue] : trimmed
105
+ return {
106
+ ...state,
107
+ ...next,
108
+ history: trimmed,
109
+ active: activeValue,
110
+ activeStreamId: activeValue ? activeValue.id : null,
111
+ messages,
112
+ }
113
+ }
114
+
115
+ /** 把当前 active 定稿进 history(不改变 active 对象的 streaming 标记,调用方负责)。 */
116
+ function finalized(next, item) {
117
+ const active = state.active
118
+ const history = active ? [...state.history, { ...active, ...(item ?? {}) }] : state.history
119
+ return bundle(next, history, null)
120
+ }
121
+
122
+ function applyOne(message) {
123
+ const type = text(message?.type)
124
+ const id = text(message?.messageId ?? message?.id)
125
+
126
+ switch (type) {
127
+ case 'stream_start': {
128
+ if (!id) return state
129
+ const existingHistory = state.history.find((entry) => entry.id === id)
130
+ if (existingHistory) return state // 已定稿的同 id 消息不再复活
131
+ if (state.active && state.active.id === id) {
132
+ // 幂等:重复 start 保持同一条
133
+ return bundle({}, state.history, { ...state.active, streaming: true })
134
+ }
135
+ const role = text(message.role) === 'pjm' ? 'pjm' : 'pm'
136
+ const entry = { id, role, kind: 'text', content: '', streaming: true, at: now() }
137
+ const history = state.active ? [...state.history, { ...state.active, streaming: false }] : state.history
138
+ return bundle({}, history, entry)
139
+ }
140
+
141
+ case 'stream_chunk': {
142
+ if (!id) return state
143
+ if (!state.active || state.active.id !== id) {
144
+ return { ...state, orphanChunks: state.orphanChunks + 1 }
145
+ }
146
+ const content = `${text(state.active.content)}${text(message.delta)}`
147
+ const capped = capContent(content, state.truncated)
148
+ return bundle(
149
+ { truncated: capped.truncated },
150
+ state.history,
151
+ { ...state.active, content: capped.content, truncated: capped.content.length >= STREAM_LIMITS.maxContentChars },
152
+ )
153
+ }
154
+
155
+ case 'stream_end': {
156
+ if (!id) return state
157
+ if (!state.active || state.active.id !== id) return { ...state, orphanChunks: state.orphanChunks + 1 }
158
+ const full = text(message.fullContent)
159
+ const current = state.active
160
+ const finalContent = full.length > text(current.content).length
161
+ ? full.slice(0, STREAM_LIMITS.maxContentChars)
162
+ : current.content
163
+ const entry = {
164
+ ...current,
165
+ streaming: false,
166
+ interrupted: message.interrupted === true ? true : current.interrupted === true,
167
+ content: finalContent,
168
+ }
169
+ return bundle({}, [...state.history, entry], null)
170
+ }
171
+
172
+ case 'interrupted': {
173
+ if (!id) return state
174
+ if (!state.active || state.active.id !== id) return { ...state, orphanChunks: state.orphanChunks + 1 }
175
+ const saved = text(message.savedContent)
176
+ const current = state.active
177
+ const entry = {
178
+ ...current,
179
+ streaming: false,
180
+ interrupted: message.aborted !== false,
181
+ content: saved.length > text(current.content).length ? saved : current.content,
182
+ }
183
+ return bundle({}, [...state.history, entry], null)
184
+ }
185
+
186
+ case 'card_choice': {
187
+ if (!id) return state
188
+ const existing = state.history.findIndex((entry) => entry.id === id && entry.kind === 'choice')
189
+ const options = Array.isArray(message.options)
190
+ ? message.options.slice(0, STREAM_LIMITS.maxOptions).map((option) => (
191
+ typeof option === 'string' ? { label: option, value: option } : { label: text(option?.label), value: text(option?.value ?? option?.label) }
192
+ ))
193
+ : []
194
+ const card = {
195
+ id,
196
+ role: 'pm',
197
+ kind: 'choice',
198
+ content: text(message.question),
199
+ options,
200
+ answered: false,
201
+ answer: null,
202
+ streaming: false,
203
+ at: now(),
204
+ }
205
+ if (existing >= 0) {
206
+ const history = state.history.slice()
207
+ history[existing] = { ...history[existing], ...card }
208
+ return bundle({}, history, state.active)
209
+ }
210
+ const history = state.active
211
+ ? [...state.history, { ...state.active, streaming: false }, card]
212
+ : [...state.history, card]
213
+ return bundle({}, history, null)
214
+ }
215
+
216
+ case 'card_prd': {
217
+ if (!id) return state
218
+ const doc = {
219
+ id,
220
+ role: 'pm',
221
+ kind: 'doc',
222
+ content: text(message.title),
223
+ summary: text(message.summary ?? message.content ?? ''),
224
+ status: ['draft', 'approved', 'shipped'].includes(text(message.status)) ? text(message.status) : 'draft',
225
+ streaming: false,
226
+ at: now(),
227
+ }
228
+ const history = state.active
229
+ ? [...state.history, { ...state.active, streaming: false }, doc]
230
+ : [...state.history, doc]
231
+ return bundle({}, history, null)
232
+ }
233
+
234
+ case 'card_status': {
235
+ if (!id) return state
236
+ const progress = Math.max(0, Math.min(100, Number(message.progress) || 0))
237
+ const entry = {
238
+ id,
239
+ role: 'pjm',
240
+ kind: 'status',
241
+ content: text(message.content),
242
+ progress,
243
+ statusText: text(message.statusText),
244
+ streaming: false,
245
+ at: now(),
246
+ }
247
+ const existing = state.history.findIndex((item) => item.id === id && item.kind === 'status')
248
+ if (existing >= 0) {
249
+ const history = state.history.slice()
250
+ history[existing] = { ...history[existing], ...entry }
251
+ return bundle({}, history, state.active)
252
+ }
253
+ const history = state.active
254
+ ? [...state.history, { ...state.active, streaming: false }, entry]
255
+ : [...state.history, entry]
256
+ return bundle({}, history, null)
257
+ }
258
+
259
+ case 'error': {
260
+ const entry = {
261
+ id: id || `error-${now()}`,
262
+ role: 'system',
263
+ kind: 'error',
264
+ content: text(message.message),
265
+ code: text(message.code),
266
+ streaming: false,
267
+ at: now(),
268
+ }
269
+ const history = state.active
270
+ ? [...state.history, { ...state.active, streaming: false }, entry]
271
+ : [...state.history, entry]
272
+ return bundle({ lastError: entry }, history, null)
273
+ }
274
+
275
+ case 'dual': {
276
+ const snapshot = message.snapshot ?? null
277
+ let safeSnapshot = snapshot
278
+ try {
279
+ if (snapshot && JSON.stringify(snapshot).length > STREAM_LIMITS.maxSnapshotBytes) safeSnapshot = null
280
+ } catch { safeSnapshot = null }
281
+ return { ...state, snapshot: safeSnapshot }
282
+ }
283
+
284
+ case 'hello': {
285
+ return {
286
+ ...state,
287
+ capabilities: {
288
+ protocol: Number(message.protocol) || 0,
289
+ dualSnapshot: message?.capabilities?.dualSnapshot === true,
290
+ chat: message?.capabilities?.chat === true,
291
+ answer: message?.capabilities?.answer === true,
292
+ limitations: message?.limitations ?? null,
293
+ },
294
+ }
295
+ }
296
+
297
+ case 'pong':
298
+ return state
299
+
300
+ default:
301
+ return { ...state, ignored: state.ignored + 1 }
302
+ }
303
+ }
304
+
305
+ return {
306
+ getState: () => state,
307
+ apply: (message) => { state = applyOne(message); notify(); return state },
308
+ /**
309
+ * 用户回答选择题(本地乐观更新):
310
+ * * 只把那张卡片改成 answered 并写入 answer;
311
+ * * 可选追加一条本地用户气泡(`local: true`);
312
+ * * history 换新数组,其余消息对象引用保持不变。
313
+ * * 重复回答同一张卡片会被拒绝。
314
+ */
315
+ answerChoice: (messageId, value, options = {}) => {
316
+ const id = text(messageId)
317
+ const answer = text(value)
318
+ if (!id || !answer) return { ok: false, code: 'answer-incomplete', error: 'answerChoice requires messageId and value' }
319
+ const index = state.history.findIndex((entry) => entry.id === id)
320
+ if (index < 0) return { ok: false, code: 'choice-not-found', error: `no message with id ${id}` }
321
+ const current = state.history[index]
322
+ if (current.kind !== 'choice') return { ok: false, code: 'not-a-choice', error: `${id} is not a choice card` }
323
+ if (current.answered === true) return { ok: false, code: 'choice-already-answered', error: `choice ${id} was already answered` }
324
+ const history = state.history.slice()
325
+ history[index] = { ...current, answered: true, answer }
326
+ const echo = text(options.echo)
327
+ if (echo) {
328
+ history.push({ id: `local-${now()}`, role: 'user', kind: 'text', content: echo, local: true, streaming: false, at: now() })
329
+ }
330
+ state = bundle({}, history, state.active)
331
+ notify()
332
+ return { ok: true, state }
333
+ },
334
+ /** 批量应用,只通知一次(UI 可按帧 flush)。 */
335
+ applyBatch: (messages) => {
336
+ const list = Array.isArray(messages) ? messages : []
337
+ for (const message of list) state = applyOne(message)
338
+ if (list.length > 0) notify()
339
+ return state
340
+ },
341
+ /** 外部 store 订阅(配合 useSyncExternalStore)。返回取消订阅函数。 */
342
+ subscribe: (listener) => {
343
+ if (typeof listener !== 'function') return () => {}
344
+ listeners.add(listener)
345
+ return () => listeners.delete(listener)
346
+ },
347
+ reset: () => { state = emptyState(); notify(); return state },
348
+ }
349
+ }
@@ -0,0 +1,182 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+ // @ts-nocheck
4
+
5
+ /**
6
+ * 最小 RFC 6455 服务端实现(不引入 `ws` 依赖)。
7
+ *
8
+ * 只覆盖本通道需要的能力:
9
+ * * 握手:`Sec-WebSocket-Accept` = base64(sha1(key + GUID));
10
+ * * 收帧:客户端帧必须带掩码(RFC 要求),支持 7/16/64 位长度、分片续帧、close/ping/pong;
11
+ * * 发帧:服务端文本帧不加掩码,另发 ping/close。
12
+ *
13
+ * 不做:permessage-deflate、二进制 payload 处理(收到即忽略)、扩展协商。
14
+ */
15
+
16
+ import crypto from 'node:crypto'
17
+
18
+ export const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
19
+ export const WS_MAX_PAYLOAD = 256 * 1024
20
+
21
+ export const OPCODE = Object.freeze({ continuation: 0x0, text: 0x1, binary: 0x2, close: 0x8, ping: 0x9, pong: 0xa })
22
+
23
+ /** 计算握手 accept 值(RFC 6455 §1.3)。 */
24
+ export function acceptKey(clientKey) {
25
+ return crypto.createHash('sha1').update(String(clientKey) + WS_GUID).digest('base64')
26
+ }
27
+
28
+ /** 是否是合法的 WebSocket 升级请求;返回具名原因以便拒绝时说明。 */
29
+ export function validateUpgradeRequest(req) {
30
+ const headers = req?.headers ?? {}
31
+ const upgrade = String(headers.upgrade ?? '').toLowerCase()
32
+ const connection = String(headers.connection ?? '').toLowerCase()
33
+ const key = String(headers['sec-websocket-key'] ?? '').trim()
34
+ const version = String(headers['sec-websocket-version'] ?? '').trim()
35
+ if (upgrade !== 'websocket') return { ok: false, code: 'not-websocket-upgrade' }
36
+ if (!connection.split(',').map((part) => part.trim()).includes('upgrade')) return { ok: false, code: 'missing-connection-upgrade' }
37
+ if (!key || Buffer.from(key, 'base64').length !== 16) return { ok: false, code: 'invalid-websocket-key' }
38
+ if (version !== '13') return { ok: false, code: 'unsupported-websocket-version', version }
39
+ return { ok: true, key, accept: acceptKey(key) }
40
+ }
41
+
42
+ /** 生成 101 响应文本。 */
43
+ export function upgradeResponse(accept) {
44
+ return [
45
+ 'HTTP/1.1 101 Switching Protocols',
46
+ 'Upgrade: websocket',
47
+ 'Connection: Upgrade',
48
+ `Sec-WebSocket-Accept: ${accept}`,
49
+ '', '',
50
+ ].join('\r\n')
51
+ }
52
+
53
+ /** 生成拒绝响应(带状态码与具名原因)。 */
54
+ export function rejectionResponse(status, code, message) {
55
+ const body = JSON.stringify({ ok: false, code, message })
56
+ return [
57
+ `HTTP/1.1 ${status} ${status === 401 ? 'Unauthorized' : status === 400 ? 'Bad Request' : 'Forbidden'}`,
58
+ 'Content-Type: application/json; charset=utf-8',
59
+ `Content-Length: ${Buffer.byteLength(body)}`,
60
+ 'Connection: close',
61
+ '', body,
62
+ ].join('\r\n')
63
+ }
64
+
65
+ function encodeFrame(opcode, payloadBuffer) {
66
+ const length = payloadBuffer.length
67
+ let header = null
68
+ if (length < 126) {
69
+ header = Buffer.alloc(2)
70
+ header[1] = length
71
+ } else if (length < 65536) {
72
+ header = Buffer.alloc(4)
73
+ header[1] = 126
74
+ header.writeUInt16BE(length, 2)
75
+ } else {
76
+ header = Buffer.alloc(10)
77
+ header[1] = 127
78
+ header.writeBigUInt64BE(BigInt(length), 2)
79
+ }
80
+ header[0] = 0x80 | opcode // FIN + opcode
81
+ return Buffer.concat([header, payloadBuffer])
82
+ }
83
+
84
+ export function encodeText(value) {
85
+ return encodeFrame(OPCODE.text, Buffer.from(String(value), 'utf8'))
86
+ }
87
+
88
+ export function encodeClose(code = 1000, reason = '') {
89
+ const reasonBuffer = Buffer.from(String(reason), 'utf8')
90
+ const payload = Buffer.alloc(2 + reasonBuffer.length)
91
+ payload.writeUInt16BE(Number(code) || 1000, 0)
92
+ reasonBuffer.copy(payload, 2)
93
+ return encodeFrame(OPCODE.close, payload)
94
+ }
95
+
96
+ export function encodePing() {
97
+ return encodeFrame(OPCODE.ping, Buffer.alloc(0))
98
+ }
99
+
100
+ export function encodePong(payload = Buffer.alloc(0)) {
101
+ return encodeFrame(OPCODE.pong, Buffer.from(payload))
102
+ }
103
+
104
+ /**
105
+ * 增量帧解析器。`push(buffer)` 返回本次解析出的完整消息与需要回应的控制帧。
106
+ * 超过 maxPayload 的帧返回 error 并停止后续解析(由调用方断开连接)。
107
+ */
108
+ export function createFrameParser({ maxPayload = WS_MAX_PAYLOAD } = {}) {
109
+ let pending = Buffer.alloc(0)
110
+ let fragments = []
111
+ let fragmentOpcode = null
112
+
113
+ function takeMessage(opcode, payload) {
114
+ if (opcode === OPCODE.continuation) {
115
+ if (fragmentOpcode === null) return { error: 'unexpected-continuation' }
116
+ fragments.push(payload)
117
+ return null
118
+ }
119
+ fragments = [payload]
120
+ fragmentOpcode = opcode
121
+ return null
122
+ }
123
+
124
+ return {
125
+ push(chunk) {
126
+ pending = pending.length === 0 ? Buffer.from(chunk) : Buffer.concat([pending, chunk])
127
+ const messages = []
128
+ const controls = []
129
+ const errors = []
130
+ while (pending.length >= 2) {
131
+ const first = pending[0]
132
+ const second = pending[1]
133
+ const fin = (first & 0x80) !== 0
134
+ const opcode = first & 0x0f
135
+ const masked = (second & 0x80) !== 0
136
+ let length = second & 0x7f
137
+ let offset = 2
138
+ if (length === 126) {
139
+ if (pending.length < offset + 2) break
140
+ length = pending.readUInt16BE(offset)
141
+ offset += 2
142
+ } else if (length === 127) {
143
+ if (pending.length < offset + 8) break
144
+ const big = pending.readBigUInt64BE(offset)
145
+ if (big > BigInt(maxPayload)) { errors.push('payload-too-large'); return { messages, controls, errors } }
146
+ length = Number(big)
147
+ offset += 8
148
+ }
149
+ if (length > maxPayload) { errors.push('payload-too-large'); return { messages, controls, errors } }
150
+ if (!masked) { errors.push('client-frame-must-be-masked'); return { messages, controls, errors } }
151
+ if (pending.length < offset + 4 + length) break
152
+ const mask = pending.subarray(offset, offset + 4)
153
+ offset += 4
154
+ const payload = Buffer.from(pending.subarray(offset, offset + length))
155
+ for (let index = 0; index < payload.length; index += 1) payload[index] ^= mask[index % 4]
156
+ pending = pending.subarray(offset + length)
157
+
158
+ if (opcode === OPCODE.ping) { controls.push({ type: 'ping', payload }); continue }
159
+ if (opcode === OPCODE.pong) { controls.push({ type: 'pong', payload }); continue }
160
+ if (opcode === OPCODE.close) {
161
+ controls.push({ type: 'close', payload })
162
+ fragmentOpcode = null
163
+ fragments = []
164
+ continue
165
+ }
166
+ if (opcode === OPCODE.binary) { controls.push({ type: 'binary-ignored' }); continue }
167
+
168
+ const result = takeMessage(opcode, payload)
169
+ if (result && result.error) { errors.push(result.error); return { messages, controls, errors } }
170
+ if (fin) {
171
+ const merged = Buffer.concat(fragments)
172
+ const frameOpcode = fragmentOpcode
173
+ fragments = []
174
+ fragmentOpcode = null
175
+ if (frameOpcode === OPCODE.text) messages.push(merged.toString('utf8'))
176
+ else controls.push({ type: 'binary-ignored' })
177
+ }
178
+ }
179
+ return { messages, controls, errors }
180
+ },
181
+ }
182
+ }
@@ -105,3 +105,38 @@ export function renderClosureDocument(plan) {
105
105
  '#### 项目资料汇总', '', bullets(closure.artifacts), '',
106
106
  ].join('\n')
107
107
  }
108
+
109
+ /** PM 的复核 + 复盘 + 总结(闭环第三步)。 */
110
+ export function renderReviewDocument(review, { feedback = null, handoff = null } = {}) {
111
+ const checks = review.acceptanceChecks
112
+ const failed = checks.filter((check) => check.met !== true)
113
+ const out = [
114
+ `# 复核与复盘 · ${review.id}`, '',
115
+ `- 结论:**${review.verdict === 'accepted' ? '通过' : '退回修改'}**`,
116
+ `- 关联计划:${review.planId}`,
117
+ `- 关联 PRD:${review.prdId ?? '未关联'}`,
118
+ `- 反馈 ID:${review.feedbackId}`, '',
119
+ '## 一、复核:验收逐条核对', '',
120
+ '| 验收标准 | 结果 | 证据 | 备注 |',
121
+ '|---|---|---|---|',
122
+ ...checks.map((check) => `| ${cell(check.criterion)} | ${check.met ? '通过' : '未通过'} | ${cell(check.evidence.join(';') || '—')} | ${cell(check.note || '—')} |`), '',
123
+ failed.length > 0 ? `**未通过项**:${failed.map((check) => check.criterion).join(';')}` : '**全部验收项通过。**', '',
124
+ ]
125
+ if (feedback) {
126
+ out.push('## 二、执行回执(PJM 自反馈)', '', feedback.summary, '')
127
+ out.push(`- 步骤:${feedback.metrics.done}/${feedback.metrics.steps}(${feedback.metrics.percent}%)`)
128
+ out.push(`- 估时/实际:${feedback.metrics.estimatedMinutes} / ${feedback.metrics.actualMinutes} 分钟`)
129
+ if (feedback.deviations.length) out.push('- 偏差:', bullets(feedback.deviations))
130
+ if (feedback.openIssues.length) out.push('- 遗留问题:', bullets(feedback.openIssues))
131
+ out.push('')
132
+ }
133
+ if (handoff) {
134
+ out.push('## 三、交接对照', '', `- 交接包:${handoff.id}(${handoff.status})`, `- 验收标准数:${handoff.acceptance.length}`, '')
135
+ }
136
+ out.push('## 四、复盘', '')
137
+ out.push('### 保持(做对了什么)', '', bullets(review.retrospective.kept), '')
138
+ out.push('### 改进(哪里不顺)', '', bullets(review.retrospective.improve), '')
139
+ out.push('### 经验(下次怎么做)', '', bullets(review.retrospective.lessons), '')
140
+ out.push('## 五、总结', '', review.summary, '')
141
+ return out.join('\n').trim() + '\n'
142
+ }