Files
Agent-System/internal/squad/rdev_overlay.go
T
weichenandCursor b550911667 feat(squad): load rdev overlays and enforce document gates
Each rdev step now gets a role-filtered overlay so writing discipline stays out of the template. User needs, system requirements, review minutes, and upload indexes fail the quality gate when IDs, decisions, or attachment indexes are incomplete.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 10:12:10 +00:00

205 lines
5.3 KiB
Go

package squad
import (
"encoding/json"
"os"
"path/filepath"
"strings"
)
// RDevStageOverlay 按参考项目 StageOverlay 规则拼接本阶段纪律,并按角色过滤。
// 模板仍管章节结构;这里只提供写作纪律与粒度下限。
func RDevStageOverlay(taskKey, role, tier string) string {
meta, ok := LookupRDevTask(taskKey)
if !ok {
return ""
}
stageKey := strings.TrimPrefix(taskKey, "rd_")
root := findOverlayRoot()
if root == "" {
return ""
}
names := rdevOverlayNames(stageKey, meta.Phase, string(meta.Kind), tier)
var parts []string
for _, name := range names {
content := readOverlayFile(root, name)
if content == "" {
continue
}
if filtered := filterOverlayByRole(content, role); filtered != "" {
parts = append(parts, filtered)
}
}
return strings.TrimSpace(strings.Join(parts, "\n\n"))
}
func rdevOverlayNames(stageKey, phase, artifactKind, tier string) []string {
seen := map[string]bool{}
var names []string
add := func(name string) {
name = strings.TrimSpace(name)
if name == "" || seen[name] {
return
}
seen[name] = true
names = append(names, name)
}
for _, path := range []string{
"overlays/_shared/doc-structure.md",
"overlays/_shared/self-contained-handoff.md",
"overlays/_shared/acceptance-rubric.md",
"overlays/_shared/granularity.md",
"overlays/_shared/anti-truncation.md",
} {
add(path)
}
add("rdev")
if artifactKind != "" && artifactKind != "document" {
add("kind_" + artifactKind)
}
add("rdev_ph" + strings.TrimSpace(phase))
if strings.HasPrefix(stageKey, "sw_") {
add("rdev_software")
add("kind_software")
}
if ko := rdevInferredKind(stageKey); ko != "" {
add(ko)
}
add(stageKey)
switch strings.ToLower(strings.TrimSpace(tier)) {
case "high", "medium":
add("complexity_" + strings.ToLower(strings.TrimSpace(tier)))
}
switch stageKey {
case "user_needs", "system_reqs":
add("overlays/_shared/ur-rs-expansion.md")
}
if stageKey == "design_vv" || stageKey == "validation_plan" || stageKey == "tech_req" || (strings.HasPrefix(stageKey, "sw_") && strings.Contains(stageKey, "test")) {
add("overlays/_shared/rs-tsys-expansion.md")
}
return names
}
func rdevInferredKind(stageKey string) string {
switch {
case strings.HasPrefix(stageKey, "risk_"), stageKey == "hazard_analysis":
return "kind_risk"
case strings.Contains(stageKey, "validation"), strings.Contains(stageKey, "verification"), stageKey == "design_vv", stageKey == "user_test", stageKey == "clinical", stageKey == "biocompatibility", stageKey == "emc_safety", stageKey == "cleaning", stageKey == "animal_study":
return "kind_verification"
case strings.HasPrefix(stageKey, "design_"), strings.HasPrefix(stageKey, "system_"), stageKey == "tech_req", stageKey == "user_needs", stageKey == "process_flow", stageKey == "proposal", stageKey == "planning_review", stageKey == "req_review", stageKey == "input_phase_review", stageKey == "output_review":
return "kind_design_record"
default:
return ""
}
}
func findOverlayRoot() string {
wd, _ := os.Getwd()
candidates := []string{wd}
dir := wd
for i := 0; i < 8; i++ {
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
candidates = append(candidates, dir)
}
if env := strings.TrimSpace(os.Getenv("APP_ROOT")); env != "" {
candidates = append(candidates, env)
}
candidates = append(candidates, "/app")
for _, root := range candidates {
if root == "" {
continue
}
if _, err := os.Stat(filepath.Join(root, "configs", "overlays", "rdev.md")); err == nil {
return root
}
}
return ""
}
func readOverlayFile(root, name string) string {
name = strings.TrimSpace(name)
if name == "" || strings.Contains(name, "..") {
return ""
}
var rel string
if strings.Contains(name, "/") {
rel = filepath.Join("configs", filepath.FromSlash(name))
} else {
rel = filepath.Join("configs", "overlays", name+".md")
}
b, err := os.ReadFile(filepath.Join(root, rel))
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func filterOverlayByRole(text, role string) string {
text = strings.TrimSpace(text)
if text == "" {
return ""
}
role = strings.ToLower(strings.TrimSpace(role))
if role == "rdev_leader" {
role = "leader"
}
lines := strings.Split(text, "\n")
hasRole := false
for _, line := range lines {
if _, ok := parseRoleHeading(line); ok {
hasRole = true
break
}
}
if !hasRole {
return text
}
var out []string
include := true
for _, line := range lines {
if r, ok := parseRoleHeading(line); ok {
include = role != "" && r == role
if include {
out = append(out, line)
}
continue
}
if include {
out = append(out, line)
}
}
return strings.TrimSpace(strings.Join(out, "\n"))
}
func parseRoleHeading(line string) (string, bool) {
trim := strings.TrimSpace(line)
if !strings.HasPrefix(trim, "### ") {
return "", false
}
rest := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(trim, "### ")))
if !strings.HasPrefix(rest, "role:") {
return "", false
}
role := strings.TrimSpace(rest[len("role:"):])
if role == "" || strings.ContainsAny(role, " \t") {
return "", false
}
return role, true
}
func rdevTierFromWorkspace(workDir string) string {
b, err := os.ReadFile(filepath.Join(workDir, "blackboard.json"))
if err != nil {
return ""
}
var bb BlackboardState
if json.Unmarshal(b, &bb) != nil {
return ""
}
return rdevComplexityTier(bb.Fields)
}