@twinsearth/dsh-master 3.3.3 → 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 (57) hide show
  1. package/README.md +17 -0
  2. package/client.js +126 -1
  3. package/cordis.patch.yml +51 -0
  4. package/dist-host/agents/pjm-agent.js +62 -0
  5. package/dist-host/agents/pm-agent.js +57 -0
  6. package/dist-host/agents/prompts.js +71 -0
  7. package/dist-host/agents/toolkit.js +64 -0
  8. package/dist-host/channel/abort-registry.js +93 -0
  9. package/dist-host/channel/protocol.js +114 -0
  10. package/dist-host/channel/server.js +349 -0
  11. package/dist-host/channel/stream-state.js +343 -0
  12. package/dist-host/channel/ws.js +199 -0
  13. package/dist-host/dual/documents.js +143 -0
  14. package/dist-host/dual/orchestrator.js +214 -0
  15. package/dist-host/dual/schema.js +367 -0
  16. package/dist-host/engine/atomic-switch.js +159 -0
  17. package/dist-host/engine/health-probe.js +86 -0
  18. package/dist-host/engine/index.js +18 -0
  19. package/dist-host/engine/llm-stream.js +215 -0
  20. package/dist-host/engine/plugin-updater.js +392 -0
  21. package/dist-host/engine/safe-update.js +150 -0
  22. package/dist-host/index.js +154 -3
  23. package/dist-host/kernel.js +27 -1
  24. package/dist-host/plugins/cost-optimizer.js +3 -3
  25. package/dist-host/plugins/dual-manager.js +374 -77
  26. package/docs/AGENTS-PM-PJM.md +207 -0
  27. package/docs/ARCHITECTURE.md +2 -0
  28. package/docs/CHANGELOG.md +74 -0
  29. package/docs/CHANNEL-WEBSOCKET.md +169 -0
  30. package/docs/DESIGN-command-center.md +147 -0
  31. package/docs/ENGINE-PRIMITIVES.md +215 -0
  32. package/docs/GAP-ANALYSIS-fang-session.md +75 -0
  33. package/docs/REVIEW-PM-PJM-3.3.4.md +179 -0
  34. package/docs/examples/command-center/README.md +167 -0
  35. package/package.json +5 -1
  36. package/src/agents/pjm-agent.ts +62 -0
  37. package/src/agents/pm-agent.ts +58 -0
  38. package/src/agents/prompts.ts +74 -0
  39. package/src/agents/toolkit.ts +64 -0
  40. package/src/channel/abort-registry.ts +85 -0
  41. package/src/channel/protocol.ts +110 -0
  42. package/src/channel/server.ts +289 -0
  43. package/src/channel/stream-state.ts +349 -0
  44. package/src/channel/ws.ts +182 -0
  45. package/src/dual/documents.ts +142 -0
  46. package/src/dual/orchestrator.ts +223 -0
  47. package/src/dual/schema.ts +355 -0
  48. package/src/engine/atomic-switch.ts +146 -0
  49. package/src/engine/health-probe.ts +84 -0
  50. package/src/engine/index.ts +20 -0
  51. package/src/engine/llm-stream.ts +189 -0
  52. package/src/engine/plugin-updater.ts +359 -0
  53. package/src/engine/safe-update.ts +148 -0
  54. package/src/index.ts +143 -4
  55. package/src/kernel.ts +28 -1
  56. package/src/plugins/cost-optimizer.ts +3 -3
  57. package/src/plugins/dual-manager.ts +337 -76
