@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
@@ -2,31 +2,33 @@
2
2
  // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
3
  // @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐(worker/ 仍严格检查)。
4
4
  /**
5
- * 子插件 3:双主管(PM + PJM)。
5
+ * 双智能体共享服务(PM + PJM)。
6
6
  *
7
- * 定位:运行在母体/元主内的元 Agent;PM 把模糊需求变成结构化 PRD,PJM 把 PRD
8
- * 拆成步骤并通过瓮城的 fangForker 调度分身执行。本子插件不直接运行生产任务:
9
- * * 没有 fangForker.spawn 时 dispatch fail-closed;
10
- * * 歧义澄清复用瓮城决策中心(fangDecisionCenter),不另造一套审批;
11
- * * 角色:只允许 mother / essence(分身里没有“管理层”)。
7
+ * `master-pm` / `master-pjm` 是两个独立的 DSH Agent preset;本服务保存它们协作的
8
+ * 结构化 PRD、六类盘点、WBS、节点与结案状态,并负责文档落盘。
9
+ *
10
+ * 边界:
11
+ * * PM 只做需求与证据盘点,不执行代码、不派发分身;
12
+ * * PJM 只做计划、隔离派发、进度与结案,不替用户决定需求;
13
+ * * 没有 fangForker.spawn 时 dispatch fail-closed,绝不在宿主本地降级执行;
14
+ * * 歧义澄清复用瓮城决策中心,不另造一套审批;
15
+ * * 文档只写当前 Session workspace 下的 docs/pm 或 docs/pjm,不接受任意路径。
12
16
  */
13
- import { FANG_SERVICE, ROW, SERVICE, createIdFactory, detectRole, enabledOf, masterError, roleAllowed, safeGet, } from "../kernel.js";
17
+ import fs from 'node:fs/promises';
18
+ import path from 'node:path';
19
+ import { FANG_SERVICE, MASTER_VERSION, ROW, SERVICE, createIdFactory, detectRole, enabledOf, masterError, roleAllowed, safeGet, } from "../kernel.js";
20
+ import { closePlan, acceptHandoff, normalizeAssessment, normalizeFeedback, normalizeHandoff, normalizePlan, normalizePrd, normalizeReview, planProgress } from "../dual/schema.js";
21
+ import { renderAssessmentDocument, renderClosureDocument, renderPrdDocument, renderProjectDocument, renderReviewDocument } from "../dual/documents.js";
22
+ import { nextPjmAction, nextPmAction } from "../dual/orchestrator.js";
14
23
  export const name = 'dsh-master-dual-manager';
15
24
  export const inject = ['dshMaster'];
16
25
  export const ROW_ID = ROW.dual;
17
- const PRIORITIES = Object.freeze(['low', 'normal', 'high', 'critical']);
18
- const DEFAULT_STEP_KIND = 'development';
19
- function stringList(value) {
20
- if (!Array.isArray(value))
21
- return [];
22
- return value.map((item) => String(item ?? '').trim()).filter(Boolean);
23
- }
24
26
  function missing(name) {
25
27
  return masterError(`${name}-required`, `${name} is required`);
26
28
  }
27
29
  /**
28
30
  * @param {object} ctx
29
- * @param {{enabled?:boolean}} [config]
31
+ * @param {{enabled?:boolean, dispatch?:object}} [config]
30
32
  */
