@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,148 @@
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
+ * 快照 → 更新 → 冒烟 → 失败回滚(PluginSafeUpdater 的可编译实现)。
7
+ *
8
+ * 关键设计:**不假装有 VM**。执行能力由调用方注入一个 `executor`:
9
+ *
10
+ * executor.snapshot(name) → 建快照
11
+ * executor.restore(name) → 回滚到快照
12
+ * executor.run(argv, options) → 跑一条命令,返回 { ok, stdout, stderr, code }
13
+ *
14
+ * 房主本身没有 vm-sandbox;真实接入要么由瓮城的 VM 层提供 executor,
15
+ * 要么由宿主进程管理器提供。这里只保证**顺序、门禁与回滚语义**正确。
16
+ */
17
+
18
+ function asString(value) { return String(value ?? '').trim() }
19
+
20
+ function executorOf(input) {
21
+ const executor = input && typeof input === 'object' ? input.executor : null
22
+ if (!executor || typeof executor.snapshot !== 'function' || typeof executor.restore !== 'function' || typeof executor.run !== 'function') {
23
+ return null
24
+ }
25
+ return executor
26
+ }
27
+
28
+ /**
29
+ * @param {{
30
+ * executor?: {snapshot:Function, restore:Function, run:Function},
31
+ * snapshotName?: string,
32
+ * update?: string[], // 更新命令 argv
33
+ * smoke?: string[], // 冒烟命令 argv(可选)
34
+ * restart?: string[], // 回滚后重启命令 argv(可选)
35
+ * now?: () => number,
36
+ * }} input
37
+ */
38
+ export async function runSafeUpdate(input = {}) {
39
+ const executor = executorOf(input)
40
+ if (!executor) {
41
+ return { ok: false, code: 'executor-required', error: 'an executor with snapshot/restore/run is required; no VM is assumed' }
42
+ }
43
+ const now = typeof input.now === 'function' ? input.now : () => Date.now()
44
+ const snapshotName = asString(input.snapshotName) || `pre-update-${now()}`
45
+ const steps = []
46
+ const record = (step, detail) => { steps.push({ step, at: now(), ...detail }) }
47
+
48
+ // 1. 快照是硬前置:拿不到快照就不允许更新,否则失败时无法回滚。
49
+ try {
50
+ const snapshot = await executor.snapshot(snapshotName)
51
+ if (snapshot && snapshot.ok === false) {
52
+ record('snapshot', { ok: false, detail: snapshot })
53
+ return { ok: false, code: 'snapshot-failed', error: 'snapshot failed; refusing to update', snapshotName, steps }
54
+ }
55
+ record('snapshot', { ok: true })
56
+ } catch (error) {
57
+ record('snapshot', { ok: false, error: String(error?.message ?? error) })
58
+ return { ok: false, code: 'snapshot-threw', error: String(error?.message ?? error), snapshotName, steps }
59
+ }
60
+
61
+ const rollback = async (reason, detail) => {
62
+ record('rollback-start', { reason })
63
+ let restored = null
64
+ try { restored = await executor.restore(snapshotName) } catch (error) { restored = { ok: false, error: String(error?.message ?? error) } }
65
+ record('restore', { ok: restored?.ok !== false, detail: restored })
66
+ if (Array.isArray(input.restart) && input.restart.length > 0) {
67
+ try {
68
+ const restarted = await executor.run(input.restart, { phase: 'restart' })
69
+ record('restart', { ok: restarted?.ok !== false, detail: restarted })
70
+ } catch (error) {
71
+ record('restart', { ok: false, error: String(error?.message ?? error) })
72
+ }
73
+ }
74
+ return { ok: false, code: reason, error: detail, snapshotName, steps, rolledBack: restored?.ok !== false }
75
+ }
76
+
77
+ // 2. 更新
78
+ const update = Array.isArray(input.update) && input.update.length > 0 ? input.update : null
79
+ if (!update) return await rollback('update-command-required', 'update argv is required')
80
+ try {
81
+ const updated = await executor.run(update, { phase: 'update' })
82
+ if (updated && updated.ok === false) {
83
+ record('update', { ok: false, detail: updated })
84
+ return await rollback('update-failed', 'update command failed')
85
+ }
86
+ record('update', { ok: true })
87
+ } catch (error) {
88
+ record('update', { ok: false, error: String(error?.message ?? error) })
89
+ return await rollback('update-threw', String(error?.message ?? error))
90
+ }
91
+
92
+ // 3. 冒烟(可选但推荐):不通过同样回滚,绝不“更新成功但起不来”。
93
+ if (Array.isArray(input.smoke) && input.smoke.length > 0) {
94
+ try {
95
+ const smoke = await executor.run(input.smoke, { phase: 'smoke' })
96
+ if (smoke && smoke.ok === false) {
97
+ record('smoke', { ok: false, detail: smoke })
98
+ return await rollback('smoke-failed', 'smoke test failed after update')
99
+ }
100
+ record('smoke', { ok: true })
101
+ } catch (error) {
102
+ record('smoke', { ok: false, error: String(error?.message ?? error) })
103
+ return await rollback('smoke-threw', String(error?.message ?? error))
104
+ }
105
+ }
106
+
107
+ return { ok: true, code: 'updated', snapshotName, steps, rolledBack: false }
108
+ }
109
+
110
+ /**
111
+ * 组合使用:切指针 + 健康检查 + 失败回滚。适合蓝绿切换后的验证。
112
+ * 指针切换成功但健康检查失败时,把指针切回旧目标。
113
+ */
114
+ export async function switchAndVerify(input = {}) {
115
+ const { readPointer, switchWithRollback } = await import('./atomic-switch.ts')
116
+ const { probeHealth } = await import('./health-probe.ts')
117
+ // 先记住旧目标:健康检查失败时要切回它,而不是切到“刚切过去的新目标”。
118
+ const previous = await readPointer({ linkPath: input.linkPath, fs: input.fs })
119
+ const switched = await switchWithRollback({
120
+ linkPath: input.linkPath,
121
+ targetDir: input.targetDir,
122
+ fs: input.fs,
123
+ platform: input.platform,
124
+ now: input.now,
125
+ })
126
+ if (!switched.ok) return { ok: false, code: switched.code, error: switched.error, switch: switched, health: null, previous }
127
+ const health = await probeHealth({
128
+ url: input.healthUrl,
129
+ fetchImpl: input.fetchImpl,
130
+ timeoutMs: input.timeoutMs,
131
+ intervalMs: input.intervalMs,
132
+ maxAttempts: input.maxAttempts,
133
+ now: input.now,
134
+ sleepImpl: input.sleepImpl,
135
+ })
136
+ if (health.ok) return { ok: true, code: 'switched-and-healthy', switch: switched, health, previous }
137
+ if (!previous.ok || !previous.resolved) {
138
+ return { ok: false, code: 'health-failed-after-switch', switch: switched, health, previous, rollback: null }
139
+ }
140
+ const back = await switchWithRollback({
141
+ linkPath: input.linkPath,
142
+ targetDir: previous.resolved,
143
+ fs: input.fs,
144
+ platform: input.platform,
145
+ now: input.now,
146
+ }).catch((error) => ({ ok: false, error: String(error?.message ?? error) }))
147
+ return { ok: false, code: 'health-failed-after-switch', switch: switched, health, previous, rollback: back }
148
+ }
package/src/index.ts CHANGED
@@ -38,14 +38,19 @@ import {
38
38
  safeGet,
39
39
  tokenEquals,
40
40
  } from './kernel.ts'
