@twinsearth/dsh-master 3.3.2 → 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、工具边界静态断言)。
@@ -35,8 +35,8 @@
35
35
  └──────────────────────────────┘
36
36
 
37
37
  旁路(后续版本):
38
- cost-optimizer → fork:HarnessMM observe 层(3.3.0)
39
- dual-manager → mother/essence:PM + PJM(3.4.0)
38
+ cost-optimizer → fork/mother:HarnessMM observe 层(3.3.0;3.3.3 起母体也开放)
39
+ dual-manager → mother/essence/fork:PM + PJM(3.4.0;3.3.3 起分身也开放)
40
40
  ```
41
41
 
42
42
  ### 角色矩阵(代码强制,见 `src/kernel.mjs ROLE_MATRIX`)
@@ -116,7 +116,7 @@
116
116
  ## 7. 3.3.0 / 3.3.1 成本优化(已实施并发布)
117
117
 
118
118
  ```text
119
- master-cost-optimizer(行,仅 fork)= 编排器 + 服务 dshMasterCost
119
+ master-cost-optimizer(行,fork/mother)= 编排器 + 服务 dshMasterCost
120
120
  │
121
121
  系统层(常驻):ledger 账本 │ budget 预算闸 │ policy 灰度 │ attribution 归因 │ audit 审计链 │ bus 事件总线
122
122
  │ hooks │ reports(→ 瓮城回流出口:fang-relay 真实投递)
@@ -134,5 +134,7 @@ master-cost-optimizer(行,仅 fork)= 编排器 + 服务 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,32 @@
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
+
17
+ ## 3.3.3(2026-11-13 已发布)
18
+
19
+ > 三方发布:annotated tag `v3.3.3` + GitHub Release + npm `@twinsearth/dsh-master@3.3.3`。
20
+ > 接口包无变化(仍 `@twinsearth/dsh-fang-contract@1.1.1`)。
21
+
22
+ **主题:角色矩阵按用户要求放开(母体全开 + 分身挂双主管)。**
23
+
24
+ - `ROLE_MATRIX` 调整:`superagent-worker` / `superagent-loop` 增加 **mother**;`cost-optimizer` 增加 **mother**(变 fork/mother);`dual-manager` 增加 **fork**(变 mother/essence/fork)。
25
+ - **保留的唯一限制**:`superagent-router` 仍为 essence/mother —— 分身不注册控制面(只执行)。
26
+ - 废止旧约束「母体不接纳执行体」(README 3.2.10 行曾记录)。同步更新 7 条测试断言(矩阵精确值、母体/分身角色门、成本与双主管准入、package-contract 安全断言 + 新增「Router 永不进 fork」不变量),README 角色表与架构说明。
27
+ - 影响面:母体现在可派发并运行 Worker/Loop,母体也能挂成本优化(成本账 + 瓮城回流);分身可挂双主管。安全边界不变:预算闸、Mandate 守卫、角色检测仍按原样生效,只是准入名单放宽。
28
+ - 测试:全量 **449 条**(`test:clean` 449/449)。
29
+
1
30
  ## 3.3.2(2026-11-13 已发布)
2
31
 
3
32
  > 三方发布:annotated tag `v3.3.2` + GitHub Release + npm `@twinsearth/dsh-master@3.3.2`。
@@ -1,7 +1,7 @@
1
1
  # 房主 dsh-Master 3.3.0 架构设计:成本优化 & 省 Token 子插件(系统 + 模块)
2
2
 
3
- > 状态:**已实施并发布(3.3.0 / 3.3.1 三方发布:annotated tag + GitHub Release + npm)**。基线代码 `3.2.10`,本文件描述 3.3.0 的架构与落地路径;实施进度见 §17。
4
- > 关联:`docs/ROADMAP.md`(3.3.0 = HarnessMM 经济性)、`docs/ARCHITECTURE.md`(角色矩阵 / `master-cost-optimizer` 行 → 仅 fork)、
3
+ > 状态:**已实施并发布(3.3.0 / 3.3.1 / 3.3.2 三方发布:annotated tag + GitHub Release + npm)**。基线代码 `3.2.10`,本文件描述 3.3.0 的架构与落地路径;实施进度见 §17。
4
+ > 关联:`docs/ROADMAP.md`(3.3.0 = HarnessMM 经济性)、`docs/ARCHITECTURE.md`(角色矩阵 / `master-cost-optimizer` 行 → fork/mother;3.3.3 起母体也开放)、
5
5
  > `src/plugins/cost-optimizer.ts`(3.2.x 的 observe 骨架,本设计在其上扩展且**不破坏**现有服务接口)。
6
6
  > 参考实现:`dsh-ops`(MIT,工具层)、`HarnessMM`(Apache-2.0,arXiv:2610.32813,决策层)。**只移植协议语义、自研实现,不复制第三方代码。**
7
7
  > 移植边界的完整定义、当前实现来源核对与新增模块检查清单见 [`docs/THIRD-PARTY.md`](THIRD-PARTY.md),由 `test/license-provenance.test.mjs` 自动守卫。
@@ -12,7 +12,7 @@
12
12
 
13
13
  ```text
14
14
  ┌──────────────────────────────────────────┐
15
- │ master-cost-optimizer(行,仅 fork) │
15
+ │ master-cost-optimizer(行,fork/mother) │
16
16
  │ = 编排器 + 对外服务 dshMasterCost │
17
17
  └───────────────┬──────────────────────────┘
18
18
  │
@@ -158,7 +158,7 @@
158
158
 
159
159
  ```text
160
160
  ┌──────────────────────────────────────────┐
161
- │ master-cost-optimizer(行,仅 fork) │
161
+ │ master-cost-optimizer(行,fork/mother) │
162
162
  │ = 编排器 + 对外服务 dshMasterCost │
163
163
  └───────────────┬──────────────────────────┘
164
164
  │
@@ -1055,5 +1055,6 @@ node scripts/milestones.mjs --input evidence.json # 追加 M3 守卫准确率 /
1055
1055
  | **M2 余项 / M3–M4** | ✅ 已发布(3.3.0) | A2 渐进纰漏、A3 批量、B1–B4、真实 fork 对照实验、面板成本块(见 §14) |
1056
1056
  | **M5 回流收口** | ✅ 已发布(3.3.1) | contract 1.1.0(`fang.relay.publish`/`status`)+ `src/cost/fang-relay.ts` + `scripts/fang-relay-probe.mjs`;M5 由 `fang-relay-out-of-scope` 改为对端回执判定 |
1057
1057
  | **M5 真实瓮城实测** | ✅ 已发布(3.3.2) | 瓮城 3.1.35 内嵌 1.1.0 并自报 `dsh-fang-bundle`;探针 `--peer fang` 用瓮城发布物当对端(VENDOR.json 校验 8/8、peerLabel 取其 orchestrator);实测 6 条回执、幂等重投通过;同批修 contract 1.1.1 回执计数缺陷并同步 |
1058
+ | **角色矩阵放开** | ✅ 已发布(3.3.3) | 母体全开(Worker/Loop/成本进 mother)+ 分身挂双主管;唯一保留限制 Router 不进 fork;7 条测试与 5 处文档同步 |
1058
1059
 
1059
- *3.3.0 / 3.3.1 / 3.3.2 均已三方发布(annotated tag + GitHub Release + npm);contract 1.0.0 / 1.1.0 / 1.1.1 已发 npm。*
1060
+ *3.3.0 / 3.3.1 / 3.3.2 / 3.3.3 均已三方发布(annotated tag + GitHub Release + npm);contract 1.0.0 / 1.1.0 / 1.1.1 已发 npm。*