Promote root output.md only when the runner succeeded, protected parents were untouched, and the local target is trusted. Restore errors abort instead of being ignored. Co-authored-by: Cursor <cursoragent@cursor.com>
737 lines
22 KiB
Go
737 lines
22 KiB
Go
package squad
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
"unicode"
|
||
"unicode/utf8"
|
||
|
||
"chat-agent-system/internal/agent"
|
||
"chat-agent-system/internal/stream"
|
||
)
|
||
|
||
// ProhibitedPhrases 医疗器械文档小队严禁出现的敷衍套话与省略词
|
||
var ProhibitedPhrases = []string{
|
||
"不做展开",
|
||
"不展开",
|
||
"篇幅有限",
|
||
"从略",
|
||
"不赘述",
|
||
"略过细节",
|
||
"仅作概要",
|
||
"待续",
|
||
"…(略)",
|
||
"(略)",
|
||
}
|
||
|
||
// QualityGate 负责小队交付物的三层质量门禁与合规审计
|
||
type QualityGate struct{}
|
||
|
||
func NewQualityGate() *QualityGate {
|
||
return &QualityGate{}
|
||
}
|
||
|
||
// Validate 对交付正文执行静态 AST 与质量合规校验
|
||
func (q *QualityGate) Validate(wsDir string, taskKey string, content string) (*QualityReport, error) {
|
||
now := time.Now()
|
||
report := &QualityReport{
|
||
Passed: true,
|
||
Score: 100.0,
|
||
TitleCheck: true,
|
||
MissingSections: make([]string, 0),
|
||
ProhibitedPhrases: make([]string, 0),
|
||
Violations: make([]string, 0),
|
||
QuoteMatches: true,
|
||
CheckedAt: now,
|
||
}
|
||
|
||
// 1. 检查标题完整性 (对比 template.md 的 ## 章节)
|
||
tmplPath := filepath.Join(wsDir, "template.md")
|
||
tmplData, err := os.ReadFile(tmplPath)
|
||
if err == nil {
|
||
expectedHeadings := extractHeadings(string(tmplData))
|
||
actualHeadings := extractHeadings(content)
|
||
for _, h := range expectedHeadings {
|
||
found := false
|
||
for _, act := range actualHeadings {
|
||
if strings.Contains(act, h) || strings.Contains(h, act) {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found && !strings.Contains(content, h) {
|
||
report.MissingSections = append(report.MissingSections, h)
|
||
report.Violations = append(report.Violations, fmt.Sprintf("缺少必填章节: %s", h))
|
||
report.Score -= 15.0
|
||
}
|
||
}
|
||
if len(report.MissingSections) > 0 {
|
||
report.TitleCheck = false
|
||
report.Passed = false
|
||
}
|
||
}
|
||
|
||
// 2. 检查敷衍违规套话(否定性自检/禁令复述不计入,避免门禁自污染)
|
||
for _, phrase := range ProhibitedPhrases {
|
||
if containsProhibitedUsage(content, phrase) {
|
||
report.ProhibitedPhrases = append(report.ProhibitedPhrases, phrase)
|
||
// 违规说明勿回写禁用原词,避免修复指令/自检再次自污染
|
||
report.Violations = append(report.Violations, "检测到正文中的省略/敷衍写法(须实质展开;自检勿复述禁用原词)")
|
||
report.Score -= 20.0
|
||
report.Passed = false
|
||
}
|
||
}
|
||
|
||
// 3. 检查未渲染的双大括号模板占位符
|
||
if strings.Contains(content, "{{") && strings.Contains(content, "}}") {
|
||
report.Violations = append(report.Violations, "检测到未替换的双大括号占位符 {{...}}")
|
||
report.Score -= 10.0
|
||
report.Passed = false
|
||
}
|
||
|
||
// 4. CDO 报价任务检查
|
||
if taskKey == "cdo_quote" {
|
||
xlsxPath := filepath.Join(wsDir, "CDO报价核算-V01.xlsx")
|
||
if _, err := os.Stat(xlsxPath); err != nil {
|
||
report.QuoteMatches = false
|
||
report.Violations = append(report.Violations, "CDO 报价任务缺少配套核算文件 CDO报价核算-V01.xlsx")
|
||
report.Score -= 25.0
|
||
report.Passed = false
|
||
}
|
||
}
|
||
|
||
// 5. 智能产品经理工作流:系统需求规格书检查
|
||
if taskKey == "system_reqs" {
|
||
if !strings.Contains(content, "REQ-") && !strings.Contains(content, "SYS-REQ") {
|
||
report.Violations = append(report.Violations, "系统需求规格书缺少规范的需求编号(如 REQ-01 / SYS-REQ-01)")
|
||
report.Score -= 20.0
|
||
report.Passed = false
|
||
}
|
||
hasVerification := strings.Contains(content, "测试") || strings.Contains(content, "分析") ||
|
||
strings.Contains(content, "审查") || strings.Contains(content, "演示") ||
|
||
strings.Contains(content, "检验") || strings.Contains(content, "检") || strings.Contains(content, "测")
|
||
if !hasVerification {
|
||
report.Violations = append(report.Violations, "系统需求清单缺少验证方式字段(测试/分析/审查/演示)")
|
||
report.Score -= 15.0
|
||
report.Passed = false
|
||
}
|
||
}
|
||
|
||
// 6. 智能测试经理工作流:系统验证方案及计划检查
|
||
if taskKey == "test_manager" {
|
||
if !strings.Contains(content, "TC-") && !strings.Contains(content, "TEST-") {
|
||
report.Violations = append(report.Violations, "系统验证方案缺少规范的测试用例编号(如 TC-01 / TEST-01)")
|
||
report.Score -= 20.0
|
||
report.Passed = false
|
||
}
|
||
if !strings.Contains(content, "REQ-") {
|
||
report.Violations = append(report.Violations, "系统验证方案缺少对上游系统需求编号(REQ-)的追溯覆盖")
|
||
report.Score -= 15.0
|
||
report.Passed = false
|
||
}
|
||
tcPath := filepath.Join(wsDir, "artifacts", "test_cases.json")
|
||
if tcData, err := os.ReadFile(tcPath); err == nil && len(tcData) > 0 {
|
||
var dummy any
|
||
if err := json.Unmarshal(tcData, &dummy); err != nil {
|
||
report.Violations = append(report.Violations, "artifacts/test_cases.json 不是合法的 JSON 格式")
|
||
report.Score -= 10.0
|
||
report.Passed = false
|
||
}
|
||
}
|
||
}
|
||
|
||
if report.Score < 0 {
|
||
report.Score = 0
|
||
}
|
||
if report.Score < 80.0 {
|
||
report.Passed = false
|
||
}
|
||
|
||
return report, nil
|
||
}
|
||
|
||
// sanitizeViolationForAgent 修复指令中不得原样回写禁用套话,避免二次自污染。
|
||
func sanitizeViolationForAgent(v string) string {
|
||
out := v
|
||
for _, phrase := range ProhibitedPhrases {
|
||
if phrase != "" && strings.Contains(out, phrase) {
|
||
out = strings.ReplaceAll(out, phrase, "「省略/敷衍写法」")
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// containsProhibitedUsage 仅当禁词被用作正文省略/敷衍时命中。
|
||
// 否定性自检、写作禁令复述(如:无"…"等敷衍套话)不得误报。
|
||
func containsProhibitedUsage(content, phrase string) bool {
|
||
if phrase == "" || content == "" {
|
||
return false
|
||
}
|
||
searchFrom := 0
|
||
for {
|
||
rel := strings.Index(content[searchFrom:], phrase)
|
||
if rel < 0 {
|
||
return false
|
||
}
|
||
abs := searchFrom + rel
|
||
if isNegatedOrMetaProhibitedCitation(content, abs, phrase) ||
|
||
isActionPlanStatusCell(content, abs, phrase) {
|
||
searchFrom = abs + len(phrase)
|
||
continue
|
||
}
|
||
return true
|
||
}
|
||
}
|
||
|
||
func isNegatedOrMetaProhibitedCitation(content string, idx int, phrase string) bool {
|
||
lineStart := strings.LastIndex(content[:idx], "\n") + 1
|
||
lineEnd := strings.Index(content[idx:], "\n")
|
||
if lineEnd < 0 {
|
||
lineEnd = len(content)
|
||
} else {
|
||
lineEnd = idx + lineEnd
|
||
}
|
||
line := content[lineStart:lineEnd]
|
||
localIdx := idx - lineStart
|
||
if localIdx < 0 || localIdx > len(line) {
|
||
return false
|
||
}
|
||
|
||
// 仅同一自检/规则行,且须同时具备元标注 + 否定词紧邻或引号引用结构。
|
||
// 禁止用「48 字窗口出现 无」豁免:正文「此处无更多数据,不做展开」必须仍命中。
|
||
if !lineHasProhibitedMetaMarker(line) {
|
||
return false
|
||
}
|
||
return phraseQuotedOnLine(line, phrase) || negationAdjacentToPhrase(line, localIdx)
|
||
}
|
||
|
||
func lineHasProhibitedMetaMarker(line string) bool {
|
||
metaMarkers := []string{
|
||
"写作禁令", "敷衍套话", "省略套话", "省略敷衍", "禁止使用", "严禁写",
|
||
"负向红线", "审核结论", "遵守情况", "交付契约", "写作纪律", "验收标准",
|
||
}
|
||
for _, m := range metaMarkers {
|
||
if strings.Contains(line, m) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func phraseQuotedOnLine(line, phrase string) bool {
|
||
if phrase == "" {
|
||
return false
|
||
}
|
||
wrappers := [][2]string{
|
||
{`"`, `"`},
|
||
{"「", "」"},
|
||
{`'`, `'`},
|
||
{"“", "”"},
|
||
{"‘", "’"},
|
||
}
|
||
for _, w := range wrappers {
|
||
if strings.Contains(line, w[0]+phrase+w[1]) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
var (
|
||
actionPlanStatusHeaders = []string{"阶段", "状态", "进度"}
|
||
actionPlanActionHeaders = []string{"行动项", "行动", "动作", "后续动作", "后续行动", "工作内容"}
|
||
actionPlanOwnerHeaders = []string{"负责方", "责任人", "负责人"}
|
||
actionPlanTriggerHeaders = []string{"触发条件", "触发", "完成条件", "前置条件"}
|
||
)
|
||
|
||
// isActionPlanStatusCell 仅豁免行动计划/后续动作表中「阶段/状态/进度」列的独立单元格「待续」。
|
||
// 命中列必须与表头状态列索引一致;动作/负责/触发按表头取值。不得凭同行任意列或邻近词放宽。
|
||
// 只接受同一连续表格:表头后须紧跟合法 separator,且数据行列数与表头相等。
|
||
func isActionPlanStatusCell(content string, idx int, phrase string) bool {
|
||
if phrase != "待续" || idx < 0 || idx >= len(content) {
|
||
return false
|
||
}
|
||
lineStart := strings.LastIndex(content[:idx], "\n") + 1
|
||
lineEnd := strings.Index(content[idx:], "\n")
|
||
if lineEnd < 0 {
|
||
lineEnd = len(content)
|
||
} else {
|
||
lineEnd = idx + lineEnd
|
||
}
|
||
line := content[lineStart:lineEnd]
|
||
cells := markdownTableCells(line)
|
||
if len(cells) < 4 || isMarkdownTableSeparator(cells) {
|
||
return false
|
||
}
|
||
header := findPrecedingTableHeader(content, lineStart)
|
||
if len(header) == 0 || len(cells) != len(header) {
|
||
return false
|
||
}
|
||
cols, ok := parseActionPlanColumns(header)
|
||
if !ok {
|
||
return false
|
||
}
|
||
cell, hitIdx, ok := tableCellAt(line, idx-lineStart)
|
||
if !ok || hitIdx != cols.status || stripMarkdownEmphasis(cell) != "待续" {
|
||
return false
|
||
}
|
||
if cols.action >= len(cells) || cols.owner >= len(cells) || cols.trigger >= len(cells) || cols.status >= len(cells) {
|
||
return false
|
||
}
|
||
if strings.TrimSpace(stripMarkdownEmphasis(cells[cols.owner])) == "" ||
|
||
strings.TrimSpace(stripMarkdownEmphasis(cells[cols.trigger])) == "" {
|
||
return false
|
||
}
|
||
return hasSubstantialActionText(stripMarkdownEmphasis(cells[cols.action]))
|
||
}
|
||
|
||
func markdownTableCells(line string) []string {
|
||
trimmed := strings.TrimSpace(line)
|
||
if !strings.HasPrefix(trimmed, "|") {
|
||
return nil
|
||
}
|
||
parts := strings.Split(trimmed, "|")
|
||
if len(parts) > 0 && parts[0] == "" {
|
||
parts = parts[1:]
|
||
}
|
||
if len(parts) > 0 && strings.TrimSpace(parts[len(parts)-1]) == "" {
|
||
parts = parts[:len(parts)-1]
|
||
}
|
||
if len(parts) == 0 {
|
||
return nil
|
||
}
|
||
return parts
|
||
}
|
||
|
||
func tableCellAt(line string, localIdx int) (string, int, bool) {
|
||
if localIdx < 0 || localIdx >= len(line) {
|
||
return "", -1, false
|
||
}
|
||
var pipes []int
|
||
for i := 0; i < len(line); i++ {
|
||
if line[i] == '|' {
|
||
pipes = append(pipes, i)
|
||
}
|
||
}
|
||
if len(pipes) < 2 {
|
||
return "", -1, false
|
||
}
|
||
for i := 0; i < len(pipes)-1; i++ {
|
||
start, end := pipes[i], pipes[i+1]
|
||
if localIdx > start && localIdx < end {
|
||
return line[start+1 : end], i, true
|
||
}
|
||
}
|
||
return "", -1, false
|
||
}
|
||
|
||
func isMarkdownTableSeparator(cells []string) bool {
|
||
if len(cells) == 0 {
|
||
return false
|
||
}
|
||
for _, c := range cells {
|
||
s := strings.Trim(strings.TrimSpace(c), ":")
|
||
s = strings.TrimSpace(s)
|
||
if s == "" || strings.Trim(s, "-") != "" {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func stripMarkdownEmphasis(s string) string {
|
||
s = strings.TrimSpace(s)
|
||
s = strings.ReplaceAll(s, "**", "")
|
||
s = strings.ReplaceAll(s, "__", "")
|
||
s = strings.TrimSpace(s)
|
||
if len(s) >= 2 && strings.HasPrefix(s, "*") && strings.HasSuffix(s, "*") {
|
||
s = strings.TrimSpace(s[1 : len(s)-1])
|
||
}
|
||
return strings.TrimSpace(s)
|
||
}
|
||
|
||
func hasSubstantialActionText(s string) bool {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" || strings.Contains(s, "待续") {
|
||
return false
|
||
}
|
||
if utf8.RuneCountInString(s) < 12 {
|
||
return false
|
||
}
|
||
letters := 0
|
||
for _, r := range s {
|
||
if unicode.Is(unicode.Han, r) || unicode.IsLetter(r) {
|
||
letters++
|
||
}
|
||
}
|
||
return letters >= 10
|
||
}
|
||
|
||
func findPrecedingTableHeader(content string, lineStart int) []string {
|
||
var above []string
|
||
prevEnd := lineStart - 1
|
||
for prevEnd >= 0 {
|
||
prevStart := 0
|
||
if prevEnd > 0 {
|
||
if i := strings.LastIndex(content[:prevEnd], "\n"); i >= 0 {
|
||
prevStart = i + 1
|
||
}
|
||
}
|
||
line := strings.TrimSpace(content[prevStart:prevEnd])
|
||
if line == "" {
|
||
break
|
||
}
|
||
cells := markdownTableCells(line)
|
||
if cells == nil {
|
||
break
|
||
}
|
||
above = append(above, line)
|
||
prevEnd = prevStart - 1
|
||
}
|
||
if len(above) < 2 {
|
||
return nil
|
||
}
|
||
header := markdownTableCells(above[len(above)-1])
|
||
sep := markdownTableCells(above[len(above)-2])
|
||
if len(header) == 0 || isMarkdownTableSeparator(header) {
|
||
return nil
|
||
}
|
||
if !isMarkdownTableSeparator(sep) || len(sep) != len(header) {
|
||
return nil
|
||
}
|
||
return header
|
||
}
|
||
|
||
type actionPlanColumns struct {
|
||
status int
|
||
action int
|
||
owner int
|
||
trigger int
|
||
}
|
||
|
||
func headerColumnIndex(header []string, names []string) int {
|
||
want := make(map[string]struct{}, len(names))
|
||
for _, n := range names {
|
||
want[n] = struct{}{}
|
||
}
|
||
for i, c := range header {
|
||
if _, ok := want[stripMarkdownEmphasis(c)]; ok {
|
||
return i
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
|
||
func parseActionPlanColumns(header []string) (actionPlanColumns, bool) {
|
||
cols := actionPlanColumns{
|
||
status: headerColumnIndex(header, actionPlanStatusHeaders),
|
||
action: headerColumnIndex(header, actionPlanActionHeaders),
|
||
owner: headerColumnIndex(header, actionPlanOwnerHeaders),
|
||
trigger: headerColumnIndex(header, actionPlanTriggerHeaders),
|
||
}
|
||
if cols.status < 0 || cols.action < 0 || cols.owner < 0 || cols.trigger < 0 {
|
||
return actionPlanColumns{}, false
|
||
}
|
||
seen := map[int]struct{}{cols.status: {}, cols.action: {}, cols.owner: {}, cols.trigger: {}}
|
||
if len(seen) != 4 {
|
||
return actionPlanColumns{}, false
|
||
}
|
||
return cols, true
|
||
}
|
||
|
||
func isActionPlanTableHeader(cells []string) bool {
|
||
_, ok := parseActionPlanColumns(cells)
|
||
return ok
|
||
}
|
||
|
||
func negationAdjacentToPhrase(line string, phraseIdx int) bool {
|
||
if phraseIdx <= 0 || phraseIdx > len(line) {
|
||
return false
|
||
}
|
||
prefix := strings.TrimRight(line[:phraseIdx], " \t\"'「“‘")
|
||
negMarkers := []string{"无", "禁止", "严禁", "不得", "勿", "杜绝", "没有", "不含", "未使用", "不出现", "不应出现"}
|
||
for _, n := range negMarkers {
|
||
if strings.HasSuffix(prefix, n) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// GenerateManifest 扫描工作区核心文件生成 SHA-256 指纹与防篡改审计清单
|
||
func (q *QualityGate) GenerateManifest(wsDir string, docKey string, report QualityReport) (*Manifest, error) {
|
||
now := time.Now()
|
||
files := make(map[string]string)
|
||
|
||
trackedPaths := []string{
|
||
"workspace.json",
|
||
"squad.json",
|
||
"workflow.json",
|
||
"template.md",
|
||
"blackboard.json",
|
||
"output.md",
|
||
filepath.Join("context", "index.json"),
|
||
filepath.Join("context", "user.md"),
|
||
filepath.Join("context", "project.md"),
|
||
filepath.Join("context", "agent.md"),
|
||
}
|
||
|
||
for _, rel := range trackedPaths {
|
||
p := filepath.Join(wsDir, rel)
|
||
if hash, err := computeSHA256(p); err == nil {
|
||
files[filepath.ToSlash(rel)] = hash
|
||
}
|
||
}
|
||
|
||
// 扫描 artifacts/
|
||
artDir := filepath.Join(wsDir, "artifacts")
|
||
if entries, err := os.ReadDir(artDir); err == nil {
|
||
for _, e := range entries {
|
||
if !e.IsDir() {
|
||
rel := filepath.Join("artifacts", e.Name())
|
||
p := filepath.Join(wsDir, rel)
|
||
if hash, err := computeSHA256(p); err == nil {
|
||
files[filepath.ToSlash(rel)] = hash
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 扫描 tasks/*/output.md
|
||
tasksDir := filepath.Join(wsDir, "tasks")
|
||
if entries, err := os.ReadDir(tasksDir); err == nil {
|
||
for _, e := range entries {
|
||
if e.IsDir() {
|
||
outPath := filepath.Join(tasksDir, e.Name(), "output.md")
|
||
if hash, err := computeSHA256(outPath); err == nil {
|
||
rel := filepath.Join("tasks", e.Name(), "output.md")
|
||
files[filepath.ToSlash(rel)] = hash
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
manifest := &Manifest{
|
||
ManifestVersion: "1.0.0",
|
||
WorkspaceID: filepath.Base(wsDir),
|
||
DocKey: docKey,
|
||
GeneratedAt: now,
|
||
Deliverable: filepath.Join(wsDir, "output.md"),
|
||
Files: files,
|
||
QualityReport: report,
|
||
}
|
||
|
||
manifestPath := filepath.Join(wsDir, "manifest.json")
|
||
if err := writeJSON(manifestPath, manifest); err != nil {
|
||
return nil, fmt.Errorf("write manifest.json: %w", err)
|
||
}
|
||
|
||
return manifest, nil
|
||
}
|
||
|
||
// EnsureQualityOrRepair 质量门禁自动闭环:若质检未通过,动态向工作流追加修复步骤直到达标或达到重试上限。
|
||
// 提升根 output.md 的唯一条件:runErr==nil、无受保护父级越界、本地目标可信。
|
||
func (q *QualityGate) EnsureQualityOrRepair(
|
||
ctx context.Context,
|
||
wsDir string,
|
||
taskKey string,
|
||
driver *WorkflowDriver,
|
||
backend agent.Backend,
|
||
maxRetries int,
|
||
opts ExecuteOptions,
|
||
) (*QualityReport, error) {
|
||
outPath := filepath.Join(wsDir, "output.md")
|
||
contentBytes, err := os.ReadFile(outPath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("read output.md: %w", err)
|
||
}
|
||
|
||
report, err := q.Validate(wsDir, taskKey, string(contentBytes))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if report.Passed || maxRetries <= 0 {
|
||
_, _ = q.GenerateManifest(wsDir, taskKey, *report)
|
||
return report, nil
|
||
}
|
||
|
||
// 治理:加载预算账本(修复轮预算预扣 + 跨轮停滞检测)
|
||
ledger := LoadOrCreateLedger(wsDir, DefaultBudgetPolicy())
|
||
prevRepairContent := string(contentBytes)
|
||
stallHinted := false
|
||
stallBreak := false
|
||
|
||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||
// 治理:修复轮预算预扣(超限硬停,杜绝机械空转烧成本)
|
||
if lErr := ledger.ReserveRepair(); lErr != nil {
|
||
_ = ledger.Save(wsDir)
|
||
if st := stream.FromContext(ctx); st != nil {
|
||
st.EmitPhase("governance", "修复预算硬停: "+lErr.Error(), "taskgov", 0, 0, 0)
|
||
}
|
||
break
|
||
}
|
||
|
||
repairStepID := fmt.Sprintf("step_repair_%d", attempt)
|
||
subDir := fmt.Sprintf("tasks/repair_%d", attempt)
|
||
|
||
var acceptCriteria []string
|
||
if len(report.MissingSections) > 0 {
|
||
acceptCriteria = append(acceptCriteria, "补齐缺失章节: "+strings.Join(report.MissingSections, ", "))
|
||
}
|
||
if len(report.ProhibitedPhrases) > 0 {
|
||
acceptCriteria = append(acceptCriteria, "清除正文中实际使用的省略/敷衍写法;自检表勿复述禁用原词")
|
||
}
|
||
for _, v := range report.Violations {
|
||
acceptCriteria = append(acceptCriteria, "解决违规点: "+v)
|
||
}
|
||
|
||
repairStep := StepSpec{
|
||
ID: repairStepID,
|
||
Role: "writer",
|
||
Title: fmt.Sprintf("根据质量门禁反馈修复交付初稿 (第%d次修正)", attempt),
|
||
SubWorkspace: subDir,
|
||
Mode: "execute",
|
||
Needs: []string{"output.md"},
|
||
InputLayout: InputLayoutSnapshot,
|
||
Accept: acceptCriteria,
|
||
}
|
||
|
||
parentSnap := SnapshotWorkspaceInputs(wsDir)
|
||
|
||
if err := driver.AddStep(wsDir, repairStep); err != nil {
|
||
return report, fmt.Errorf("add repair step: %w", err)
|
||
}
|
||
|
||
repairAbs := filepath.Join(wsDir, filepath.FromSlash(subDir))
|
||
if err := WriteRepairTaskMarkdown(repairAbs, attempt, report, stallHinted); err != nil {
|
||
return report, fmt.Errorf("write repair TASK.md: %w", err)
|
||
}
|
||
|
||
// 执行修复步骤(治理入账:轨迹 + 预算)
|
||
runErr := driver.runOneStep(ctx, wsDir, repairStep, backend, opts.Timeout, opts.StepNotify, nil, &[]string{}, ledger)
|
||
|
||
repairedBytes, localOK := TrustedLocalDeliverable(repairAbs, RepairTargetName)
|
||
mutated := ParentInputsMutated(wsDir, parentSnap)
|
||
if len(mutated) > 0 {
|
||
recordContainmentViolation(wsDir, attempt, mutated, localOK, parentSnap)
|
||
if rerr := restoreWorkspaceInputsFn(wsDir, parentSnap); rerr != nil {
|
||
appendGovAudit(wsDir, GovAuditRestoreFailure, map[string]string{
|
||
"attempt": fmt.Sprintf("%d", attempt),
|
||
"error": rerr.Error(),
|
||
"mutated": strings.Join(mutated, ","),
|
||
})
|
||
if st := stream.FromContext(ctx); st != nil {
|
||
st.EmitPhase("governance", "修复 containment 恢复失败: "+rerr.Error(), "taskgov", 0, 0, 0)
|
||
}
|
||
return report, fmt.Errorf("restore parent inputs after containment: %w", rerr)
|
||
}
|
||
if st := stream.FromContext(ctx); st != nil {
|
||
st.EmitPhase("governance", "修复 containment 违规: 父级输入已被越界改写,已恢复运行前字节,本轮作废", "taskgov", 0, 0, 0)
|
||
}
|
||
}
|
||
|
||
promote, reason := DecideRepairPromote(runErr, mutated, localOK)
|
||
if !promote {
|
||
detail := map[string]string{"attempt": fmt.Sprintf("%d", attempt), "reason": reason}
|
||
if runErr != nil {
|
||
detail["error"] = runErr.Error()
|
||
}
|
||
appendGovAudit(wsDir, "repair_round_discarded", detail)
|
||
continue
|
||
}
|
||
|
||
// 仅 runErr==nil、无 containment、本地目标可信时才覆盖根 output.md 并 Validate。
|
||
sim := OutputSimilarity(prevRepairContent, string(repairedBytes))
|
||
if sim >= 0.98 {
|
||
if stallHinted {
|
||
if st := stream.FromContext(ctx); st != nil {
|
||
st.EmitPhase("governance", fmt.Sprintf("修复停滞止损: 连续两轮修复产物相似度 %.1f%%,终止机械重试", sim*100), "taskgov", 0, 0, 0)
|
||
}
|
||
appendGovAudit(wsDir, "repair_stall_break", map[string]string{"attempt": fmt.Sprintf("%d", attempt), "similarity": fmt.Sprintf("%.3f", sim)})
|
||
stallBreak = true
|
||
} else {
|
||
stallHinted = true
|
||
appendGovAudit(wsDir, "repair_stall_hinted", map[string]string{"attempt": fmt.Sprintf("%d", attempt), "similarity": fmt.Sprintf("%.3f", sim)})
|
||
}
|
||
}
|
||
prevRepairContent = string(repairedBytes)
|
||
if err := writeRegularFile(outPath, repairedBytes); err != nil {
|
||
return report, fmt.Errorf("promote repair output: %w", err)
|
||
}
|
||
report, _ = q.Validate(wsDir, taskKey, string(repairedBytes))
|
||
if report.Passed || stallBreak {
|
||
break
|
||
}
|
||
}
|
||
_ = ledger.Save(wsDir)
|
||
|
||
_, _ = q.GenerateManifest(wsDir, taskKey, *report)
|
||
return report, nil
|
||
}
|
||
|
||
func extractHeadings(md string) []string {
|
||
var headings []string
|
||
lines := strings.Split(md, "\n")
|
||
for _, line := range lines {
|
||
trimmed := strings.TrimSpace(line)
|
||
if strings.HasPrefix(trimmed, "## ") {
|
||
raw := strings.TrimPrefix(trimmed, "## ")
|
||
clean := cleanHeadingTitle(raw)
|
||
if clean != "" {
|
||
headings = append(headings, clean)
|
||
}
|
||
}
|
||
}
|
||
return headings
|
||
}
|
||
|
||
func cleanHeadingTitle(title string) string {
|
||
// 去除前缀序号如 "一、", "二、", "1.", "1.1", "(一)" 等
|
||
t := strings.TrimSpace(title)
|
||
runes := []rune(t)
|
||
// 去掉中文序号如 "一、", "二、", "三、", "四、", "五、", "六、", "七、", "八、", "九、", "十、"
|
||
chineseNums := []string{"一、", "二、", "三、", "四、", "五、", "六、", "七、", "八、", "九、", "十、"}
|
||
for _, cn := range chineseNums {
|
||
if strings.HasPrefix(t, cn) {
|
||
t = strings.TrimPrefix(t, cn)
|
||
break
|
||
}
|
||
}
|
||
// 去掉数字序号如 "1. ", "1、"
|
||
for len(runes) > 0 && ((runes[0] >= '0' && runes[0] <= '9') || runes[0] == '.' || runes[0] == '、' || runes[0] == ' ') {
|
||
t = strings.TrimSpace(string(runes[1:]))
|
||
runes = []rune(t)
|
||
}
|
||
// 去除可能括号后缀
|
||
if idx := strings.Index(t, "("); idx > 0 {
|
||
t = strings.TrimSpace(t[:idx])
|
||
}
|
||
if idx := strings.Index(t, "("); idx > 0 {
|
||
t = strings.TrimSpace(t[:idx])
|
||
}
|
||
return strings.TrimSpace(t)
|
||
}
|
||
|
||
func computeSHA256(filePath string) (string, error) {
|
||
f, err := os.Open(filePath)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer f.Close()
|
||
|
||
h := sha256.New()
|
||
if _, err := io.Copy(h, f); err != nil {
|
||
return "", err
|
||
}
|
||
return hex.EncodeToString(h.Sum(nil)), nil
|
||
}
|