@@ -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
+ }
@@ -0,0 +1,142 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+ // @ts-nocheck
4
+
5
+ import { ASSESSMENT_CATEGORIES, planProgress } from './schema.ts'
6
+
7
+ const lines = (items, render) => items.length ? items.map(render).join('\n') : '- (无)'
8
+ const bullets = (items) => lines(items, (item) => `- ${item}`)
9
+ const cell = (value) => String(value ?? '—').replaceAll('|', '\\|').replaceAll('\n', ' ')
10
+
11
+ const CATEGORY_TITLES = {
12
+ completed: '1. 已完成(进度与证据)',
13
+ unfinished: '2. 未完成(原因与后续)',
14
+ blocked: '3. 无法实现 / 受阻(卡点)',
15
+ planned: '4. 已计划待开发',
16
+ conflicts: '5. 相互矛盾 / 冲突(待重新判断)',
17
+ ambiguities: '6. 理解歧义(待确认)',
18
+ }
19
+
20
+ export function renderPrdDocument(prd, assessment = null) {
21
+ return [
22
+ `# PRD · ${prd.title}`, '',
23
+ `- 文档 ID:\`${prd.id}\``,
24
+ `- 状态:${prd.status}`,
25
+ `- 优先级:${prd.priority}`,
26
+ `- 请求方:${prd.requester}`, '',
27
+ '## WHY · 解决什么问题、达成什么目标', '',
28
+ prd.why || prd.goal, '',
29
+ `**目标**:${prd.goal}`, '',
30
+ '## WHAT · 功能、模块、架构与结果', '',
31
+ bullets(prd.what), '',
32
+ '### 架构', '', prd.architecture || '(待补充)', '',
33
+ '### 模块', '',
34
+ lines(prd.modules, (item) => `- **${item.name}**(${item.status}):${item.result || item.architecture || '待补充'}`), '',
35
+ '### 预期结果', '', bullets(prd.outcomes), '',
36
+ '## 约束与非目标', '',
37
+ '### 约束', '', bullets(prd.constraints), '',
38
+ '### 非目标', '', bullets(prd.nonGoals), '',
39
+ '## 验收标准', '', bullets(prd.acceptance), '',
40
+ '## 假设与待澄清问题', '',
41
+ '### 假设', '', bullets(prd.assumptions), '',
42
+ '### 待澄清', '', bullets(prd.openQuestions), '',
43
+ assessment ? `\n---\n\n${renderAssessmentDocument(assessment)}` : '',
44
+ ].join('\n').trim() + '\n'
45
+ }
46
+
47
+ export function renderAssessmentDocument(assessment) {
48
+ const out = [`# 产品现状盘点 · ${assessment.title}`, '', `- 盘点 ID:\`${assessment.id}\``, `- 关联 PRD:${assessment.prdId ?? '未关联'}`, '']
49
+ if (assessment.why) out.push('## WHY', '', assessment.why, '')
50
+ if (assessment.what.length) out.push('## WHAT', '', bullets(assessment.what), '')
51
+ for (const category of ASSESSMENT_CATEGORIES) {
52
+ out.push(`## ${CATEGORY_TITLES[category]}`, '')
53
+ const items = assessment[category]
54
+ out.push(lines(items, (item) => {
55
+ if (category === 'completed') return `- **${item.title}**(${item.progress}%)${item.detail ? `:${item.detail}` : ''}\n - 证据:${item.evidence.join(';')}`
56
+ if (category === 'unfinished') return `- **${item.title}**:${item.reason}\n - 后续:${item.nextStep || '待计划'}`
57
+ if (category === 'blocked') return `- **${item.title}**:${item.reason}\n - 卡点:${item.blocker}\n - 下一步:${item.nextStep || '待决策'}`
58
+ if (category === 'planned') return `- **${item.title}**(${item.priority}):${item.plan || '待拆解'}${item.estimateMinutes ? `\n - 估时:${item.estimateMinutes} 分钟` : ''}`
59
+ if (category === 'conflicts') return `- **${item.title}**:${item.question}\n - 选项:${item.options.map((option) => `${option.value}=${option.label}`).join(';') || '待补充'}`
60
+ return `- **${item.title}**:${item.question}\n - 当前理解:${item.currentInterpretation || '未形成结论'}`
61
+ }), '')
62
+ }
63
+ return out.join('\n').trim() + '\n'
64
+ }
65
+
66
+ export function renderProjectDocument(plan) {
67
+ const progress = planProgress(plan)
68
+ return [
69
+ `# 项目计划 · ${plan.title}`, '',
70
+ `- 计划 ID:\`${plan.id}\``,
71
+ `- 关联 PRD:\`${plan.prdId}\``,
72
+ `- 状态:${plan.status}`,
73
+ `- 进度:${progress.done}/${progress.total}(${progress.percent}%)`,
74
+ `- 总估时:${progress.estimatedMinutes} 分钟(估算,不是承诺工期)`, '',
75
+ '## 1. 项目会审', '', bullets(plan.projectReview), '',
76
+ '## 2. 项目计划(HOW / WHERE)', '', plan.how || '(待补充)', '', `**场景**:${plan.where}`, '',
77
+ '### WBS', '',
78
+ '| 步骤 | 内容 | Owner | 场景 | 估时(分钟) | 依赖 | 状态 |',
79
+ '|---|---|---|---|---:|---|---|',
80
+ ...plan.steps.map((step) => `| ${cell(step.id)} | ${cell(step.title)} | ${cell(step.owner)} | ${cell(step.where)} | ${step.estimateMinutes} | ${cell(step.dependsOn.join(', '))} | ${cell(step.status)} |`), '',
81
+ '## 3. 项目需求与内容', '', bullets(plan.requirements), '',
82
+ '## 4. 项目时间', '',
83
+ `- 开始时间:${plan.schedule.startAt ?? '待定'}`,
84
+ `- 总估时:${plan.schedule.totalEstimateMinutes} 分钟`, '',
85
+ lines(plan.schedule.milestones, (item) => `- **${item.title}**:${item.estimateMinutes} 分钟 · ${item.owner} · 依赖 ${item.dependsOn.join(', ') || '无'}`), '',
86
+ '## 5. 项目人员', '',
87
+ lines(plan.team, (item) => `- **${item.role}** · ${item.owner}:${item.responsibility || '待分配'}`), '',
88
+ '## 6. 测试、部署、交付与验收', '',
89
+ '### 测试', '', bullets(plan.quality.tests), '',
90
+ '### 部署', '', bullets(plan.quality.deployment), '',
91
+ '### 验收', '', bullets(plan.quality.acceptance), '',
92
+ '## 7. 项目结案', '',
93
+ plan.closure.status === 'closed' ? renderClosureDocument(plan) : '- 尚未结案;必须全部步骤完成后才能结案。',
94
+ ].join('\n').trim() + '\n'
95
+ }
96
+
97
+ export function renderClosureDocument(plan) {
98
+ const closure = plan.closure
99
+ return [
100
+ `### 结案状态:${closure.status}`, '',
101
+ `- 结案时间:${closure.closedAt ?? '未结案'}`,
102
+ `- 总结:${closure.summary || '未填写'}`, '',
103
+ '#### 评审报告', '', closure.reviewReport || '(待补充)', '',
104
+ '#### 验收记录', '', bullets(closure.acceptance), '',
105
+ '#### 项目资料汇总', '', bullets(closure.artifacts), '',
106
+ ].join('\n')
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
+ }
@@ -0,0 +1,223 @@
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
+ * PM/PJM 编排策略(3.3.5)。
7
+ *
8
+ * 解决的问题:模型知道工具清单,但不知道“现在这一步该用哪个”。让它在 7 个工具里猜,
9
+ * 就会出现「PRD 还没写就交接」「还有未决问题就派发」这类错误。
10
+ *
11
+ * 这里把闭环状态机写成确定性函数:给定结构化状态,返回**唯一推荐动作**与理由。
12
+ * 模型仍然负责内容(写 PRD、拆 WBS、判定验收),编排不替它做判断,也不产生副作用。
13
+ *
14
+ * 约束:
15
+ * * 纯函数,不读全局、不写数据;
16
+ * * 每个 action 都必须对应真实存在的工具名(测试会校验,防止漂移);
17
+ * * 无法推进时返回 `blocked` + 具名原因,而不是编一个动作。
18
+ */
19
+
20
+ export const PM_ACTIONS = Object.freeze([
21
+ 'create_prd', 'create_assessment', 'clarify', 'handoff', 'review', 'done', 'blocked',
22
+ ])
23
+
24
+ export const PJM_ACTIONS = Object.freeze([
25
+ 'take_handoff', 'return_to_pm', 'create_plan', 'dispatch', 'report', 'complete_feedback', 'close', 'done', 'blocked',
26
+ ])
27
+
28
+ /** action → 真实工具名(agent preset 里注册过;测试比对)。 */
29
+ export const ACTION_TOOL = Object.freeze({
30
+ create_prd: 'pm_create_prd',
31
+ create_assessment: 'pm_create_assessment',
32
+ clarify: 'pm_create_prd',
33
+ handoff: 'pm_handoff_to_pjm',
34
+ review: 'pm_review_feedback',
35
+ take_handoff: 'pjm_take_handoff',
36
+ return_to_pm: 'pjm_take_handoff',
37
+ create_plan: 'pjm_create_plan',
38
+ dispatch: 'pjm_dispatch_step',
39
+ report: 'pjm_report',
40
+ complete_feedback: 'pjm_submit_feedback',
41
+ close: 'pjm_close_project',
42
+ done: null,
43
+ blocked: null,
44
+ })
45
+
46
+ /**
47
+ * PM 的下一步:PRD → 盘点 → 澄清 → 交接 → 复核。
48
+ * @param {{prd?: object|null, assessment?: object|null, handoff?: object|null, feedback?: object|null, review?: object|null}} state
49
+ */
50
+ export function nextPmAction(state = {}) {
51
+ const prd = state.prd ?? null
52
+ const assessment = state.assessment ?? null
53
+ const handoff = state.handoff ?? null
54
+ const feedback = state.feedback ?? null
55
+ const review = state.review ?? null
56
+
57
+ if (!prd) {
58
+ return { action: 'create_prd', tool: ACTION_TOOL.create_prd, reason: 'no-prd-yet', detail: '先写结构化 PRD(title + goal)' }
59
+ }
60
+ if (!assessment) {
61
+ return { action: 'create_assessment', tool: ACTION_TOOL.create_assessment, reason: 'no-assessment-yet', detail: '六类盘点必须先落库,completed 项要有证据' }
62
+ }
63
+ const openQuestions = Array.isArray(prd.openQuestions) ? prd.openQuestions.length : 0
64
+ const conflicts = Array.isArray(assessment.conflicts) ? assessment.conflicts.length : 0
65
+ if (openQuestions > 0 || conflicts > 0) {
66
+ return {
67
+ action: 'clarify',
68
+ tool: null,
69
+ reason: openQuestions > 0 ? 'open-questions-unresolved' : 'conflicts-unresolved',
70
+ detail: openQuestions > 0
71
+ ? `PRD 还有 ${openQuestions} 个未决问题,必须用 ask_user_question 一次问一个决策点`
72
+ : `盘点里有 ${conflicts} 个冲突,必须让用户重新判断(回复:新需求新方案)`,
73
+ blocking: true,
74
+ }
75
+ }
76
+ if (!handoff) {
77
+ return {
78
+ action: 'handoff',
79
+ tool: ACTION_TOOL.handoff,
80
+ reason: 'ready-to-handoff',
81
+ detail: '验收标准齐全且无未决项,打交接包给 PJM',
82
+ argsHint: { prdId: prd.id, assessmentId: assessment.id },
83
+ }
84
+ }
85
+ if (handoff.status === 'needs-clarification') {
86
+ return {
87
+ action: 'blocked',
88
+ tool: null,
89
+ reason: handoff.reason || 'handoff-not-ready',
90
+ detail: '交接包未就绪,先解决未决问题再重新交接',
91
+ blocking: true,
92
+ }
93
+ }
94
+ if (feedback && !review) {
95
+ return {
96
+ action: 'review',
97
+ tool: ACTION_TOOL.review,
98
+ reason: 'feedback-awaiting-review',
99
+ detail: 'PJM 已交反馈:逐条核对验收(met + 证据),写复盘与总结;结论必须与验收一致',
100
+ argsHint: { feedbackId: feedback.id },
101
+ }
102
+ }
103
+ if (review) {
104
+ return {
105
+ action: 'done',
106
+ tool: null,
107
+ reason: review.verdict === 'accepted' ? 'loop-accepted' : 'loop-changes-requested',
108
+ detail: review.verdict === 'accepted' ? '本轮闭环已通过' : 'PJM 需按复核意见重做',
109
+ }
110
+ }
111
+ return {
112
+ action: 'blocked',
113
+ tool: null,
114
+ reason: 'waiting-for-pjm',
115
+ detail: '等 PJM 接单并提交反馈;PM 不替它派发',
116
+ blocking: false,
117
+ }
118
+ }
119
+
120
+ /**
121
+ * PJM 的下一步:接单 → 计划 → 派发 → 反馈 → 结案。
122
+ * @param {{handoff?: object|null, plan?: object|null, feedback?: object|null}} state
123
+ */
124
+ export function nextPjmAction(state = {}) {
125
+ const handoff = state.handoff ?? null
126
+ const plan = state.plan ?? null
127
+ const feedback = state.feedback ?? null
128
+
129
+ if (handoff && handoff.status === 'needs-clarification') {
130
+ return {
131
+ action: 'return_to_pm',
132
+ tool: null,
133
+ reason: 'handoff-needs-clarification',
134
+ detail: '交接包未就绪:退回 PM 澄清,不得硬开工',
135
+ blocking: true,
136
+ }
137
+ }
138
+ if (handoff && handoff.status === 'ready' && !plan) {
139
+ return {
140
+ action: 'take_handoff',
141
+ tool: ACTION_TOOL.take_handoff,
142
+ reason: 'handoff-ready',
143
+ detail: '接单后再拆 WBS',
144
+ argsHint: { handoffId: handoff.id },
145
+ }
146
+ }
147
+ if (!plan) {
148
+ return {
149
+ action: 'create_plan',
150
+ tool: ACTION_TOOL.create_plan,
151
+ reason: 'no-plan-yet',
152
+ detail: '用 pjm_create_plan 写七类项目内容,步骤必须自己拆(交接包不含 WBS)',
153
+ }
154
+ }
155
+ const steps = Array.isArray(plan.steps) ? plan.steps : []
156
+ const failed = steps.filter((step) => step.status === 'failed')
157
+ const running = steps.filter((step) => step.status === 'running' || step.status === 'dispatching')
158
+ const pending = steps.filter((step) => step.status === 'pending')
159
+ const blockedReport = Array.isArray(plan.reports) ? [...plan.reports].reverse().find((report) => report.status === 'blocked') : null
160
+
161
+ if (blockedReport && (blockedReport.blocker || blockedReport.summary)) {
162
+ return {
163
+ action: 'report',
164
+ tool: ACTION_TOOL.report,
165
+ reason: 'blocked-reported',
166
+ detail: `最近一次节点报告为阻塞:${blockedReport.blocker || blockedReport.summary};需要 PM/用户决策后才能继续`,
167
+ blocking: true,
168
+ }
169
+ }
170
+ if (failed.length > 0) {
171
+ return {
172
+ action: 'report',
173
+ tool: ACTION_TOOL.report,
174
+ reason: 'step-failed',
175
+ detail: `${failed.length} 个步骤失败:如实汇报失败原因,不得写成完成`,
176
+ argsHint: { planId: plan.id, status: 'blocked' },
177
+ }
178
+ }
179
+ if (running.length > 0) {
180
+ return {
181
+ action: 'report',
182
+ tool: ACTION_TOOL.report,
183
+ reason: 'step-running',
184
+ detail: '有步骤在隔离执行体中运行:等结果并汇报节点,不重复派发',
185
+ argsHint: { planId: plan.id, status: 'progress' },
186
+ }
187
+ }
188
+ if (pending.length > 0) {
189
+ const next = pending[0]
190
+ return {
191
+ action: 'dispatch',
192
+ tool: ACTION_TOOL.dispatch,
193
+ reason: 'step-pending',
194
+ detail: '派发下一个待办步骤(走 fangForker.spawn;不可用即 fail-closed)',
195
+ argsHint: { planId: plan.id, stepId: next.id },
196
+ }
197
+ }
198
+ if (feedback) {
199
+ if (plan.status === 'closed') {
200
+ return { action: 'done', tool: null, reason: 'project-closed', detail: '项目已结案,等 PM 复核结论' }
201
+ }
202
+ return {
203
+ action: 'close',
204
+ tool: ACTION_TOOL.close,
205
+ reason: 'all-steps-done',
206
+ detail: '全部步骤完成且已提交反馈:结案需要总结、评审报告、验收与资料清单',
207
+ argsHint: { planId: plan.id },
208
+ }
209
+ }
210
+ return {
211
+ action: 'complete_feedback',
212
+ tool: ACTION_TOOL.complete_feedback,
213
+ reason: 'all-steps-done-no-feedback',
214
+ detail: '全部步骤完成:提交交付、偏差、遗留问题与测试证据,交给 PM 复核',
215
+ argsHint: { planId: plan.id },
216
+ }
217
+ }
218
+
219
+ /** 给 UI/测试用的紧凑摘要。 */
220
+ export function describeAction(result) {
221
+ if (!result || !result.action) return 'blocked: unknown state'
222
+ return [result.action, result.reason, result.detail].filter(Boolean).join(' · ')
223
+ }