41
+ import { randomBytes } from 'node:crypto'
42
+
41
43
  import { BRIDGE_PATH, filterEnv } from './superagent/dispatch.ts'
42
44
  import { createBridgeTokenManager } from './superagent/bridge-tokens.ts'
45
+ import { attachCommandCenterChannel, CHANNEL_PATH } from './channel/server.ts'
43
46
 
44
47
  export const name = 'dsh-master'
45
48
  export const inject = []
46
49
 
47
50
  /** 状态路由(Client UI 读取;只读、非敏感、loopback-only 由宿主 webServer 保证)。 */
48
51
  export const STATUS_ROUTE = '/dsh-master/api/status'
52
+ /** 3.3.5:智囊团只读快照(交接/计划/反馈/复核)。 */
53
+ export const DUAL_ROUTE = '/dsh-master/api/dual'
49
54
 
50
55
  /**
51
56
  * 房主核心 apply。
@@ -123,8 +128,80 @@ export function apply(ctx, config = {}) {
123
128
  }))
124
129
  }
125
130
 
126
- const service = {
127
- id: SERVICE.master,
131
+ /**
132
+ * 3.3.5:智囊团只读快照(面板数据源)。
133
+ * 只暴露“谁在等什么”,不暴露 token、不提供写入口;dual 未装配时给 available:false。
134
+ */
135
+ const dualSnapshot = () => {
136
+ const dual = safeGet(ctx, SERVICE.dual)
137
+ if (!dual || typeof dual.status !== 'function') {
138
+ return { ok: true, available: false, reason: 'dual-service-unavailable', updatedAt: Date.now(), handoffs: [], plans: [], feedbacks: [], reviews: [], loop: null }
139
+ }
140
+ const safe = (fn, fallback) => { try { return fn() } catch { return fallback } }
141
+ const handoffs = safe(() => dual.pm.listHandoffs().items, [])
142
+ const plans = safe(() => dual.pjm.listPlans().items, [])
143
+ const feedbacks = safe(() => dual.pjm.listFeedback().items, [])
144
+ const reviews = safe(() => dual.pm.listReviews().items, [])
145
+ return {
146
+ ok: true,
147
+ available: true,
148
+ updatedAt: Date.now(),
149
+ agents: { pm: 'master-pm', pjm: 'master-pjm' },
150
+ loop: safe(() => dual.status().loop, null),
151
+ handoffs: handoffs.map((item) => ({
152
+ id: item.id,
153
+ prdId: item.prdId,
154
+ title: item.title,
155
+ status: item.status,
156
+ reason: item.reason ?? null,
157
+ openQuestions: (item.openQuestions ?? []).slice(0, 10),
158
+ acceptance: (item.acceptance ?? []).slice(0, 10),
159
+ planId: item.planId ?? null,
160
+ preparedAt: item.preparedAt ?? null,
161
+ })),
162
+ plans: plans.map((plan) => {
163
+ const progress = safe(() => dual.pjm.progress(plan.id).progress, null)
164
+ const lastReport = Array.isArray(plan.reports) && plan.reports.length > 0 ? plan.reports.at(-1) : null
165
+ return {
166
+ id: plan.id,
167
+ prdId: plan.prdId,
168
+ title: plan.title,
169
+ status: plan.status,
170
+ handoffId: plan.handoffId ?? null,
171
+ progress,
172
+ lastReport: lastReport ? { status: lastReport.status, summary: lastReport.summary, blocker: lastReport.blocker ?? null, at: lastReport.at } : null,
173
+ pmReview: plan.pmReview ?? null,
174
+ feedbackId: plan.feedbackId ?? null,
175
+ }
176
+ }),
177
+ feedbacks: feedbacks.map((item) => ({
178
+ id: item.id,
179
+ planId: item.planId,
180
+ prdId: item.prdId ?? null,
181
+ createdAt: item.createdAt ?? null,
182
+ summary: String(item.summary ?? '').slice(0, 500),
183
+ metrics: item.metrics ?? null,
184
+ openIssues: (item.openIssues ?? []).slice(0, 10),
185
+ })),
186
+ reviews: reviews.map((review) => ({
187
+ id: review.id,
188
+ feedbackId: review.feedbackId,
189
+ planId: review.planId,
190
+ verdict: review.verdict,
191
+ createdAt: review.createdAt ?? null,
192
+ failed: (review.acceptanceChecks ?? []).filter((check) => check.met !== true).map((check) => check.criterion),
193
+ })),
194
+ }
195
+ }
196
+
197
+ /**
198
+ * 智能体通道 token(3.3.5):每次加载随机生成;只读 status 暴露给同源面板。
199
+ * 跨站页面由 Origin 校验挡住;token 只是纵深防御,不是唯一门槛。
200
+ */
201
+ const channelToken = String(config.channelToken ?? '').trim() || randomBytes(24).toString('hex')
202
+ const safeOrigin = (fn) => { try { return String(fn() ?? '') } catch { return '' } }
203
+
204
+ const service = { id: SERVICE.master,
128
205
  name: 'dsh-master',
129
206
  version: MASTER_VERSION,
130
207
  role,
@@ -187,6 +264,8 @@ export function apply(ctx, config = {}) {
187
264
  }
188
265
  } catch { return null }
