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