@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
@@ -3,19 +3,26 @@
3
3
  // @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐(worker/ 仍严格检查)。
4
4
 
5
5
  /**
6
- * 子插件 3:双主管(PM + PJM)。
6
+ * 双智能体共享服务(PM + PJM)。
7
7
  *
8
- * 定位:运行在母体/元主内的元 Agent;PM 把模糊需求变成结构化 PRD,PJM 把 PRD
9
- * 拆成步骤并通过瓮城的 fangForker 调度分身执行。本子插件不直接运行生产任务:
10
- * * 没有 fangForker.spawn 时 dispatch fail-closed;
11
- * * 歧义澄清复用瓮城决策中心(fangDecisionCenter),不另造一套审批;
12
- * * 角色:只允许 mother / essence(分身里没有“管理层”)。
8
+ * `master-pm` / `master-pjm` 是两个独立的 DSH Agent preset;本服务保存它们协作的
9
+ * 结构化 PRD、六类盘点、WBS、节点与结案状态,并负责文档落盘。
10
+ *
11
+ * 边界:
12
+ * * PM 只做需求与证据盘点,不执行代码、不派发分身;
13
+ * * PJM 只做计划、隔离派发、进度与结案,不替用户决定需求;
14
+ * * 没有 fangForker.spawn 时 dispatch fail-closed,绝不在宿主本地降级执行;
15
+ * * 歧义澄清复用瓮城决策中心,不另造一套审批;
16
+ * * 文档只写当前 Session workspace 下的 docs/pm 或 docs/pjm,不接受任意路径。
13
17
  */
14
18
 