189
266
  })(),
267
+ /** 3.3.5:智囊团 WebSocket 通道(同源面板用;chat 目前返回具名缺口)。 */
268
+ channel: { path: CHANNEL_PATH, token: channelToken },
190
269
  roleMatrix: ROLE_MATRIX,
191
270
  bridge: {
192
271
  enabled: Boolean(String(config.bridgeToken ?? '').trim()) || bridgeTokens.describe().tokens.length > 0,
@@ -234,6 +313,30 @@ export function apply(ctx, config = {}) {
234
313
  } catch (error) {
235
314
  logWarn(`[dsh-master] status route registration failed: ${String(error?.message ?? error)}`)
236
315
  }
316
+ // v3.3.5:智囊团只读数据源(交接 / 计划 / 反馈 / 复核)。无写口、无 token。
317
+ try {
318
+ webServer.register({
319
+ kind: 'exact',
320
+ path: DUAL_ROUTE,
321
+ method: 'GET',
322
+ handler: async (req, res) => jsonResponse(res, 200, dualSnapshot()),
323
+ })
324
+ } catch (error) {
325
+ logWarn(`[dsh-master] dual route registration failed: ${String(error?.message ?? error)}`)
326
+ }
327
+ // v3.3.5:智囊团 WebSocket 通道(双向;当前只推快照,chat 返回具名缺口)。
328
+ try {
329
+ const channel = attachCommandCenterChannel({
330
+ webServer,
331
+ token: channelToken,
332
+ dualSnapshot,
333
+ allowedOrigin: safeOrigin(() => webServer.getOrigin?.()),
334
+ logger: { info: logWarn, warn: logWarn, error: logWarn },
335
+ })
336
+ logWarn(`[dsh-master] command-center channel: ${channel.attached ? channel.path : `not attached (${channel.reason})`}`)
337
+ } catch (error) {
338
+ logWarn(`[dsh-master] command-center channel failed: ${String(error?.message ?? error)}`)
339
+ }
237
340
  // v3.2.3:原身 Router → 母体 forker 的跨进程派发桥。
238
341
  try {
239
342
  webServer.register({
package/src/kernel.ts CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  import crypto from 'node:crypto'
12
12
 
13
- export const MASTER_VERSION = '3.3.4'
13
+ export const MASTER_VERSION = '3.3.5'
14
14
  export const FANG_CONTRACT_MIN = '3.1.14'
15
15
 
16
16
  export const SERVICE = Object.freeze({
@@ -75,18 +75,18 @@ export const AGENT_CATALOG = Object.freeze([
75
75
  preset: 'master-pm',
76
76
  name: '产品经理(PM)',
77
77
  nameEn: 'Product Manager',
78
- responsibilities: ['需求澄清与选择题请示', 'WHY/WHAT 与 PRD', '六类现状盘点(证据驱动)'],
79
- tools: ['pm_create_prd', 'pm_create_assessment', 'pm_write_prd', 'pm_list_prds'],
80
- boundary: '只读代码与文档 + 向用户提问 + 写 PRD/盘点;不执行、不派发、不发布。',
78
+ responsibilities: ['需求澄清与选择题请示', 'WHY/WHAT 与 PRD', '六类现状盘点(证据驱动)', '交接给 PJM', '复核/复盘/总结'],
79
+ tools: ['pm_create_prd', 'pm_create_assessment', 'pm_write_prd', 'pm_list_prds', 'pm_handoff_to_pjm', 'pm_review_feedback', 'pm_pipeline', 'pm_next_action'],
80
+ boundary: '只读代码与文档 + 向用户提问 + 写 PRD/盘点/复核;不执行、不派发、不发布。',
81
81
  }),
82
82
  Object.freeze({
83
83
  id: 'master-pjm',
84
84
  preset: 'master-pjm',
85
85
  name: '项目经理(PJM)',
86
86
  nameEn: 'Project Manager',
87
- responsibilities: ['WBS 与七类项目管理', '隔离派发与节点汇总', '测试/交付/验收与结案'],
88
- tools: ['pjm_create_plan', 'pjm_dispatch_step', 'pjm_complete_step', 'pjm_project_status', 'pjm_write_project', 'pjm_close_project'],
89
- boundary: '只读代码 + 计划/派发/汇报;派发必须经 fangForker.spawn,无 forker 即 fail-closed。',
87
+ responsibilities: ['WBS 与七类项目管理', '接单与隔离派发', '节点自反馈', '测试/交付/验收与结案'],
88
+ tools: ['pjm_create_plan', 'pjm_take_handoff', 'pjm_dispatch_step', 'pjm_complete_step', 'pjm_project_status', 'pjm_check_step', 'pjm_report', 'pjm_submit_feedback', 'pjm_write_project', 'pjm_close_project', 'pjm_next_action'],
89
+ boundary: '只读代码 + 计划/派发/反馈;派发必须经 fangForker.spawn,无 forker 即 fail-closed;不替 PM 决定需求。',
90
90
  }),
91
91
  ])
92
92
 
@@ -117,7 +117,7 @@ export function apply(ctx, config = {}) {
117
117
 
118
118
  const service = {
119
119
  id: SERVICE.cost,
120
- version: '3.3.4',
120
+ version: '3.3.5',
121
121
  role,
122
122
  get mode() {
123
123
  return system.policy.describe().mode
@@ -354,7 +354,7 @@ export function apply(ctx, config = {}) {
354
354
  return {
355
355
  ok: true,
356
356
  id: 'cost-optimizer',
357
- version: '3.3.4',
357
+ version: '3.3.5',
358
358
  role,
359
359
  mode: current.mode,
360
360
  requestedMode: current.requestedMode,
@@ -396,7 +396,7 @@ export function apply(ctx, config = {}) {
396
396
  doubleCountGuard: report.doubleCountGuard,
397
397
  }
398
398
  })(),
399
- note: '3.3.4:系统层记账/预算/灰度/审计 + A1 工具输出紧凑化(默认 shadow)+ 瓮城回流出口(contract 1.1.1 真实投递,回执计数修复);工具面 A2–A3 与决策复用 B1–B4 已接入(shadow 默认);3.3.3 起角色矩阵母体全开、分身挂双主管;3.3.4 起内置 PM/PJM 两个智能体 preset。',
399
+ note: '3.3.5:系统层记账/预算/灰度/审计 + A1 工具输出紧凑化(默认 shadow)+ 瓮城回流出口(contract 1.1.1 真实投递,回执计数修复);工具面 A2–A3 与决策复用 B1–B4 已接入(shadow 默认);3.3.3 起角色矩阵母体全开、分身挂双主管;3.3.4 起内置 PM/PJM 两个智能体 preset;3.3.5 起补齐引擎原语、可中断 LLM 流、WS 通道与流式管线。',
400
400
  }
401
401
  },
402
402
  }
@@ -31,8 +31,9 @@ import {
31
31
  roleAllowed,
32
32
  safeGet,
33
33
  } from '../kernel.ts'
34
- import { closePlan, normalizeAssessment, normalizePlan, normalizePrd, planProgress } from '../dual/schema.ts'
35
- import { renderAssessmentDocument, renderClosureDocument, renderPrdDocument, renderProjectDocument } from '../dual/documents.ts'
34
+ import { closePlan, acceptHandoff, normalizeAssessment, normalizeFeedback, normalizeHandoff, normalizePlan, normalizePrd, normalizeReview, planProgress } from '../dual/schema.ts'
35
+ import { renderAssessmentDocument, renderClosureDocument, renderPrdDocument, renderProjectDocument, renderReviewDocument } from '../dual/documents.ts'
36
+ import { nextPjmAction, nextPmAction } from '../dual/orchestrator.ts'
36
37
 
37
38
  export const name = 'dsh-master-dual-manager'
38
39
  export const inject = ['dshMaster']
@@ -61,9 +62,15 @@ export function apply(ctx, config = {}) {
61
62
  const newPrdId = createIdFactory('prd')
62
63
  const newAssessmentId = createIdFactory('review')
63
64
  const newPlanId = createIdFactory('plan')
65
+ const newHandoffId = createIdFactory('handoff')
66
+ const newFeedbackId = createIdFactory('feedback')
67
+ const newReviewId = createIdFactory('pmreview')
64
68
  const prds = new Map()
65
69
  const assessments = new Map()
66
70
  const plans = new Map()
71
+ const handoffs = new Map()
72
+ const feedbacks = new Map()
73
+ const reviews = new Map()
67
74
 
68
75
  const decisionCenter = () => safeGet(ctx, FANG_SERVICE.decisionCenter)
69
76
  const forker = () => {
@@ -141,6 +148,100 @@ export function apply(ctx, config = {}) {
141
148
  if (!assessment) return masterError('assessment-not-found', 'Assessment not found', { id: String(id ?? '') })
142
149
  return await writeWorkspaceDocument(agent, path.join('docs', 'pm', `REVIEW-${id}.md`), renderAssessmentDocument(assessment))
143
150
  },
151
+ /** 闭环第一步:PM 把 PRD(+盘点)打成交接包;open questions / 冲突未清则不给 ready。 */
152
+ createHandoff(prdId, input = {}) {
153
+ const prd = prds.get(String(prdId ?? ''))
154
+ if (!prd) return masterError('prd-not-found', 'PRD not found', { id: String(prdId ?? '') })
155
+ const assessmentId = String(input.assessmentId ?? '').trim()
156
+ const assessment = assessmentId ? assessments.get(assessmentId) : null
157
+ if (assessmentId && !assessment) return masterError('assessment-not-found', 'Assessment not found', { id: assessmentId })
158
+ const made = normalizeHandoff(prd, assessment, { ...input, prdId: prd.id }, { id: newHandoffId() })
159
+ if (!made.ok) return made
160
+ handoffs.set(made.handoff.id, made.handoff)
161
+ return { ...made, ready: made.handoff.status === 'ready', reason: made.handoff.reason }
162
+ },
163
+ getHandoff(id) {
164
+ const handoff = handoffs.get(String(id ?? ''))
165
+ return handoff ? { ok: true, handoff } : masterError('handoff-not-found', 'Handoff not found', { id: String(id ?? '') })
166
+ },
167
+ listHandoffs() {
168
+ return { ok: true, items: [...handoffs.values()] }
169
+ },
170
+ /** 闭环第三步:PM 复核 PJM 反馈(逐条验收 + 复盘 + 总结),结论必须与验收一致。 */
171
+ reviewFeedback(feedbackId, input = {}) {
172
+ const feedback = feedbacks.get(String(feedbackId ?? ''))
173
+ if (!feedback) return masterError('feedback-not-found', 'Feedback not found', { id: String(feedbackId ?? '') })
174
+ const made = normalizeReview(feedback, { ...input, feedbackId: feedback.id }, { id: newReviewId() })
175
+ if (!made.ok) return made
176
+ reviews.set(made.review.id, made.review)
177
+ const plan = plans.get(feedback.planId)
178
+ const handoff = [...handoffs.values()].find((item) => item.planId === feedback.planId) ?? null
179
+ if (plan) {
180
+ plan.pmReview = Object.freeze({
181
+ reviewId: made.review.id,
182
+ verdict: made.review.verdict,
183
+ accepted: made.review.verdict === 'accepted',
184
+ reviewedAt: made.review.createdAt,
185
+ })
186
+ // 结案后仍保留事实,只标注“是否被 PM 接受”;不伪造重新开场。
187
+ plan.updatedAt = Date.now()
188
+ }
189
+ return { ok: true, review: made.review, planAccepted: made.review.verdict === 'accepted', handoffId: handoff?.id ?? null }
190
+ },
191
+ getReview(id) {
192
+ const review = reviews.get(String(id ?? ''))
193
+ return review ? { ok: true, review } : masterError('pm-review-not-found', 'Review not found', { id: String(id ?? '') })
194
+ },
195
+ listReviews() {
196
+ return { ok: true, items: [...reviews.values()] }
197
+ },
198
+ /** 闭环状态:一个 PRD 走到哪一步(交接/计划/反馈/复核)。 */
199
+ pipeline(prdId) {
200
+ const prd = prds.get(String(prdId ?? ''))
201
+ if (!prd) return masterError('prd-not-found', 'PRD not found', { id: String(prdId ?? '') })
202
+ const handoff = [...handoffs.values()].filter((item) => item.prdId === prd.id).at(-1) ?? null
203
+ const plan = handoff?.planId ? plans.get(handoff.planId) ?? null : [...plans.values()].filter((item) => item.prdId === prd.id).at(-1) ?? null
204
+ const feedback = plan ? [...feedbacks.values()].filter((item) => item.planId === plan.id).at(-1) ?? null : null
205
+ const review = feedback ? [...reviews.values()].filter((item) => item.feedbackId === feedback.id).at(-1) ?? null : null
206
+ const stage = review
207
+ ? (review.verdict === 'accepted' ? 'accepted' : 'changes-requested')
208
+ : feedback ? 'feedback-submitted'
209
+ : plan ? (plan.status === 'closed' ? 'closed-without-feedback' : 'planning')
210
+ : handoff ? (handoff.status === 'ready' ? 'handoff-ready' : 'needs-clarification')
211
+ : 'drafted'
212
+ return {
213
+ ok: true,
214
+ prdId: prd.id,
215
+ stage,
216
+ handoffId: handoff?.id ?? null,
217
+ handoffStatus: handoff?.status ?? null,
218
+ planId: plan?.id ?? null,
219
+ planStatus: plan?.status ?? null,
220
+ feedbackId: feedback?.id ?? null,
221
+ reviewId: review?.id ?? null,
222
+ verdict: review?.verdict ?? null,
223
+ }
224
+ },
225
+ /** 闭环第三步落盘:复核 + 复盘 + 总结文档。 */
226
+ async writeReview(reviewId, { agent } = {}) {
227
+ const review = reviews.get(String(reviewId ?? ''))
228
+ if (!review) return masterError('pm-review-not-found', 'Review not found', { id: String(reviewId ?? '') })
229
+ const feedback = feedbacks.get(review.feedbackId) ?? null
230
+ const handoff = feedback ? [...handoffs.values()].find((item) => item.planId === review.planId) ?? null : null
231
+ const markdown = renderReviewDocument(review, { feedback, handoff })
232
+ return await writeWorkspaceDocument(agent, path.join('docs', 'pm', `REVIEW-${review.id}.md`), markdown)
233
+ },
234
+ /** 编排建议:PM 下一步该做什么(纯推荐,不产生副作用)。 */
235
+ nextAction(prdId) {
236
+ const prd = prds.get(String(prdId ?? ''))
237
+ if (!prd) return masterError('prd-not-found', 'PRD not found', { id: String(prdId ?? '') })
238
+ const assessment = [...assessments.values()].filter((item) => item.prdId === prd.id).at(-1) ?? null
239
+ const handoff = [...handoffs.values()].filter((item) => item.prdId === prd.id).at(-1) ?? null
240
+ const plan = handoff?.planId ? plans.get(handoff.planId) ?? null : null
241
+ const feedback = plan ? [...feedbacks.values()].filter((item) => item.planId === plan.id).at(-1) ?? null : null
242
+ const review = feedback ? [...reviews.values()].filter((item) => item.feedbackId === feedback.id).at(-1) ?? null : null
243
+ return { ok: true, prdId: prd.id, ...nextPmAction({ prd, assessment, handoff, feedback, review }), pipeline: pm.pipeline(prd.id) }
244
+ },
144
245
  /** 歧义澄清:复用瓮城决策中心,返回 ticket 或明确的 unavailable。 */
145
246
  async askUser({ question, context = '', options = [], level = 'yellow' } = {}) {
146
247
  const q = String(question ?? '').trim()
@@ -166,15 +267,112 @@ export function apply(ctx, config = {}) {
166
267
  }
167
268
 
168
269
  const pjm = {
169
- /** PRD → 七类项目计划与可调度 WBS。 */
270
+ /** PRD → 七类项目计划与可调度 WBS;给 handoffId 时同时完成接单。 */
170
271
  plan(prdId, input = {}) {
171
272
  const prd = prds.get(String(prdId ?? ''))
172
273
  if (!prd) return masterError('prd-not-found', 'PRD not found', { id: String(prdId ?? '') })
274
+ const handoffId = String(input.handoffId ?? '').trim()
275
+ const handoff = handoffId ? handoffs.get(handoffId) : null
276
+ if (handoffId && !handoff) return masterError('handoff-not-found', 'Handoff not found', { id: handoffId })
277
+ if (handoff && handoff.status === 'needs-clarification') {
278
+ return masterError('handoff-not-ready', 'PM must resolve open questions before PJM plans', { reason: handoff.reason, openQuestions: handoff.openQuestions })
279
+ }
280
+ if (handoff && handoff.prdId !== prd.id) return masterError('handoff-prd-mismatch', 'Handoff belongs to a different PRD', { handoffId, prdId: prd.id })
173
281
  const made = normalizePlan(prd, input, { id: newPlanId() })
174
282
  if (!made.ok) return made
175
283
  plans.set(made.plan.id, made.plan)
284
+ if (handoff) {
285
+ const accepted = acceptHandoff(handoff, { planId: made.plan.id })
286
+ if (!accepted.ok) return { ...accepted, plan: made.plan }
287
+ made.plan.handoffId = handoff.id
288
+ }
176
289
  return made
177
290
  },
291
+ /** 接单(独立调用):只有 ready 的交接包能被接走。 */
292
+ takeHandoff(handoffId, input = {}) {
293
+ const handoff = handoffs.get(String(handoffId ?? ''))
294
+ if (!handoff) return masterError('handoff-not-found', 'Handoff not found', { id: String(handoffId ?? '') })
295
+ return acceptHandoff(handoff, { planId: input.planId })
296
+ },
297
+ listHandoffs() {
298
+ return { ok: true, items: [...handoffs.values()] }
299
+ },
300
+ /** 节点自反馈:执行中随时可报,不等到最后;blocked 必须写原因。 */
301
+ report(planId, input = {}) {
302
+ const plan = plans.get(String(planId ?? ''))
303
+ if (!plan) return masterError('plan-not-found', 'Plan not found', { id: String(planId ?? '') })
304
+ const summary = String(input.summary ?? '').trim()
305
+ if (!summary) return missing('summary')
306
+ const status = ['progress', 'blocked', 'done'].includes(String(input.status)) ? String(input.status) : 'progress'
307
+ if (status === 'blocked' && !String(input.blocker ?? input.reason ?? '').trim()) {
308
+ return masterError('blocker-required', 'a blocked report must name the blocker')
309
+ }
310
+ plan.reports = Array.isArray(plan.reports) ? plan.reports : []
311
+ plan.reports.push(Object.freeze({
312
+ stepId: String(input.stepId ?? '') || null,
313
+ status,
314
+ summary,
315
+ blocker: String(input.blocker ?? input.reason ?? '').trim() || null,
316
+ evidence: Array.isArray(input.evidence) ? input.evidence.map(String).filter(Boolean) : [],
317
+ at: Date.now(),
318
+ }))
319
+ plan.updatedAt = Date.now()
320
+ return { ok: true, planId: plan.id, status, reports: plan.reports.length, progress: planProgress(plan) }
321
+ },
322
+ /** 项目级自反馈:计划全部步骤完成后提交给 PM 复核。 */
323
+ submitFeedback(planId, input = {}) {
324
+ const plan = plans.get(String(planId ?? ''))
325
+ if (!plan) return masterError('plan-not-found', 'Plan not found', { id: String(planId ?? '') })
326
+ const prd = prds.get(plan.prdId) ?? null
327
+ const handoff = [...handoffs.values()].find((item) => item.planId === plan.id) ?? null
328
+ const made = normalizeFeedback(plan, prd, { ...input, planId: plan.id, handoffId: handoff?.id ?? input.handoffId }, { id: newFeedbackId() })
329
+ if (!made.ok) return made
330
+ feedbacks.set(made.feedback.id, made.feedback)
331
+ plan.feedbackId = made.feedback.id
332
+ return { ...made, handoffId: handoff?.id ?? null }
333
+ },
334
+ listFeedback() {
335
+ return { ok: true, items: [...feedbacks.values()] }
336
+ },
337
+ /** 单步状态:房主没有 VM 作业队列,这里报的是派发/执行/汇报的真实状态。 */
338
+ checkStep(planId, stepId) {
339
+ const plan = plans.get(String(planId ?? ''))
340
+ if (!plan) return masterError('plan-not-found', 'Plan not found', { id: String(planId ?? '') })
341
+ const step = plan.steps.find((item) => item.id === String(stepId ?? ''))
342
+ if (!step) return masterError('step-not-found', 'Step not found', { id: String(stepId ?? '') })
343
+ const reports = (Array.isArray(plan.reports) ? plan.reports : []).filter((report) => !report.stepId || report.stepId === step.id)
344
+ return {
345
+ ok: true,
346
+ planId: plan.id,
347
+ stepId: step.id,
348
+ title: step.title,
349
+ status: step.status,
350
+ owner: step.owner ?? null,
351
+ where: step.where ?? null,
352
+ estimateMinutes: step.estimateMinutes ?? null,
353
+ actualMinutes: step.actualMinutes ?? null,
354
+ summary: step.summary ?? null,
355
+ evidence: step.evidence ?? [],
356
+ acceptance: step.acceptance ?? [],
357
+ fork: step.fork ?? null,
358
+ reports: reports.slice(-5),
359
+ note: 'no-vm-jobs: 房主经 fangForker 派发隔离执行体,没有 vm_job_submit / vm_job_status 这类作业队列',
360
+ }
361
+ },
362
+ /** 编排建议:PJM 下一步该做什么(纯推荐,不产生副作用)。 */
363
+ nextAction(planId) {
364
+ const plan = plans.get(String(planId ?? ''))
365
+ if (!plan) return masterError('plan-not-found', 'Plan not found', { id: String(planId ?? '') })
366
+ const handoff = [...handoffs.values()].find((item) => item.planId === plan.id)
367
+ ?? [...handoffs.values()].filter((item) => item.prdId === plan.prdId).at(-1)
368
+ ?? null
369
+ const feedback = [...feedbacks.values()].filter((item) => item.planId === plan.id).at(-1) ?? null
370
+ return { ok: true, planId: plan.id, ...nextPjmAction({ handoff, plan, feedback }), progress: planProgress(plan) }
371
+ },
372
+ getFeedback(id) {
373
+ const feedback = feedbacks.get(String(id ?? ''))
374
+ return feedback ? { ok: true, feedback } : masterError('feedback-not-found', 'Feedback not found', { id: String(id ?? '') })
375
+ },
178
376
  getPlan(id) {
179
377
  const plan = plans.get(String(id ?? ''))
180
378
  return plan ? { ok: true, plan } : masterError('plan-not-found', 'Plan not found', { id: String(id ?? '') })
@@ -287,8 +485,15 @@ export function apply(ctx, config = {}) {
287
485
  version: MASTER_VERSION,
288
486
  role,
289
487
  agents: { pm: 'master-pm', pjm: 'master-pjm' },
290
- pm: { prds: prds.size, assessments: assessments.size },
291
- pjm: { plans: plans.size, closed: [...plans.values()].filter((plan) => plan.status === 'closed').length },
488
+ pm: { prds: prds.size, assessments: assessments.size, handoffs: handoffs.size, reviews: reviews.size },
489
+ pjm: { plans: plans.size, closed: [...plans.values()].filter((plan) => plan.status === 'closed').length, feedbacks: feedbacks.size },
490
+ loop: {
491
+ pendingHandoffs: [...handoffs.values()].filter((item) => item.status !== 'accepted').length,
492
+ acceptedHandoffs: [...handoffs.values()].filter((item) => item.status === 'accepted').length,
493
+ awaitingReview: [...feedbacks.values()].filter((item) => ![...reviews.values()].some((review) => review.feedbackId === item.id)).length,
494
+ accepted: [...reviews.values()].filter((review) => review.verdict === 'accepted').length,
495
+ changesRequested: [...reviews.values()].filter((review) => review.verdict === 'changes-requested').length,
496
+ },
292
497
  cooperation: {
293
498
  decisionCenter: Boolean(decisionCenter()),
294
499
  forker: Boolean(forker()),
@@ -301,7 +506,7 @@ export function apply(ctx, config = {}) {
301
506
  master.registerSubplugin({
302
507
  id: 'dual-manager',
303
508
  name: 'dsh-master-dual-manager',
304
- capabilities: ['pm-prd', 'pm-assessment', 'pm-document', 'pm-ask-user', 'pjm-plan', 'pjm-dispatch', 'pjm-progress', 'pjm-close'],
509
+ capabilities: ['pm-prd', 'pm-assessment', 'pm-document', 'pm-ask-user', 'pm-handoff', 'pm-review', 'pjm-plan', 'pjm-dispatch', 'pjm-progress', 'pjm-close', 'pjm-feedback'],
305
510
  description: 'PM/PJM 双智能体共享服务;master-pm 与 master-pjm preset 复用此服务',
306
511
  })
307
512
  return service