diff --git a/configs/agents/rdev_leader.md b/configs/agents/rdev_leader.md new file mode 100644 index 00000000..b55a5fc3 --- /dev/null +++ b/configs/agents/rdev_leader.md @@ -0,0 +1,30 @@ +## 角色 + +你是研发/设计控制小队的负责人(花名册名称:研发设计控制负责人)。按 ISO 13485 把本阶段做成可追溯的 DHF 记录。你是用户与小队之间的唯一接口:专员向你汇报,只有你对用户说话、提交阶段文档。 + +你的价值不在于自己写长文,而在于把本阶段设计控制要求变成可验收的任务,审核专员产出,整合成一份可提交的 Markdown 终稿。结论短、论证完整:卡片可以短,DHF 文档不能短。 + +**明确边界**:不做报价核算与商务承诺,不写商务对接口径。 + +## 汇报纪律 + +- 只有你可以把成果交给用户(通过系统落文档)。不要把未审核的专员原文直接当终稿。 +- 需要用户决策的事项一律上升,不要代用户拍板。 +- 专员产出不合格时退回说明差距;系统按剧本执行,你负责审核与整合。 + +## 本阶段你要做的(DHF 终审) + +1. 用一句话回述本阶段交付物及其 DHF 归属。 +2. 阅读快照、材料文本、前序文档和各专员 `output.md`。 +3. 按本阶段 RD 模板整合终稿,结论先行;与前序已批准文件保持一致。 +4. **追溯表必填核查**:本文件 ID、上游输入、下游验证/确认;未知项写入「待确认」而非留空。 +5. **不适用理由完整性**:声明不适用时须写完整理由,禁止空壳「不适用」。 +6. 缺信息标「待确认」,**禁止编造**试验数据、标准号、URL、签字、附件正文。 +7. 按 TASK.md 指定的交付文件落盘:终稿步写 `output.md`;若本步是「审核撰写计划」(review_plan),只写 `review_notes.md`,**禁止**把审核意见写入或覆盖 `output.md`。 +8. 专员已标注的出处必须保留;你整合时把能溯源的句子标上〔…〕,无依据的分析不要删,也不要补造引用。 + +## 汇报格式(终稿 output.md 开头;计划审核则写在 review_notes.md) + +**注意**:你整合生成的终稿文档结构必须严格遵循 `template.md` 的所有章节结构,不得自行替换为审阅报告结构!你的审阅和把关成果体现为:在保持 `template.md` 章节骨架(如 ## 结论、## 适用性、专业正文各节、## 追溯、## 待确认清单、## 签署 等)的基础上,将专员的草稿进行逻辑润色、出处溯源和终审定稿输出。 + +**待确认清单**:在模板对应章节中集中列出文中所有「待确认」。 diff --git a/internal/squad/rdev_leader_prompt_test.go b/internal/squad/rdev_leader_prompt_test.go new file mode 100644 index 00000000..a7e01cbb --- /dev/null +++ b/internal/squad/rdev_leader_prompt_test.go @@ -0,0 +1,73 @@ +package squad_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "chat-agent-system/internal/squad" +) + +func TestRDevLeaderPrompt_UsesDesignControlNotBusiness(t *testing.T) { + tmpDir := t.TempDir() + engine := squad.NewWorkspaceEngine() + + ws, err := engine.PrepareWorkspace(squad.WorkspacePrepareRequest{ + WorkDir: filepath.Join(tmpDir, "rd_design_input_spec"), + TaskKey: "rd_design_input_spec", + StageTitle: "设计输入规格书", + ProjectName: "rdev-s0-prompt", + Backend: "stub", + }) + if err != nil { + t.Fatalf("PrepareWorkspace rd_design_input_spec: %v", err) + } + + leaderAgents := filepath.Join(ws.WorkDir, "tasks", "04_review", "AGENTS.md") + body, err := os.ReadFile(leaderAgents) + if err != nil { + t.Fatalf("read leader AGENTS.md: %v", err) + } + text := string(body) + if !strings.Contains(text, "设计控制") { + t.Fatalf("rdev leader AGENTS.md must mention 设计控制; got snippet:\n%s", truncate(text, 400)) + } + for _, banned := range []string{"商务对接负责人", "CDO"} { + if strings.Contains(text, banned) { + t.Fatalf("rdev leader AGENTS.md must not contain %q", banned) + } + } +} + +func TestIntakeLeaderPrompt_StillBusiness(t *testing.T) { + tmpDir := t.TempDir() + engine := squad.NewWorkspaceEngine() + + ws, err := engine.PrepareWorkspace(squad.WorkspacePrepareRequest{ + WorkDir: filepath.Join(tmpDir, "intake"), + TaskKey: "intake", + StageTitle: "用户初始需求收集表", + ProjectName: "intake-prompt-regression", + Backend: "stub", + }) + if err != nil { + t.Fatalf("PrepareWorkspace intake: %v", err) + } + + leaderAgents := filepath.Join(ws.WorkDir, "tasks", "03_review", "AGENTS.md") + body, err := os.ReadFile(leaderAgents) + if err != nil { + t.Fatalf("read intake leader AGENTS.md: %v", err) + } + if !strings.Contains(string(body), "商务对接负责人") { + t.Fatalf("intake leader AGENTS.md must retain 商务对接负责人") + } +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/internal/squad/rdev_steps.go b/internal/squad/rdev_steps.go index f4be2989..fdf14beb 100644 --- a/internal/squad/rdev_steps.go +++ b/internal/squad/rdev_steps.go @@ -91,6 +91,7 @@ func rdevLeaderAgent() AgentSpec { Model: "claude-3-7-sonnet", Tools: []string{"read", "write", "edit"}, BashPermission: "deny", + InstructionsFile: "configs/agents/rdev_leader.md", Responsibilities: []string{"DHF 完整性终审", "不适用理由审核", "禁止编造检查"}, ForbiddenActions: []string{"严禁代签", "严禁伪造附件内容"}, } diff --git a/internal/squad/workspace.go b/internal/squad/workspace.go index b2d951dc..37792803 100644 --- a/internal/squad/workspace.go +++ b/internal/squad/workspace.go @@ -468,13 +468,20 @@ func (e *WorkspaceEngine) buildSubWorkspaces(workDir string, wf WorkflowPlan, sq return err } - // 1. 生成 AGENTS.md + // 1. 生成 AGENTS.md:优先 AgentSpec.InstructionsFile,再按角色名回落,再 inline 渲染 var agentsBuilder strings.Builder - inst, err := template.GetAgentInstruction(step.Role) - if err == nil { + spec := agentMap[step.Role] + var inst string + var err error + if file := strings.TrimSpace(spec.InstructionsFile); file != "" { + inst, err = template.GetAgentInstructionFile(file) + } + if err != nil || strings.TrimSpace(inst) == "" { + inst, err = template.GetAgentInstruction(step.Role) + } + if err == nil && strings.TrimSpace(inst) != "" { agentsBuilder.WriteString(inst) } else { - spec := agentMap[step.Role] agentsBuilder.WriteString(fmt.Sprintf("# 角色: %s (%s)\n\n", spec.Name, spec.Role)) if len(spec.Responsibilities) > 0 { agentsBuilder.WriteString("## 核心职责\n") diff --git a/internal/template/loader.go b/internal/template/loader.go index 1b93e5d6..f68f8292 100644 --- a/internal/template/loader.go +++ b/internal/template/loader.go @@ -56,11 +56,27 @@ func GetDocTemplate(name string) (string, error) { func GetAgentInstruction(role string) (string, error) { role = strings.TrimSuffix(role, ".md") + if role == "" || strings.Contains(role, "..") || strings.ContainsAny(role, `/\`) { + return "", fmt.Errorf("read agent instruction: invalid role %q", role) + } + return GetAgentInstructionFile(filepath.Join("configs", "agents", role+".md")) +} + +// GetAgentInstructionFile 读取 configs/agents/ 下指定相对路径的指令文件, +// 并自动追加 configs/shared/writing.md。rel 必须落在 configs/agents/ 内,拒绝 ".."。 +func GetAgentInstructionFile(rel string) (string, error) { + rel = filepath.ToSlash(strings.TrimSpace(rel)) + if rel == "" || strings.Contains(rel, "..") { + return "", fmt.Errorf("read agent instruction file: invalid path %q", rel) + } + if !strings.HasPrefix(rel, "configs/agents/") { + return "", fmt.Errorf("read agent instruction file: must be under configs/agents/: %q", rel) + } root := findProjectRoot() - path := filepath.Join(root, "configs", "agents", role+".md") + path := filepath.Join(root, filepath.FromSlash(rel)) b, err := os.ReadFile(path) if err != nil { - return "", fmt.Errorf("read agent instruction %s: %w", role, err) + return "", fmt.Errorf("read agent instruction file %s: %w", rel, err) } inst := string(b)