31
33
  export function apply(ctx, config = {}) {
32
34
  if (!enabledOf(config))
@@ -34,7 +36,7 @@ export function apply(ctx, config = {}) {
34
36
  const role = detectRole();
35
37
  if (!roleAllowed(ROW_ID, role)) {
36
38
  try {
37
- ctx?.logger?.info?.(`[dsh-master] dual-manager skipped: role=${role} not in mother/essence`);
39
+ ctx?.logger?.info?.(`[dsh-master] dual-manager skipped: role=${role}`);
38
40
  }
39
41
  catch { /* ignore */ }
40
42
  return { skipped: true, reason: `role-not-allowed:${role}` };
@@ -44,37 +46,54 @@ export function apply(ctx, config = {}) {
44
46
  return { skipped: true, reason: 'master-core-unavailable' };
45
47
  }
46
48
  const newPrdId = createIdFactory('prd');
49
+ const newAssessmentId = createIdFactory('review');
47
50
  const newPlanId = createIdFactory('plan');
51
+ const newHandoffId = createIdFactory('handoff');
52
+ const newFeedbackId = createIdFactory('feedback');
53
+ const newReviewId = createIdFactory('pmreview');
48
54
  const prds = new Map();
55
+ const assessments = new Map();
49
56
  const plans = new Map();
57
+ const handoffs = new Map();
58
+ const feedbacks = new Map();
59
+ const reviews = new Map();
50
60
  const decisionCenter = () => safeGet(ctx, FANG_SERVICE.decisionCenter);
51
61
  const forker = () => {
52
62
  const service = safeGet(ctx, FANG_SERVICE.forker);
53
63
  return service && typeof service.spawn === 'function' ? service : null;
54
64
  };
65
+ /** 文档写口:只接受代码内生成的相对路径,根目录来自当前 Session。 */
66
+ async function writeWorkspaceDocument(agent, relativePath, content) {
67
+ const workingDirectory = safeGet(ctx, 'workingDirectory');
68
+ if (!workingDirectory)
69
+ return masterError('working-directory-unavailable', 'workingDirectory service is required to write agent documents');
70
+ try {
71
+ const root = agent && typeof workingDirectory.ensure === 'function'
72
+ ? await workingDirectory.ensure(agent)
73
+ : String(workingDirectory.defaultDirectory ?? '');
74
+ if (!root)
75
+ return masterError('working-directory-unavailable', 'no active Session workspace');
76
+ const base = path.resolve(String(root));
77
+ const target = path.resolve(base, relativePath);
78
+ if (target !== base && !target.startsWith(`${base}${path.sep}`)) {
79
+ return masterError('document-path-outside-workspace', 'document path must stay inside the Session workspace');
80
+ }
81
+ await fs.mkdir(path.dirname(target), { recursive: true });
82
+ await fs.writeFile(target, String(content), 'utf8');
83
+ return { ok: true, path: target, bytes: Buffer.byteLength(String(content), 'utf8') };
84
+ }
85
+ catch (error) {
86
+ return masterError('document-write-failed', String(error?.message ?? error));
87
+ }
88
+ }
55
89
  const pm = {
56
- /** 需求 → 结构化 PRD(不调用模型;3.2.x 接入 PM agent 后由模型填充字段)。 */
90
+ /** 需求 → 结构化 PRD(模型通过 pm-agent 工具填充;服务本身不调用模型)。 */
57
91
  createPrd(input = {}) {
58
- const title = String(input.title ?? '').trim();
59
- const goal = String(input.goal ?? '').trim();
60
- if (!title)
61
- return missing('title');
62
- if (!goal)
63
- return missing('goal');
64
- const priority = PRIORITIES.includes(String(input.priority ?? 'normal')) ? String(input.priority ?? 'normal') : 'normal';
65
- const prd = Object.freeze({
66
- id: newPrdId(),
67
- title,
68
- goal,
69
- priority,
70
- constraints: Object.freeze(stringList(input.constraints)),
71
- acceptance: Object.freeze(stringList(input.acceptance)),
72
- requester: String(input.requester ?? 'user'),
73
- status: 'draft',
74
- createdAt: Date.now(),
75
- });
76
- prds.set(prd.id, prd);
77
- return { ok: true, prd };
92
+ const made = normalizePrd(input, { id: newPrdId() });
93
+ if (!made.ok)
94
+ return made;
95
+ prds.set(made.prd.id, made.prd);
96
+ return made;
78
97
  },
79
98
  getPrd(id) {
80
99
  const prd = prds.get(String(id ?? ''));
@@ -83,6 +102,147 @@ export function apply(ctx, config = {}) {
83
102
  listPrds() {
84
103
  return { ok: true, items: [...prds.values()] };
85
104
  },
105
+ /** 六类现状盘点;completed 必须由调用方给出可复核证据。 */
106
+ createAssessment(input = {}) {
107
+ const prdId = String(input.prdId ?? '').trim();
108
+ if (prdId && !prds.has(prdId))
109
+ return masterError('prd-not-found', 'PRD not found', { id: prdId });
110
+ const made = normalizeAssessment(input, { id: newAssessmentId() });
111
+ if (!made.ok)
112
+ return made;
113
+ assessments.set(made.assessment.id, made.assessment);
114
+ return made;
115
+ },
116
+ getAssessment(id) {
117
+ const assessment = assessments.get(String(id ?? ''));
118
+ return assessment ? { ok: true, assessment } : masterError('assessment-not-found', 'Assessment not found', { id: String(id ?? '') });
119
+ },
120
+ listAssessments() {
121
+ return { ok: true, items: [...assessments.values()] };
122
+ },
123
+ renderPrd(id, { assessmentId = '' } = {}) {
124
+ const prd = prds.get(String(id ?? ''));
125
+ if (!prd)
126
+ return masterError('prd-not-found', 'PRD not found', { id: String(id ?? '') });
127
+ const assessment = assessmentId ? assessments.get(String(assessmentId)) : null;
128
+ if (assessmentId && !assessment)
129
+ return masterError('assessment-not-found', 'Assessment not found', { id: String(assessmentId) });
130
+ return { ok: true, markdown: renderPrdDocument(prd, assessment) };
131
+ },
132
+ async writePrd(id, { assessmentId = '', agent } = {}) {
133
+ const rendered = pm.renderPrd(id, { assessmentId });
134
+ if (!rendered.ok)
135
+ return rendered;
136
+ return await writeWorkspaceDocument(agent, path.join('docs', 'pm', `PRD-${id}.md`), rendered.markdown);
137
+ },
138
+ async writeAssessment(id, { agent } = {}) {
139
+ const assessment = assessments.get(String(id ?? ''));
140
+ if (!assessment)
141
+ return masterError('assessment-not-found', 'Assessment not found', { id: String(id ?? '') });
142
+ return await writeWorkspaceDocument(agent, path.join('docs', 'pm', `REVIEW-${id}.md`), renderAssessmentDocument(assessment));
143
+ },
144
+ /** 闭环第一步:PM 把 PRD(+盘点)打成交接包;open questions / 冲突未清则不给 ready。 */
145
+ createHandoff(prdId, input = {}) {
146
+ const prd = prds.get(String(prdId ?? ''));
147
+ if (!prd)
148
+ return masterError('prd-not-found', 'PRD not found', { id: String(prdId ?? '') });
149
+ const assessmentId = String(input.assessmentId ?? '').trim();
150
+ const assessment = assessmentId ? assessments.get(assessmentId) : null;
151
+ if (assessmentId && !assessment)
152
+ return masterError('assessment-not-found', 'Assessment not found', { id: assessmentId });
153
+ const made = normalizeHandoff(prd, assessment, { ...input, prdId: prd.id }, { id: newHandoffId() });
154
+ if (!made.ok)
155
+ return made;
156
+ handoffs.set(made.handoff.id, made.handoff);
157
+ return { ...made, ready: made.handoff.status === 'ready', reason: made.handoff.reason };
158
+ },
159
+ getHandoff(id) {
160
+ const handoff = handoffs.get(String(id ?? ''));
161
+ return handoff ? { ok: true, handoff } : masterError('handoff-not-found', 'Handoff not found', { id: String(id ?? '') });
162
+ },
163
+ listHandoffs() {
164
+ return { ok: true, items: [...handoffs.values()] };
165
+ },
166
+ /** 闭环第三步:PM 复核 PJM 反馈(逐条验收 + 复盘 + 总结),结论必须与验收一致。 */
167
+ reviewFeedback(feedbackId, input = {}) {
168
+ const feedback = feedbacks.get(String(feedbackId ?? ''));
169
+ if (!feedback)
170
+ return masterError('feedback-not-found', 'Feedback not found', { id: String(feedbackId ?? '') });
171
+ const made = normalizeReview(feedback, { ...input, feedbackId: feedback.id }, { id: newReviewId() });
172
+ if (!made.ok)
173
+ return made;
174
+ reviews.set(made.review.id, made.review);
175
+ const plan = plans.get(feedback.planId);
176
+ const handoff = [...handoffs.values()].find((item) => item.planId === feedback.planId) ?? null;
177
+ if (plan) {
178
+ plan.pmReview = Object.freeze({
179
+ reviewId: made.review.id,
180
+ verdict: made.review.verdict,
181
+ accepted: made.review.verdict === 'accepted',
182
+ reviewedAt: made.review.createdAt,
183
+ });
184
+ // 结案后仍保留事实,只标注“是否被 PM 接受”;不伪造重新开场。
185
+ plan.updatedAt = Date.now();
186
+ }
187
+ return { ok: true, review: made.review, planAccepted: made.review.verdict === 'accepted', handoffId: handoff?.id ?? null };
188
+ },
189
+ getReview(id) {
190
+ const review = reviews.get(String(id ?? ''));
191
+ return review ? { ok: true, review } : masterError('pm-review-not-found', 'Review not found', { id: String(id ?? '') });
192
+ },
193
+ listReviews() {
194
+ return { ok: true, items: [...reviews.values()] };
195
+ },
196
+ /** 闭环状态:一个 PRD 走到哪一步(交接/计划/反馈/复核)。 */
197
+ pipeline(prdId) {
198
+ const prd = prds.get(String(prdId ?? ''));
199
+ if (!prd)
200
+ return masterError('prd-not-found', 'PRD not found', { id: String(prdId ?? '') });
201
+ const handoff = [...handoffs.values()].filter((item) => item.prdId === prd.id).at(-1) ?? null;
202
+ const plan = handoff?.planId ? plans.get(handoff.planId) ?? null : [...plans.values()].filter((item) => item.prdId === prd.id).at(-1) ?? null;
203
+ const feedback = plan ? [...feedbacks.values()].filter((item) => item.planId === plan.id).at(-1) ?? null : null;
204
+ const review = feedback ? [...reviews.values()].filter((item) => item.feedbackId === feedback.id).at(-1) ?? null : null;
205
+ const stage = review
206
+ ? (review.verdict === 'accepted' ? 'accepted' : 'changes-requested')
207
+ : feedback ? 'feedback-submitted'
208
+ : plan ? (plan.status === 'closed' ? 'closed-without-feedback' : 'planning')
209
+ : handoff ? (handoff.status === 'ready' ? 'handoff-ready' : 'needs-clarification')
210
+ : 'drafted';
211
+ return {
212
+ ok: true,
213
+ prdId: prd.id,
214
+ stage,
215
+ handoffId: handoff?.id ?? null,
216
+ handoffStatus: handoff?.status ?? null,
217
+ planId: plan?.id ?? null,
218
+ planStatus: plan?.status ?? null,
219
+ feedbackId: feedback?.id ?? null,
220
+ reviewId: review?.id ?? null,
221
+ verdict: review?.verdict ?? null,
222
+ };
223
+ },
224
+ /** 闭环第三步落盘:复核 + 复盘 + 总结文档。 */
225
+ async writeReview(reviewId, { agent } = {}) {
226
+ const review = reviews.get(String(reviewId ?? ''));
227
+ if (!review)
228
+ 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)
238
+ return masterError('prd-not-found', 'PRD not found', { id: String(prdId ?? '') });
239
+ const assessment = [...assessments.values()].filter((item) => item.prdId === prd.id).at(-1) ?? null;
240
+ const handoff = [...handoffs.values()].filter((item) => item.prdId === prd.id).at(-1) ?? null;
241
+ const plan = handoff?.planId ? plans.get(handoff.planId) ?? null : null;
242
+ const feedback = plan ? [...feedbacks.values()].filter((item) => item.planId === plan.id).at(-1) ?? null : null;
243
+ const review = feedback ? [...reviews.values()].filter((item) => item.feedbackId === feedback.id).at(-1) ?? null : null;
244
+ return { ok: true, prdId: prd.id, ...nextPmAction({ prd, assessment, handoff, feedback, review }), pipeline: pm.pipeline(prd.id) };
245
+ },
86
246
  /** 歧义澄清:复用瓮城决策中心,返回 ticket 或明确的 unavailable。 */
87
247
  async askUser({ question, context = '', options = [], level = 'yellow' } = {}) {
88
248
  const q = String(question ?? '').trim();
@@ -110,61 +270,154 @@ export function apply(ctx, config = {}) {
110
270
  },
111
271
  };
112
272
  const pjm = {
113
- /** PRD → 可调度步骤计划。 */
114
- plan(prdId, { steps = [] } = {}) {
273
+ /** PRD → 七类项目计划与可调度 WBS;给 handoffId 时同时完成接单。 */
274
+ plan(prdId, input = {}) {
115
275
  const prd = prds.get(String(prdId ?? ''));
116
276
  if (!prd)
117
277
  return masterError('prd-not-found', 'PRD not found', { id: String(prdId ?? '') });
118
- if (!Array.isArray(steps) || steps.length === 0)
119
- return missing('steps');
120
- const normalized = [];
121
- for (const [index, step] of steps.entries()) {
122
- const title = String(step?.title ?? '').trim();
123
- const prompt = String(step?.prompt ?? '').trim();
124
- if (!title)
125
- return missing(`steps[${index}].title`);
126
- if (!prompt)
127
- return missing(`steps[${index}].prompt`);
128
- normalized.push({
129
- id: String(step?.id ?? `step-${index + 1}`),
130
- title,
131
- prompt,
132
- kind: String(step?.kind ?? DEFAULT_STEP_KIND),
133
- status: 'pending',
134
- fork: null,
135
- });
278
+ const handoffId = String(input.handoffId ?? '').trim();
279
+ const handoff = handoffId ? handoffs.get(handoffId) : null;
280
+ if (handoffId && !handoff)
281
+ return masterError('handoff-not-found', 'Handoff not found', { id: handoffId });
282
+ if (handoff && handoff.status === 'needs-clarification') {
283
+ return masterError('handoff-not-ready', 'PM must resolve open questions before PJM plans', { reason: handoff.reason, openQuestions: handoff.openQuestions });
136
284
  }
137
- const plan = {
138
- id: newPlanId(),
139
- prdId: prd.id,
140
- status: 'planned',
141
- steps: normalized,
142
- createdAt: Date.now(),
285
+ if (handoff && handoff.prdId !== prd.id)
286
+ return masterError('handoff-prd-mismatch', 'Handoff belongs to a different PRD', { handoffId, prdId: prd.id });
287
+ const made = normalizePlan(prd, input, { id: newPlanId() });
288
+ if (!made.ok)
289
+ return made;
290
+ plans.set(made.plan.id, made.plan);
291
+ if (handoff) {
292
+ const accepted = acceptHandoff(handoff, { planId: made.plan.id });
293
+ if (!accepted.ok)
294
+ return { ...accepted, plan: made.plan };
295
+ made.plan.handoffId = handoff.id;
296
+ }
297
+ return made;
298
+ },
299
+ /** 接单(独立调用):只有 ready 的交接包能被接走。 */
300
+ takeHandoff(handoffId, input = {}) {
301
+ const handoff = handoffs.get(String(handoffId ?? ''));
302
+ if (!handoff)
303
+ return masterError('handoff-not-found', 'Handoff not found', { id: String(handoffId ?? '') });
304
+ return acceptHandoff(handoff, { planId: input.planId });
305
+ },
306
+ listHandoffs() {
307
+ return { ok: true, items: [...handoffs.values()] };
308
+ },
309
+ /** 节点自反馈:执行中随时可报,不等到最后;blocked 必须写原因。 */
310
+ report(planId, input = {}) {
311
+ const plan = plans.get(String(planId ?? ''));
312
+ if (!plan)
313
+ return masterError('plan-not-found', 'Plan not found', { id: String(planId ?? '') });
314
+ const summary = String(input.summary ?? '').trim();
315
+ if (!summary)
316
+ return missing('summary');
317
+ const status = ['progress', 'blocked', 'done'].includes(String(input.status)) ? String(input.status) : 'progress';
318
+ if (status === 'blocked' && !String(input.blocker ?? input.reason ?? '').trim()) {
319
+ return masterError('blocker-required', 'a blocked report must name the blocker');
320
+ }
321
+ plan.reports = Array.isArray(plan.reports) ? plan.reports : [];
322
+ plan.reports.push(Object.freeze({
323
+ stepId: String(input.stepId ?? '') || null,
324
+ status,
325
+ summary,
326
+ blocker: String(input.blocker ?? input.reason ?? '').trim() || null,
327
+ evidence: Array.isArray(input.evidence) ? input.evidence.map(String).filter(Boolean) : [],
328
+ at: Date.now(),
329
+ }));
330
+ plan.updatedAt = Date.now();
331
+ return { ok: true, planId: plan.id, status, reports: plan.reports.length, progress: planProgress(plan) };
332
+ },
333
+ /** 项目级自反馈:计划全部步骤完成后提交给 PM 复核。 */
334
+ submitFeedback(planId, input = {}) {
335
+ const plan = plans.get(String(planId ?? ''));
336
+ if (!plan)
337
+ return masterError('plan-not-found', 'Plan not found', { id: String(planId ?? '') });
338
+ const prd = prds.get(plan.prdId) ?? null;
339
+ const handoff = [...handoffs.values()].find((item) => item.planId === plan.id) ?? null;
340
+ const made = normalizeFeedback(plan, prd, { ...input, planId: plan.id, handoffId: handoff?.id ?? input.handoffId }, { id: newFeedbackId() });
341
+ if (!made.ok)
342
+ return made;
343
+ feedbacks.set(made.feedback.id, made.feedback);
344
+ plan.feedbackId = made.feedback.id;
345
+ return { ...made, handoffId: handoff?.id ?? null };
346
+ },
347
+ listFeedback() {
348
+ return { ok: true, items: [...feedbacks.values()] };
349
+ },
350
+ /** 单步状态:房主没有 VM 作业队列,这里报的是派发/执行/汇报的真实状态。 */
351
+ checkStep(planId, stepId) {
352
+ const plan = plans.get(String(planId ?? ''));
353
+ if (!plan)
354
+ return masterError('plan-not-found', 'Plan not found', { id: String(planId ?? '') });
355
+ const step = plan.steps.find((item) => item.id === String(stepId ?? ''));
356
+ if (!step)
357
+ return masterError('step-not-found', 'Step not found', { id: String(stepId ?? '') });
358
+ const reports = (Array.isArray(plan.reports) ? plan.reports : []).filter((report) => !report.stepId || report.stepId === step.id);
359
+ return {
360
+ ok: true,
361
+ planId: plan.id,
362
+ stepId: step.id,
363
+ title: step.title,
364
+ status: step.status,
365
+ owner: step.owner ?? null,
366
+ where: step.where ?? null,
367
+ estimateMinutes: step.estimateMinutes ?? null,
368
+ actualMinutes: step.actualMinutes ?? null,
369
+ summary: step.summary ?? null,
370
+ evidence: step.evidence ?? [],
371
+ acceptance: step.acceptance ?? [],
372
+ fork: step.fork ?? null,
373
+ reports: reports.slice(-5),
374
+ note: 'no-vm-jobs: 房主经 fangForker 派发隔离执行体,没有 vm_job_submit / vm_job_status 这类作业队列',
143
375
  };
144
- plans.set(plan.id, plan);
145
- return { ok: true, plan };
376
+ },
377
+ /** 编排建议:PJM 下一步该做什么(纯推荐,不产生副作用)。 */
378
+ nextAction(planId) {
379
+ const plan = plans.get(String(planId ?? ''));
380
+ if (!plan)
381
+ return masterError('plan-not-found', 'Plan not found', { id: String(planId ?? '') });
382
+ const handoff = [...handoffs.values()].find((item) => item.planId === plan.id)
383
+ ?? [...handoffs.values()].filter((item) => item.prdId === plan.prdId).at(-1)
384
+ ?? null;
385
+ const feedback = [...feedbacks.values()].filter((item) => item.planId === plan.id).at(-1) ?? null;
386
+ return { ok: true, planId: plan.id, ...nextPjmAction({ handoff, plan, feedback }), progress: planProgress(plan) };
387
+ },
388
+ getFeedback(id) {
389
+ const feedback = feedbacks.get(String(id ?? ''));
390
+ return feedback ? { ok: true, feedback } : masterError('feedback-not-found', 'Feedback not found', { id: String(id ?? '') });
146
391
  },
147
392
  getPlan(id) {
148
393
  const plan = plans.get(String(id ?? ''));
149
394
  return plan ? { ok: true, plan } : masterError('plan-not-found', 'Plan not found', { id: String(id ?? '') });
150
395
  },
396
+ progress(id) {
397
+ const plan = plans.get(String(id ?? ''));
398
+ if (!plan)
399
+ return masterError('plan-not-found', 'Plan not found', { id: String(id ?? '') });
400
+ return { ok: true, planId: plan.id, status: plan.status, progress: planProgress(plan), steps: plan.steps };
401
+ },
151
402
  /** 调瓮城 fangForker.spawn 派生执行分身;无 forker 时 fail-closed。 */
152
403
  async dispatch(planId, { stepId } = {}) {
153
404
  const plan = plans.get(String(planId ?? ''));
154
405
  if (!plan)
155
406
  return masterError('plan-not-found', 'Plan not found', { id: String(planId ?? '') });
407
+ if (plan.status === 'closed')
408
+ return masterError('plan-already-closed', 'Closed plans cannot dispatch new steps');
156
409
  const step = plan.steps.find((item) => item.id === String(stepId ?? ''));
157
410
  if (!step)
158
411
  return masterError('step-not-found', 'Step not found', { id: String(stepId ?? '') });
159
412
  if (step.fork)
160
413
  return masterError('step-already-dispatched', 'Step already dispatched', { stepId: step.id, fork: step.fork });
161
414
  const fangForker = forker();
162
- if (!fangForker) {
415
+ if (!fangForker)
163
416
  return masterError('fang-forker-unavailable', 'dsh-fang fangForker.spawn is required to dispatch a step');
164
- }
165
417
  // 先占位再 await,避免两个并发 dispatch 同时看到 step.fork=null 而重复派生。
166
418
  step.status = 'dispatching';
167
419
  step.fork = { pending: true };
420
+ plan.updatedAt = Date.now();
168
421
  try {
169
422
  const spawnConfig = config.dispatch && typeof config.dispatch === 'object' ? config.dispatch : {};
170
423
  const fork = await fangForker.spawn({
@@ -188,6 +441,7 @@ export function apply(ctx, config = {}) {
188
441
  step.status = 'running';
189
442
  step.fork = fork ?? null;
190
443
  plan.status = 'running';
444
+ plan.updatedAt = Date.now();
191
445
  return { ok: true, planId: plan.id, stepId: step.id, step, fork };
192
446
  }
193
447
  catch (error) {
@@ -196,40 +450,83 @@ export function apply(ctx, config = {}) {
196
450
  return masterError('fang-spawn-threw', String(error?.message ?? error));
197
451
  }
198
452
  },
199
- completeStep(planId, stepId, { status = 'done', summary = '' } = {}) {
453
+ completeStep(planId, stepId, { status = 'done', summary = '', evidence = [], actualMinutes = null } = {}) {
200
454
  const plan = plans.get(String(planId ?? ''));
201
455
  if (!plan)
202
456
  return masterError('plan-not-found', 'Plan not found', { id: String(planId ?? '') });
457
+ if (plan.status === 'closed')
458
+ return masterError('plan-already-closed', 'Closed plans cannot be modified');
203
459
  const step = plan.steps.find((item) => item.id === String(stepId ?? ''));
204
460
  if (!step)
205
461
  return masterError('step-not-found', 'Step not found', { id: String(stepId ?? '') });
206
- const next = status === 'failed' ? 'failed' : 'done';
207
- step.status = next;
462
+ step.status = status === 'failed' ? 'failed' : 'done';
208
463
  step.summary = String(summary);
464
+ step.evidence = Array.isArray(evidence) ? evidence.map(String).filter(Boolean) : [];
465
+ const minutes = Number(actualMinutes);
466
+ step.actualMinutes = Number.isFinite(minutes) && minutes >= 0 ? minutes : null;
209
467
  const states = plan.steps.map((item) => item.status);
210
468
  plan.status = states.every((state) => state === 'done')
211
469
  ? 'done'
212
- : (states.some((state) => state === 'running') ? 'running' : 'planned');
470
+ : (states.some((state) => state === 'running' || state === 'dispatching') ? 'running' : 'planned');
471
+ plan.updatedAt = Date.now();
213
472
  return { ok: true, plan };
214
473
  },
474
+ close(planId, input = {}) {
475
+ const plan = plans.get(String(planId ?? ''));
476
+ if (!plan)
477
+ return masterError('plan-not-found', 'Plan not found', { id: String(planId ?? '') });
478
+ if (plan.status === 'closed')
479
+ return masterError('plan-already-closed', 'Plan is already closed');
480
+ return closePlan(plan, input);
481
+ },
482
+ renderProject(planId) {
483
+ const plan = plans.get(String(planId ?? ''));
484
+ if (!plan)
485
+ return masterError('plan-not-found', 'Plan not found', { id: String(planId ?? '') });
486
+ return { ok: true, markdown: renderProjectDocument(plan) };
487
+ },
488
+ async writeProject(planId, { agent } = {}) {
489
+ const rendered = pjm.renderProject(planId);
490
+ if (!rendered.ok)
491
+ return rendered;
492
+ return await writeWorkspaceDocument(agent, path.join('docs', 'pjm', `PROJECT-${planId}.md`), rendered.markdown);
493
+ },
494
+ async writeClosure(planId, { agent } = {}) {
495
+ const plan = plans.get(String(planId ?? ''));
496
+ if (!plan)
497
+ return masterError('plan-not-found', 'Plan not found', { id: String(planId ?? '') });
498
+ if (plan.closure.status !== 'closed')
499
+ return masterError('plan-not-closed', 'Project must be closed before writing its closure report');
500
+ const markdown = `# 项目结案 · ${plan.title}\n\n- 计划 ID:\`${plan.id}\`\n- 关联 PRD:\`${plan.prdId}\`\n\n${renderClosureDocument(plan)}\n`;
501
+ return await writeWorkspaceDocument(agent, path.join('docs', 'pjm', `CLOSURE-${planId}.md`), markdown);
502
+ },
215
503
  listPlans() {
216
504
  return { ok: true, items: [...plans.values()] };
217
505
  },
218
506
  };
219
507
  const service = {
220
508
  id: SERVICE.dual,
221
- version: '3.2.5',
509
+ version: MASTER_VERSION,
222
510
  role,
223
511
  pm,
224
512
  pjm,
513
+ agents: Object.freeze({ pm: 'master-pm', pjm: 'master-pjm' }),
225
514
  status() {
226
515
  return {
227
516
  ok: true,
228
517
  id: 'dual-manager',
229
- version: '3.2.5',
518
+ version: MASTER_VERSION,
230
519
  role,
231
- pm: { prds: prds.size },
232
- pjm: { plans: plans.size },
520
+ agents: { pm: 'master-pm', pjm: 'master-pjm' },
521
+ pm: { prds: prds.size, assessments: assessments.size, handoffs: handoffs.size, reviews: reviews.size },
522
+ pjm: { plans: plans.size, closed: [...plans.values()].filter((plan) => plan.status === 'closed').length, feedbacks: feedbacks.size },
523
+ loop: {
524
+ pendingHandoffs: [...handoffs.values()].filter((item) => item.status !== 'accepted').length,
525
+ acceptedHandoffs: [...handoffs.values()].filter((item) => item.status === 'accepted').length,
526
+ awaitingReview: [...feedbacks.values()].filter((item) => ![...reviews.values()].some((review) => review.feedbackId === item.id)).length,
527
+ accepted: [...reviews.values()].filter((review) => review.verdict === 'accepted').length,
528
+ changesRequested: [...reviews.values()].filter((review) => review.verdict === 'changes-requested').length,
529
+ },
233
530
  cooperation: {
234
531
  decisionCenter: Boolean(decisionCenter()),
235
532
  forker: Boolean(forker()),
@@ -244,8 +541,8 @@ export function apply(ctx, config = {}) {
244
541
  master.registerSubplugin({
245
542
  id: 'dual-manager',
246
543
  name: 'dsh-master-dual-manager',
247
- capabilities: ['pm-prd', 'pm-ask-user', 'pjm-plan', 'pjm-dispatch'],
248
- description: 'PM + PJM 双主管(仅 mother/essence)',
544
+ capabilities: ['pm-prd', 'pm-assessment', 'pm-document', 'pm-ask-user', 'pm-handoff', 'pm-review', 'pjm-plan', 'pjm-dispatch', 'pjm-progress', 'pjm-close', 'pjm-feedback'],
545
+ description: 'PM/PJM 双智能体共享服务;master-pm 与 master-pjm preset 复用此服务',
249
546
  });
250
547
  return service;
251
548
  }