19
+ import fs from 'node:fs/promises'
20
+ import path from 'node:path'
21
+
15
22
  import {
16
23
  FANG_SERVICE,
24
+ MASTER_VERSION,
17
25
  ROW,
18
- ROLE,
19
26
  SERVICE,
20
27
  createIdFactory,
21
28
  detectRole,
@@ -24,34 +31,27 @@ import {
24
31
  roleAllowed,
25
32
  safeGet,
26
33
  } from '../kernel.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'
27
37
 
28
38
  export const name = 'dsh-master-dual-manager'
29
39
  export const inject = ['dshMaster']
30
40
  export const ROW_ID = ROW.dual
31
41
 
32
- const PRIORITIES = Object.freeze(['low', 'normal', 'high', 'critical'])
33
- const DEFAULT_STEP_KIND = 'development'
34
-
35
- function stringList(value) {
36
- if (!Array.isArray(value)) return []
37
- return value.map((item) => String(item ?? '').trim()).filter(Boolean)
38
- }
39
-
40
42
  function missing(name) {
41
43
  return masterError(`${name}-required`, `${name} is required`)
42
44
  }
43
45
 
44
46
  /**
45
47
  * @param {object} ctx
46
- * @param {{enabled?:boolean}} [config]
48
+ * @param {{enabled?:boolean, dispatch?:object}} [config]
47
49
  */
48
50
  export function apply(ctx, config = {}) {
49
51
  if (!enabledOf(config)) return { skipped: true, reason: 'disabled-by-config' }
50
52
  const role = detectRole()
51
53
  if (!roleAllowed(ROW_ID, role)) {
52
- try {
53
- ctx?.logger?.info?.(`[dsh-master] dual-manager skipped: role=${role} not in mother/essence`)
54
- } catch { /* ignore */ }
54
+ try { ctx?.logger?.info?.(`[dsh-master] dual-manager skipped: role=${role}`) } catch { /* ignore */ }
55
55
  return { skipped: true, reason: `role-not-allowed:${role}` }
56
56
  }
57
57
  const master = safeGet(ctx, SERVICE.master)
@@ -60,9 +60,17 @@ export function apply(ctx, config = {}) {
60
60
  }
61
61
 
62
62
  const newPrdId = createIdFactory('prd')
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()
69
+ const assessments = new Map()
65
70
  const plans = new Map()
71
+ const handoffs = new Map()
72
+ const feedbacks = new Map()
73
+ const reviews = new Map()
66
74
 
67
75
  const decisionCenter = () => safeGet(ctx, FANG_SERVICE.decisionCenter)
68
76
  const forker = () => {
@@ -70,27 +78,35 @@ export function apply(ctx, config = {}) {
70
78
  return service && typeof service.spawn === 'function' ? service : null
71
79
  }
72
80
 
81
+ /** 文档写口:只接受代码内生成的相对路径,根目录来自当前 Session。 */
82
+ async function writeWorkspaceDocument(agent, relativePath, content) {
83
+ const workingDirectory = safeGet(ctx, 'workingDirectory')
84
+ if (!workingDirectory) return masterError('working-directory-unavailable', 'workingDirectory service is required to write agent documents')
85
+ try {
86
+ const root = agent && typeof workingDirectory.ensure === 'function'
87
+ ? await workingDirectory.ensure(agent)
88
+ : String(workingDirectory.defaultDirectory ?? '')
89
+ if (!root) return masterError('working-directory-unavailable', 'no active Session workspace')
90
+ const base = path.resolve(String(root))
91
+ const target = path.resolve(base, relativePath)
92
+ if (target !== base && !target.startsWith(`${base}${path.sep}`)) {
93
+ return masterError('document-path-outside-workspace', 'document path must stay inside the Session workspace')
94
+ }
95
+ await fs.mkdir(path.dirname(target), { recursive: true })
96
+ await fs.writeFile(target, String(content), 'utf8')
97
+ return { ok: true, path: target, bytes: Buffer.byteLength(String(content), 'utf8') }
98
+ } catch (error) {
99
+ return masterError('document-write-failed', String(error?.message ?? error))
100
+ }
101
+ }
102
+
73
103
  const pm = {
74
- /** 需求 → 结构化 PRD(不调用模型;3.2.x 接入 PM agent 后由模型填充字段)。 */
104
+ /** 需求 → 结构化 PRD(模型通过 pm-agent 工具填充;服务本身不调用模型)。 */
75
105
  createPrd(input = {}) {
76
- const title = String(input.title ?? '').trim()
77
- const goal = String(input.goal ?? '').trim()
78
- if (!title) return missing('title')
79
- if (!goal) return missing('goal')
80
- const priority = PRIORITIES.includes(String(input.priority ?? 'normal')) ? String(input.priority ?? 'normal') : 'normal'
81
- const prd = Object.freeze({
82
- id: newPrdId(),
83
- title,
84
- goal,
85
- priority,
86
- constraints: Object.freeze(stringList(input.constraints)),
87
- acceptance: Object.freeze(stringList(input.acceptance)),
88
- requester: String(input.requester ?? 'user'),
89
- status: 'draft',
90
- createdAt: Date.now(),
91
- })
92
- prds.set(prd.id, prd)
93
- return { ok: true, prd }
106
+ const made = normalizePrd(input, { id: newPrdId() })
107
+ if (!made.ok) return made
108
+ prds.set(made.prd.id, made.prd)
109
+ return made
94
110
  },
95
111
  getPrd(id) {
96
112
  const prd = prds.get(String(id ?? ''))
@@ -99,6 +115,133 @@ export function apply(ctx, config = {}) {
99
115
  listPrds() {
100
116
  return { ok: true, items: [...prds.values()] }
101
117
  },
118
+ /** 六类现状盘点;completed 必须由调用方给出可复核证据。 */
119
+ createAssessment(input = {}) {
120
+ const prdId = String(input.prdId ?? '').trim()
121
+ if (prdId && !prds.has(prdId)) return masterError('prd-not-found', 'PRD not found', { id: prdId })
122
+ const made = normalizeAssessment(input, { id: newAssessmentId() })
123
+ if (!made.ok) return made
124
+ assessments.set(made.assessment.id, made.assessment)
125
+ return made
126
+ },
127
+ getAssessment(id) {
128
+ const assessment = assessments.get(String(id ?? ''))
129
+ return assessment ? { ok: true, assessment } : masterError('assessment-not-found', 'Assessment not found', { id: String(id ?? '') })
130
+ },
131
+ listAssessments() {
132
+ return { ok: true, items: [...assessments.values()] }
133
+ },
134
+ renderPrd(id, { assessmentId = '' } = {}) {
135
+ const prd = prds.get(String(id ?? ''))
136
+ if (!prd) return masterError('prd-not-found', 'PRD not found', { id: String(id ?? '') })
137
+ const assessment = assessmentId ? assessments.get(String(assessmentId)) : null
138
+ if (assessmentId && !assessment) return masterError('assessment-not-found', 'Assessment not found', { id: String(assessmentId) })
139
+ return { ok: true, markdown: renderPrdDocument(prd, assessment) }
140
+ },
141
+ async writePrd(id, { assessmentId = '', agent } = {}) {
142
+ const rendered = pm.renderPrd(id, { assessmentId })
143
+ if (!rendered.ok) return rendered
144
+ return await writeWorkspaceDocument(agent, path.join('docs', 'pm', `PRD-${id}.md`), rendered.markdown)
145
+ },
146
+ async writeAssessment(id, { agent } = {}) {
147
+ const assessment = assessments.get(String(id ?? ''))
148
+ if (!assessment) return masterError('assessment-not-found', 'Assessment not found', { id: String(id ?? '') })
149
+ return await writeWorkspaceDocument(agent, path.join('docs', 'pm', `REVIEW-${id}.md`), renderAssessmentDocument(assessment))
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
+ },
102
245
  /** 歧义澄清:复用瓮城决策中心,返回 ticket 或明确的 unavailable。 */
103
246
  async askUser({ question, context = '', options = [], level = 'yellow' } = {}) {
104
247
  const q = String(question ?? '').trim()
@@ -124,54 +267,135 @@ export function apply(ctx, config = {}) {
124
267
  }
125
268
 
126
269
  const pjm = {
127
- /** PRD → 可调度步骤计划。 */
128
- plan(prdId, { steps = [] } = {}) {
270
+ /** PRD → 七类项目计划与可调度 WBS;给 handoffId 时同时完成接单。 */
271
+ plan(prdId, input = {}) {
129
272
  const prd = prds.get(String(prdId ?? ''))
130
273
  if (!prd) return masterError('prd-not-found', 'PRD not found', { id: String(prdId ?? '') })
131
- if (!Array.isArray(steps) || steps.length === 0) return missing('steps')
132
- const normalized = []
133
- for (const [index, step] of steps.entries()) {
134
- const title = String(step?.title ?? '').trim()
135
- const prompt = String(step?.prompt ?? '').trim()
136
- if (!title) return missing(`steps[${index}].title`)
137
- if (!prompt) return missing(`steps[${index}].prompt`)
138
- normalized.push({
139
- id: String(step?.id ?? `step-${index + 1}`),
140
- title,
141
- prompt,
142
- kind: String(step?.kind ?? DEFAULT_STEP_KIND),
143
- status: 'pending',
144
- fork: null,
145
- })
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 })
146
279
  }
147
- const plan = {
148
- id: newPlanId(),
149
- prdId: prd.id,
150
- status: 'planned',
151
- steps: normalized,
152
- createdAt: Date.now(),
280
+ if (handoff && handoff.prdId !== prd.id) return masterError('handoff-prd-mismatch', 'Handoff belongs to a different PRD', { handoffId, prdId: prd.id })
281
+ const made = normalizePlan(prd, input, { id: newPlanId() })
282
+ if (!made.ok) return made
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
153
288
  }
154
- plans.set(plan.id, plan)
155
- return { ok: true, plan }
289
+ return made
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 ?? '') })
156
375
  },
157
376
  getPlan(id) {
158
377
  const plan = plans.get(String(id ?? ''))
159
378
  return plan ? { ok: true, plan } : masterError('plan-not-found', 'Plan not found', { id: String(id ?? '') })
160
379
  },
380
+ progress(id) {
381
+ const plan = plans.get(String(id ?? ''))
382
+ if (!plan) return masterError('plan-not-found', 'Plan not found', { id: String(id ?? '') })
383
+ return { ok: true, planId: plan.id, status: plan.status, progress: planProgress(plan), steps: plan.steps }
384
+ },
161
385
  /** 调瓮城 fangForker.spawn 派生执行分身;无 forker 时 fail-closed。 */
162
386
  async dispatch(planId, { stepId } = {}) {
163
387
  const plan = plans.get(String(planId ?? ''))
164
388
  if (!plan) return masterError('plan-not-found', 'Plan not found', { id: String(planId ?? '') })
389
+ if (plan.status === 'closed') return masterError('plan-already-closed', 'Closed plans cannot dispatch new steps')
165
390
  const step = plan.steps.find((item) => item.id === String(stepId ?? ''))
166
391
  if (!step) return masterError('step-not-found', 'Step not found', { id: String(stepId ?? '') })
167
392
  if (step.fork) return masterError('step-already-dispatched', 'Step already dispatched', { stepId: step.id, fork: step.fork })
168
393
  const fangForker = forker()
169
- if (!fangForker) {
170
- return masterError('fang-forker-unavailable', 'dsh-fang fangForker.spawn is required to dispatch a step')
171
- }
394
+ if (!fangForker) return masterError('fang-forker-unavailable', 'dsh-fang fangForker.spawn is required to dispatch a step')
172
395
  // 先占位再 await,避免两个并发 dispatch 同时看到 step.fork=null 而重复派生。
173
396
  step.status = 'dispatching'
174
397
  step.fork = { pending: true }
398
+ plan.updatedAt = Date.now()
175
399
  try {
176
400
  const spawnConfig = config.dispatch && typeof config.dispatch === 'object' ? config.dispatch : {}
177
401
  const fork = await fangForker.spawn({
@@ -194,26 +418,54 @@ export function apply(ctx, config = {}) {
194
418
  step.status = 'running'
195
419
  step.fork = fork ?? null
196
420
  plan.status = 'running'
421
+ plan.updatedAt = Date.now()
197
422
  return { ok: true, planId: plan.id, stepId: step.id, step, fork }
198
423
  } catch (error) {
199
424
  step.status = 'pending'; step.fork = null
200
425
  return masterError('fang-spawn-threw', String(error?.message ?? error))
201
426
  }
202
427
  },
203
- completeStep(planId, stepId, { status = 'done', summary = '' } = {}) {
428
+ completeStep(planId, stepId, { status = 'done', summary = '', evidence = [], actualMinutes = null } = {}) {
204
429
  const plan = plans.get(String(planId ?? ''))
205
430
  if (!plan) return masterError('plan-not-found', 'Plan not found', { id: String(planId ?? '') })
431
+ if (plan.status === 'closed') return masterError('plan-already-closed', 'Closed plans cannot be modified')
206
432
  const step = plan.steps.find((item) => item.id === String(stepId ?? ''))
207
433
  if (!step) return masterError('step-not-found', 'Step not found', { id: String(stepId ?? '') })
208
- const next = status === 'failed' ? 'failed' : 'done'
209
- step.status = next
434
+ step.status = status === 'failed' ? 'failed' : 'done'
210
435
  step.summary = String(summary)
436
+ step.evidence = Array.isArray(evidence) ? evidence.map(String).filter(Boolean) : []
437
+ const minutes = Number(actualMinutes)
438
+ step.actualMinutes = Number.isFinite(minutes) && minutes >= 0 ? minutes : null
211
439
  const states = plan.steps.map((item) => item.status)
212
440
  plan.status = states.every((state) => state === 'done')
213
441
  ? 'done'
214
- : (states.some((state) => state === 'running') ? 'running' : 'planned')
442
+ : (states.some((state) => state === 'running' || state === 'dispatching') ? 'running' : 'planned')
443
+ plan.updatedAt = Date.now()
215
444
  return { ok: true, plan }
216
445
  },
446
+ close(planId, input = {}) {
447
+ const plan = plans.get(String(planId ?? ''))
448
+ if (!plan) return masterError('plan-not-found', 'Plan not found', { id: String(planId ?? '') })
449
+ if (plan.status === 'closed') return masterError('plan-already-closed', 'Plan is already closed')
450
+ return closePlan(plan, input)
451
+ },
452
+ renderProject(planId) {
453
+ const plan = plans.get(String(planId ?? ''))
454
+ if (!plan) return masterError('plan-not-found', 'Plan not found', { id: String(planId ?? '') })
455
+ return { ok: true, markdown: renderProjectDocument(plan) }
456
+ },
457
+ async writeProject(planId, { agent } = {}) {
458
+ const rendered = pjm.renderProject(planId)
459
+ if (!rendered.ok) return rendered
460
+ return await writeWorkspaceDocument(agent, path.join('docs', 'pjm', `PROJECT-${planId}.md`), rendered.markdown)
461
+ },
462
+ async writeClosure(planId, { agent } = {}) {
463
+ const plan = plans.get(String(planId ?? ''))
464
+ if (!plan) return masterError('plan-not-found', 'Plan not found', { id: String(planId ?? '') })
465
+ if (plan.closure.status !== 'closed') return masterError('plan-not-closed', 'Project must be closed before writing its closure report')
466
+ const markdown = `# 项目结案 · ${plan.title}\n\n- 计划 ID:\`${plan.id}\`\n- 关联 PRD:\`${plan.prdId}\`\n\n${renderClosureDocument(plan)}\n`
467
+ return await writeWorkspaceDocument(agent, path.join('docs', 'pjm', `CLOSURE-${planId}.md`), markdown)
468
+ },
217
469
  listPlans() {
218
470
  return { ok: true, items: [...plans.values()] }
219
471
  },
@@ -221,18 +473,27 @@ export function apply(ctx, config = {}) {
221
473
 
222
474
  const service = {
223
475
  id: SERVICE.dual,
224
- version: '3.2.5',
476
+ version: MASTER_VERSION,
225
477
  role,
226
478
  pm,
227
479
  pjm,
480
+ agents: Object.freeze({ pm: 'master-pm', pjm: 'master-pjm' }),
228
481
  status() {
229
482
  return {
230
483
  ok: true,
231
484
  id: 'dual-manager',
232
- version: '3.2.5',
485
+ version: MASTER_VERSION,
233
486
  role,
234
- pm: { prds: prds.size },
235
- pjm: { plans: plans.size },
487
+ agents: { pm: 'master-pm', pjm: 'master-pjm' },
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
+ },
236
497
  cooperation: {
237
498
  decisionCenter: Boolean(decisionCenter()),
238
499
  forker: Boolean(forker()),
@@ -245,8 +506,8 @@ export function apply(ctx, config = {}) {
245
506
  master.registerSubplugin({
246
507
  id: 'dual-manager',
247
508
  name: 'dsh-master-dual-manager',
248
- capabilities: ['pm-prd', 'pm-ask-user', 'pjm-plan', 'pjm-dispatch'],
249
- description: 'PM + PJM 双主管(仅 mother/essence)',
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'],
510
+ description: 'PM/PJM 双智能体共享服务;master-pm 与 master-pjm preset 复用此服务',
250
511
  })
251
512
  return service
252
513
  }