code_diff.patch
64.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
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
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
diff --git a/docs/dynamic-work-graph-design-skill-requirements.md b/docs/dynamic-work-graph-design-skill-requirements.md
new file mode 100644
index 0000000..a64ce76
--- /dev/null
+++ b/docs/dynamic-work-graph-design-skill-requirements.md
@@ -0,0 +1,68 @@
+# 跨 Org Agent 的动态 Work Graph 设计能力——需求文档
+
+**日期**: 2026-08-05
+**性质**: 需求文档(BRD/PRD 层面),不含详细技术设计。回应 `docs/todo/graph-engineering-open-items.md` 条目 E 的确认结果——"Planner agent 里面要有多个 skill,不仅仅是 brd/prd/sdd/benchmark,还有给其他 Org Graph 节点的 agent 做各种动态 Work Graph 设计,提示词强化肯定要做"。
+**依赖**:本文档定义的"节点分组可并行"标记方式需要跟 `docs/work-graph-parallel-execution-requirements.md`(条目 G)的详细设计对齐,建议 G 的 `graph_manifest` 数据结构定下来之后再最终敲定本文档的 FR-2。
+**状态**: 草案,待评审。
+
+## 1. 背景——一个比原开放问题更大的真实缺口
+
+综述版/14 步版对照笔记原本提出的问题是"要不要把识别并行节点的判断标准写进 Planning Skill 的 prompt"。深入代码后发现实际缺口比这更大:
+
+**当前生产代码里,只有 Planner 一个 Org Agent 真正用上了"动态生成 `graph_manifest` 再执行"这套机制**:
+
+- `src/work_graph_plan.py::plan_work_graph()` 的 `_PROMPT_TEMPLATE`(`work_graph_plan.py:64-74`)是**硬编码**的:不管调用方传入的 `org_node` 是什么,提示词永远要求"Output a graph_manifest with one node per artifact_type (brd, prd, sdd, benchmark)"——这个函数虽然签名上接受任意 `org_node`,但实质上只能用来生成 Planner 的文档写作链。
+- 唯一真实调用它的地方是 `src/e2e_pipeline.py::plan_documents()`,而且写死传入 `"planner"`(`e2e_pipeline.py:220`)。
+- **Coder 和 Validator 目前完全不走这条 plan→execute 的路径**。`work_graph_orchestrate.py` 模块 docstring 明确记录:真正跑通的闭环(`e2e_pipeline.py`,U-014)里,Coder/Validator 用的是"一个更简单、独立的机制(flat `org_node_trigger.trigger_org_node()` calls)"——一次性的单个 Agent 调用,不经过任何"先规划出一张图、再按图执行"的过程。
+
+也就是说,架构文档 §5"每个 Org Agent 拥有一个 Planning Skill,在接到任务时先生成本轮的 Work Graph"这句话,目前只对 Planner 成立。Coder 的 to-do 拆解+并行分派(架构文档 §6.2)、Validator 的功能/性能/边界测试并行执行(架构文档 §6.3)都还停留在设计意图,没有对应的动态规划实现——这正是用户说"还有给其他 Org Graph 节点的 agent 做各种动态 Work Graph 的设计"这句话所指的真实缺口,不是一个小的提示词优化。
+
+**一个已经存在、值得保留的细节**:`_PROMPT_TEMPLATE` 里已经有 `edge_type: 'zero_token' | 'needs_judgment'` 的区分(`work_graph_plan.py:69-70`),这正好对应 14 步版"边是数据契约,简单聚合应该是零 token 的代码边,只有真正需要判断力的合并才值得用 Agent"这条原则——只是目前这个字段只用在 Planner 的纯串行链上,从未真正驱动过任何"这条边要不要派生一个 Agent"的执行决策。新方案应该保留并真正用起来这个字段,而不是重新发明一套。
+
+## 2. 目标
+
+1. 把"如何设计一张动态 Work Graph"(识别节点边界、识别哪些节点可以 fan-out、区分 `zero_token`/`needs_judgment` 边、给 fan-out 出的并行节点分组打标记)这套方法论,从 Planner 专属的硬编码提示词里抽出来,做成一个**跨 Org Agent 通用**的能力。
+2. 让 Coder、Validator 也能各自调用这套能力,生成自己的 `graph_manifest`,走上跟 Planner 一样的 plan→execute 路径,而不是继续停留在"一次性单个 Agent 调用"的简化实现。
+3. 在生成 `graph_manifest` 阶段就识别并标注"哪些节点互不依赖、可以并行执行"——即使执行层的并行能力(条目 G)还没上线,至少先把这个信息在数据模型里留出来,不需要等 G 上线后再回头改 Planning 阶段的产出格式。
+
+## 3. 非目标
+
+1. **不要求第一版就覆盖 Coder/Validator 的全部真实工作场景**。架构文档 §6.2/§6.3 描述的 Coder to-do 拆解、Validator 多类型测试并行是长期目标,第一版只需要证明"Coder/Validator 也能用同一套通用方法论生成一个可执行的 `graph_manifest`"这件事本身走得通,不需要一次性把每种真实业务场景都覆盖。
+2. **不改变 `plan_work_graph`/`run_graph` 的既有函数签名和执行机制**。这是提示词/方法论层面的扩展("用什么内容去问 Claude"),不是执行引擎的改动——`_PROMPT_TEMPLATE` 硬编码这件事本身要改,但改的方式是让它按 `org_node`/任务类型选择不同的提示词模板,不是重写 `plan_work_graph` 的调用方式。
+3. **不在本文档里定案"节点分组可并行"的具体字段格式**。这个格式需要跟条目 G 的详细设计对齐(G 需要知道 `graph_manifest` 里怎么表达"这组节点可以并发触发",才能实现相应的调度逻辑),本文档只提出需求,不抢先定字段名。
+
+## 4. 功能需求
+
+| # | 需求描述 | 优先级 |
+|---|---|---|
+| FR-1 | 把"如何设计一张 Work Graph"的通用方法论(节点边界怎么划、`zero_token`/`needs_judgment` 边怎么区分、fan-out 候选点怎么识别)抽成一个独立的 Skill(暂定名 `design-dynamic-work-graph`,具体命名留待设计阶段确定),跟现有 `skills/write-brd` 一类专属技能平级存放。 | 必须 |
+| FR-2 | 这个 Skill 的产出需要能标注"哪些节点属于同一个可并行分组"——具体字段格式待 G 的设计确定后再补全,本需求只要求"设计阶段必须把这个信息在 `graph_manifest` 层面表达出来"这件事本身。 | 必须 |
+| FR-3 | `work_graph_plan.py::plan_work_graph()` 的提示词生成逻辑需要从"永远硬编码 brd/prd/sdd/benchmark"改造成"按调用方传入的任务描述/`org_node` 动态生成",具体改造方式(拆成多个模板、还是让 Skill 内容本身驱动 Claude 自主判断要生成哪些节点)留待设计阶段决定。 | 必须 |
+| FR-4 | Coder、Validator 各自的 `.claude/agents/{coder,validator}.md`(及其 canonical 来源 `src/agent_profile.py::FRONTMATTER`)需要引用这个新 Skill。 | 必须 |
+| FR-5 | 需要至少一条真实的 Coder 或 Validator 场景,验证"调用新 Skill 生成 `graph_manifest` → 走 `plan_work_graph`/`run_graph` 路径执行 → 产出正确结果"这条链路真的跑得通,不是只停留在 Planner 一个场景上验证。 | 应该 |
+
+## 5. 验收标准
+
+1. 给 Coder 一个可以自然拆成 2-3 个互不依赖子任务的真实场景(比如"给三个独立的模块各自补一个单元测试"),验证 Coder 能通过新 Skill 生成一个 `graph_manifest`(而不是走现在的单次 `trigger_org_node` 调用),且该 `graph_manifest` 正确标注了这几个节点互不依赖。
+2. 验证 `plan_work_graph()` 面对 Coder/Validator 的调用时,不再原样返回"one node per artifact_type (brd, prd, sdd, benchmark)"这个 Planner 专属结构。
+3. 验证 Planner 现有的 BRD→PRD→SDD→Benchmark 场景在改造后行为不受影响(回归测试,`src/test_e2e_pipeline.py` 等既有用例应继续通过)。
+4. 部署后能在 Coder/Validator 的 `.claude/agents/*.md` 里看到新 Skill 出现在 `skills:` frontmatter 列表。
+
+## 6. 约束与假设
+
+- 复用现有的 `plan_work_graph()`/`run_graph()`/`work_graph_execute.execute_work_graph()` 执行机制,不新建一套并行的规划-执行引擎。
+- `_PROMPT_TEMPLATE` 里已有的 `zero_token`/`needs_judgment` 边类型区分要保留,新 Skill 的方法论要明确指导 Claude 在生成边时如何选择这个字段,而不是留空/随意填。
+- Coder/Validator 引入 plan→execute 路径后,原来的 `org_node_trigger.trigger_org_node()` 单次调用机制不需要立刻废弃——对于确实只需要单个节点就能完成的简单任务,继续走这条更轻量的路径是合理的(呼应架构文档和 14 步版共同的原则:"任务很小或孤立时不需要上图")。新 Skill 解决的是"任务复杂到值得规划成图时,Coder/Validator 有没有能力做这件事",不是要求所有任务都必须走图。
+
+## 7. 风险
+
+- **`_PROMPT_TEMPLATE` 硬编码改造有回归风险**:这是唯一一条已经在生产里真实跑通、支撑着 Planner BRD→PRD→SDD→Benchmark 全链路的代码路径,改造时必须有充分的回归测试覆盖,不能因为扩展到 Coder/Validator 而破坏 Planner 现有场景。
+- **FR-2 的具体字段格式依赖 G 的设计**,如果 G 的设计阶段推迟或方向调整,本条目的 FR-2/FR-3 也需要跟着调整,两者需要保持沟通同步,不要各自独立往前推进导致字段对不上。
+- **Coder/Validator 引入图规划后,"什么时候该规划成图、什么时候该走简单单次调用"这个判断本身也需要方法论指导**——如果不加约束,可能出现"任务很小也被规划成图"的过度设计,第 6 节约束里提到的原则需要在 Skill 提示词里显式写出来,不能只是隐式假设 Claude 会自己把握分寸。
+
+## 8. 开放问题(留给详细设计阶段回答)
+
+- [ ] 新 Skill 的具体命名和目录结构。
+- [ ] `_PROMPT_TEMPLATE` 具体怎么改造——是给每个 `org_node` 各自一份模板,还是设计成让 Claude 根据任务描述自主决定要生成哪些节点(更灵活但更难约束输出格式)?
+- [ ] FR-2 的并行分组字段具体格式(待 G 确定)。
+- [ ] Coder/Validator 第一个接入的真实业务场景选哪个,作为验收标准 1 的具体测试用例?
diff --git a/docs/graphlemon_architecture.md b/docs/graphlemon_architecture.md
index 52976ea..8224953 100644
--- a/docs/graphlemon_architecture.md
+++ b/docs/graphlemon_architecture.md
@@ -21,8 +21,8 @@
| 术语 | 定义 |
|---|---|
-| **Org Graph(Organizer Graph)** | 固定的、由人预先设计的顶层多 Agent 主流程图。节点是"角色"(Planner / Coder / Validator),边是角色间的移交(handoff)与回退(loop-back)关系。一个 Org Graph 定义一类"工厂"(如 Dev Organizer)。 |
-| **Work Graph** | Org Graph 中某个节点 Agent 接到任务后,动态规划并生成的执行图。节点是"subagent + skill 的一次执行",边支持串行、并行 fan-out/fan-in、循环(loop)。每次任务执行都重新规划、重新生成,不复用上一次的图实例(但可以复用/学习历史图的经验)。 |
+| **Org Graph(Organizer Graph)** | 固定的、由人预先设计的顶层多 Agent 主流程图。节点是"角色"(Planner / Coder / Validator),边是角色间的移交(handoff)与回退(loop-back)关系。一个 Org Graph 定义一类"工厂"(如 Dev Organizer)。等价于业界"Graph Engineering"语境下的**角色图**(role graph)——谁有权做什么、权限边界如何划分,必须慢变、可审计,不允许模型运行时自主调整(见下方"术语对照")。 |
+| **Work Graph** | Org Graph 中某个节点 Agent 接到任务后,动态规划并生成的执行图。节点是"subagent + skill 的一次执行",边支持串行、并行 fan-out/fan-in、循环(loop)。每次任务执行都重新规划、重新生成,不复用上一次的图实例(但可以复用/学习历史图的经验)。等价于业界"Graph Engineering"语境下的**工作图**(work graph)——任务怎么拆、怎么合可以现场灵活调整,允许快变(见下方"术语对照")。 |
| **Org Agent** | Org Graph 上的固定节点角色,例如 Planner Agent、Coder Agent、Validator Agent。每个 Org Agent 拥有一个"Planning Skill",用于在接到任务时生成自己的 Work Graph。 |
| **Work Node** | Work Graph 中的最小执行单元 = 一个 Subagent 实例 + 它调用的一个(或一组)Skill + 明确的输入/输出契约。 |
| **Skill** | 可复用的能力单元(提示词模板 + 工具集 + 产出规范),是 Work Node 执行任务时调用的"方法"。Skill 本身也是可被迭代的对象。 |
@@ -33,6 +33,8 @@
| **Bug / Issue 溯源** | Validator 发现问题后,向上追溯问题源头属于 Planning 阶段的哪个环节,还是 Coder 阶段的哪个环节,并进一步判断是"这次执行的偶发失误"还是"Skill 本身存在缺陷"。 |
| **Org-level Loop** | Org Graph 层面的回退边:Validator → Planner,把验证阶段发现的问题作为新任务反馈给 Planner,驱动下一轮 Org Graph 循环。 |
+**术语对照(2026-08-05 补充)**:《Loop Engineering 就凉了?一文看懂 Graph Engineering 的"新范式"》(`docs/reference/graph-engineering-vs-loop-engineering-overview.md`)提出一条治理红线——"工作图"(任务怎么拆怎么合,可以快变)vs "角色图"(谁有权改数据库、谁能绕过审批,必须慢变、可审计)。这条红线跟本文档 Org Graph(人工固定设计,架构变更需人工评审,见下方"关键设计原则 1")/ Work Graph(每次任务动态生成,见"关键设计原则 2")的边界是同一件事的两种命名:**Org Graph ≡ 角色图,Work Graph ≡ 工作图**——不是新概念,只是行业已经给这条边界起了名字。对照笔记见 `docs/reference/graph-engineering-vs-loop-engineering-overview.diff-with-graphlemon.md` §5。
+
## 3. 整体架构:两层图
```
diff --git a/docs/todo/graph-engineering-open-items.md b/docs/todo/graph-engineering-open-items.md
index b5d57fa..0ea9d3f 100644
--- a/docs/todo/graph-engineering-open-items.md
+++ b/docs/todo/graph-engineering-open-items.md
@@ -30,7 +30,7 @@
**确认结果(2026-08-05)**:`工作图` 是 `Work Graph` 的字面翻译,graphlemon 本来就这么叫;`角色图` 不是 `Org Graph` 的字面翻译,但语义完全对得上——Org Graph 本来就是"角色(Planner/Coder/Validator)之间固定的组织关系图",`角色图` 是更准确的中文释义,不是新造的概念。两组术语直接画等号:**Work Graph ≡ 工作图,Org Graph ≡ 角色图**。
-**待办**:在 `docs/graphlemon_architecture.md` §2 术语表或 §3 整体架构处补一句显式对应说明("Org Graph 即角色图、Work Graph 即工作图"),引用综述版文章的治理红线原文作为设计依据。纯文档改动,不涉及代码,可以随手做掉。
+**待办**:✅ 已完成(2026-08-05)。`docs/graphlemon_architecture.md` §2 术语表的 Org Graph/Work Graph 定义行已补充等价说明,并在术语表后新增"术语对照"段落引用综述版文章原文。
---
@@ -44,7 +44,7 @@
**确认结果(2026-08-05)**:不改 Work Graph 执行模型本身(不引入图内回路)。做成一个独立的 Skill(例如 `validate-until-dry` 之类),把"连续 K 轮无新发现才停止、去重比对全部见过的而非仅已确认的"这套收敛逻辑封装成 Validator 可调用的方法论/工具,并且要求 `.claude/agents/validator.md` 在跑到"未知规模缺陷扫描"这类场景时记得调用它——落地方式是 Skill + Agent 定义引用,不是改 Graph Engine 底层。
-**待办**:写需求文档,明确这个 Skill 的触发场景(哪些任务算"未知规模扫描")、K 值默认多少、和现有 `root_cause_trace`/Org-level Loop 3 轮上限的关系(互不冲突:3 轮上限管的是 Org Graph 层 Validator→Planner 回退次数,这个 Skill 管的是 Validator 自己 Work Graph 内部一次验证过程要扫多少轮)。
+**待办**:✅ 需求文档已产出 → `docs/validator-loop-until-dry-skill-requirements.md`。关键设计澄清:loop-until-dry 不需要图内回路——收敛循环发生在**单个 Work Node 一次 `claude -p` 会话内部**(多轮工具调用),`graph_manifest` 本身仍是无环的,不需要碰 `topological_order`。
---
@@ -70,10 +70,7 @@
**确认结果(2026-08-05)**:立项。
-**待办**:写需求文档。需要先定义清楚几个问题再展开:
-- "分叉重放"具体指什么粒度——回到某个 `work_node_execution` 之前,用新的输入/prompt 重新执行该节点及其下游,产出一条平行的执行分支?
-- 分叉出来的新分支和原分支在数据模型上怎么共存(`work_graph_run`/`work_node_execution` 现有 schema 需不需要扩展"parent_run_id"之类的字段)?
-- 和 G(并行执行)、控制台的关系——分叉调试大概率需要在控制台里可视化操作,属于第 13 节人机交互控制台的能力扩展,需要跟现有 v_task_detail 等页面的设计对齐。
+**待办**:✅ 需求文档已产出 → `docs/work-graph-time-travel-debugging-requirements.md`。已明确跟 Deploy Supervisor 自动回滚(`docs/supervisor-auto-rollback-design.md`)的命名消歧。最大的数据模型影响点:`work_graph_run` 表现有的 `ux_work_graph_run_active` 唯一约束(同一 `task_id`+`org_node` 同时只能有一个非终态 run)会挡住分叉分支,需要在设计阶段决定怎么扩展。
---
@@ -87,10 +84,7 @@
**确认结果(2026-08-05)**:提示词强化肯定要做——把"对每个箭头问是否真的有数据流动"的提问方式、以及"并行可分解 / 单一上下文装不下 / 收益覆盖成本"这三条尺子写进 Planning 相关 prompt。**但范围比原开放问题更大**:不能只强化 Planner 自己写 BRD/PRD/SDD/Benchmark 那条链的 prompt——架构文档 §5 本来就说"每个 Org Agent 拥有一个 Planning Skill"(Coder 和 Validator 各自也要规划自己的 Work Graph),"如何识别 fan-out/fan-in、设计一张动态 Work Graph"这套方法论应该是**跨 Org Agent 通用**的能力,而不是分别在 Planner/Coder/Validator 三份 Planning Skill 里各写一遍。用户原话:"planner agent 里面要有多个 skill,不仅仅是 brd prd sdd benchmark 还有给其他 org graph 的 node 节点的 agent 做各种动态 work graph 的设计"。
-**待办**:写需求文档,需要明确:
-- 这套"动态 Work Graph 设计方法论"要不要抽成一个独立的、三个 Org Agent 共用的 Skill(类似现在 `skills/` 目录下已有的 `write-brd`/`write-prd` 等,新增一个例如 `design-dynamic-work-graph`),还是分别内嵌进各自的 Planning 环节?
-- Planner 除了 BRD/PRD/SDD/Benchmark 这条主链的 skill 外,还需要哪些新 skill——用户提到"给其他 org graph 的 node 节点的 agent 做各种动态 work graph 的设计",这暗示 Planner 可能要承担"帮 Coder/Validator 设计他们各自 Work Graph 结构"的角色,这跟架构文档 §5"每个 Org Agent 自己的 Planning Skill 各自生成自己的 Work Graph"的原有设计是否冲突,需要先对齐。
-- 和 F(三视角评审团做成 fan-out 图)、G(并行执行)的衔接——这个 Skill 生成的 graph_manifest 里,fan-out 的节点组要用什么标记方式,让 G 的执行引擎能识别出"这些节点可以并发"。
+**待办**:✅ 需求文档已产出 → `docs/dynamic-work-graph-design-skill-requirements.md`。深挖代码后发现真实缺口比原开放问题更大:`work_graph_plan.py::plan_work_graph()` 的提示词目前**硬编码**成永远只生成 brd/prd/sdd/benchmark 四个节点,且只有 Planner(`e2e_pipeline.py::plan_documents`)真正调用它——**Coder 和 Validator 现在根本没有走"先规划图再执行"这条路径**,走的是更简化的单次 `org_node_trigger.trigger_org_node()` 调用。所以 E 不只是"强化提示词",而是要先把动态 Work Graph 规划能力从 Planner 专属扩展成三个 Org Agent 通用。
**依赖关系**:这条和「G. Work Graph 节点并行执行」是互补的——G 解决"执行层能不能并发跑",E 解决"规划层会不会生成值得并发的图"。两条需要一起排期,建议先看 G 的需求文档,E 的需求文档要在 G 确认的数据结构(`graph_manifest` 里怎么表达"这组节点可并行")基础上写,避免两边字段对不上。
@@ -106,9 +100,7 @@
**确认结果(2026-08-05)**:要做,而且明确了实现方式——**不是**继续用现在 `root_cause_trace.judge_root_cause` 那种 Python 层直接顺序调用三个 `perspective_fn` 的写法,而是把三视角评审团**做成一张真正的 Work Graph fan-out/fan-in 菱形拓扑**:fan-out 出 N 个独立的验证节点(每个节点是一次独立的 `claude -p` 调用,带自己的视角提示词),fan-in 节点做投票/汇总。用户原话:"三视角评审团可以做,做成 work graph 的 fan out 图来实现吧"。
-**待办**:写需求文档。这条**强依赖 G(并行执行)**——评审团的 fan-out 节点如果还是串行跑,就丢失了"多视角同时判断"的时间收益(现在 `judge_root_cause` 本来就是纯函数式顺序调用,改成 Work Graph fan-out 如果不配合并行执行,反而更慢)。需要确认:
-- 现有 `root_cause_trace.judge_root_cause` 要不要保留作为"轻量级/无需 worktree/纯 Python 场景"的备选实现,还是完全被 Work Graph fan-out 版本取代?
-- 哪些验证点要升级成这种 fan-out 评审团(SDD 审核?代码变更审核?)——"高风险"判定标准怎么定。
+**待办**:✅ 需求文档已产出 → `docs/verifier-fan-out-jury-requirements.md`。明确标注**强依赖 G**——没有并行执行能力,把 `judge_root_cause` 改成 Work Graph fan-out 反而更慢(多了 Work Node 调度开销,没有并行收益),建议排在 G 之后。
---
@@ -128,16 +120,18 @@
## 确认结果汇总(2026-08-05)
-| 项 | 结果 | 后续动作 |
+| 项 | 结果 | 需求文档 |
|---|---|---|
-| A | ✅ 确认,两组术语等价 | 待办:架构文档补一句对应说明(可直接做,无需求文档) |
-| B | ✅ 确认,做成 Validator 的 Skill | 待办:写需求文档 |
+| A | ✅ 已完成 | 无需求文档,直接改了 `docs/graphlemon_architecture.md` §2 术语表 |
+| B | ✅ 已确认,需求文档已产出 | `docs/validator-loop-until-dry-skill-requirements.md` |
| C | ⛔ 暂不做 | 无 |
-| D | ✅ 确认立项 | 待办:写需求文档 |
-| E | ✅ 确认,范围扩大为跨 Org Agent 的通用 Work Graph 设计能力 | 待办:写需求文档(依赖 G 的数据结构先定) |
-| F | ✅ 确认,用 Work Graph fan-out/fan-in 实现 | 待办:写需求文档(强依赖 G) |
-| G | ✅ 确认,核心优先级 | 需求文档已就绪,进入详细设计阶段,是 E/F 的前置依赖 |
+| D | ✅ 已确认,需求文档已产出 | `docs/work-graph-time-travel-debugging-requirements.md` |
+| E | ✅ 已确认,需求文档已产出(范围比原开放问题更大) | `docs/dynamic-work-graph-design-skill-requirements.md` |
+| F | ✅ 已确认,需求文档已产出 | `docs/verifier-fan-out-jury-requirements.md` |
+| G | ✅ 已确认,核心优先级,需求文档已产出 | `docs/work-graph-parallel-execution-requirements.md` |
-**建议的推进顺序**:G 先进入详细设计(E、F 都依赖它的并行执行能力和数据结构),A 可以随手做掉(纯文档改动,不占用设计精力)。B、D 相对独立,可以在 G 设计的同时并行写需求文档。E、F 的需求文档建议等 G 的详细设计里"`graph_manifest` 怎么表达可并行节点组"这个数据结构定下来之后再写,避免字段对不上要返工。
+**当前状态**:A 已落地;B/C/D/E/F/G 六项全部完成需求文档(BRD/PRD 层面),均未进入详细设计(SDD 层面)。
-下一步要我先做哪一件——是把 G 推进到详细设计,还是先把 B/D 的需求文档写掉,或者先去把 A 的架构文档措辞补上?
+**推进顺序建议**:G 是 E、F 的前置依赖(E 的并行分组字段格式、F 的 fan-out 时间收益都需要 G 先定型),建议优先把 G 推进到详细设计。B、D 相对独立,可以随时单独推进详细设计。E、F 的详细设计需要等 G 的数据结构定下来。
+
+下一步要我先把哪一份需求文档推进到详细设计(SDD 层面)?还是先把六份需求文档过一遍评审,确认没有需要调整的地方,再决定设计阶段的排期?
diff --git a/docs/validator-loop-until-dry-skill-requirements.md b/docs/validator-loop-until-dry-skill-requirements.md
new file mode 100644
index 0000000..fa295d1
--- /dev/null
+++ b/docs/validator-loop-until-dry-skill-requirements.md
@@ -0,0 +1,59 @@
+# Validator "未知规模扫描收敛" Skill——需求文档
+
+**日期**: 2026-08-05
+**性质**: 需求文档(BRD/PRD 层面),不含详细技术设计。回应 `docs/todo/graph-engineering-open-items.md` 条目 B 的确认结果——"loop-until-dry 落地在 Validator,做成一个 Skill,`validator.md` 运行时记得调用"。
+**状态**: 草案,待评审。
+
+## 1. 背景
+
+14 步版对照笔记(`docs/reference/Graph Engineering with Claude_ 14-Step roadmap from 0 to graph architect (Full Course).diff-with-graphlemon.md` §1 第 11 行、§2)指出:graphlemon 现有的收敛机制只有 Org-level Loop 的固定 3 轮上限(`docs/sdd/04-P-流程与状态流转.md` 已标注"未来优化候选"),而 14 步版描述的 loop-until-dry(连续 K 轮无新发现才停止,去重要对比"全部见过的"而非"已确认的")没有对应实现。
+
+同一份笔记也指出一个结构性事实:`work_graph_orchestrate.py::topological_order` 对有环的 `graph_manifest` 直接报错("graph_manifest has a dependency cycle")——**Work Graph 被设计成严格 DAG,图内不支持回路**。这意味着 loop-until-dry **不能**实现成"`graph_manifest` 里画一条指回自己的边",那需要改动整个执行模型(`run_graph()`/`topological_order()`),代价远大于收益。
+
+**关键澄清(本需求的立论基础)**:loop-until-dry 不需要是 Work Graph 层面的图内回路。它可以是**一个 Work Node 内部、单次 `agent()` 调用会话自己的行为**——一次 `claude -p` 调用本身就是一个多轮工具调用的会话(读文件、搜索、判断、再读文件……),只要 Skill 的提示词要求 Agent"持续搜索直到连续两轮没有新发现才停止、去重比对全部见过而非仅已确认的结果",这个收敛循环完全发生在**一次 Work Node 执行内部**,`graph_manifest` 本身仍然是一条无环的边(该节点的输入是任务描述,输出是收敛后的结构化 bug list),不需要改动 `topological_order`/`run_graph` 的任何一行代码。这跟"B. loop-until-dry 要不要在 Validator 落地"的确认结果——"做成一个 Skill"——是同一个思路:Skill 是提示词层面的方法论,不是图结构。
+
+## 2. 目标
+
+1. 给 Validator 一个新的 Skill,让它在面对"未知规模缺陷扫描"类任务时,能够自主判断收敛(连续 K 轮无新发现才停止),而不是像现在这样只能做"扫一遍就结束"的单轮验证。
+2. 这个 Skill 的去重逻辑必须对比"全部已见过的候选项",而不是只对比"已确认通过的结果"——避免被验证器打回的候选项在下一轮又被当成新发现重新提出,导致循环永远不收敛(14 步版原文点名的"几乎所有人第一次实现都会踩的坑")。
+3. `.claude/agents/validator.md`(及其 canonical 来源 `src/agent_profile.py::FRONTMATTER["validator"]`)需要引用这个新 Skill,并且 Validator 的人设文档(`agents/validator/SOUL.md` 迁移版)需要有明确指引:什么场景下应该主动调用这个 Skill,而不是走默认的单轮验证路径。
+
+## 3. 非目标
+
+1. **不改 Work Graph 执行模型**。不给 `graph_manifest`/`topological_order`/`run_graph` 引入图内回路支持,见第 1 节的关键澄清。
+2. **不改 Org-level Loop 的 3 轮上限**。Org-level Loop 管的是"Validator 判定不通过、打回 Planner/Coder、再来一轮"这个更粗粒度的循环,跟这个 Skill 管的"Validator 一次验证任务内部要扫多少轮才算扫完"是两层不同的收敛机制,互不冲突、互不替代。
+3. **不要求这个 Skill 覆盖 Validator 的所有验证场景**。大多数验证任务(比如验收一个边界清晰的 Development Unit)本来就是有限规模的,不需要 loop-until-dry;这个 Skill 只服务于"规模未知"这一类场景(比如"扫描整个代码库找所有未处理的 Promise""审计所有路由文件的鉴权缺失"这种没有预先划定范围的任务)。
+
+## 4. 功能需求
+
+| # | 需求描述 | 优先级 |
+|---|---|---|
+| FR-1 | 新增一个 Skill(暂定名 `unknown-scope-defect-sweep`,具体命名留待设计阶段确定),描述 loop-until-dry 的方法论:持续搜索/扫描 → 每轮结果与"全部已见过的候选项"去重 → 连续 K 轮(默认值留待设计阶段确定,参考 14 步版建议的 K=2)无新发现 → 停止并汇总。 | 必须 |
+| FR-2 | Skill 需要明确定义"候选项"的去重键(key)该怎么取——不同任务场景(bug 扫描 vs 路由审计 vs 依赖审计)候选项的自然标识不同,Skill 需要给出通用指导原则,而不是绑定某一种具体场景。 | 必须 |
+| FR-3 | Skill 执行过程中每一轮的候选发现,在最终汇总前应当经过验证(复用 Validator 已有的 PASS/FAIL/INCONCLUSIVE 判定框架或 `root_cause_trace` 机制,具体复用方式留待设计阶段确定)——不能是"只要出现过就算数",避免把误报也计入最终结果。 | 应该 |
+| FR-4 | `src/agent_profile.py::FRONTMATTER["validator"]["skills"]` 需要加入这个新 Skill 的目录名,并且 `agents/validator/SOUL.md`(canonical 人设源文件)需要新增一段指引:什么信号(比如任务描述里出现"扫描/审计/全部/所有"这类无边界表述)应该触发这个 Skill,而不是走默认单轮验证路径。 | 必须 |
+| FR-5 | Skill 需要有一个硬性上限(最大轮次或最大 token/时间预算),防止"连续 K 轮无新发现才停"这个收敛条件本身因为某种原因(比如候选项生成逻辑有 bug,每轮都能生成一个"看起来新"但实际重复的假发现)永远不触发,导致单次 Work Node 执行无限跑下去。 | 必须 |
+
+## 5. 验收标准
+
+1. 给 Validator 一个人为构造的"扫描规模未知"任务(例如一个测试仓库里散落 5 处同一类缺陷),验证 Validator 使用新 Skill 后能够找全全部 5 处,并在连续两轮无新发现后主动停止,不需要人工告诉它"已经找完了"。
+2. 验证去重逻辑:人为构造一个"候选项会被验证器打回、但下一轮扫描逻辑本身还会再次生成同一个候选项"的场景,确认该候选项不会被无限重复计入"新发现",循环能够正常收敛(不是因为侥幸没再被搜到,而是因为去重键命中了"已见过"记录)。
+3. 验证 FR-5 的硬上限:人为构造一个"永远能生成看似新其实重复"的极端场景,确认 Skill 会在触达硬上限后强制停止并如实报告"可能未收敛",而不是无限执行下去耗尽预算。
+4. `.claude/agents/validator.md` 部署后(`deploy_agent_definitions`),能看到新 Skill 出现在 `skills:` frontmatter 列表里。
+
+## 6. 约束与假设
+
+- 复用 Validator 现有的技能目录约定(`skills/<name>/SKILL.md`,参考 `skills/qa-task-validation/SKILL.md` 的文件结构),但**不复用**该文件里遗留的飞书 Base 相关内容(`tblnlIVgHNaQXLAL` 等)——那是尚未清理的 Hermes profile 遗留细节,新 Skill 要按 graphlemon 当前的实际架构(无飞书 Base 依赖)编写,不能照抄旧格式。
+- 假设这个 Skill 在**单次 Work Node 执行内部**完成收敛循环(一次 `claude -p` 调用内的多轮工具使用),不依赖 Work Graph 层面的任何调度改动,因此和「G. Work Graph 节点并行执行」没有直接依赖关系,可以独立排期。
+
+## 7. 风险
+
+- **收敛判定本身依赖模型自我报告"这轮有没有新发现"**,如果模型判断不准确(漏报"这是新发现"或误报"这不是新发现"),收敛条件可能提前/延迟触发。FR-3 的"每轮发现经过验证"部分程度上缓解这个风险,但无法完全消除——这是提示词工程层面的固有局限,不是可以被架构设计完全解决的问题。
+- **K 值和硬上限的默认值需要实测校准**,不同任务规模下合适的 K 值可能不同,第一版先给一个保守默认值,观察真实运行数据后再调整。
+
+## 8. 开放问题(留给详细设计阶段回答)
+
+- [ ] Skill 的具体命名和目录结构。
+- [ ] FR-3"复用已有验证框架"的具体接线方式——是在 Skill 提示词里直接要求"每个候选项过一遍 PASS/FAIL/INCONCLUSIVE 判断",还是调用 `root_cause_trace` 现成的多视角判断机制?
+- [ ] K 值、硬上限的默认数字。
+- [ ] `agents/validator/SOUL.md` 里新增指引段落的具体触发条件描述(关键词匹配?任务类型标注?)。
diff --git a/docs/verifier-fan-out-jury-requirements.md b/docs/verifier-fan-out-jury-requirements.md
new file mode 100644
index 0000000..39eba7a
--- /dev/null
+++ b/docs/verifier-fan-out-jury-requirements.md
@@ -0,0 +1,69 @@
+# 三视角评审团——Work Graph Fan-out 实现——需求文档
+
+**日期**: 2026-08-05
+**性质**: 需求文档(BRD/PRD 层面),不含详细技术设计。回应 `docs/todo/graph-engineering-open-items.md` 条目 F 的确认结果——"三视角评审团可以做,做成 Work Graph 的 fan-out 图来实现"。
+**依赖**:本文档描述的 fan-out 验证节点若要真正获得"多视角同时判断"的时间收益,强依赖 `docs/work-graph-parallel-execution-requirements.md`(条目 G)的并行执行能力——没有 G,把 `judge_root_cause` 现有的顺序调用改成 Work Graph fan-out 反而更慢(多了 Work Node 调度开销,却没有并行收益)。建议 G 先进入实现或至少详细设计定型后,再排期本文档。
+**状态**: 草案,待评审。
+
+## 1. 背景
+
+`src/root_cause_trace.py::judge_root_cause()` 已经实现了 14 步版"多视角差异化校验"模式的核心逻辑:
+
+```python
+def judge_root_cause(perspective_fns):
+ """要求 >= 2/3 视角一致才定案 ... 否则 raise InconclusiveEscalateError"""
+ judgments = [fn() for fn in perspective_fns]
+ ...
+```
+
+三个视角(正确性/需求完整性/Skill 系统性缺陷)目前只用在 Validator 的 `test_failed` 根因溯源场景,且是**纯 Python 函数式实现**——`perspective_fns` 是一组闭包,`judge_root_cause` 一个一个顺序调用它们(`judgments = [fn() for fn in perspective_fns]` 是一个普通的列表推导,不是并发调用),每个 `fn()` 内部大概率是一次独立的 `claude -p` 调用(`real_validator_perspective` 函数名暗示了这一点)。
+
+这次确认的方向:把这套机制从"Python 函数顺序调用三个闭包"升级为"Work Graph 里一张真正的菱形拓扑"——fan-out 出 N 个独立的验证 Work Node(每个节点携带自己的视角提示词,是一次独立的 Work Node 执行,而不是一个 Python 闭包),fan-in 节点做投票/汇总。这跟综述版对照笔记(`docs/reference/graph-engineering-vs-loop-engineering-overview.diff-with-graphlemon.md` §2)指出的"这正好对应文章第 08 章'评审团'式多视角交叉审"是同一个模式,只是要把它从函数调用升格为图结构。
+
+## 2. 目标
+
+1. 把三视角(或未来可能扩展到更多视角)评审团实现成一张真正的 Work Graph fan-out/fan-in 菱形:一个 split 节点(或直接由调用方给出待评审对象)→ N 个并行的独立验证 Work Node(各自视角、各自独立的 `claude -p` 调用、各自独立的持久化状态) → 一个 fan-in 节点汇总投票结果。
+2. 这套 fan-out 评审团机制要能被**不同的验证场景复用**,不只是 Validator 的根因溯源——`run_graph()` 现有的 `verify_fn`(单一验证器 + 失败重试一次)应该能够选择性地升级为这套多视角 fan-out 验证,用于高风险节点(比如 SDD 审核、代码变更审核)。
+3. 每个视角的验证节点获得**独立、干净的上下文**(呼应 5 步版对照笔记已确认的"Verifier 需要干净上下文"这条原则)——不是同一个对话里问三次不同角度的问题,而是三次完全独立的 Work Node 执行。
+
+## 3. 非目标
+
+1. **不要求所有验证点都升级成 fan-out 评审团**。这是一种"重打法",只用于高风险节点,大多数验证场景继续用现有的单一 `verify_fn`(一个验证器 + 失败重试一次)就够了——具体"高风险"的判定标准留待设计阶段确定,但原则上默认走轻量路径,评审团是可选升级,不是新的默认行为。
+2. **不要求第一版就废弃 `root_cause_trace.judge_root_cause` 的现有 Python 函数式实现**。是否完全替换、还是保留作为"不需要 worktree/轻量场景"的备选实现,留待设计阶段确定(见第 8 节开放问题)。
+3. **不在本文档定案 fan-out 验证节点的并发触发机制细节**。具体怎么并行触发这几个验证节点,是条目 G 的职责范围,本文档只提出"这几个节点需要能并行执行"这个需求,不重复 G 已经在做的设计工作。
+
+## 4. 功能需求
+
+| # | 需求描述 | 优先级 |
+|---|---|---|
+| FR-1 | 设计一种可复用的 `graph_manifest` 模式(菱形拓扑:1 个输入节点或直接的调用方输入 → N 个独立视角验证节点 → 1 个汇总节点),可以被任何需要"多视角评审团"式验证的调用方复用,不是针对根因溯源场景写死的一次性结构。 | 必须 |
+| FR-2 | N 个视角验证节点的具体视角内容(提示词)需要能按调用场景参数化——根因溯源场景用"正确性/需求完整性/Skill 缺陷"三视角,未来其他场景(比如代码审查)可能需要"正确性/安全/性能"这类不同的视角组合,机制本身要跟具体视角内容解耦。 | 必须 |
+| FR-3 | 汇总节点需要保留现有 `judge_root_cause` 已经验证过的判定规则——`>= 2/3` 视角一致才定案,不足则升级(`InconclusiveEscalateError` 的等价行为),这条规则本身经过实战验证,不需要重新设计,只需要把它从"Python 函数内的逻辑"迁移成"fan-in 节点的判定逻辑"。 | 必须 |
+| FR-4 | `run_graph()` 的 `verify_fn` 接口需要能够对接这种 fan-out 评审团实现——具体是扩展 `verify_fn` 的调用约定,还是提供一个新的 `verify_fn` 工厂函数(生成一个内部会触发 fan-out 评审团的 `verify_fn`),留待设计阶段确定,但调用方(比如 `e2e_pipeline.py::plan_documents` 的 `_prd_consistency_verify_fn`)不应该需要感知内部是单一验证器还是评审团。 | 应该 |
+| FR-5 | Validator 现有的 `test_failed` 根因溯源场景要能迁移到这套新机制上,作为第一个真实落地场景,验证机制本身可用,而不是只停留在通用接口设计层面。 | 必须 |
+
+## 5. 验收标准
+
+1. 给定一个需要根因溯源的模拟场景,用新的 fan-out 评审团机制跑一遍,验证三个视角节点各自作为独立的 Work Node 执行(能在 `execution_event`/日志里看到三次独立的节点执行记录,而不是一次函数调用内部的三次闭包调用)。
+2. 验证判定规则等价性:构造"2/3 一致""不足 2/3"两种场景,确认新机制的判定结果跟现有 `judge_root_cause` 单测覆盖的场景给出相同的结论。
+3. 验证视角节点确实使用独立、干净的上下文——每个视角节点的 `input_payload` 只包含它需要判断的内容,不包含其他视角节点的推理过程。
+4. 在 G(并行执行)就绪后补充验证:三个视角节点的执行时间有重叠(不是排队执行)。
+
+## 6. 约束与假设
+
+- 复用现有的 Work Node 执行机制(`work_node_run.run_work_node`/`work_graph_execute.execute_work_graph`),每个视角节点是一次独立的单节点 workflow 脚本调用,跟现有其他 Work Node 没有本质区别。
+- 复用现有的 `graph_manifest`/`run_graph()` 执行入口,不新建专门的"评审团执行器"——评审团只是 `graph_manifest` 的一种特定拓扑形状(菱形),不需要新的执行引擎能力(依赖 G 提供的并行触发能力,但执行入口本身还是 `run_graph()`)。
+- `root_cause_trace.judge_root_cause` 现有的单测(如果存在)应该被保留或迁移,作为判定规则本身正确性的回归保障,不因为改成图结构就失去测试覆盖。
+
+## 7. 风险
+
+- **强依赖 G,排期上不能先于 G**:如果 G 推迟,这条也要跟着推迟,或者先落地"fan-out 但暂时串行执行"的过渡版本(功能正确但没有时间收益),需要跟用户确认是否接受这种过渡状态,还是宁可等 G 一起上。
+- **每个视角节点变成独立 Work Node 后,token 成本会上升**:现有 Python 函数式实现里,"三次独立调用"这件事本身没有变化,但包装成 Work Node 后每个节点会多一层 schema 校验往返、多一次单节点 workflow 脚本渲染的开销——这个开销预计很小,但需要在设计阶段实测确认,不是纯粹的免费升级。
+- **"高风险"判定标准如果定得太宽,会导致大量场景都触发评审团**,造成不必要的成本增长——需要在设计阶段给出明确、克制的判定标准,而不是默认"能用评审团就用评审团"。
+
+## 8. 开放问题(留给详细设计阶段回答)
+
+- [ ] `root_cause_trace.judge_root_cause` 的 Python 函数式实现要不要保留作为轻量级备选(比如单元测试场景、不需要真实 `claude` 子进程调用的场景),还是完全被 Work Graph 版本取代?
+- [ ] FR-4 的具体接口设计——`verify_fn` 扩展方式。
+- [ ] 除了 Validator 根因溯源,哪些验证点要升级成评审团(SDD 审核?代码变更审核?)——"高风险"判定标准怎么定,是节点类型固定标注还是运行时动态判断?
+- [ ] 视角数量 N 是否需要可配置(不同场景可能需要 2 个视角或 5 个视角,不一定总是 3 个)?
diff --git a/docs/work-graph-time-travel-debugging-requirements.md b/docs/work-graph-time-travel-debugging-requirements.md
new file mode 100644
index 0000000..ffabb5a
--- /dev/null
+++ b/docs/work-graph-time-travel-debugging-requirements.md
@@ -0,0 +1,73 @@
+# Work Graph 执行历史 Time-Travel 调试——需求文档
+
+**日期**: 2026-08-05
+**性质**: 需求文档(BRD/PRD 层面),不含详细技术设计。回应 `docs/todo/graph-engineering-open-items.md` 条目 D 的确认结果——"time travel 可以立项"。
+**状态**: 草案,待评审。
+
+**命名消歧(仿照 CLAUDE.md「两个 Supervisor」的先例)**:本文档说的"Time-Travel 调试"跟 `docs/supervisor-auto-rollback-design.md` 描述的 Deploy Supervisor 自动回滚是两件完全不同的事——不要混淆:
+
+| | Time-Travel 调试(本文档) | Deploy Supervisor 自动回滚 |
+|---|---|---|
+| 回滚的对象 | 某一次任务的 Work Graph 执行过程(某个 `work_node_execution` 的中间状态) | graphlemon 自身的代码版本(git commit) |
+| 触发者 | 人工调试意图("我想看看如果这一步换个输入会怎样") | 自动健康检查失败信号 |
+| 影响范围 | 单个 task 的执行历史,不影响其他任务 | 整个 graphlemon 进程的运行版本 |
+| 相关文档 | 本文档 | `docs/supervisor-auto-rollback-design.md` |
+
+## 1. 背景
+
+综述版对照笔记(`docs/reference/graph-engineering-vs-loop-engineering-overview.diff-with-graphlemon.md` §6)指出:LangGraph 的 checkpointer 机制带来四个能力——人在回路、记忆、时间旅行调试、容错。逐项对照 graphlemon 现状:
+
+| LangGraph 能力 | graphlemon 现状 |
+|---|---|
+| 容错(部分节点失败保留已成功节点输出) | ✅ 已具备(`work_node_execution` 按节点独立持久化状态) |
+| 人在回路(暂停/恢复) | ✅ 已具备(`src/task_execution.py` 的 Start/Pause/Stop) |
+| 记忆(多轮交互间保留上下文) | ✅ 已具备(`.claude/agent-memory/`) |
+| 时间旅行调试(回到任意历史检查点重放/分叉) | ❌ 未实现,现状是"用 git 历史 + 手动重跑" |
+
+`work_node_execution` 表(`src/schema.py:138`)目前按节点持久化 `input_payload`/`output_payload`/`status`/`retry_count` 等字段,`work_graph_run` 表(`src/schema.py:118`)持久化 `graph_manifest` 和整体 `status`——这些数据本身已经具备"回放某一次执行发生了什么"的信息基础,缺的是"从某个历史节点分叉出一条新的执行分支"这个操作本身。
+
+## 2. 目标
+
+1. 允许人工从某次 Work Graph 执行的某个历史节点(`work_node_execution`)出发,用新的输入(不同的 prompt、不同的上游产物)重新执行该节点及其下游依赖节点,产出一条与原执行并存、可独立查看的新分支,而不覆盖或污染原始执行记录。
+2. 分叉出来的新分支要能在控制台里被查看、区分(哪条是原始记录、哪条是分叉出来的调试分支)。
+3. 这个能力主要服务于调试/复盘场景(例如:"如果 SDD 阶段换一版方案,后续的 Coder 实现会有什么不同"),不是生产环境的正常任务执行路径的一部分。
+
+## 3. 非目标
+
+1. **不是自动化机制**。分叉重放由人工在控制台里主动触发,不是系统自主决定"要不要回溯重跑"的行为——这跟 Org-level Loop 那种系统自主触发的回退是两回事。
+2. **不追求支持任意粒度的时间点**(比如"回到某个节点执行到一半的中间状态")。粒度限定在 `work_node_execution` 这个已有的持久化单元——从"某个节点完成之后(它的 `output_payload` 已经落库)"这个时间点分叉,不试图捕获节点内部一次 `claude -p` 调用执行过程中的中间状态。
+3. **不在第一版里做完整的可视化 diff/比较工具**(比如"原分支和新分支的产物并排对比高亮差异")。第一版先解决"分叉出一条新分支并能分别查看"这个核心能力,比较/diff 视图留给后续迭代。
+
+## 4. 功能需求
+
+| # | 需求描述 | 优先级 |
+|---|---|---|
+| FR-1 | 给定一个已完成(`status='done'`)的 `work_node_execution`,人工可以指定新的输入(覆盖原 `input_payload` 的部分或全部字段),触发从这个节点开始的重新执行。 | 必须 |
+| FR-2 | 分叉执行产生的是一次**新的** `work_graph_run`(不是修改原 `work_graph_run` 的记录),需要能追溯"这次运行是从哪个历史节点、哪个原始 run 分叉出来的"。 | 必须 |
+| FR-3 | 分叉出的新 run 只重新执行"从分叉点开始,到该节点的下游依赖链",分叉点**之前**的节点(分叉点本身的上游)直接复用原始 run 已经产出的 `output_payload`,不重新执行——这跟现有断点续跑"复用已完成节点输出"的逻辑是同一个原则的延伸。 | 必须 |
+| FR-4 | 控制台(架构文档 §13 的人机交互控制台)需要能列出"某个 task 下所有的分叉分支",并支持切换查看每条分支各自的节点执行详情(复用现有 v_task_detail 一类页面的节点级展示能力)。 | 应该 |
+| FR-5 | 分叉出的调试分支不应该影响原始任务的正常执行状态——即使分叉分支的执行结果比原分支"更好",也不会自动替换/合并回原始任务的 `task.status`,是否采纳分叉分支的结果需要人工另外决定(比如人工把分叉分支的产物手动复制回主线,或者认可分叉分支本身就是新的正确路径)。 | 必须 |
+
+## 5. 验收标准
+
+1. 给定一个已经跑完的简单 Work Graph(比如 Planner 的 BRD→PRD→SDD→Benchmark 四节点链),从 PRD 节点分叉、换一个不同的 PRD 内容,触发重新执行,验证 SDD、Benchmark 两个下游节点用新的 PRD 内容重新跑了一遍,而 BRD 节点没有被重新执行(直接复用原值)。
+2. 验证原始 run 的记录在分叉操作后完全不受影响——分叉前后分别查询原始 `work_graph_run`/`work_node_execution`,字段值一致。
+3. 验证同一个历史节点可以被分叉多次,产生多条互不干扰的分支,且每条分支都能独立追溯到"从哪个节点、哪个原始 run 分叉而来"。
+4. 控制台里能看到某个 task 下全部分支的列表,并能分别打开查看。
+
+## 6. 约束与假设
+
+- `work_graph_run` 表当前有一条唯一约束:`ux_work_graph_run_active`——同一个 `(task_id, org_node)` 组合同一时间只能有一个非终态的 run(`src/schema.py:134`)。分叉产生的新 run 如果沿用同一个 `task_id`,会撞上这条约束(分叉分支和原分支可能同时处于非终态)。需要在设计阶段决定:分叉出的 run 是否需要一个独立的标识维度(比如新增一个 `parent_run_id`/`fork_of_node_exec_id` 字段,并调整唯一约束的范围,使其只约束"同一条分支线"内部,而不是整个 `task_id`),这是本需求对现有数据模型影响最大的一处,必须在设计阶段仔细过一遍现有依赖这条约束的代码路径(比如断点续跑逻辑是否假设了"同一 task_id+org_node 只有一个活跃 run")。
+- 分叉执行复用现有的每节点独立子进程执行机制(`work_node_run.run_work_node`/`work_graph_execute.execute_work_graph`),不需要新的执行引擎——分叉本质上是"用一份新的 `graph_manifest`(去掉已复用的上游节点)+ 新的初始 `outputs` 字典(预填充复用节点的输出)调用一次 `run_graph()`",复用度很高。
+
+## 7. 风险
+
+- **数据模型改动的影响面需要在设计阶段仔细评估**,`ux_work_graph_run_active` 这条唯一约束背后可能还有其他代码路径隐式依赖"一个 task_id+org_node 只有一个活跃 run"这个假设(比如查询任务当前状态的逻辑、WebSocket 推送逻辑),改动前需要逐一排查,不能只看 schema 本身。
+- **控制台可视化工作量可能比后端数据模型改动更大**——FR-4 涉及新的 UI 交互(选择分叉点、对比分支列表),需要跟现有 `docs/wireframes/` 里的 `v_task_detail` 类页面设计对齐,这部分工作量需要在设计阶段单独评估,不要低估。
+
+## 8. 开放问题(留给详细设计阶段回答)
+
+- [ ] `parent_run_id`/`fork_of_node_exec_id` 这类新字段的具体 schema 设计,以及 `ux_work_graph_run_active` 约束要怎么调整。
+- [ ] 分叉操作的触发入口——只在控制台里做,还是也要开一个后端 API 供其他方式调用?
+- [ ] FR-5"是否采纳分叉分支结果"的人工操作具体是什么交互(一个"设为主线"按钮?还是纯只读展示,人工另外手动处理)?
+- [ ] 是否需要给分叉分支设置生命周期/清理策略(长期堆积的调试分支会不会需要定期清理,类似原始执行日志的保留天数机制)?
diff --git a/frontend/app.js b/frontend/app.js
index 5a7da1d..da24c1e 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -1738,6 +1738,9 @@ function renderSkillAuditLog(skillId) {
<div class="breadcrumb"><a href="#/skills">技能库</a> / ${escapeHtml(skillId)}</div>
<h1>${escapeHtml(skillId)} 变更审计</h1>
<div id="skill_audit_error" class="error-banner" style="display:none;"></div>
+ <h2>Skill 内容</h2>
+ <div id="skill_content"><div class="hint">加载中…</div></div>
+ <h2>迭代记录</h2>
<div id="skill_audit_list"><div class="hint">加载中…</div></div>
</div>
`;
@@ -1746,6 +1749,24 @@ function renderSkillAuditLog(skillId) {
function mountSkillAuditLog(skillId) {
const listEl = document.getElementById("skill_audit_list");
const errorEl = document.getElementById("skill_audit_error");
+ const contentEl = document.getElementById("skill_content");
+
+ // Independent of the iteration-history panel below (a skill can have real
+ // content with zero skill_iteration rows -- that's the normal post-
+ // migration state) -- fetched and rendered separately, side by side, not
+ // as an either/or with refresh()'s own list.
+ fetch("/api/skills/" + encodeURIComponent(skillId) + "/content").then(async (resp) => {
+ if (resp.status !== 200) {
+ const body = await resp.json();
+ contentEl.innerHTML = '<div class="hint">' + (body.error === "CONTENT_MISSING" ? "该 Skill 的真实文件尚未迁移" : "该 Skill 不存在") + "</div>";
+ return;
+ }
+ const skill = await resp.json();
+ const supportHtml = skill.support_files.length
+ ? '<div class="hint">支持文件:' + skill.support_files.map(escapeHtml).join("、") + "</div>"
+ : "";
+ contentEl.innerHTML = supportHtml + '<pre class="code">' + escapeHtml(skill.content) + "</pre>";
+ });
async function refresh() {
const resp = await fetch("/api/skills/" + encodeURIComponent(skillId) + "/audit");
diff --git a/src/backend_server.py b/src/backend_server.py
index 2b442d4..470de0d 100644
--- a/src/backend_server.py
+++ b/src/backend_server.py
@@ -195,6 +195,10 @@ class _Handler(BaseHTTPRequestHandler):
skill_id = parsed.path[len(_SKILLS_PREFIX):-len("/audit")]
self._handle_skill_audit_log(skill_id)
return
+ if parsed.path.startswith(_SKILLS_PREFIX) and parsed.path.endswith("/content"):
+ skill_id = parsed.path[len(_SKILLS_PREFIX):-len("/content")]
+ self._handle_skill_content(skill_id)
+ return
if parsed.path == "/api/deployments":
self._handle_deployment_list()
return
@@ -704,6 +708,22 @@ class _Handler(BaseHTTPRequestHandler):
else:
self._send_json(200, result)
+ def _handle_skill_content(self, skill_id):
+ """c_skill_content (bug-fix Unit, no MVCP operation_id -- same
+ "unowned gap" precedent as c_artifact_get before U-025). Real
+ SKILL.md content off disk, alongside (not instead of) the existing
+ /audit iteration history -- v_skill_audit_log's page renders both
+ independently."""
+ try:
+ with self.server.conn_lock:
+ result = skill_query.get_skill_content(self.server.conn, skill_id, repo_path=self.server.skill_repo_path)
+ except skill_query.SkillNotFoundError:
+ self._send_json(404, {"error": "NOT_FOUND"})
+ except skill_query.SkillContentMissingError:
+ self._send_json(404, {"error": "CONTENT_MISSING"})
+ else:
+ self._send_json(200, result)
+
def _handle_skill_diff(self, query_string):
params = urllib.parse.parse_qs(query_string)
before = params.get("before", [None])[0]
diff --git a/src/skill_query.py b/src/skill_query.py
index 395e987..326727b 100644
--- a/src/skill_query.py
+++ b/src/skill_query.py
@@ -10,6 +10,8 @@ rather than inventing a second way to locate it.
import os
import subprocess
+from src import skill_update
+
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -52,6 +54,67 @@ def get_skill_audit_log(conn, skill_id):
]
+class SkillContentMissingError(Exception):
+ """CONTENT_MISSING -- get_skill_content on a skill row whose real
+ SKILL.md file isn't present on disk. Distinct from SkillNotFoundError
+ (no such skill row at all): a skill row can legitimately exist with its
+ real file not-yet-migrated, and callers (v_skill_audit_log's new "Skill
+ 内容" panel) need to tell the two apart to show the right message."""
+
+
+def get_skill_content(conn, skill_id, repo_path=None):
+ """v_skill_audit_log's new "Skill 内容" panel (bug fix: this page used to
+ show only skill_iteration history -- correct on its own terms, but
+ silent about the real SKILL.md content a skill migration already wrote
+ to disk under skills/<skill_id>/). Independent of get_skill_audit_log:
+ a skill can have real content with zero iterations, which is exactly
+ the post-migration, pre-any-self-iteration state today.
+
+ skill_id is user-controlled (comes straight off the URL), but it is
+ never spliced into a filesystem path itself -- the lookup below is a
+ parameterized query, and the actual on-disk path comes from the
+ trusted `skill.file_path` DB column. Still applies two-layer
+ path-traversal protection on that DB value: reuse
+ skill_update._skill_file_path() for the same SKILL.md-suffix
+ resolution c_skill_update/c_skill_rollback already rely on, then
+ realpath+containment-check the result against the repo's skills/ root
+ before ever opening it -- defense in depth against a future schema/data
+ bug turning this read path into an arbitrary-file-read, not a fix for
+ an exploit that's live today.
+ """
+ row = conn.execute("SELECT file_path FROM skill WHERE skill_id = ?", (skill_id,)).fetchone()
+ if row is None:
+ raise SkillNotFoundError(skill_id)
+
+ repo_root = repo_path or _REPO_ROOT
+ skills_root = os.path.realpath(os.path.join(repo_root, "skills"))
+ rel_path = skill_update._skill_file_path(row[0])
+ abs_path = os.path.realpath(os.path.join(repo_root, rel_path))
+ if os.path.commonpath([abs_path, skills_root]) != skills_root:
+ # Layer 1: DB-stored file_path resolves outside skills/ entirely.
+ raise SkillContentMissingError(skill_id)
+
+ try:
+ with open(abs_path, "r", encoding="utf-8", errors="replace") as f:
+ content = f.read()
+ except OSError:
+ # Layer 2: within skills/ but the file itself isn't there (not-yet-
+ # migrated skill, or a stale DB row).
+ raise SkillContentMissingError(skill_id)
+
+ skill_dir = os.path.dirname(abs_path)
+ support_files = []
+ for dirpath, _dirnames, filenames in os.walk(skill_dir):
+ for name in filenames:
+ full_path = os.path.realpath(os.path.join(dirpath, name))
+ if full_path == abs_path:
+ continue
+ support_files.append(os.path.relpath(full_path, skill_dir))
+ support_files.sort()
+
+ return {"skill_id": skill_id, "content": content, "support_files": support_files}
+
+
def get_diff(before, after, repo_path=None):
"""c_skill_diff: `git diff <before> <after>` -- skill_update.py's
_write_and_commit makes every iteration an atomic single-file commit,
diff --git a/src/test_backend_server.py b/src/test_backend_server.py
index c156be1..5dc6071 100644
--- a/src/test_backend_server.py
+++ b/src/test_backend_server.py
@@ -1521,6 +1521,23 @@ class SkillRouteTest(unittest.TestCase):
status, body = self._post("/api/skills/skill-1/rollback", {})
self.assertEqual(status, 400)
+ def test_content_returns_real_file_content(self):
+ skill_update._write_and_commit(self.tmp.name, "skills/skill-1/SKILL.md", "real content\n", "seed skill")
+ status, body = self._get("/api/skills/skill-1/content")
+ self.assertEqual(status, 200)
+ self.assertEqual(body["content"], "real content\n")
+ self.assertEqual(body["support_files"], [])
+
+ def test_content_nonexistent_skill_returns_404(self):
+ status, body = self._get("/api/skills/no-such-skill/content")
+ self.assertEqual(status, 404)
+ self.assertEqual(body["error"], "NOT_FOUND")
+
+ def test_content_missing_file_returns_404_content_missing(self):
+ status, body = self._get("/api/skills/skill-1/content")
+ self.assertEqual(status, 404)
+ self.assertEqual(body["error"], "CONTENT_MISSING")
+
class SettingsRouteTest(unittest.TestCase):
"""U-030: c_concurrency_config_update / c_budget_config_update /
diff --git a/src/test_skill_query.py b/src/test_skill_query.py
index b97a63f..3f4b8bd 100644
--- a/src/test_skill_query.py
+++ b/src/test_skill_query.py
@@ -5,6 +5,7 @@ graphlemon's own -- same reasoning as test_skill_update.py).
Run: python3 -m unittest src.test_skill_query -v
"""
+import os
import subprocess
import tempfile
import unittest
@@ -87,6 +88,85 @@ class GetSkillAuditLogTest(SkillQueryTestBase):
self.assertEqual(result[0]["change_summary"], "fix A")
+class GetSkillContentTest(SkillQueryTestBase):
+ """Bug fix: the skill detail page showed only skill_iteration history
+ and nothing for the real migrated SKILL.md content sitting on disk.
+ Uses the same tmp git repo as the rest of this file's tests -- these
+ file reads don't need git at all, but keeping repo_path consistent with
+ the other tests here avoids inventing a second fixture shape."""
+
+ def _write_skill_file(self, skill_id, content, extra_files=None):
+ skill_dir = os.path.join(self.tmp.name, "skills", skill_id)
+ os.makedirs(skill_dir, exist_ok=True)
+ with open(os.path.join(skill_dir, "SKILL.md"), "w", encoding="utf-8") as f:
+ f.write(content)
+ for rel in extra_files or []:
+ full = os.path.join(skill_dir, rel)
+ os.makedirs(os.path.dirname(full), exist_ok=True)
+ with open(full, "w", encoding="utf-8") as f:
+ f.write("support file content")
+
+ def test_nonexistent_skill_raises_not_found(self):
+ with self.assertRaises(skill_query.SkillNotFoundError):
+ skill_query.get_skill_content(self.conn, "no-such-skill", repo_path=self.tmp.name)
+
+ def test_returns_real_file_content(self):
+ self._insert_skill("s1", "coder")
+ self._write_skill_file("s1", "real skill content\n")
+ result = skill_query.get_skill_content(self.conn, "s1", repo_path=self.tmp.name)
+ self.assertEqual(result["skill_id"], "s1")
+ self.assertEqual(result["content"], "real skill content\n")
+ self.assertEqual(result["support_files"], [])
+
+ def test_lists_support_files_alongside_content(self):
+ self._insert_skill("s1", "coder")
+ self._write_skill_file(
+ "s1", "content\n",
+ extra_files=["references/notes.md", "templates/t.md", "scripts/run.sh"],
+ )
+ result = skill_query.get_skill_content(self.conn, "s1", repo_path=self.tmp.name)
+ self.assertEqual(
+ set(result["support_files"]),
+ {"references/notes.md", "templates/t.md", "scripts/run.sh"},
+ )
+
+ def test_row_exists_but_file_missing_raises_content_missing(self):
+ self._insert_skill("s1", "coder")
+ with self.assertRaises(skill_query.SkillContentMissingError):
+ skill_query.get_skill_content(self.conn, "s1", repo_path=self.tmp.name)
+
+ def test_file_path_already_ending_in_md_is_read_directly(self):
+ self._insert_skill("s1", "coder")
+ self.conn.execute("UPDATE skill SET file_path = ? WHERE skill_id = ?", ("skills/s1/SKILL.md", "s1"))
+ self.conn.commit()
+ self._write_skill_file("s1", "direct path content\n")
+ result = skill_query.get_skill_content(self.conn, "s1", repo_path=self.tmp.name)
+ self.assertEqual(result["content"], "direct path content\n")
+
+ def test_path_traversal_in_stored_file_path_is_rejected(self):
+ """Defense-in-depth: even if skill.file_path were somehow poisoned
+ with a traversal sequence (not reachable via the HTTP skill_id
+ param today, since that only drives a parameterized WHERE clause),
+ the resolved path must stay confined to skills/."""
+ self._insert_skill("s1", "coder")
+ self.conn.execute(
+ "UPDATE skill SET file_path = ? WHERE skill_id = ?", ("../../../../etc/passwd", "s1"),
+ )
+ self.conn.commit()
+ with self.assertRaises(skill_query.SkillContentMissingError):
+ skill_query.get_skill_content(self.conn, "s1", repo_path=self.tmp.name)
+
+ def test_path_traversal_via_dotdot_segments_stays_confined(self):
+ self._insert_skill("s1", "coder")
+ self.conn.execute(
+ "UPDATE skill SET file_path = ? WHERE skill_id = ?",
+ ("skills/../../outside/SKILL.md", "s1"),
+ )
+ self.conn.commit()
+ with self.assertRaises(skill_query.SkillContentMissingError):
+ skill_query.get_skill_content(self.conn, "s1", repo_path=self.tmp.name)
+
+
class GetDiffTest(SkillQueryTestBase):
def test_diff_between_two_commits_shows_content_change(self):
before_sha = skill_update._write_and_commit(self.tmp.name, "skills/s1/SKILL.md", "v1 content\n", "seed")