dsh-workplan 0.0.0-stage → 1.4.3

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/src/plan.ts ADDED
@@ -0,0 +1,1591 @@
1
+ /**
2
+ * Pure planning helpers for dsh-workplan.
3
+ *
4
+ * This module deliberately has no dependency on DeepSeek Harness services. It
5
+ * owns the plan contract, JSON extraction/validation, the planner prompt, the
6
+ * durable message format, and the pure status/graph helpers used by the host
7
+ * runtime and by unit tests.
8
+ */
9
+
10
+ import { randomUUID } from 'node:crypto'
11
+
12
+ export const PLAN_VERSION = 1
13
+ export const MAX_STAGES = 6
14
+ export const MAX_SUBTASKS = 15
15
+ export const MAX_CONCURRENT = 5
16
+ export const MAX_PLAN_JSON_CHARS = 60000
17
+
18
+ export const PLAN_MARKER_BEGIN = '<!-- DSH-WORKPLAN:BEGIN -->'
19
+ export const PLAN_MARKER_END = '<!-- DSH-WORKPLAN:END -->'
20
+ export const RESULT_MARKER_BEGIN = '<!-- DSH-WORKPLAN-RESULT:BEGIN -->'
21
+ export const RESULT_MARKER_END = '<!-- DSH-WORKPLAN-RESULT:END -->'
22
+ export const CANCEL_MARKER_BEGIN = '<!-- DSH-WORKPLAN-CANCEL:BEGIN -->'
23
+ export const CANCEL_MARKER_END = '<!-- DSH-WORKPLAN-CANCEL:END -->'
24
+
25
+ export const STATUSES = Object.freeze(['pending', 'in_progress', 'done', 'blocked'])
26
+ export const LIKELIHOODS = Object.freeze(['low', 'medium', 'high'])
27
+
28
+ export const DEPARTMENTS = Object.freeze({
29
+ engineering: { zh: '工程研发', en: 'Engineering' },
30
+ research: { zh: '研究分析', en: 'Research' },
31
+ design: { zh: '设计', en: 'Design' },
32
+ product: { zh: '产品', en: 'Product' },
33
+ data: { zh: '数据', en: 'Data' },
34
+ qa: { zh: '质量验证', en: 'QA' },
35
+ security: { zh: '安全', en: 'Security' },
36
+ devops: { zh: '运维', en: 'DevOps' },
37
+ writing: { zh: '文档写作', en: 'Writing' },
38
+ operations: { zh: '运营', en: 'Operations' },
39
+ legal: { zh: '法务合规', en: 'Legal' },
40
+ finance: { zh: '财务', en: 'Finance' },
41
+ })
42
+
43
+ export const DEPARTMENT_IDS = Object.freeze(Object.keys(DEPARTMENTS))
44
+
45
+ export class PlanError extends Error {
46
+ code: string
47
+ details: unknown
48
+
49
+ constructor(message: string, code: any = 'PLAN_ERROR', details: unknown = undefined) {
50
+ super(message)
51
+ this.name = 'PlanError'
52
+ this.code = code
53
+ this.details = details
54
+ }
55
+ }
56
+
57
+ export function isRecord(value: any) {
58
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
59
+ }
60
+
61
+ export function truncate(value: any, max: any) {
62
+ if (typeof value !== 'string') return ''
63
+ return value.length <= max ? value : `${value.slice(0, Math.max(0, max - 1))}…`
64
+ }
65
+
66
+ export function messageText(message: any) {
67
+ if (!message || !Array.isArray(message.content)) return ''
68
+ return message.content
69
+ .filter((block: any) => block && block.type === 'text' && typeof block.text === 'string')
70
+ .map((block: any) => block.text)
71
+ .join('\n')
72
+ }
73
+
74
+ /*
75
+ * Lightweight conversational inputs must not start the planning pipeline.
76
+ *
77
+ * This is deliberately a conservative, deterministic token list rather than a
78
+ * model call: a greeting should never pay for a planner request or spawn
79
+ * department agents. The check only returns true when EVERY meaningful token is
80
+ * a known greeting/thanks/acknowledgement/farewell. "你好,帮我写代码" keeps
81
+ * the task text and therefore still gets a plan.
82
+ */
83
+ const SOCIAL_PHRASES = Object.freeze([
84
+ 'thank you very much',
85
+ 'nice to meet you',
86
+ 'how are you doing',
87
+ 'have a nice day',
88
+ 'see you later',
89
+ 'good afternoon',
90
+ 'good morning',
91
+ 'good evening',
92
+ "how's it going",
93
+ 'hello there',
94
+ 'hi there',
95
+ 'hey there',
96
+ 'thank you',
97
+ 'see you',
98
+ 'good night',
99
+ 'what\'s up',
100
+ 'thanks a lot',
101
+ 'goodbye',
102
+ 'bye bye',
103
+ '谢谢你',
104
+ '谢谢你了',
105
+ '辛苦了',
106
+ '麻烦你了',
107
+ '好久不见',
108
+ '最近怎么样',
109
+ '最近好吗',
110
+ '很高兴认识你',
111
+ '早上好',
112
+ '上午好',
113
+ '中午好',
114
+ '下午好',
115
+ '晚上好',
116
+ '晚安',
117
+ '早安',
118
+ '午安',
119
+ '早点休息',
120
+ '亲爱的',
121
+ '大家好',
122
+ '你好',
123
+ '您好',
124
+ '哈喽',
125
+ '请问',
126
+ '在吗',
127
+ '在么',
128
+ '谢谢',
129
+ '多谢',
130
+ '再见',
131
+ '拜拜',
132
+ '好的',
133
+ '收到',
134
+ '明白了',
135
+ '没问题',
136
+ '不客气',
137
+ '没关系',
138
+ 'hello',
139
+ 'thanks',
140
+ 'thank you',
141
+ 'good morning',
142
+ 'good night',
143
+ 'see you',
144
+ 'okay',
145
+ 'ok',
146
+ 'yes',
147
+ 'yep',
148
+ 'sure',
149
+ 'cool',
150
+ 'great',
151
+ 'nice',
152
+ 'lol',
153
+ 'hey',
154
+ 'hi',
155
+ 'yo',
156
+ 'sup',
157
+ 'thx',
158
+ 'bye',
159
+ '嗯嗯',
160
+ '哈哈',
161
+ '嘿嘿',
162
+ '呵呵',
163
+ '老师',
164
+ '同学',
165
+ '朋友',
166
+ '大佬',
167
+ '老板',
168
+ '先生',
169
+ '女士',
170
+ '好',
171
+ '行',
172
+ '可以',
173
+ '嗯',
174
+ '哦',
175
+ '噢',
176
+ '嗨',
177
+ '嘿',
178
+ ])
179
+
180
+ const SOCIAL_TRAILING_PARTICLES = /[呀啊呢吧啦哦噢嗯哈嘛喽咯哒滴了喔唔嘿呵吗]+$/u
181
+
182
+ export function isSocialMessage(value: any) {
183
+ if (typeof value !== 'string') return false
184
+ const original = value.trim()
185
+ if (original.length === 0 || original.length > 60) return false
186
+ let rest = original.toLowerCase()
187
+ for (const phrase of SOCIAL_PHRASES) {
188
+ if (!rest.includes(phrase)) continue
189
+ rest = rest.split(phrase).join(' ')
190
+ }
191
+ rest = rest.replace(/[\s\p{P}\p{S}]+/gu, '')
192
+ rest = rest.replace(SOCIAL_TRAILING_PARTICLES, '')
193
+ return rest.length === 0
194
+ }
195
+
196
+ /*
197
+ * A bare "continue" carries no new task content. The planner only sees the
198
+ * current message, so it cannot know what to continue; planning it produces a
199
+ * huge plan for an unknown object. The main Agent has the conversation history
200
+ * and should continue directly (or ask which thread), so this gate bypasses the
201
+ * planner exactly like a social message.
202
+ */
203
+ const VAGUE_CONTINUATIONS = Object.freeze([
204
+ 'continue',
205
+ 'keep going',
206
+ 'carry on',
207
+ 'go on',
208
+ 'proceed',
209
+ '继续推进',
210
+ '继续做',
211
+ '继续吧',
212
+ '接着来',
213
+ '接着做',
214
+ '往下推进',
215
+ '往下做',
216
+ '然后呢',
217
+ '接下来',
218
+ '下一步',
219
+ '继续',
220
+ '接着',
221
+ '往下',
222
+ '再来',
223
+ '开始吧',
224
+ '干吧',
225
+ 'next',
226
+ ])
227
+
228
+ export function isVagueContinuation(value: any) {
229
+ if (typeof value !== 'string') return false
230
+ const original = value.trim()
231
+ if (original.length === 0 || original.length > 24) return false
232
+ let rest = original.toLowerCase()
233
+ for (const phrase of VAGUE_CONTINUATIONS) {
234
+ if (!rest.includes(phrase)) continue
235
+ rest = rest.split(phrase).join(' ')
236
+ }
237
+ rest = rest.replace(/[\s\p{P}\p{S}]+/gu, '')
238
+ if (rest.length === 0) return true
239
+ /*
240
+ * "B继续" / "按B继续" / "方案B继续" continue a decision already made in the
241
+ * conversation. The main Agent can see that decision; the planner only sees
242
+ * this short message, so planning it invents a huge new task. Require a short
243
+ * tail so "继续修复这个 bug" is still planned.
244
+ */
245
+ const compact = original.toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, '')
246
+ if (/继续|接着|往下|下一步/.test(compact)) {
247
+ const tail = compact.replace(/继续|接着|往下|下一步|方案|按照|按|就|吧|呀|啊|呢/g, '')
248
+ if (tail.length <= 2) return true
249
+ }
250
+ return false
251
+ }
252
+
253
+ /*
254
+ * A status question asks about work already recorded; planning and dispatching
255
+ * it creates a second plan about the first plan and is guaranteed to feed back
256
+ * on itself. The main Agent can answer it from the session log/dashboard, so
257
+ * this gate bypasses the planner. It deliberately requires a question/status
258
+ * form so a real repair task such as "修复卡住的任务" still gets planned.
259
+ */
260
+ const STATUS_PATTERNS = Object.freeze([
261
+ /完成(了)?(吗|么|没有|没|嘛)/,
262
+ /(全部|全都|都|已经).{0,6}完成/,
263
+ /(做|写|改|修)(完|好)(了)?(吗|么|没有|没|嘛)/,
264
+ /进度(如何|怎么样|多少|到哪|呢|吗)?/,
265
+ /卡住(了)?(吗|么|没有|没|嘛)?/,
266
+ /(还|剩)(有|下|余)?.{0,6}(多少|几|什么|哪些)/,
267
+ /是否.{0,8}(完成|做完|卡住|进行|通过)/,
268
+ /正确(吗|么|不)/,
269
+ /(显示|数字|统计|具体工作).{0,12}(正确|对不上|不一致|不对)/,
270
+ /(对不上|不一致)(吗|么|?|\?)/,
271
+ /(status|progress|done yet|completed|how many.*(left|remain)|what.*(left|remain))/i,
272
+ ])
273
+
274
+ export function isStatusQuestion(value: any) {
275
+ if (typeof value !== 'string') return false
276
+ const original = value.trim()
277
+ if (original.length === 0 || original.length > 120) return false
278
+ const looksLikeQuestion = /[??]/.test(original)
279
+ || /(吗|呢|没有|是否|多少|几|如何|怎么样|到哪)/.test(original)
280
+ || /^(查|看|告诉我|确认|核对).{0,8}(进度|状态|完成|卡住)/.test(original)
281
+ if (!looksLikeQuestion) return false
282
+ // A request to change/repair the progress UI is a real task, not a status
283
+ // question, even though it contains the word 进度.
284
+ if (/(改进|优化|设计|修复|实现|增加|调整|开发|重构|排查|诊断|改造|排版)/.test(original)) return false
285
+ return STATUS_PATTERNS.some((pattern) => pattern.test(original))
286
+ }
287
+
288
+ function push(errors: string[], path: string, message: string) {
289
+ errors.push(`${path}: ${message}`)
290
+ }
291
+
292
+ function readString(value: any, path: any, min: any, max: any, errors: any) {
293
+ if (typeof value !== 'string') {
294
+ push(errors, path, '必须是字符串')
295
+ return ''
296
+ }
297
+ const text = value.trim()
298
+ if (text.length < min) {
299
+ push(errors, path, `长度不能小于 ${min}`)
300
+ return text
301
+ }
302
+ if (text.length > max) {
303
+ push(errors, path, `长度不能超过 ${max}`)
304
+ return text.slice(0, max)
305
+ }
306
+ return text
307
+ }
308
+
309
+ function readStringArray(value: any, path: string, options: any, errors: string[]) {
310
+ const opts = { min: 0, max: 20, itemMax: 2000, ...options }
311
+ if (!Array.isArray(value)) {
312
+ push(errors, path, '必须是字符串数组')
313
+ return []
314
+ }
315
+ if (value.length < opts.min) push(errors, path, `至少需要 ${opts.min} 项`)
316
+ if (value.length > opts.max) push(errors, path, `最多允许 ${opts.max} 项`)
317
+ const out: string[] = []
318
+ value.slice(0, opts.max).forEach((item: any, index: any) => {
319
+ if (typeof item !== 'string') {
320
+ push(errors, `${path}[${index}]`, '必须是字符串')
321
+ return
322
+ }
323
+ const text = item.trim()
324
+ if (text.length === 0) {
325
+ push(errors, `${path}[${index}]`, '不能为空')
326
+ return
327
+ }
328
+ if (text.length > opts.itemMax) {
329
+ push(errors, `${path}[${index}]`, `长度不能超过 ${opts.itemMax}`)
330
+ out.push(text.slice(0, opts.itemMax))
331
+ return
332
+ }
333
+ out.push(text)
334
+ })
335
+ return out
336
+ }
337
+
338
+ function readEnum(value: any, path: any, allowed: any, errors: any) {
339
+ if (!allowed.includes(value)) {
340
+ push(errors, path, `必须是 ${allowed.join(' / ')} 之一`)
341
+ return allowed[0]
342
+ }
343
+ return value
344
+ }
345
+
346
+ function readTaskUnderstanding(value: any, errors: any) {
347
+ const source = isRecord(value) ? value : {}
348
+ if (!isRecord(value)) push(errors, 'taskUnderstanding', '必须是对象')
349
+ return {
350
+ originalRequest: readString(source.originalRequest, 'taskUnderstanding.originalRequest', 1, 8000, errors),
351
+ deliverable: readString(source.deliverable, 'taskUnderstanding.deliverable', 1, 2000, errors),
352
+ successCriteria: readStringArray(
353
+ source.successCriteria,
354
+ 'taskUnderstanding.successCriteria',
355
+ { min: 1, max: 20, itemMax: 600 },
356
+ errors,
357
+ ),
358
+ constraints: readStringArray(
359
+ source.constraints,
360
+ 'taskUnderstanding.constraints',
361
+ { min: 0, max: 20, itemMax: 600 },
362
+ errors,
363
+ ),
364
+ knownFacts: readStringArray(
365
+ source.knownFacts,
366
+ 'taskUnderstanding.knownFacts',
367
+ { min: 0, max: 20, itemMax: 600 },
368
+ errors,
369
+ ),
370
+ unknowns: readStringArray(
371
+ source.unknowns,
372
+ 'taskUnderstanding.unknowns',
373
+ { min: 0, max: 20, itemMax: 600 },
374
+ errors,
375
+ ),
376
+ assumptions: readStringArray(
377
+ source.assumptions,
378
+ 'taskUnderstanding.assumptions',
379
+ { min: 0, max: 20, itemMax: 600 },
380
+ errors,
381
+ ),
382
+ }
383
+ }
384
+
385
+ function readContradictions(value: any, errors: any) {
386
+ const source = isRecord(value) ? value : {}
387
+ if (!isRecord(value)) push(errors, 'contradictions', '必须是对象')
388
+ const primary = isRecord(source.primary) ? source.primary : {}
389
+ if (!isRecord(source.primary)) push(errors, 'contradictions.primary', '必须是对象')
390
+ return {
391
+ primary: {
392
+ statement: readString(primary.statement, 'contradictions.primary.statement', 1, 1200, errors),
393
+ evidence: readStringArray(
394
+ primary.evidence,
395
+ 'contradictions.primary.evidence',
396
+ { min: 1, max: 10, itemMax: 800 },
397
+ errors,
398
+ ),
399
+ whyPrimary: readString(primary.whyPrimary, 'contradictions.primary.whyPrimary', 1, 1200, errors),
400
+ },
401
+ secondary: readStringArray(
402
+ source.secondary,
403
+ 'contradictions.secondary',
404
+ { min: 0, max: 10, itemMax: 800 },
405
+ errors,
406
+ ),
407
+ transformationConditions: readStringArray(
408
+ source.transformationConditions,
409
+ 'contradictions.transformationConditions',
410
+ { min: 0, max: 10, itemMax: 800 },
411
+ errors,
412
+ ),
413
+ }
414
+ }
415
+
416
+ function readSubtasks(value: any, stageIndex: any, errors: any) {
417
+ const path = `stages[${stageIndex}].subtasks`
418
+ if (!Array.isArray(value)) {
419
+ push(errors, path, '必须是数组')
420
+ return []
421
+ }
422
+ if (value.length < 1) push(errors, path, '每个阶段至少需要 1 个子任务')
423
+ if (value.length > MAX_SUBTASKS) push(errors, path, `最多允许 ${MAX_SUBTASKS} 个子任务`)
424
+ return value.slice(0, MAX_SUBTASKS).map((item: any, index: any) => {
425
+ const source = isRecord(item) ? item : {}
426
+ const base = `${path}[${index}]`
427
+ if (!isRecord(item)) push(errors, base, '必须是对象')
428
+ const department = typeof source.department === 'string'
429
+ ? source.department
430
+ : ''
431
+ if (!DEPARTMENTS[department as keyof typeof DEPARTMENTS]) {
432
+ push(errors, `${base}.department`, `必须是 ${DEPARTMENT_IDS.join(' / ')} 之一`)
433
+ }
434
+ return {
435
+ id: readString(source.id, `${base}.id`, 1, 16, errors),
436
+ title: readString(source.title, `${base}.title`, 1, 200, errors),
437
+ department,
438
+ role: readString(source.role, `${base}.role`, 1, 200, errors),
439
+ objective: readString(source.objective, `${base}.objective`, 1, 2000, errors),
440
+ method: readStringArray(source.method, `${base}.method`, { min: 1, max: 8, itemMax: 800 }, errors),
441
+ inputs: readStringArray(source.inputs, `${base}.inputs`, { min: 0, max: 12, itemMax: 800 }, errors),
442
+ outputs: readStringArray(source.outputs, `${base}.outputs`, { min: 1, max: 12, itemMax: 800 }, errors),
443
+ dependsOn: readStringArray(source.dependsOn, `${base}.dependsOn`, { min: 0, max: 12, itemMax: 16 }, errors),
444
+ acceptance: readStringArray(source.acceptance, `${base}.acceptance`, { min: 1, max: 8, itemMax: 800 }, errors),
445
+ risk: readString(source.risk, `${base}.risk`, 1, 1000, errors),
446
+ }
447
+ })
448
+ }
449
+
450
+ function readStages(value: any, errors: any) {
451
+ if (!Array.isArray(value)) {
452
+ push(errors, 'stages', '必须是数组')
453
+ return []
454
+ }
455
+ if (value.length < 1 || value.length > MAX_STAGES) {
456
+ push(errors, 'stages', `数量必须为 1-${MAX_STAGES}`)
457
+ }
458
+ return value.slice(0, MAX_STAGES).map((item: any, index: any) => {
459
+ const source = isRecord(item) ? item : {}
460
+ const base = `stages[${index}]`
461
+ if (!isRecord(item)) push(errors, base, '必须是对象')
462
+ return {
463
+ id: readString(source.id, `${base}.id`, 1, 16, errors),
464
+ name: readString(source.name, `${base}.name`, 1, 200, errors),
465
+ goal: readString(source.goal, `${base}.goal`, 1, 1200, errors),
466
+ rationale: readString(source.rationale, `${base}.rationale`, 1, 1200, errors),
467
+ checkpoint: readString(source.checkpoint, `${base}.checkpoint`, 1, 1200, errors),
468
+ subtasks: readSubtasks(source.subtasks, index, errors),
469
+ }
470
+ })
471
+ }
472
+
473
+ function readRisks(value: any, errors: any) {
474
+ if (!Array.isArray(value)) {
475
+ push(errors, 'risks', '必须是数组')
476
+ return []
477
+ }
478
+ if (value.length > 8) push(errors, 'risks', '最多允许 8 项')
479
+ return value.slice(0, 8).map((item: any, index: any) => {
480
+ const source = isRecord(item) ? item : {}
481
+ const base = `risks[${index}]`
482
+ if (!isRecord(item)) push(errors, base, '必须是对象')
483
+ return {
484
+ risk: readString(source.risk, `${base}.risk`, 1, 800, errors),
485
+ likelihood: readEnum(source.likelihood, `${base}.likelihood`, LIKELIHOODS, errors),
486
+ impact: readString(source.impact, `${base}.impact`, 1, 800, errors),
487
+ mitigation: readString(source.mitigation, `${base}.mitigation`, 1, 800, errors),
488
+ trigger: readString(source.trigger, `${base}.trigger`, 1, 800, errors),
489
+ }
490
+ })
491
+ }
492
+
493
+ function readVerification(value: any, errors: any) {
494
+ if (!Array.isArray(value)) {
495
+ push(errors, 'verification', '必须是数组')
496
+ return []
497
+ }
498
+ if (value.length < 1) push(errors, 'verification', '至少需要 1 项')
499
+ if (value.length > 12) push(errors, 'verification', '最多允许 12 项')
500
+ return value.slice(0, 12).map((item: any, index: any) => {
501
+ const source = isRecord(item) ? item : {}
502
+ const base = `verification[${index}]`
503
+ if (!isRecord(item)) push(errors, base, '必须是对象')
504
+ return {
505
+ item: readString(source.item, `${base}.item`, 1, 600, errors),
506
+ method: readString(source.method, `${base}.method`, 1, 800, errors),
507
+ evidence: readString(source.evidence, `${base}.evidence`, 1, 800, errors),
508
+ }
509
+ })
510
+ }
511
+
512
+ function readPracticeLoop(value: any, errors: any) {
513
+ const source = isRecord(value) ? value : {}
514
+ if (!isRecord(value)) push(errors, 'practiceLoop', '必须是对象')
515
+ return {
516
+ plan: readString(source.plan, 'practiceLoop.plan', 1, 800, errors),
517
+ do: readString(source.do, 'practiceLoop.do', 1, 800, errors),
518
+ check: readString(source.check, 'practiceLoop.check', 1, 800, errors),
519
+ act: readString(source.act, 'practiceLoop.act', 1, 800, errors),
520
+ }
521
+ }
522
+
523
+ function checkPlanGraph(plan: any, errors: string[]) {
524
+ const stageIds = new Set()
525
+ const subtaskIds = new Set()
526
+ const order = new Map()
527
+ const dependencies = new Map()
528
+ let nextOrder = 0
529
+ let total = 0
530
+
531
+ for (const [stageIndex, stage] of plan.stages.entries()) {
532
+ if (!stage.id) {
533
+ push(errors, `stages[${stageIndex}].id`, '不能为空')
534
+ continue
535
+ }
536
+ if (stageIds.has(stage.id)) {
537
+ push(errors, `stages[${stageIndex}].id`, `阶段 ID 重复:${stage.id}`)
538
+ }
539
+ stageIds.add(stage.id)
540
+ for (const [subtaskIndex, subtask] of stage.subtasks.entries()) {
541
+ const base = `stages[${stageIndex}].subtasks[${subtaskIndex}]`
542
+ total += 1
543
+ if (!subtask.id) {
544
+ push(errors, `${base}.id`, '不能为空')
545
+ continue
546
+ }
547
+ if (subtaskIds.has(subtask.id)) {
548
+ push(errors, `${base}.id`, `子任务 ID 重复:${subtask.id}`)
549
+ }
550
+ subtaskIds.add(subtask.id)
551
+ order.set(subtask.id, nextOrder)
552
+ dependencies.set(subtask.id, subtask.dependsOn)
553
+ nextOrder += 1
554
+ }
555
+ }
556
+
557
+ if (total < 1) push(errors, 'stages', '至少需要 1 个子任务')
558
+ if (total > MAX_SUBTASKS) push(errors, 'stages', `子任务总数不能超过 ${MAX_SUBTASKS}`)
559
+
560
+ for (const [stageIndex, stage] of plan.stages.entries()) {
561
+ for (const [subtaskIndex, subtask] of stage.subtasks.entries()) {
562
+ const base = `stages[${stageIndex}].subtasks[${subtaskIndex}]`
563
+ for (const dependency of subtask.dependsOn) {
564
+ if (!order.has(dependency)) {
565
+ push(errors, `${base}.dependsOn`, `未知子任务依赖:${dependency}`)
566
+ continue
567
+ }
568
+ if (order.get(dependency) >= order.get(subtask.id)) {
569
+ push(errors, `${base}.dependsOn`, `依赖必须位于更早阶段或更早位置:${dependency}`)
570
+ }
571
+ }
572
+ }
573
+ }
574
+
575
+ const visiting = new Set()
576
+ const visited = new Set()
577
+ function visit(id: any) {
578
+ if (visited.has(id)) return
579
+ if (visiting.has(id)) {
580
+ push(errors, 'stages', `依赖存在环:${id}`)
581
+ return
582
+ }
583
+ visiting.add(id)
584
+ for (const dependency of dependencies.get(id) ?? []) visit(dependency)
585
+ visiting.delete(id)
586
+ visited.add(id)
587
+ }
588
+ for (const id of dependencies.keys()) visit(id)
589
+
590
+ let serialized = ''
591
+ try {
592
+ serialized = JSON.stringify(plan)
593
+ } catch {
594
+ push(errors, 'plan', '无法序列化为 JSON')
595
+ }
596
+ if (serialized.length > MAX_PLAN_JSON_CHARS) {
597
+ push(errors, 'plan', `计划 JSON 超过 ${MAX_PLAN_JSON_CHARS} 字符,请精简`)
598
+ }
599
+ }
600
+
601
+ /**
602
+ * Validate an untrusted planner object. Never throws for malformed input.
603
+ *
604
+ * @param {unknown} raw
605
+ * @returns {{ok: true, plan: object} | {ok: false, errors: string[]}}
606
+ */
607
+ export function validatePlan(raw: any) {
608
+ if (!isRecord(raw)) {
609
+ return { ok: false, errors: ['计划必须是 JSON 对象'] }
610
+ }
611
+ const errors: string[] = []
612
+ if (raw.version !== PLAN_VERSION) errors.push(`version: 必须为 ${PLAN_VERSION}`)
613
+ const plan = {
614
+ version: PLAN_VERSION,
615
+ title: readString(raw.title, 'title', 1, 200, errors),
616
+ taskUnderstanding: readTaskUnderstanding(raw.taskUnderstanding, errors),
617
+ contradictions: readContradictions(raw.contradictions, errors),
618
+ stages: readStages(raw.stages, errors),
619
+ risks: readRisks(raw.risks, errors),
620
+ verification: readVerification(raw.verification, errors),
621
+ practiceLoop: readPracticeLoop(raw.practiceLoop, errors),
622
+ }
623
+ checkPlanGraph(plan, errors)
624
+ if (errors.length > 0) return { ok: false, errors }
625
+ return { ok: true, plan }
626
+ }
627
+
628
+ export function attachPlanMeta(plan: any, meta: any) {
629
+ return {
630
+ ...plan,
631
+ planId: meta.planId,
632
+ sessionId: meta.sessionId,
633
+ createdTurn: meta.createdTurn,
634
+ createdAt: meta.createdAt,
635
+ sourceMessageIds: [...meta.sourceMessageIds],
636
+ }
637
+ }
638
+
639
+ export function flattenSubtasks(plan: any): Array<{ stage: any; subtask: any }> {
640
+ const out: Array<{ stage: any; subtask: any }> = []
641
+ for (const stage of plan.stages) {
642
+ for (const subtask of stage.subtasks) {
643
+ out.push({ stage, subtask })
644
+ }
645
+ }
646
+ return out
647
+ }
648
+
649
+ export function extractJson(text: any) {
650
+ if (typeof text !== 'string' || text.trim() === '') {
651
+ throw new PlanError('规划器没有返回内容', 'EMPTY_OUTPUT')
652
+ }
653
+ let value = text.trim()
654
+ const fenced = value.match(/```(?:json)?\s*([\s\S]*?)```/i)
655
+ if (fenced) value = fenced[1].trim()
656
+ const first = value.indexOf('{')
657
+ const last = value.lastIndexOf('}')
658
+ if (first < 0 || last <= first) {
659
+ throw new PlanError('规划器返回内容中没有 JSON 对象', 'NO_JSON')
660
+ }
661
+ const candidate = value.slice(first, last + 1)
662
+ try {
663
+ return JSON.parse(candidate)
664
+ } catch (error) {
665
+ throw new PlanError(`规划器返回的 JSON 无法解析:${error instanceof Error ? error.message : String(error)}`, 'INVALID_JSON')
666
+ }
667
+ }
668
+
669
+ export function departmentLabel(id: any, language: any = 'zh') {
670
+ const entry = DEPARTMENTS[id as keyof typeof DEPARTMENTS]
671
+ if (!entry) return id
672
+ return language === 'en' ? entry.en : entry.zh
673
+ }
674
+
675
+ export function buildPlannerPrompt(taskText: any, cwd: any, conversationContext: any = '') {
676
+ const departments = DEPARTMENT_IDS
677
+ .map((id: any) => { const d = DEPARTMENTS[id as keyof typeof DEPARTMENTS]; return `- ${id}(${d.zh} / ${d.en})` })
678
+ .join('\n')
679
+ const task = truncate(taskText, 16000)
680
+ const directory = typeof cwd === 'string' && cwd ? cwd : '(未知)'
681
+ const context = truncate(conversationContext, 7000)
682
+ return [
683
+ '你是一个任务分解规划器。请用马克思主义哲学的科学方法分析下面的任务,并只输出一个 JSON 对象。',
684
+ '',
685
+ '必须遵守:',
686
+ '1. 实事求是:knownFacts 只能来自用户输入或可验证事实;不得编造文件、接口、数据或结论。',
687
+ '2. 抓主要矛盾:找出决定任务成败的核心矛盾,写清证据和“为什么它是主要矛盾”。',
688
+ '3. 具体问题具体分析:根据任务类型选择部门和方法,不要机械套模板。',
689
+ '4. 实践—认识—再实践:每个阶段设置检查点,用结果修正后续行动。',
690
+ '5. 质量互变:把大目标拆成可验收的量变阶段,设置阶段门槛。',
691
+ '6. 矛盾转化:说明什么条件下次要矛盾会上升为主要矛盾,以及如何应对。',
692
+ '',
693
+ '硬性约束:',
694
+ `- 阶段 1-${MAX_STAGES} 个;子任务总数 1-${MAX_SUBTASKS} 个;每个阶段至少 1 个子任务。`,
695
+ '- 按任务实际复杂度决定规模:简单任务 1-3 个子任务,复杂任务才展开;不得为了显得细致而堆叠子任务。',
696
+ '- 子任务 ID 使用 T1、T2…;阶段 ID 使用 S1、S2…;所有 ID 唯一。',
697
+ '- dependsOn 只能引用更早阶段或更早位置的子任务;不得形成环。',
698
+ '- dependsOn 只在真正需要前序产出时填写;能独立并行的子任务必须留空,不要为了形式制造依赖链。',
699
+ '- department 必须从下面的部门中选择一个。',
700
+ '- method、outputs、acceptance 必须是非空数组;inputs 可以为空,未知写“待确认”。',
701
+ '- unknowns 和 assumptions 必须如实列出;没有就写空数组,不能把假设写成事实。',
702
+ '- 子任务必须可独立执行、可验收;不要为了凑数拆无意义任务。',
703
+ '- 子任务由子 Agent 独立执行:它们没有派发权限、不能重启服务、不能安装或升级依赖。禁止把「调用 dsh_workplan_dispatch」「重启/停止 dsh web」「安装或升级插件」这类只有主 Agent 才能执行的动作写进子任务;确实需要时,写成“给出结论或建议,由主 Agent 执行”。',
704
+ '- 单个子任务应能在 10 分钟内完成;动手重的任务要拆成多个小步,避免整条计划被一个慢子任务拖住。',
705
+ '- 不要输出 Markdown 代码围栏、解释或前后缀;只输出一个 JSON 对象。',
706
+ '',
707
+ '可用部门:',
708
+ departments,
709
+ '',
710
+ `工作目录:${directory}`,
711
+ '',
712
+ '最近对话(只用于理解“它 / 这个 / B / 上一项”等指代,不是新的任务;不要把它当成额外需求):',
713
+ context || '(无可用对话背景)',
714
+ '',
715
+ '任务:',
716
+ task,
717
+ '',
718
+ '输出 JSON 结构(字段必须齐全,值要具体):',
719
+ '{',
720
+ ' "version": 1,',
721
+ ' "title": "短标题",',
722
+ ' "taskUnderstanding": {',
723
+ ' "originalRequest": "用户原任务复述",',
724
+ ' "deliverable": "最终交付物",',
725
+ ' "successCriteria": ["可验收标准"],',
726
+ ' "constraints": ["约束"],',
727
+ ' "knownFacts": ["仅来自用户输入或可验证事实"],',
728
+ ' "unknowns": ["未验证项:如何验证"],',
729
+ ' "assumptions": ["明确标注的假设"]',
730
+ ' },',
731
+ ' "contradictions": {',
732
+ ' "primary": {',
733
+ ' "statement": "主要矛盾",',
734
+ ' "evidence": ["证据"],',
735
+ ' "whyPrimary": "为什么它决定成败"',
736
+ ' },',
737
+ ' "secondary": ["次要矛盾"],',
738
+ ' "transformationConditions": ["矛盾转化条件"]',
739
+ ' },',
740
+ ' "stages": [',
741
+ ' {',
742
+ ' "id": "S1",',
743
+ ' "name": "阶段名",',
744
+ ' "goal": "阶段目标",',
745
+ ' "rationale": "为什么需要该阶段",',
746
+ ' "checkpoint": "阶段检查点",',
747
+ ' "subtasks": [',
748
+ ' {',
749
+ ' "id": "T1",',
750
+ ' "title": "子任务",',
751
+ ' "department": "engineering",',
752
+ ' "role": "部门内角色",',
753
+ ' "objective": "目标",',
754
+ ' "method": ["具体方法/步骤"],',
755
+ ' "inputs": ["输入;未知写待确认"],',
756
+ ' "outputs": ["输出/交付物"],',
757
+ ' "dependsOn": [],',
758
+ ' "acceptance": ["可测试的验收标准"],',
759
+ ' "risk": "主要风险"',
760
+ ' }',
761
+ ' ]',
762
+ ' }',
763
+ ' ],',
764
+ ' "risks": [',
765
+ ' {',
766
+ ' "risk": "风险",',
767
+ ' "likelihood": "low",',
768
+ ' "impact": "影响",',
769
+ ' "mitigation": "缓解",',
770
+ ' "trigger": "触发信号"',
771
+ ' }',
772
+ ' ],',
773
+ ' "verification": [',
774
+ ' { "item": "验证项", "method": "方法", "evidence": "证据形式" }',
775
+ ' ],',
776
+ ' "practiceLoop": {',
777
+ ' "plan": "计划",',
778
+ ' "do": "执行",',
779
+ ' "check": "检查",',
780
+ ' "act": "修正"',
781
+ ' }',
782
+ '}',
783
+ ].join('\n')
784
+ }
785
+
786
+ export function makeUserMessage(text: any, source: any) {
787
+ return {
788
+ id: randomUUID(),
789
+ role: 'user',
790
+ content: [{ type: 'text', text }],
791
+ source,
792
+ }
793
+ }
794
+
795
+ export function buildPlanSummary(plan: any) {
796
+ const total = flattenSubtasks(plan).length
797
+ const primary = plan.contradictions.primary.statement
798
+ return truncate(
799
+ `【任务分解】${plan.title}|${plan.stages.length} 个阶段 / ${total} 个子任务|主要矛盾:${primary}`,
800
+ 300,
801
+ )
802
+ }
803
+
804
+ export function buildPlanMessage(plan: any) {
805
+ const summary = buildPlanSummary(plan)
806
+ const payload = {
807
+ kind: 'workplan',
808
+ version: PLAN_VERSION,
809
+ planId: plan.planId,
810
+ sessionId: plan.sessionId,
811
+ createdTurn: plan.createdTurn,
812
+ createdAt: plan.createdAt,
813
+ plan,
814
+ }
815
+ const text = `${summary}\n\n${PLAN_MARKER_BEGIN}\n${JSON.stringify(payload)}\n${PLAN_MARKER_END}`
816
+ return makeUserMessage(text, { kind: 'workplan', form: 'notice', summary })
817
+ }
818
+
819
+ export function buildDegradedMessage(options: any) {
820
+ const error = truncate(options.error ?? '未知错误', 600)
821
+ const summary = truncate(`【自动分解降级】${error}`, 300)
822
+ const payload = {
823
+ kind: 'workplan',
824
+ version: PLAN_VERSION,
825
+ planId: options.planId,
826
+ sessionId: options.sessionId,
827
+ createdTurn: options.createdTurn,
828
+ createdAt: options.createdAt,
829
+ degraded: true,
830
+ error,
831
+ title: '自动分解降级',
832
+ taskUnderstanding: {
833
+ originalRequest: truncate(options.taskText ?? '', 2000),
834
+ deliverable: '未能自动生成计划',
835
+ successCriteria: [],
836
+ constraints: [],
837
+ knownFacts: [],
838
+ unknowns: ['任务分解失败,需要用户确认是否重试'],
839
+ assumptions: [],
840
+ },
841
+ }
842
+ const text = [
843
+ summary,
844
+ '',
845
+ '自动任务分解失败,部门 Agent 未启动。',
846
+ '请先向用户说明失败原因,并询问是否重试;在用户确认前不要开始执行任务。',
847
+ '',
848
+ PLAN_MARKER_BEGIN,
849
+ JSON.stringify(payload),
850
+ PLAN_MARKER_END,
851
+ ].join('\n')
852
+ return makeUserMessage(text, { kind: 'workplan', form: 'notice', summary })
853
+ }
854
+
855
+ export function buildResultSummary(result: any) {
856
+ const stats = result.stats ?? { done: 0, total: 0, blocked: 0 }
857
+ return truncate(
858
+ `【部门执行结果】完成 ${stats.done}/${stats.total},受阻 ${stats.blocked},请主 Agent 核对并整合`,
859
+ 300,
860
+ )
861
+ }
862
+
863
+ export function buildResultMessage(result: any) {
864
+ const summary = buildResultSummary(result)
865
+ const payload = {
866
+ kind: 'workplan-result',
867
+ version: PLAN_VERSION,
868
+ planId: result.planId,
869
+ sessionId: result.sessionId,
870
+ dispatchedAt: result.dispatchedAt,
871
+ statuses: result.statuses,
872
+ errors: result.errors ?? [],
873
+ stats: result.stats,
874
+ /*
875
+ * The round's final phase has to travel with the payload. Without it a
876
+ * budget pause could not be replayed after a page refresh: `@rebuild`
877
+ * guessed `executing` from the statuses, the card said "正在做" while
878
+ * nothing was running, and the pause looked like a stall again.
879
+ */
880
+ phase: typeof result.phase === 'string' ? result.phase : undefined,
881
+ // Carried so a page refresh can still tell "resume budget exhausted" from
882
+ // "never resumed", instead of promising a retry that will be refused.
883
+ resumeCount: Number(result.resumeCount ?? 0),
884
+ resumeAvailable: result.resumeAvailable === true,
885
+ }
886
+ const blocked = Number(result.stats?.blocked ?? 0)
887
+ const paused = Number(result.stats?.pending ?? 0) > 0 && result.phase === 'paused'
888
+ const lines = [
889
+ summary,
890
+ '',
891
+ '各部门子任务结果如下,请主 Agent:',
892
+ '1. 核对每个完成项的证据与验收标准;',
893
+ '2. 处理受阻项,必要时向用户确认;',
894
+ '3. 给出最终答复,不要声称未验证的完成。',
895
+ // A blocked plan used to look identical to a finished one. State the exact
896
+ // recovery move so the model retries the failed work instead of rebuilding
897
+ // the whole plan or stopping to ask the user for a repeated request.
898
+ ...(blocked > 0
899
+ ? [result.resumeAvailable === false
900
+ ? `4. 本计划已用尽自动续跑额度(${Number(result.resumeCount ?? 0)}/${MAX_PLAN_RESUMES}),受阻项不会再自动重跑:请主 Agent 直接接手完成,不要重复派发。`
901
+ : `4. 受阻项可再次调用 dsh_workplan_dispatch 从受阻处续跑(已完成的不会重跑,本计划剩余续跑额度 ${MAX_PLAN_RESUMES - Number(result.resumeCount ?? 0)} 次)。`]
902
+ : paused
903
+ ? [`4. 本轮执行预算用尽,剩余 ${Number(result.stats?.pending ?? 0)} 项未完成(不是失败、未计入续跑额度):再次调用 dsh_workplan_dispatch 即可从剩下的步骤继续,已完成的不重跑。`]
904
+ : []),
905
+ '',
906
+ RESULT_MARKER_BEGIN,
907
+ JSON.stringify(payload),
908
+ RESULT_MARKER_END,
909
+ ]
910
+ return makeUserMessage(lines.join('\n'), {
911
+ kind: 'workplan-result',
912
+ form: 'notice',
913
+ summary,
914
+ })
915
+ }
916
+
917
+ function extractBetween(text: any, begin: any, end: any) {
918
+ if (typeof text !== 'string') return null
919
+ const start = text.indexOf(begin)
920
+ if (start < 0) return null
921
+ const finish = text.indexOf(end, start + begin.length)
922
+ if (finish < 0) return null
923
+ return text.slice(start + begin.length, finish).trim()
924
+ }
925
+
926
+ export function parseWorkplanMessage(message: any) {
927
+ const body = extractBetween(messageText(message), PLAN_MARKER_BEGIN, PLAN_MARKER_END)
928
+ if (!body) return null
929
+ try {
930
+ const payload = JSON.parse(body)
931
+ if (!isRecord(payload) || payload.kind !== 'workplan') return null
932
+ return payload
933
+ } catch {
934
+ return null
935
+ }
936
+ }
937
+
938
+ export function parseResultMessage(message: any) {
939
+ const body = extractBetween(messageText(message), RESULT_MARKER_BEGIN, RESULT_MARKER_END)
940
+ if (!body) return null
941
+ try {
942
+ const payload = JSON.parse(body)
943
+ if (!isRecord(payload) || payload.kind !== 'workplan-result') return null
944
+ return payload
945
+ } catch {
946
+ return null
947
+ }
948
+ }
949
+
950
+ export function buildCancelMessage(planId: any, reason: any = '用户取消') {
951
+ const summary = `【任务已取消】${truncate(String(reason || '用户取消'), 120)}`
952
+ const payload = {
953
+ kind: 'workplan-cancel',
954
+ version: PLAN_VERSION,
955
+ planId,
956
+ reason: String(reason || '用户取消'),
957
+ cancelledAt: Date.now(),
958
+ }
959
+ const lines = [
960
+ summary,
961
+ '',
962
+ '这次任务分解已取消。取消只停止后续执行,不会把未完成项标成完成;已完成项保留原记录。',
963
+ '',
964
+ CANCEL_MARKER_BEGIN,
965
+ JSON.stringify(payload),
966
+ CANCEL_MARKER_END,
967
+ ]
968
+ return makeUserMessage(lines.join('\n'), {
969
+ kind: 'workplan-cancel',
970
+ form: 'notice',
971
+ summary,
972
+ })
973
+ }
974
+
975
+ export function parseCancelMessage(message: any) {
976
+ const body = extractBetween(messageText(message), CANCEL_MARKER_BEGIN, CANCEL_MARKER_END)
977
+ if (!body) return null
978
+ try {
979
+ const payload = JSON.parse(body)
980
+ if (!isRecord(payload) || payload.kind !== 'workplan-cancel') return null
981
+ return payload
982
+ } catch {
983
+ return null
984
+ }
985
+ }
986
+
987
+ /*
988
+ * Replay a durable result message into statuses. Dashboard reconstruction used
989
+ * to read only the plan message, so after a page refresh every finished child
990
+ * looked pending again (0 done / 0 blocked / 12 pending). The result message is
991
+ * the only durable record of child outcomes, so it must be folded back too.
992
+ */
993
+ export function applyResultStatuses(statuses: any, result: any) {
994
+ if (!Array.isArray(statuses) || !result || !Array.isArray(result.statuses)) return statuses
995
+ let next = statuses
996
+ for (const entry of result.statuses) {
997
+ if (!isRecord(entry) || typeof entry.subtaskId !== 'string') continue
998
+ if (!STATUSES.includes(entry.status)) continue
999
+ if (!next.some((item: any) => item.subtaskId === entry.subtaskId)) continue
1000
+ try {
1001
+ next = applyStatus(next, entry.subtaskId, {
1002
+ status: entry.status,
1003
+ progress: typeof entry.progress === 'number' ? entry.progress : undefined,
1004
+ childId: entry.childId ?? null,
1005
+ startedAt: entry.startedAt ?? null,
1006
+ finishedAt: entry.finishedAt ?? null,
1007
+ summary: typeof entry.summary === 'string' ? entry.summary : undefined,
1008
+ error: entry.error ?? null,
1009
+ })
1010
+ } catch {
1011
+ // Ignore a single corrupt historical status; the rest still replay.
1012
+ }
1013
+ }
1014
+ return next
1015
+ }
1016
+
1017
+ export function createStatuses(plan: any) {
1018
+ return flattenSubtasks(plan).map(({ stage, subtask }: any) => ({
1019
+ subtaskId: subtask.id,
1020
+ stageId: stage.id,
1021
+ title: subtask.title,
1022
+ department: subtask.department,
1023
+ role: subtask.role,
1024
+ status: 'pending',
1025
+ progress: 0,
1026
+ childId: null,
1027
+ startedAt: null,
1028
+ finishedAt: null,
1029
+ summary: '',
1030
+ error: null,
1031
+ }))
1032
+ }
1033
+
1034
+ export function applyStatus(statuses: any, subtaskId: any, patch: any) {
1035
+ const index = statuses.findIndex((item: any) => item.subtaskId === subtaskId)
1036
+ if (index < 0) throw new PlanError(`未知子任务:${subtaskId}`, 'UNKNOWN_SUBTASK')
1037
+ const current = statuses[index]
1038
+ const next = { ...current }
1039
+ if (patch.status !== undefined) {
1040
+ if (!STATUSES.includes(patch.status)) {
1041
+ throw new PlanError(`非法状态:${String(patch.status)}`, 'INVALID_STATUS')
1042
+ }
1043
+ next.status = patch.status
1044
+ }
1045
+ if (patch.progress !== undefined) {
1046
+ if (!Number.isInteger(patch.progress) || patch.progress < 0 || patch.progress > 100) {
1047
+ throw new PlanError('progress 必须是 0-100 的整数', 'INVALID_PROGRESS')
1048
+ }
1049
+ next.progress = patch.progress
1050
+ }
1051
+ if (patch.childId !== undefined) next.childId = patch.childId
1052
+ if (patch.startedAt !== undefined) next.startedAt = patch.startedAt
1053
+ if (patch.finishedAt !== undefined) next.finishedAt = patch.finishedAt
1054
+ if (patch.summary !== undefined) next.summary = truncate(String(patch.summary), 3000)
1055
+ if (patch.error !== undefined) next.error = patch.error === null ? null : truncate(String(patch.error), 2000)
1056
+ if (patch.status === 'done') {
1057
+ next.progress = 100
1058
+ if (next.finishedAt === null) next.finishedAt = Date.now()
1059
+ }
1060
+ if (patch.status === 'pending') {
1061
+ /*
1062
+ * A reset means "back to the initial state", so every companion field of
1063
+ * the previous attempt goes too.
1064
+ *
1065
+ * Leaving `summary` (and, unless the caller asked for one, `error`) behind
1066
+ * made a re-queued subtask read as `pending` while still carrying the
1067
+ * verdict of the attempt that just failed. Explicit values in the same
1068
+ * patch still win, so a caller can attach a reason such as “预算用尽”.
1069
+ */
1070
+ next.progress = 0
1071
+ next.startedAt = null
1072
+ next.finishedAt = null
1073
+ next.childId = null
1074
+ if (patch.summary === undefined) next.summary = ''
1075
+ if (patch.error === undefined) next.error = null
1076
+ }
1077
+ if (patch.status === 'in_progress' && next.startedAt === null) {
1078
+ next.startedAt = Date.now()
1079
+ }
1080
+ if (patch.status === 'blocked' && next.finishedAt === null) {
1081
+ next.finishedAt = Date.now()
1082
+ }
1083
+ const copy = statuses.slice()
1084
+ copy[index] = next
1085
+ return copy
1086
+ }
1087
+
1088
+ /*
1089
+ * Derived aggregates: the only place counts are ever produced.
1090
+ *
1091
+ * Why this block exists
1092
+ * ---------------------
1093
+ * 1.4.2 kept a second copy of the truth: the record stored `stats` next to the
1094
+ * status list and refreshed it only between dispatch batches. During a batch
1095
+ * the dashboard therefore showed the *previous* counts — the card said
1096
+ * “0 正在做” while a stage badge, read from the live statuses, said “正在做”.
1097
+ * The same payload also carried `departments` aggregated live, so one JSON body
1098
+ * could contradict itself.
1099
+ *
1100
+ * The fix is structural rather than a smaller patch: `stats` is not stored
1101
+ * anywhere any more. `derivePlanSnapshot()` normalises and aggregates in a
1102
+ * single pass and hands back the very array it counted, so a payload cannot
1103
+ * pair fresh statuses with stale numbers. `computeStats()` stays as a
1104
+ * compatibility alias for existing callers and tests.
1105
+ */
1106
+
1107
+ /** One legal subtask status. Mirrors `STATUSES`, which stays the single runtime list. */
1108
+ export type SubtaskStatus = 'pending' | 'in_progress' | 'done' | 'blocked'
1109
+
1110
+ /** The canonical shape of one subtask status entry. Extra fields are preserved. */
1111
+ export interface StatusEntry {
1112
+ readonly subtaskId: string
1113
+ readonly stageId: string
1114
+ readonly title: string
1115
+ readonly department: string
1116
+ readonly role: string
1117
+ readonly status: SubtaskStatus
1118
+ readonly progress: number
1119
+ readonly childId: string | null
1120
+ readonly startedAt: number | null
1121
+ readonly finishedAt: number | null
1122
+ readonly summary: string
1123
+ readonly error: string | null
1124
+ readonly [extra: string]: unknown
1125
+ }
1126
+
1127
+ /** Everything the dashboard needs to describe a plan's progress. */
1128
+ export interface Stats {
1129
+ total: number
1130
+ done: number
1131
+ blocked: number
1132
+ inProgress: number
1133
+ pending: number
1134
+ progress: number
1135
+ }
1136
+
1137
+ /** Per-department slice of the same snapshot. */
1138
+ export interface DepartmentSummary {
1139
+ id: string
1140
+ total: number
1141
+ done: number
1142
+ blocked: number
1143
+ inProgress: number
1144
+ }
1145
+
1146
+ /** The single derived view a payload is allowed to be built from. */
1147
+ export interface PlanSnapshot {
1148
+ statuses: StatusEntry[]
1149
+ stats: Stats
1150
+ departments: DepartmentSummary[]
1151
+ }
1152
+
1153
+ export function isSubtaskStatus(value: unknown): value is SubtaskStatus {
1154
+ return typeof value === 'string' && STATUSES.includes(value)
1155
+ }
1156
+
1157
+ /** Clamp anything to an integer in `[0, 100]`; non-finite input counts as 0. */
1158
+ export function clampProgress(value: unknown): number {
1159
+ const numeric = Number(value)
1160
+ if (!Number.isFinite(numeric)) return 0
1161
+ return Math.min(100, Math.max(0, Math.round(numeric)))
1162
+ }
1163
+
1164
+ function numberOrNull(value: unknown): number | null {
1165
+ if (value === null || value === undefined) return null
1166
+ const numeric = Number(value)
1167
+ return Number.isFinite(numeric) ? numeric : null
1168
+ }
1169
+
1170
+ function stringOr(value: unknown, fallback: string): string {
1171
+ return typeof value === 'string' ? value : fallback
1172
+ }
1173
+
1174
+ /**
1175
+ * Structurally normalise one entry without touching the clock.
1176
+ *
1177
+ * Rules mirror the write path in `applyStatus`:
1178
+ * - an unknown status becomes `pending` (otherwise the four counters sum to
1179
+ * less than `total`, which is how a corrupt entry used to hide itself);
1180
+ * - `pending` carries no progress and no run bookkeeping;
1181
+ * - `done` is progress 100 even if a stale entry stored something else.
1182
+ *
1183
+ * Timestamps are only type-coerced, never filled in: filling them here would
1184
+ * change the payload on every poll just because time passed.
1185
+ */
1186
+ export function normaliseStatusEntry(entry: any): StatusEntry {
1187
+ const source: any = entry ?? {}
1188
+ const status: SubtaskStatus = isSubtaskStatus(source.status) ? source.status : 'pending'
1189
+ const progress = status === 'done' ? 100 : status === 'pending' ? 0 : clampProgress(source.progress)
1190
+ return {
1191
+ ...source,
1192
+ subtaskId: stringOr(source.subtaskId, ''),
1193
+ stageId: stringOr(source.stageId, ''),
1194
+ title: stringOr(source.title, ''),
1195
+ department: stringOr(source.department, ''),
1196
+ role: stringOr(source.role, ''),
1197
+ status,
1198
+ progress,
1199
+ childId: status === 'pending' ? null : (source.childId ?? null),
1200
+ startedAt: status === 'pending' ? null : numberOrNull(source.startedAt),
1201
+ finishedAt: status === 'pending' ? null : numberOrNull(source.finishedAt),
1202
+ summary: stringOr(source.summary, ''),
1203
+ error: typeof source.error === 'string' ? source.error : null,
1204
+ }
1205
+ }
1206
+
1207
+ /**
1208
+ * Normalise a whole list.
1209
+ *
1210
+ * Entries without an identity (`subtaskId`) are dropped rather than counted: an
1211
+ * entry that cannot be attributed to a subtask would inflate `total` and make
1212
+ * every ratio on the dashboard lie.
1213
+ */
1214
+ export function normaliseStatuses(statuses: any): StatusEntry[] {
1215
+ const list = Array.isArray(statuses) ? statuses : []
1216
+ const result: StatusEntry[] = []
1217
+ for (const entry of list) {
1218
+ if (entry === null || entry === undefined || typeof entry !== 'object') continue
1219
+ const normalised = normaliseStatusEntry(entry)
1220
+ if (normalised.subtaskId.trim() === '') continue
1221
+ result.push(normalised)
1222
+ }
1223
+ return result
1224
+ }
1225
+
1226
+ /**
1227
+ * Count in one pass.
1228
+ *
1229
+ * Overall progress weights by status instead of trusting the stored number:
1230
+ * `done` counts as 100, `in_progress` / `blocked` as their own (clamped)
1231
+ * progress, `pending` as 0. A subtask that was rewound to `pending` can
1232
+ * therefore never drag the plan's percentage up to where the cards say it is
1233
+ * finished — that was the “100% 但 11/12” contradiction users saw.
1234
+ */
1235
+ export function deriveAggregates(statuses: readonly StatusEntry[]): {
1236
+ stats: Stats
1237
+ departments: DepartmentSummary[]
1238
+ } {
1239
+ const departments = new Map<string, DepartmentSummary>()
1240
+ let done = 0
1241
+ let blocked = 0
1242
+ let inProgress = 0
1243
+ let pending = 0
1244
+ let progressSum = 0
1245
+
1246
+ for (const item of statuses) {
1247
+ if (item.status === 'done') {
1248
+ done += 1
1249
+ progressSum += 100
1250
+ } else if (item.status === 'blocked') {
1251
+ blocked += 1
1252
+ progressSum += clampProgress(item.progress)
1253
+ } else if (item.status === 'in_progress') {
1254
+ inProgress += 1
1255
+ progressSum += clampProgress(item.progress)
1256
+ } else {
1257
+ pending += 1
1258
+ }
1259
+
1260
+ const department = departments.get(item.department) ?? {
1261
+ id: item.department,
1262
+ total: 0,
1263
+ done: 0,
1264
+ blocked: 0,
1265
+ inProgress: 0,
1266
+ }
1267
+ department.total += 1
1268
+ if (item.status === 'done') department.done += 1
1269
+ else if (item.status === 'blocked') department.blocked += 1
1270
+ else if (item.status === 'in_progress') department.inProgress += 1
1271
+ departments.set(item.department, department)
1272
+ }
1273
+
1274
+ const total = statuses.length
1275
+ return {
1276
+ stats: {
1277
+ total,
1278
+ done,
1279
+ blocked,
1280
+ inProgress,
1281
+ pending,
1282
+ progress: total === 0 ? 0 : Math.round(progressSum / total),
1283
+ },
1284
+ departments: [...departments.values()],
1285
+ }
1286
+ }
1287
+
1288
+ /**
1289
+ * The one entry point used when a plan is serialised.
1290
+ *
1291
+ * Input is read once, normalised once and aggregated once; the three returned
1292
+ * values share one array instance, so `stats`, `departments` and `statuses` in
1293
+ * a single payload cannot disagree.
1294
+ */
1295
+ export function derivePlanSnapshot(raw: any): PlanSnapshot {
1296
+ const statuses = normaliseStatuses(raw)
1297
+ const { stats, departments } = deriveAggregates(statuses)
1298
+ return { statuses, stats, departments }
1299
+ }
1300
+
1301
+ /**
1302
+ * Compatibility alias for callers that only need the counts.
1303
+ *
1304
+ * @deprecated Prefer `derivePlanSnapshot(statuses).stats` so the caller counts
1305
+ * the exact array it is about to ship.
1306
+ */
1307
+ export function computeStats(statuses: any): Stats {
1308
+ return derivePlanSnapshot(statuses).stats
1309
+ }
1310
+
1311
+ /**
1312
+ * Invariant checker for tests and CI; production paths never call it because
1313
+ * pure derivation is already self-consistent.
1314
+ *
1315
+ * Returns an empty array when every invariant holds. Each message names the
1316
+ * field and the observed value so it can be pasted straight into a report.
1317
+ */
1318
+ export function aggregateInvariantViolations(snapshot: PlanSnapshot): string[] {
1319
+ const violations: string[] = []
1320
+ const { statuses, stats, departments } = snapshot
1321
+
1322
+ if (stats.total !== statuses.length) {
1323
+ violations.push(`I1 stats.total=${stats.total} 与 statuses.length=${statuses.length} 不一致`)
1324
+ }
1325
+ const counted = stats.done + stats.blocked + stats.inProgress + stats.pending
1326
+ if (counted !== stats.total) {
1327
+ violations.push(`I2 四个状态计数之和=${counted} 与 total=${stats.total} 不一致`)
1328
+ }
1329
+ for (const key of ['total', 'done', 'blocked', 'inProgress', 'pending'] as const) {
1330
+ const value = stats[key]
1331
+ if (!Number.isInteger(value) || value < 0) violations.push(`I3 stats.${key}=${String(value)} 不是非负整数`)
1332
+ }
1333
+ if (!Number.isInteger(stats.progress) || stats.progress < 0 || stats.progress > 100) {
1334
+ violations.push(`I4 stats.progress=${String(stats.progress)} 不在 0..100`)
1335
+ }
1336
+
1337
+ const seen = new Set<string>()
1338
+ for (const item of statuses) {
1339
+ if (item.subtaskId === '') violations.push('I5 存在 subtaskId 为空的条目')
1340
+ if (seen.has(item.subtaskId)) violations.push(`I5 subtaskId 重复:${item.subtaskId}`)
1341
+ seen.add(item.subtaskId)
1342
+ if (!Number.isInteger(item.progress) || item.progress < 0 || item.progress > 100) {
1343
+ violations.push(`I4 ${item.subtaskId}.progress=${String(item.progress)} 不在 0..100`)
1344
+ }
1345
+ if (item.status === 'pending') {
1346
+ if (item.progress !== 0) violations.push(`I6 ${item.subtaskId} 是 pending 但 progress=${item.progress}`)
1347
+ if (item.childId !== null) violations.push(`I6 ${item.subtaskId} 是 pending 但仍有 childId`)
1348
+ if (item.startedAt !== null) violations.push(`I6 ${item.subtaskId} 是 pending 但仍有 startedAt`)
1349
+ if (item.finishedAt !== null) violations.push(`I6 ${item.subtaskId} 是 pending 但仍有 finishedAt`)
1350
+ }
1351
+ if (item.status === 'done' && item.progress !== 100) {
1352
+ violations.push(`I6 ${item.subtaskId} 是 done 但 progress=${item.progress}`)
1353
+ }
1354
+ }
1355
+
1356
+ const sums = { total: 0, done: 0, blocked: 0, inProgress: 0 }
1357
+ for (const department of departments) {
1358
+ sums.total += department.total
1359
+ sums.done += department.done
1360
+ sums.blocked += department.blocked
1361
+ sums.inProgress += department.inProgress
1362
+ const pendingHere = statuses.filter((item) => item.department === department.id && item.status === 'pending').length
1363
+ if (department.total !== department.done + department.blocked + department.inProgress + pendingHere) {
1364
+ violations.push(`I7 部门 ${department.id} 的计数之和与 total 不一致`)
1365
+ }
1366
+ }
1367
+ if (sums.total !== stats.total) violations.push(`I7 各部门 total 之和=${sums.total} 与 stats.total=${stats.total} 不一致`)
1368
+ if (sums.done !== stats.done) violations.push(`I7 各部门 done 之和=${sums.done} 与 stats.done=${stats.done} 不一致`)
1369
+ if (sums.blocked !== stats.blocked) violations.push(`I7 各部门 blocked 之和=${sums.blocked} 与 stats.blocked=${stats.blocked} 不一致`)
1370
+ if (sums.inProgress !== stats.inProgress) {
1371
+ violations.push(`I7 各部门 inProgress 之和=${sums.inProgress} 与 stats.inProgress=${stats.inProgress} 不一致`)
1372
+ }
1373
+
1374
+ return violations
1375
+ }
1376
+
1377
+ export function planPhase(statuses: any, resultSeen = false) {
1378
+ if (statuses.length === 0) return 'pending'
1379
+ if (statuses.every((item: any) => item.status === 'done')) return resultSeen ? 'done' : 'integrating'
1380
+ if (statuses.some((item: any) => item.status === 'blocked')) return 'blocked'
1381
+ if (statuses.some((item: any) => item.status === 'in_progress' || item.status === 'done')) return 'executing'
1382
+ return 'pending'
1383
+ }
1384
+
1385
+ export function nextReadyInStage(plan: any, statuses: any[], stageId: string) {
1386
+ const byId = new Map(statuses.map((item: any) => [item.subtaskId, item]))
1387
+ const stage = plan.stages.find((item: any) => item.id === stageId)
1388
+ if (!stage) return []
1389
+ return stage.subtasks.filter((subtask: any) => {
1390
+ const status = byId.get(subtask.id)
1391
+ if (!status || status.status !== 'pending') return false
1392
+ return subtask.dependsOn.every((dependency: any) => byId.get(dependency)?.status === 'done')
1393
+ })
1394
+ }
1395
+
1396
+ /*
1397
+ * Readiness across the whole plan, not per stage. A stage is only a grouping
1398
+ * label; making every later stage wait for every earlier stage lets one slow
1399
+ * child turn an entire plan red. Only real dependsOn edges gate a subtask.
1400
+ */
1401
+ export function readySubtasks(plan: any, statuses: any[]) {
1402
+ const byId = new Map(statuses.map((item: any) => [item.subtaskId, item]))
1403
+ return flattenSubtasks(plan)
1404
+ .map((entry: any) => entry.subtask)
1405
+ .filter((subtask: any) => {
1406
+ const status = byId.get(subtask.id)
1407
+ if (!status || status.status !== 'pending') return false
1408
+ return subtask.dependsOn.every((dependency: any) => byId.get(dependency)?.status === 'done')
1409
+ })
1410
+ }
1411
+
1412
+ export function markDependentsBlocked(plan: any, statuses: any, blockedId: any) {
1413
+ let next = statuses.slice()
1414
+ let changed = true
1415
+ const blocked = new Set([blockedId])
1416
+ while (changed) {
1417
+ changed = false
1418
+ for (const { subtask } of flattenSubtasks(plan)) {
1419
+ if (blocked.has(subtask.id)) continue
1420
+ const status = next.find((item: any) => item.subtaskId === subtask.id)
1421
+ if (!status || status.status !== 'pending') continue
1422
+ if (subtask.dependsOn.some((dependency: any) => blocked.has(dependency))) {
1423
+ next = applyStatus(next, subtask.id, {
1424
+ status: 'blocked',
1425
+ error: `上游子任务受阻:${subtask.dependsOn.filter((id: any) => blocked.has(id)).join('、')}`,
1426
+ })
1427
+ blocked.add(subtask.id)
1428
+ changed = true
1429
+ }
1430
+ }
1431
+ }
1432
+ return next
1433
+ }
1434
+
1435
+ /**
1436
+ * How many times one plan may be re-dispatched after it went blocked.
1437
+ *
1438
+ * Re-dispatch used to be a silent no-op: every subtask was already `blocked`,
1439
+ * `readySubtasks()` returned nothing, and the dispatcher returned
1440
+ * `{ ok: true, phase: 'blocked' }` without running a single child. A model that
1441
+ * followed the prompt ("核对结果后再继续") could not tell "nothing ran" apart
1442
+ * from "everything already ran", so the plan stayed dead and the only recovery
1443
+ * was asking the user to repeat the request — which built a fresh plan and hit
1444
+ * the same wall.
1445
+ */
1446
+ export const MAX_PLAN_RESUMES = 4
1447
+
1448
+ /**
1449
+ * Reset every failed or half-run subtask to `pending` so it can run again.
1450
+ *
1451
+ * `done` results are preserved: the retry is a resume, not a restart. That
1452
+ * matters because a child may take minutes of real work, and re-running it
1453
+ * would also re-run its side effects.
1454
+ *
1455
+ * @param statuses - the current status list.
1456
+ * @returns a new status list with blocked/in_progress entries cleared.
1457
+ */
1458
+ export function resetBlockedStatuses(statuses: any) {
1459
+ if (!Array.isArray(statuses)) return []
1460
+ let next = statuses.slice()
1461
+ for (const item of statuses) {
1462
+ if (item?.status !== 'blocked' && item?.status !== 'in_progress') continue
1463
+ next = applyStatus(next, item.subtaskId, {
1464
+ status: 'pending',
1465
+ progress: 0,
1466
+ error: null,
1467
+ summary: '',
1468
+ startedAt: null,
1469
+ finishedAt: null,
1470
+ childId: null,
1471
+ })
1472
+ }
1473
+ return next
1474
+ }
1475
+
1476
+ /**
1477
+ * Decide whether re-dispatching this plan should resume the failed subtasks.
1478
+ *
1479
+ * Cancellation is never resumed automatically: a user cancel and a turn abort
1480
+ * both end in `cancelled`, and silently restarting work the user stopped is
1481
+ * worse than asking again. Both `blocked` and `paused` plans resume.
1482
+ *
1483
+ * @param record - the live plan record.
1484
+ * @param maxResumes - resume budget, defaults to {@link MAX_PLAN_RESUMES}.
1485
+ * @returns the decision plus a model-readable reason when refused.
1486
+ */
1487
+ export function canResumePlan(record: any, maxResumes: number = MAX_PLAN_RESUMES) {
1488
+ const statuses = Array.isArray(record?.statuses) ? record.statuses : []
1489
+ /*
1490
+ * A `paused` plan is resumable too.
1491
+ *
1492
+ * Running out of the round budget leaves the unfinished subtasks `pending` on
1493
+ * purpose, so before this they were invisible here: the payload said
1494
+ * `resumeAvailable: false` and the card told the user the automatic resume
1495
+ * budget was spent, even though re-dispatching simply continues the work.
1496
+ * Pending work after a pause counts as resumable, and a pause is not charged
1497
+ * against the resume budget (it is a time slice, not a repair).
1498
+ */
1499
+ const paused = record?.phase === 'paused'
1500
+ const failed = statuses.filter((item: any) => {
1501
+ if (item?.status === 'blocked' || item?.status === 'in_progress') return true
1502
+ return paused && item?.status === 'pending'
1503
+ })
1504
+ const used = Number(record?.resumeCount ?? 0)
1505
+ if (failed.length === 0) {
1506
+ return { resume: false, reason: '没有需要续跑的子任务', failed: 0, used, remaining: maxResumes - used }
1507
+ }
1508
+ if (record?.phase !== 'blocked' && !paused) {
1509
+ return {
1510
+ resume: false,
1511
+ reason: `计划当前阶段是 ${String(record?.phase ?? 'unknown')},不能续跑`,
1512
+ failed: failed.length,
1513
+ used,
1514
+ remaining: maxResumes - used,
1515
+ }
1516
+ }
1517
+ if (!paused && used >= maxResumes) {
1518
+ return {
1519
+ resume: false,
1520
+ reason: `续跑次数已用尽(${used}/${maxResumes}),受阻子任务需要主 Agent 直接接手完成`,
1521
+ failed: failed.length,
1522
+ used,
1523
+ remaining: 0,
1524
+ }
1525
+ }
1526
+ return { resume: true, reason: '', failed: failed.length, used, remaining: maxResumes - used }
1527
+ }
1528
+
1529
+ export function findSubtask(plan: any, subtaskId: any) {
1530
+ for (const { stage, subtask } of flattenSubtasks(plan)) {
1531
+ if (subtask.id === subtaskId) return { stage, subtask }
1532
+ }
1533
+ return null
1534
+ }
1535
+
1536
+ export function buildChildPrompt({ plan, subtask, stage, dependencyResults, cwd, conversationContext = '' }: any) {
1537
+ /*
1538
+ * Every field below is read defensively.
1539
+ *
1540
+ * A plan that was reconstructed, resumed, or written by an older version can
1541
+ * legitimately lack `taskUnderstanding` / `contradictions`. Dereferencing them
1542
+ * directly threw inside prompt building, so each child failed instantly and
1543
+ * the whole plan was blocked — a stuck plan caused by missing metadata rather
1544
+ * than by the work being impossible.
1545
+ */
1546
+ const results = Array.isArray(dependencyResults) ? dependencyResults : []
1547
+ const dependencies = results.length === 0
1548
+ ? '无'
1549
+ : results
1550
+ .map((item: any) => `- ${item.id} ${item.title}:${item.status}${item.summary ? `|${item.summary}` : ''}`)
1551
+ .join('\n')
1552
+ const background = truncate(conversationContext, 5000)
1553
+ const understanding = plan?.taskUnderstanding ?? {}
1554
+ const contradiction = plan?.contradictions?.primary?.statement ?? '(未记录)'
1555
+ const stageName = stage?.name ?? '(未命名阶段)'
1556
+ const stageGoal = stage?.goal ?? '(未记录目标)'
1557
+ return [
1558
+ `你是「${departmentLabel(subtask.department)}」部门的执行 Agent,只负责下面这一个子任务。`,
1559
+ '',
1560
+ `全局任务:${understanding.originalRequest ?? '(未记录)'}`,
1561
+ `最终交付物:${understanding.deliverable ?? '(未记录)'}`,
1562
+ `主要矛盾:${contradiction}`,
1563
+ `当前阶段:${stageName}(${stageGoal})`,
1564
+ `工作目录:${cwd || '(未知)'}`,
1565
+ '',
1566
+ '最近对话背景(用于理解“它 / 这个 / B / 上一项”等指代;不是新的任务):',
1567
+ background || '(无可用对话背景)',
1568
+ '',
1569
+ `你的子任务:${subtask.id} ${subtask.title}`,
1570
+ `角色:${subtask.role}`,
1571
+ `目标:${subtask.objective}`,
1572
+ `方法:${(subtask.method ?? []).join(';') || '(未指定)'}`,
1573
+ `输入:${Array.isArray(subtask.inputs) && subtask.inputs.length ? subtask.inputs.join(';') : '无明确输入'}`,
1574
+ `输出:${(subtask.outputs ?? []).join(';') || '(未指定)'}`,
1575
+ `验收标准:${(subtask.acceptance ?? []).join(';') || '(未指定)'}`,
1576
+ `风险:${subtask.risk}`,
1577
+ '',
1578
+ '依赖子任务的已知结果:',
1579
+ dependencies,
1580
+ '',
1581
+ '执行要求:',
1582
+ '- 只完成本子任务,不擅自扩大范围;不要派生新的 Agent。',
1583
+ '- 你没有派发权限,也不能重启或安装任何服务;遇到这类步骤,直接写清“需要主 Agent 执行什么”,不要等待或空转。',
1584
+ '- 不得编造事实、文件、命令结果或证据;无法确认时明确写“未验证”。',
1585
+ '- 可以读取工作目录、运行必要命令、修改与任务相关的文件。',
1586
+ '- 优先使用给定上下文和本地工具;只有任务明确需要最新外部信息时才联网搜索。',
1587
+ '- 控制工具调用次数,证据足够就立即汇报;不要反复搜索或等待长时间任务。',
1588
+ '- 如果确实无法完成,第一行输出 `BLOCKED: 原因`,然后简要说明。',
1589
+ '- 完成后用简洁中文汇报:做了什么、证据/文件、验收结果、遗留问题。',
1590
+ ].join('\n')
1591
+ }