@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.
- package/README.md +17 -0
- package/client.js +126 -1
- package/cordis.patch.yml +51 -0
- package/dist-host/agents/pjm-agent.js +62 -0
- package/dist-host/agents/pm-agent.js +57 -0
- package/dist-host/agents/prompts.js +71 -0
- package/dist-host/agents/toolkit.js +64 -0
- package/dist-host/channel/abort-registry.js +93 -0
- package/dist-host/channel/protocol.js +114 -0
- package/dist-host/channel/server.js +349 -0
- package/dist-host/channel/stream-state.js +343 -0
- package/dist-host/channel/ws.js +199 -0
- package/dist-host/dual/documents.js +143 -0
- package/dist-host/dual/orchestrator.js +214 -0
- package/dist-host/dual/schema.js +367 -0
- package/dist-host/engine/atomic-switch.js +159 -0
- package/dist-host/engine/health-probe.js +86 -0
- package/dist-host/engine/index.js +18 -0
- package/dist-host/engine/llm-stream.js +215 -0
- package/dist-host/engine/plugin-updater.js +392 -0
- package/dist-host/engine/safe-update.js +150 -0
- package/dist-host/index.js +154 -3
- package/dist-host/kernel.js +27 -1
- package/dist-host/plugins/cost-optimizer.js +3 -3
- package/dist-host/plugins/dual-manager.js +374 -77
- package/docs/AGENTS-PM-PJM.md +207 -0
- package/docs/ARCHITECTURE.md +2 -0
- package/docs/CHANGELOG.md +74 -0
- package/docs/CHANNEL-WEBSOCKET.md +169 -0
- package/docs/DESIGN-command-center.md +147 -0
- package/docs/ENGINE-PRIMITIVES.md +215 -0
- package/docs/GAP-ANALYSIS-fang-session.md +75 -0
- package/docs/REVIEW-PM-PJM-3.3.4.md +179 -0
- package/docs/examples/command-center/README.md +167 -0
- package/package.json +5 -1
- package/src/agents/pjm-agent.ts +62 -0
- package/src/agents/pm-agent.ts +58 -0
- package/src/agents/prompts.ts +74 -0
- package/src/agents/toolkit.ts +64 -0
- package/src/channel/abort-registry.ts +85 -0
- package/src/channel/protocol.ts +110 -0
- package/src/channel/server.ts +289 -0
- package/src/channel/stream-state.ts +349 -0
- package/src/channel/ws.ts +182 -0
- package/src/dual/documents.ts +142 -0
- package/src/dual/orchestrator.ts +223 -0
- package/src/dual/schema.ts +355 -0
- package/src/engine/atomic-switch.ts +146 -0
- package/src/engine/health-probe.ts +84 -0
- package/src/engine/index.ts +20 -0
- package/src/engine/llm-stream.ts +189 -0
- package/src/engine/plugin-updater.ts +359 -0
- package/src/engine/safe-update.ts +148 -0
- package/src/index.ts +143 -4
- package/src/kernel.ts +28 -1
- package/src/plugins/cost-optimizer.ts +3 -3
- 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
|
-
*
|
|
5
|
+
* 双智能体共享服务(PM + PJM)。
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* *
|
|
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
|
|
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}
|
|
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
|
|
90
|
+
/** 需求 → 结构化 PRD(模型通过 pm-agent 工具填充;服务本身不调用模型)。 */
|
|
57
91
|
createPrd(input = {}) {
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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,
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
518
|
+
version: MASTER_VERSION,
|
|
230
519
|
role,
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
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
|
}
|