@twinsearth/dsh-master 3.3.3 → 3.3.4

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.
@@ -2,31 +2,32 @@
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, normalizeAssessment, normalizePlan, normalizePrd, planProgress } from "../dual/schema.js";
21
+ import { renderAssessmentDocument, renderClosureDocument, renderPrdDocument, renderProjectDocument } from "../dual/documents.js";
14
22
  export const name = 'dsh-master-dual-manager';
15
23
  export const inject = ['dshMaster'];
16
24
  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
25
  function missing(name) {
25
26
  return masterError(`${name}-required`, `${name} is required`);
26
27
  }
27
28
  /**
28
29
  * @param {object} ctx
29
- * @param {{enabled?:boolean}} [config]
30
+ * @param {{enabled?:boolean, dispatch?:object}} [config]
30
31
  */
31
32
  export function apply(ctx, config = {}) {
32
33
  if (!enabledOf(config))
@@ -34,7 +35,7 @@ export function apply(ctx, config = {}) {
34
35
  const role = detectRole();
35
36
  if (!roleAllowed(ROW_ID, role)) {
36
37
  try {
37
- ctx?.logger?.info?.(`[dsh-master] dual-manager skipped: role=${role} not in mother/essence`);
38
+ ctx?.logger?.info?.(`[dsh-master] dual-manager skipped: role=${role}`);
38
39
  }
39
40
  catch { /* ignore */ }
40
41
  return { skipped: true, reason: `role-not-allowed:${role}` };
@@ -44,37 +45,48 @@ export function apply(ctx, config = {}) {
44
45
  return { skipped: true, reason: 'master-core-unavailable' };
45
46
  }
46
47
  const newPrdId = createIdFactory('prd');
48
+ const newAssessmentId = createIdFactory('review');
47
49
  const newPlanId = createIdFactory('plan');
48
50
  const prds = new Map();
51
+ const assessments = new Map();
49
52
  const plans = new Map();
50
53
  const decisionCenter = () => safeGet(ctx, FANG_SERVICE.decisionCenter);
51
54
  const forker = () => {
52
55
  const service = safeGet(ctx, FANG_SERVICE.forker);
53
56
  return service && typeof service.spawn === 'function' ? service : null;
54
57
  };
58
+ /** 文档写口:只接受代码内生成的相对路径,根目录来自当前 Session。 */
59
+ async function writeWorkspaceDocument(agent, relativePath, content) {
60
+ const workingDirectory = safeGet(ctx, 'workingDirectory');
61
+ if (!workingDirectory)
62
+ return masterError('working-directory-unavailable', 'workingDirectory service is required to write agent documents');
63
+ try {
64
+ const root = agent && typeof workingDirectory.ensure === 'function'
65
+ ? await workingDirectory.ensure(agent)
66
+ : String(workingDirectory.defaultDirectory ?? '');
67
+ if (!root)
68
+ return masterError('working-directory-unavailable', 'no active Session workspace');
69
+ const base = path.resolve(String(root));
70
+ const target = path.resolve(base, relativePath);
71
+ if (target !== base && !target.startsWith(`${base}${path.sep}`)) {
72
+ return masterError('document-path-outside-workspace', 'document path must stay inside the Session workspace');
73
+ }
74
+ await fs.mkdir(path.dirname(target), { recursive: true });
75
+ await fs.writeFile(target, String(content), 'utf8');
76
+ return { ok: true, path: target, bytes: Buffer.byteLength(String(content), 'utf8') };
77
+ }
78
+ catch (error) {
79
+ return masterError('document-write-failed', String(error?.message ?? error));
80
+ }
81
+ }
55
82
  const pm = {
56
- /** 需求 → 结构化 PRD(不调用模型;3.2.x 接入 PM agent 后由模型填充字段)。 */
83
+ /** 需求 → 结构化 PRD(模型通过 pm-agent 工具填充;服务本身不调用模型)。 */
57
84
  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 };
85
+ const made = normalizePrd(input, { id: newPrdId() });
86
+ if (!made.ok)
87
+ return made;
88
+ prds.set(made.prd.id, made.prd);
89
+ return made;
78
90
  },
79
91
  getPrd(id) {
80
92
  const prd = prds.get(String(id ?? ''));
@@ -83,6 +95,45 @@ export function apply(ctx, config = {}) {
83
95
  listPrds() {
84
96
  return { ok: true, items: [...prds.values()] };
85
97
  },
98
+ /** 六类现状盘点;completed 必须由调用方给出可复核证据。 */
99
+ createAssessment(input = {}) {
100
+ const prdId = String(input.prdId ?? '').trim();
101
+ if (prdId && !prds.has(prdId))
102
+ return masterError('prd-not-found', 'PRD not found', { id: prdId });
103
+ const made = normalizeAssessment(input, { id: newAssessmentId() });
104
+ if (!made.ok)
105
+ return made;
106
+ assessments.set(made.assessment.id, made.assessment);
107
+ return made;
108
+ },
109
+ getAssessment(id) {
110
+ const assessment = assessments.get(String(id ?? ''));
111
+ return assessment ? { ok: true, assessment } : masterError('assessment-not-found', 'Assessment not found', { id: String(id ?? '') });
112
+ },
113
+ listAssessments() {
114
+ return { ok: true, items: [...assessments.values()] };
115
+ },
116
+ renderPrd(id, { assessmentId = '' } = {}) {
117
+ const prd = prds.get(String(id ?? ''));
118
+ if (!prd)
119
+ return masterError('prd-not-found', 'PRD not found', { id: String(id ?? '') });
120
+ const assessment = assessmentId ? assessments.get(String(assessmentId)) : null;
121
+ if (assessmentId && !assessment)
122
+ return masterError('assessment-not-found', 'Assessment not found', { id: String(assessmentId) });
123
+ return { ok: true, markdown: renderPrdDocument(prd, assessment) };
124
+ },
125
+ async writePrd(id, { assessmentId = '', agent } = {}) {
126
+ const rendered = pm.renderPrd(id, { assessmentId });
127
+ if (!rendered.ok)
128
+ return rendered;
129
+ return await writeWorkspaceDocument(agent, path.join('docs', 'pm', `PRD-${id}.md`), rendered.markdown);
130
+ },
131
+ async writeAssessment(id, { agent } = {}) {
132
+ const assessment = assessments.get(String(id ?? ''));
133
+ if (!assessment)
134
+ return masterError('assessment-not-found', 'Assessment not found', { id: String(id ?? '') });
135
+ return await writeWorkspaceDocument(agent, path.join('docs', 'pm', `REVIEW-${id}.md`), renderAssessmentDocument(assessment));
136
+ },
86
137
  /** 歧义澄清:复用瓮城决策中心,返回 ticket 或明确的 unavailable。 */
87
138
  async askUser({ question, context = '', options = [], level = 'yellow' } = {}) {
88
139
  const q = String(question ?? '').trim();
@@ -110,61 +161,46 @@ export function apply(ctx, config = {}) {
110
161
  },
111
162
  };
112
163
  const pjm = {
113
- /** PRD → 可调度步骤计划。 */
114
- plan(prdId, { steps = [] } = {}) {
164
+ /** PRD → 七类项目计划与可调度 WBS。 */
165
+ plan(prdId, input = {}) {
115
166
  const prd = prds.get(String(prdId ?? ''));
116
167
  if (!prd)
117
168
  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
- });
136
- }
137
- const plan = {
138
- id: newPlanId(),
139
- prdId: prd.id,
140
- status: 'planned',
141
- steps: normalized,
142
- createdAt: Date.now(),
143
- };
144
- plans.set(plan.id, plan);
145
- return { ok: true, plan };
169
+ const made = normalizePlan(prd, input, { id: newPlanId() });
170
+ if (!made.ok)
171
+ return made;
172
+ plans.set(made.plan.id, made.plan);
173
+ return made;
146
174
  },
147
175
  getPlan(id) {
148
176
  const plan = plans.get(String(id ?? ''));
149
177
  return plan ? { ok: true, plan } : masterError('plan-not-found', 'Plan not found', { id: String(id ?? '') });
150
178
  },
179
+ progress(id) {
180
+ const plan = plans.get(String(id ?? ''));
181
+ if (!plan)
182
+ return masterError('plan-not-found', 'Plan not found', { id: String(id ?? '') });
183
+ return { ok: true, planId: plan.id, status: plan.status, progress: planProgress(plan), steps: plan.steps };
184
+ },
151
185
  /** 调瓮城 fangForker.spawn 派生执行分身;无 forker 时 fail-closed。 */
152
186
  async dispatch(planId, { stepId } = {}) {
153
187
  const plan = plans.get(String(planId ?? ''));
154
188
  if (!plan)
155
189
  return masterError('plan-not-found', 'Plan not found', { id: String(planId ?? '') });
190
+ if (plan.status === 'closed')
191
+ return masterError('plan-already-closed', 'Closed plans cannot dispatch new steps');
156
192
  const step = plan.steps.find((item) => item.id === String(stepId ?? ''));
157
193
  if (!step)
158
194
  return masterError('step-not-found', 'Step not found', { id: String(stepId ?? '') });
159
195
  if (step.fork)
160
196
  return masterError('step-already-dispatched', 'Step already dispatched', { stepId: step.id, fork: step.fork });
161
197
  const fangForker = forker();
162
- if (!fangForker) {
198
+ if (!fangForker)
163
199
  return masterError('fang-forker-unavailable', 'dsh-fang fangForker.spawn is required to dispatch a step');
164
- }
165
200
  // 先占位再 await,避免两个并发 dispatch 同时看到 step.fork=null 而重复派生。
166
201
  step.status = 'dispatching';
167
202
  step.fork = { pending: true };
203
+ plan.updatedAt = Date.now();
168
204
  try {
169
205
  const spawnConfig = config.dispatch && typeof config.dispatch === 'object' ? config.dispatch : {};
170
206
  const fork = await fangForker.spawn({
@@ -188,6 +224,7 @@ export function apply(ctx, config = {}) {
188
224
  step.status = 'running';
189
225
  step.fork = fork ?? null;
190
226
  plan.status = 'running';
227
+ plan.updatedAt = Date.now();
191
228
  return { ok: true, planId: plan.id, stepId: step.id, step, fork };
192
229
  }
193
230
  catch (error) {
@@ -196,40 +233,76 @@ export function apply(ctx, config = {}) {
196
233
  return masterError('fang-spawn-threw', String(error?.message ?? error));
197
234
  }
198
235
  },
199
- completeStep(planId, stepId, { status = 'done', summary = '' } = {}) {
236
+ completeStep(planId, stepId, { status = 'done', summary = '', evidence = [], actualMinutes = null } = {}) {
200
237
  const plan = plans.get(String(planId ?? ''));
201
238
  if (!plan)
202
239
  return masterError('plan-not-found', 'Plan not found', { id: String(planId ?? '') });
240
+ if (plan.status === 'closed')
241
+ return masterError('plan-already-closed', 'Closed plans cannot be modified');
203
242
  const step = plan.steps.find((item) => item.id === String(stepId ?? ''));
204
243
  if (!step)
205
244
  return masterError('step-not-found', 'Step not found', { id: String(stepId ?? '') });
206
- const next = status === 'failed' ? 'failed' : 'done';
207
- step.status = next;
245
+ step.status = status === 'failed' ? 'failed' : 'done';
208
246
  step.summary = String(summary);
247
+ step.evidence = Array.isArray(evidence) ? evidence.map(String).filter(Boolean) : [];
248
+ const minutes = Number(actualMinutes);
249
+ step.actualMinutes = Number.isFinite(minutes) && minutes >= 0 ? minutes : null;
209
250
  const states = plan.steps.map((item) => item.status);
210
251
  plan.status = states.every((state) => state === 'done')
211
252
  ? 'done'
212
- : (states.some((state) => state === 'running') ? 'running' : 'planned');
253
+ : (states.some((state) => state === 'running' || state === 'dispatching') ? 'running' : 'planned');
254
+ plan.updatedAt = Date.now();
213
255
  return { ok: true, plan };
214
256
  },
257
+ close(planId, input = {}) {
258
+ const plan = plans.get(String(planId ?? ''));
259
+ if (!plan)
260
+ return masterError('plan-not-found', 'Plan not found', { id: String(planId ?? '') });
261
+ if (plan.status === 'closed')
262
+ return masterError('plan-already-closed', 'Plan is already closed');
263
+ return closePlan(plan, input);
264
+ },
265
+ renderProject(planId) {
266
+ const plan = plans.get(String(planId ?? ''));
267
+ if (!plan)
268
+ return masterError('plan-not-found', 'Plan not found', { id: String(planId ?? '') });
269
+ return { ok: true, markdown: renderProjectDocument(plan) };
270
+ },
271
+ async writeProject(planId, { agent } = {}) {
272
+ const rendered = pjm.renderProject(planId);
273
+ if (!rendered.ok)
274
+ return rendered;
275
+ return await writeWorkspaceDocument(agent, path.join('docs', 'pjm', `PROJECT-${planId}.md`), rendered.markdown);
276
+ },
277
+ async writeClosure(planId, { agent } = {}) {
278
+ const plan = plans.get(String(planId ?? ''));
279
+ if (!plan)
280
+ return masterError('plan-not-found', 'Plan not found', { id: String(planId ?? '') });
281
+ if (plan.closure.status !== 'closed')
282
+ return masterError('plan-not-closed', 'Project must be closed before writing its closure report');
283
+ const markdown = `# 项目结案 · ${plan.title}\n\n- 计划 ID:\`${plan.id}\`\n- 关联 PRD:\`${plan.prdId}\`\n\n${renderClosureDocument(plan)}\n`;
284
+ return await writeWorkspaceDocument(agent, path.join('docs', 'pjm', `CLOSURE-${planId}.md`), markdown);
285
+ },
215
286
  listPlans() {
216
287
  return { ok: true, items: [...plans.values()] };
217
288
  },
218
289
  };
219
290
  const service = {
220
291
  id: SERVICE.dual,
221
- version: '3.2.5',
292
+ version: MASTER_VERSION,
222
293
  role,
223
294
  pm,
224
295
  pjm,
296
+ agents: Object.freeze({ pm: 'master-pm', pjm: 'master-pjm' }),
225
297
  status() {
226
298
  return {
227
299
  ok: true,
228
300
  id: 'dual-manager',
229
- version: '3.2.5',
301
+ version: MASTER_VERSION,
230
302
  role,
231
- pm: { prds: prds.size },
232
- pjm: { plans: plans.size },
303
+ agents: { pm: 'master-pm', pjm: 'master-pjm' },
304
+ pm: { prds: prds.size, assessments: assessments.size },
305
+ pjm: { plans: plans.size, closed: [...plans.values()].filter((plan) => plan.status === 'closed').length },
233
306
  cooperation: {
234
307
  decisionCenter: Boolean(decisionCenter()),
235
308
  forker: Boolean(forker()),
@@ -244,8 +317,8 @@ export function apply(ctx, config = {}) {
244
317
  master.registerSubplugin({
245
318
  id: 'dual-manager',
246
319
  name: 'dsh-master-dual-manager',
247
- capabilities: ['pm-prd', 'pm-ask-user', 'pjm-plan', 'pjm-dispatch'],
248
- description: 'PM + PJM 双主管(仅 mother/essence)',
320
+ capabilities: ['pm-prd', 'pm-assessment', 'pm-document', 'pm-ask-user', 'pjm-plan', 'pjm-dispatch', 'pjm-progress', 'pjm-close'],
321
+ description: 'PM/PJM 双智能体共享服务;master-pm 与 master-pjm preset 复用此服务',
249
322
  });
250
323
  return service;
251
324
  }
@@ -0,0 +1,123 @@
1
+ # 房主智能体:PM(产品经理)与 PJM(项目经理)
2
+
3
+ > 3.3.4 交付。两个**独立的 Agent preset**,可在新建会话时选择;共享 `dual-manager` 服务的结构化状态。
4
+ > 源码:`src/agents/prompts.ts`(角色 Prompt)、`src/agents/pm-agent.ts`、`src/agents/pjm-agent.ts`、
5
+ > `src/dual/schema.ts`(契约与校验)、`src/dual/documents.ts`(文档渲染)。
6
+
7
+ ## 1. 它们是什么 / 不是什么
8
+
9
+ | | 是 | 不是 |
10
+ |---|---|---|
11
+ | PM | 一个可选择的 Agent preset;只读代码与文档、向用户出选择题、产出 PRD 与六类盘点 | 不是 UI 装饰卡片;不执行代码、不派发分身、不发布 |
12
+ | PJM | 一个可选择的 Agent preset;拆 WBS、经 `fangForker.spawn` 隔离派发、汇总节点、结案 | 不替用户决定需求;没有 forker 时不会本地降级执行 |
13
+
14
+ 两者都**不授予额外权限**:preset 决定“这个会话是谁”,`ROLE_MATRIX` 决定“子插件在哪个角色装配”,
15
+ 智能体工具边界写在 `cordis.patch.yml` 的 preset 行里(只读 fs + 提问/待办/交付工具,无 shell、无写文件)。
16
+
17
+ ## 2. PM · 产品经理
18
+
19
+ **WHY**:把用户和开发者的口语需求变成可核对、可验收的文档,先消除歧义再动手,避免“做完了才发现理解错”。
20
+ **WHAT**:结构化 PRD(WHY/WHAT/模块/约束/非目标/验收)+ 证据驱动的六类现状盘点 + 文档落盘。
21
+
22
+ 角色 Prompt(完整版见 `src/agents/prompts.ts`,摘要):
23
+
24
+ ```text
25
+ Role 房主产品经理(PM, preset: master-pm)
26
+ Goals WHY:解决什么问题/达成什么目标;WHAT:功能、模块、架构、结果
27
+ Flow 先澄清(ask_user_question,一次一个决策点,2–4 选项带推荐)
28
+ → 用 read/grep/glob 读真实代码与测试
29
+ → pm_create_prd / pm_create_assessment 落结构
30
+ → pm_write_prd 写 Markdown → present 交付
31
+ Rules 不臆测、不伪造证据、不把“能实现”写成“已完成”;
32
+ 冲突必须列双方/影响/选项;歧义必须写当前理解并等确认;
33
+ PM 不写生产代码、不跑 shell、不发布、不安装、不调度分身
34
+ ```
35
+
36
+ **六类盘点(PM 的输出契约)**
37
+
38
+ | 类别 | 必填 | 门禁 |
39
+ |---|---|---|
40
+ | completed | `evidence[]`(文件/测试/报告) | **没有证据直接拒收**,不允许自称 100% |
41
+ | unfinished | `reason`、`nextStep`、`progress` | `progress` 上限 99,不能写成完成 |
42
+ | blocked | `reason`、`blocker`、`nextStep` | 卡点必须具名,不写“暂不可行” |
43
+ | planned | `plan`、`estimateMinutes`、`priority` | 估时以分钟给出 |
44
+ | conflicts | `question`、`options[{value,label,consequence}]` | 必须给出可重新判断的选项 |
45
+ | ambiguities | `question`、`currentInterpretation` | 默认 `needsConfirmation: true` |
46
+
47
+ **工具**:`pm_create_prd`、`pm_create_assessment`、`pm_write_prd`、`pm_list_prds`(外加 preset 挂载的只读 fs、搜索、`ask_user_question`、`present`)。
48
+
49
+ ## 3. PJM · 项目经理
50
+
51
+ **HOW**:PRD → WBS(每步 prompt/owner/场景/估时/依赖/验收)。
52
+ **WHO**:每步必须有 owner,未分配写 `unassigned`,不虚构人员。
53
+ **WHERE**:明确执行场景(宿主 / fork / workspace / DSH Web);脏任务只进隔离执行体。
54
+ **WHEN**:里程碑 + 估时(计划,不是承诺工期)。
55
+
56
+ 角色 Prompt(摘要):
57
+
58
+ ```text
59
+ Role 房主项目经理(PJM, preset: master-pjm)
60
+ Goals HOW / WHO / WHERE / WHEN
61
+ Flow 项目会审 → pjm_create_plan(七类内容)
62
+ → pjm_dispatch_step(仅经 fangForker.spawn,无则 fail-closed)
63
+ → pjm_complete_step(status/summary/evidence/actualMinutes)
64
+ → pjm_project_status → pjm_write_project → pjm_close_project
65
+ Rules 真实状态汇报,失败/阻塞/未测量不得包装成完成;
66
+ 不发布、不部署、不改 profile、不碰凭据;
67
+ 不代替 PM 决定需求,不替用户回答选择题
68
+ ```
69
+
70
+ **七类项目管理(PJM 的输出契约)**:项目会审、项目计划(WBS)、项目需求与内容、项目时间、项目人员、测试/部署/交付/验收、项目结案。
71
+
72
+ **结案门禁**:全部步骤 `done` 才允许结案;结案必须给 `summary`;结案后计划冻结(不能再派发/改步骤)。
73
+ 结案文档包含评审报告、验收记录、资料清单。
74
+
75
+ **工具**:`pjm_create_plan`、`pjm_dispatch_step`、`pjm_complete_step`、`pjm_project_status`、`pjm_write_project`、`pjm_close_project`(外加只读 fs、搜索、`todo_write`、`present`)。
76
+
77
+ ## 4. 协作流水线
78
+
79
+ ```text
80
+ 用户口语需求
81
+ │ (新会话选 preset: master-pm)
82
+ ▼
83
+ PM: 澄清选择题 → PRD(prd_*) → 六类盘点(review_*) → docs/pm/*.md
84
+ │ 只移交 prdId + 验收标准 + 未决问题
85
+ ▼
86
+ PJM: 会审 → WBS(plan_*) → fangForker.spawn 派生分身 → 节点汇总
87
+ │ │
88
+ │ 每步记录 evidence / actualMinutes
89
+ ▼
90
+ PJM: 全绿 → 结案(summary/评审报告/验收/资料)→ docs/pjm/*.md
91
+ ```
92
+
93
+ 当前实现里两者**不共享隐式上下文**:PM 只写 PRD/盘点,PJM 只读 PRD 与自己的计划,跨角色只传 id 与结构。
94
+
95
+ ## 5. 如何启用
96
+
97
+ 1. bundle 已声明两个 preset 行(`cordis.patch.yml`:`preset-master-pm` / `preset-master-pjm`);
98
+ 2. 新会话创建时在 Agent preset 选择器里选「产品经理(PM)」或「项目经理(PJM)」;
99
+ 3. 面板「房主」抽屉会多出两张智能体卡片 + 一张**智囊团 Command Center** 只读摘要
100
+ (PRD / 盘点 / 计划 / 已结案 / 决策中心 / 派发可用性),数据来自 `/dsh-master/api/status` 的 `dual` 段;
101
+ 4. 没有 `agentPresets` 服务的宿主(非 DSH web bundle)会显示「本宿主不可用」,不会假装可以选择。
102
+
103
+ 面板**只读**:PRD、盘点、计划、结案只能由 PM/PJM 会话里的工具创建;面板不提供写入口,也不直接调工具。
104
+
105
+ ## 6. 已知限制(不隐藏)
106
+
107
+ - **未做宿主级验证**:本会话守则 §二 禁止把插件装进任何 profile,因此 preset 只做了源码/契约/工具注册测试,尚未在真实 Host 里点选验证;面板卡片的 `可选择` 也只反映 `agentPresets` 服务是否存在。
108
+ - **没有 VM 工具面**:当前 Host 与本仓库都没有 `vm_create/vm_exec` 那套 27 工具;PJM 的隔离派发走既有 `fangForker.spawn`。若要接 vm-sandbox,需要单独授权并新增适配层。
109
+ - **文档只写 workspace**:`docs/pm`、`docs/pjm` 之外拒绝写入;不提供任意路径写口。
110
+ - **PM 不能改代码、PJM 不能改需求**:这是刻意的边界,不是功能缺失。
111
+
112
+ ## 7. 测试覆盖
113
+
114
+ `test/dual-agents.test.mjs`(10 条):
115
+
116
+ - PRD 校验;completed 无证据拒收;六类字段齐全;
117
+ - PRD/盘点/计划/结案的服务层门禁与冻结;
118
+ - 派发 fail-closed 与真实 fork 记录;
119
+ - 两个 preset 的 persona 与工具清单(PM 4 个、PJM 6 个,且无 shell/写文件);
120
+ - 端到端工具调用;文档只写 workspace。
121
+
122
+ 另有 `test/kernel.test.mjs`(AGENT_CATALOG 边界)、`test/core.test.mjs`(status 分开报告 agents)、
123
+ `test/package-contract.test.mjs`(preset 行、exports、工具边界静态断言)。
@@ -134,5 +134,7 @@ master-cost-optimizer(行,fork/mother)= 编排器 + 服务 dshMasterCost
134
134
  - A1 默认 **shadow**:计算「本可节省」但返回原文;只有 `enforce` 且紧凑结果更短才替换,且归因单独入账(不重复计消耗)。
135
135
  - 瓮城回流(3.3.1 起):`src/cost/fang-relay.ts` 走 `@twinsearth/dsh-fang-contract` 1.1.x 的
136
136
  `fang.relay.publish` / `fang.relay.status`;只有对端回执才算送达,不可用给具名原因。
137
+ - 智能体(3.3.4):`master-pm` / `master-pjm` 两个 Agent preset,共享 `dual-manager` 的结构化状态;
138
+ preset 决定“会话是谁”,角色矩阵决定“子插件在哪装配”,两者都不授予额外执行权限(见 `docs/AGENTS-PM-PJM.md`)。
137
139
  - 详见 `docs/DESIGN-v3.3.0-cost-optimization.md`(§0 总体架构、§17 实施进度)、
138
140
  `docs/EXPERIMENT-M5-real-ab.md`(真实 A/B)、`docs/EXPERIMENT-M5-fang-relay.md`(回流实测)。
package/docs/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## 3.3.4(2026-11-14 已发布)
2
+
3
+ > 三方发布:annotated tag `v3.3.4` + GitHub Release + npm `@twinsearth/dsh-master@3.3.4`。
4
+ > 接口包无变化(仍 `@twinsearth/dsh-fang-contract@1.1.1`)。
5
+
6
+ **主题:房主新增两个可选择的智能体 —— 产品经理(PM)与项目经理(PJM)。**
7
+
8
+ - **两个 Agent preset**:`master-pm`、`master-pjm`(`cordis.patch.yml` 的 `preset-master-pm` / `preset-master-pjm` 行),可在新会话创建时选择;已存在的会话不受影响。
9
+ - **角色 Prompt 落进代码**:`src/agents/prompts.ts` 给出 PM 的 WHY/WHAT/六类盘点契约与 PJM 的 HOW/WHO/WHERE/WHEN/七类项目管理契约,均写死边界(PM 不执行、PJM 无 forker 即 fail-closed)。
10
+ - **结构化契约与门禁**:`src/dual/schema.ts` —— PRD 必填 title/goal;**completed 项没有 evidence 直接拒收**(不允许自称 100%);plan 步骤必须有 title/prompt,估时缺省显式给 30 分钟;**全部步骤 done 才允许结案**,结案必须给 summary,结案后计划冻结。
11
+ - **`dual-manager` 升级为共享服务**:保留原有 `pm/prd`、`pjm/plan/dispatch` 兼容面,新增 `createAssessment`(六类盘点)、`writePrd`/`writeAssessment`/`writeProject`/`writeClosure`(只写 Session workspace 下的 `docs/pm`、`docs/pjm`)、`progress`(进度按步骤实测)、`close`(结案)。
12
+ - **两个智能体插件**:`src/agents/pm-agent.ts`(4 个工具)、`src/agents/pjm-agent.ts`(6 个工具),通过 `ctx.systemPrompt.section` 挂 persona、`ctx.tools.register` 挂 scoped tools;缺少 `systemPrompt`/`tools`/`diskMasterDual` 时返回具名错误,不静默失败。
13
+ - **面板与状态**:`status().agents` 单独报告两个智能体(与子插件行分开),`status().dual` 给出智囊团只读摘要(PRD/盘点/计划/已结案/协作可用性;未装配时为 `null`,不造空壳);面板新增两张智能体卡片与「智囊团 · Command Center」只读卡片,**无写入口**;`AGENT_CATALOG` 写死工具边界。
14
+ - **边界**:两个 preset 都不挂 shell、不授予写文件(文档只经 agent 工具写入 workspace);PJM 的隔离派发仍只走 `fangForker.spawn`,没有 `vm_create/vm_exec` 适配层。
15
+ - 测试:新增 `test/dual-agents.test.mjs`(10 条),并扩充 kernel/core/package-contract 断言;全量 **459 条**。
16
+
1
17
  ## 3.3.3(2026-11-13 已发布)
2
18
 
3
19
  > 三方发布:annotated tag `v3.3.3` + GitHub Release + npm `@twinsearth/dsh-master@3.3.3`。