mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-06-23 20:53:10 +08:00
Enhance spec-driven coding workflow
This commit is contained in:
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
type ContextBuilder struct {
|
||||
workspace string
|
||||
cwd string
|
||||
skillsLoader *skills.SkillsLoader
|
||||
memory *MemoryStore
|
||||
toolsSummary func() []string // Function to get tool summaries dynamically
|
||||
@@ -37,6 +38,7 @@ func NewContextBuilder(workspace string, toolsSummaryFunc func() []string) *Cont
|
||||
|
||||
return &ContextBuilder{
|
||||
workspace: workspace,
|
||||
cwd: wd,
|
||||
skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),
|
||||
memory: NewMemoryStore(workspace),
|
||||
toolsSummary: toolsSummaryFunc,
|
||||
@@ -67,8 +69,12 @@ Your workspace is at: %s
|
||||
- Daily Notes: %s/memory/YYYY-MM-DD.md
|
||||
- Skills: %s/skills/{skill-name}/SKILL.md
|
||||
|
||||
## Spec-Driven Coding
|
||||
- Active project spec docs (when present): %s/{spec.md,tasks.md,checklist.md}
|
||||
- Keep spec.md as project scope / decisions, tasks.md as execution plan, checklist.md as final verification gate
|
||||
|
||||
%s`,
|
||||
now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection)
|
||||
now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, cb.projectRootPath(), toolsSection)
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) buildToolsSection() string {
|
||||
@@ -127,6 +133,18 @@ func (cb *ContextBuilder) BuildSystemPromptWithMemoryNamespace(memoryNamespace s
|
||||
return strings.Join(parts, "\n\n---\n\n")
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) projectRootPath() string {
|
||||
root := strings.TrimSpace(cb.cwd)
|
||||
if root == "" {
|
||||
root, _ = os.Getwd()
|
||||
}
|
||||
root, _ = filepath.Abs(root)
|
||||
if root == "" {
|
||||
root = "."
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) LoadBootstrapFiles() string {
|
||||
bootstrapFiles := []string{
|
||||
"AGENTS.md",
|
||||
@@ -155,6 +173,77 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
|
||||
return result
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) LoadProjectPlanningFiles() string {
|
||||
root := cb.projectRootPath()
|
||||
if root == "" {
|
||||
return ""
|
||||
}
|
||||
files := []struct {
|
||||
name string
|
||||
description string
|
||||
maxChars int
|
||||
}{
|
||||
{name: "spec.md", description: "Project scope and decisions", maxChars: 4000},
|
||||
{name: "tasks.md", description: "Execution plan and progress", maxChars: 5000},
|
||||
{name: "checklist.md", description: "Verification checklist", maxChars: 3000},
|
||||
}
|
||||
var parts []string
|
||||
for _, file := range files {
|
||||
fullPath := filepath.Join(root, file.name)
|
||||
data, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(string(data))
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
if file.maxChars > 0 && len(text) > file.maxChars {
|
||||
text = strings.TrimSpace(text[:file.maxChars]) + "\n\n[TRUNCATED]"
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("## %s\n\nPath: %s\nPurpose: %s\n\n%s", file.name, fullPath, file.description, text))
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) shouldUseSpecCoding(currentMessage string) bool {
|
||||
text := strings.ToLower(strings.TrimSpace(currentMessage))
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
if containsAnyKeyword(text,
|
||||
"spec coding", "spec-driven", "spec驱动", "规范驱动", "用 spec", "spec.md", "tasks.md", "checklist.md",
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if !containsAnyKeyword(text,
|
||||
"写代码", "改代码", "编码", "实现", "开发", "修复", "重构", "补测试", "加测试", "测试",
|
||||
"implement", "implementation", "code", "coding", "fix", "refactor", "rewrite", "add test", "update test",
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if containsAnyKeyword(text,
|
||||
"小改", "小修", "微调", "轻微", "小幅", "顺手改", "顺便改", "一行", "两行", "单文件", "简单修复", "简单改一下",
|
||||
"tiny", "small tweak", "minor", "small fix", "quick fix", "one-line", "one line", "two-line", "single-file", "single file",
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return containsAnyKeyword(text,
|
||||
"多文件", "跨模块", "模块", "架构", "设计", "完整", "系统性", "成套", "专项", "一轮", "整体", "项目", "范围", "方案", "联调",
|
||||
"feature", "workflow", "module", "architecture", "design", "project", "scope", "end-to-end", "full", "multi-file", "cross-cutting",
|
||||
"debug", "排查", "回归", "返工", "问题定位",
|
||||
)
|
||||
}
|
||||
|
||||
func containsAnyKeyword(text string, keywords ...string) bool {
|
||||
for _, keyword := range keywords {
|
||||
if strings.Contains(text, strings.ToLower(strings.TrimSpace(keyword))) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) shouldLoadBootstrap() bool {
|
||||
identityPath := filepath.Join(cb.workspace, "IDENTITY.md")
|
||||
userPath := filepath.Join(cb.workspace, "USER.md")
|
||||
@@ -187,6 +276,11 @@ func (cb *ContextBuilder) BuildMessagesWithMemoryNamespace(history []providers.M
|
||||
if responseLanguage != "" {
|
||||
systemPrompt += fmt.Sprintf("\n\n## Response Language\nReply in %s unless user explicitly asks to switch language. Keep code identifiers and CLI commands unchanged.", responseLanguage)
|
||||
}
|
||||
if cb.shouldUseSpecCoding(currentMessage) {
|
||||
if projectPlanning := cb.LoadProjectPlanningFiles(); projectPlanning != "" {
|
||||
systemPrompt += "\n\n## Active Project Planning\n\n" + projectPlanning
|
||||
}
|
||||
}
|
||||
|
||||
// Log system prompt summary for debugging (debug mode only)
|
||||
logger.DebugCF("agent", logger.C0143,
|
||||
|
||||
91
pkg/agent/context_spec_test.go
Normal file
91
pkg/agent/context_spec_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadProjectPlanningFilesIncludesSpecDocs(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for name, body := range map[string]string{
|
||||
"spec.md": "# spec\nscope",
|
||||
"tasks.md": "# tasks\n- [ ] one",
|
||||
"checklist.md": "# checklist\n- [ ] verify",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0644); err != nil {
|
||||
t.Fatalf("write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
cb := &ContextBuilder{workspace: t.TempDir(), cwd: root}
|
||||
got := cb.LoadProjectPlanningFiles()
|
||||
if !strings.Contains(got, "spec.md") || !strings.Contains(got, "tasks.md") || !strings.Contains(got, "checklist.md") {
|
||||
t.Fatalf("expected project planning files in output, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadProjectPlanningFilesTruncatesLargeDocs(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
large := strings.Repeat("x", 4500)
|
||||
if err := os.WriteFile(filepath.Join(root, "spec.md"), []byte(large), 0644); err != nil {
|
||||
t.Fatalf("write spec.md: %v", err)
|
||||
}
|
||||
|
||||
cb := &ContextBuilder{workspace: t.TempDir(), cwd: root}
|
||||
got := cb.LoadProjectPlanningFiles()
|
||||
if !strings.Contains(got, "[TRUNCATED]") {
|
||||
t.Fatalf("expected truncation marker, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMessagesOnlyLoadsProjectPlanningForCodingTasks(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "spec.md"), []byte("# spec\ncoding plan"), 0644); err != nil {
|
||||
t.Fatalf("write spec.md: %v", err)
|
||||
}
|
||||
cb := NewContextBuilder(t.TempDir(), nil)
|
||||
cb.cwd = root
|
||||
|
||||
coding := cb.BuildMessagesWithMemoryNamespace(nil, "", "请实现一个登录功能", nil, "cli", "direct", "", "main")
|
||||
if len(coding) == 0 || strings.Contains(coding[0].Content, "Active Project Planning") {
|
||||
t.Fatalf("did not expect small coding task to include project planning by default, got:\n%s", coding[0].Content)
|
||||
}
|
||||
|
||||
heavyCoding := cb.BuildMessagesWithMemoryNamespace(nil, "", "请实现一个完整登录注册模块,涉及多文件改动并补测试", nil, "cli", "direct", "", "main")
|
||||
if len(heavyCoding) == 0 || !strings.Contains(heavyCoding[0].Content, "Active Project Planning") {
|
||||
t.Fatalf("expected substantial coding task to include project planning, got:\n%s", heavyCoding[0].Content)
|
||||
}
|
||||
|
||||
explicitSpec := cb.BuildMessagesWithMemoryNamespace(nil, "", "这个改动不大,但请用 spec coding 流程来做", nil, "cli", "direct", "", "main")
|
||||
if len(explicitSpec) == 0 || !strings.Contains(explicitSpec[0].Content, "Active Project Planning") {
|
||||
t.Fatalf("expected explicit spec request to include project planning, got:\n%s", explicitSpec[0].Content)
|
||||
}
|
||||
|
||||
nonCoding := cb.BuildMessagesWithMemoryNamespace(nil, "", "帮我总结一下今天的工作", nil, "cli", "direct", "", "main")
|
||||
if len(nonCoding) == 0 {
|
||||
t.Fatalf("expected system message")
|
||||
}
|
||||
if strings.Contains(nonCoding[0].Content, "Active Project Planning") {
|
||||
t.Fatalf("did not expect non-coding task to include project planning, got:\n%s", nonCoding[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldUseSpecCodingRequiresExplicitAndNonTrivialCodingIntent(t *testing.T) {
|
||||
cb := &ContextBuilder{}
|
||||
cases := []struct {
|
||||
message string
|
||||
want bool
|
||||
}{
|
||||
{message: "请实现一个完整支付模块,涉及多文件改动并补测试", want: true},
|
||||
{message: "修一下这个小 bug,顺手改一行就行", want: false},
|
||||
{message: "帮我总结这个接口问题", want: false},
|
||||
{message: "这个改动不大,但请用 spec coding 流程来做", want: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := cb.shouldUseSpecCoding(tc.message); got != tc.want {
|
||||
t.Fatalf("shouldUseSpecCoding(%q) = %v, want %v", tc.message, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1011,10 +1011,57 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||
if msg.Channel == "system" {
|
||||
return al.processSystemMessage(ctx, msg)
|
||||
}
|
||||
specTaskRef := specCodingTaskRef{}
|
||||
if err := al.maybeEnsureSpecCodingDocs(msg.Content); err != nil {
|
||||
logger.WarnCF("agent", logger.C0172, map[string]interface{}{
|
||||
"session_key": msg.SessionKey,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
if taskRef, err := al.maybeStartSpecCodingTask(msg.Content); err != nil {
|
||||
logger.WarnCF("agent", logger.C0172, map[string]interface{}{
|
||||
"session_key": msg.SessionKey,
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
specTaskRef = normalizeSpecCodingTaskRef(taskRef)
|
||||
}
|
||||
if configAction, handled, configErr := al.maybeHandleSubagentConfigIntent(ctx, msg); handled {
|
||||
if configErr != nil && specTaskRef.Summary != "" {
|
||||
if err := al.maybeReopenSpecCodingTask(specTaskRef, msg.Content, configErr.Error()); err != nil {
|
||||
logger.WarnCF("agent", logger.C0172, map[string]interface{}{
|
||||
"session_key": msg.SessionKey,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
if configErr == nil && specTaskRef.Summary != "" {
|
||||
if err := al.maybeCompleteSpecCodingTask(specTaskRef, configAction); err != nil {
|
||||
logger.WarnCF("agent", logger.C0172, map[string]interface{}{
|
||||
"session_key": msg.SessionKey,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return configAction, configErr
|
||||
}
|
||||
if routed, ok, routeErr := al.maybeAutoRoute(ctx, msg); ok {
|
||||
if routeErr != nil && specTaskRef.Summary != "" {
|
||||
if err := al.maybeReopenSpecCodingTask(specTaskRef, msg.Content, routeErr.Error()); err != nil {
|
||||
logger.WarnCF("agent", logger.C0172, map[string]interface{}{
|
||||
"session_key": msg.SessionKey,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
if routeErr == nil && specTaskRef.Summary != "" {
|
||||
if err := al.maybeCompleteSpecCodingTask(specTaskRef, routed); err != nil {
|
||||
logger.WarnCF("agent", logger.C0172, map[string]interface{}{
|
||||
"session_key": msg.SessionKey,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return routed, routeErr
|
||||
}
|
||||
|
||||
@@ -1129,6 +1176,14 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||
"iteration": iteration,
|
||||
"error": err.Error(),
|
||||
})
|
||||
if specTaskRef.Summary != "" {
|
||||
if rerr := al.maybeReopenSpecCodingTask(specTaskRef, msg.Content, err.Error()); rerr != nil {
|
||||
logger.WarnCF("agent", logger.C0172, map[string]interface{}{
|
||||
"session_key": msg.SessionKey,
|
||||
"error": rerr.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("LLM call failed: %w", err)
|
||||
}
|
||||
|
||||
@@ -1252,6 +1307,14 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||
"user_length": len(userContent),
|
||||
})
|
||||
|
||||
if specTaskRef.Summary != "" {
|
||||
if err := al.maybeCompleteSpecCodingTask(specTaskRef, userContent); err != nil {
|
||||
logger.WarnCF("agent", logger.C0172, map[string]interface{}{
|
||||
"session_key": msg.SessionKey,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
al.appendDailySummaryLog(msg, userContent)
|
||||
return userContent, nil
|
||||
}
|
||||
|
||||
540
pkg/agent/spec_coding.go
Normal file
540
pkg/agent/spec_coding.go
Normal file
@@ -0,0 +1,540 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var specCodingDocNames = []string{"spec.md", "tasks.md", "checklist.md"}
|
||||
var reWhitespace = regexp.MustCompile(`\s+`)
|
||||
var reSpecTaskMeta = regexp.MustCompile(`\s*<!--\s*spec-task-id:([a-f0-9]+)\s*-->`)
|
||||
var reChecklistItem = regexp.MustCompile(`(?m)^- \[( |x)\] (.+)$`)
|
||||
|
||||
type specCodingTaskRef struct {
|
||||
ID string
|
||||
Summary string
|
||||
}
|
||||
|
||||
func (al *AgentLoop) maybeEnsureSpecCodingDocs(currentMessage string) error {
|
||||
if al == nil || al.contextBuilder == nil {
|
||||
return nil
|
||||
}
|
||||
if !al.contextBuilder.shouldUseSpecCoding(currentMessage) {
|
||||
return nil
|
||||
}
|
||||
projectRoot := al.contextBuilder.projectRootPath()
|
||||
if strings.TrimSpace(projectRoot) == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := ensureSpecCodingDocs(al.workspace, projectRoot)
|
||||
return err
|
||||
}
|
||||
|
||||
func (al *AgentLoop) maybeStartSpecCodingTask(currentMessage string) (specCodingTaskRef, error) {
|
||||
if al == nil || al.contextBuilder == nil || !al.contextBuilder.shouldUseSpecCoding(currentMessage) {
|
||||
return specCodingTaskRef{}, nil
|
||||
}
|
||||
projectRoot := al.contextBuilder.projectRootPath()
|
||||
if strings.TrimSpace(projectRoot) == "" {
|
||||
return specCodingTaskRef{}, nil
|
||||
}
|
||||
return upsertSpecCodingTask(projectRoot, currentMessage)
|
||||
}
|
||||
|
||||
func (al *AgentLoop) maybeCompleteSpecCodingTask(taskRef specCodingTaskRef, response string) error {
|
||||
if al == nil || al.contextBuilder == nil {
|
||||
return nil
|
||||
}
|
||||
taskRef = normalizeSpecCodingTaskRef(taskRef)
|
||||
if taskRef.Summary == "" {
|
||||
return nil
|
||||
}
|
||||
projectRoot := al.contextBuilder.projectRootPath()
|
||||
if strings.TrimSpace(projectRoot) == "" {
|
||||
return nil
|
||||
}
|
||||
if err := completeSpecCodingTask(projectRoot, taskRef, response); err != nil {
|
||||
return err
|
||||
}
|
||||
return updateSpecCodingChecklist(projectRoot, taskRef, response)
|
||||
}
|
||||
|
||||
func (al *AgentLoop) maybeReopenSpecCodingTask(taskRef specCodingTaskRef, currentMessage, reason string) error {
|
||||
if al == nil || al.contextBuilder == nil {
|
||||
return nil
|
||||
}
|
||||
taskRef = normalizeSpecCodingTaskRef(taskRef)
|
||||
if taskRef.Summary == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(reason) == "" && !shouldReopenSpecCodingTask(currentMessage) {
|
||||
return nil
|
||||
}
|
||||
projectRoot := al.contextBuilder.projectRootPath()
|
||||
if strings.TrimSpace(projectRoot) == "" {
|
||||
return nil
|
||||
}
|
||||
note := strings.TrimSpace(reason)
|
||||
if note == "" {
|
||||
note = strings.TrimSpace(currentMessage)
|
||||
}
|
||||
if err := reopenSpecCodingTask(projectRoot, taskRef, note); err != nil {
|
||||
return err
|
||||
}
|
||||
return resetSpecCodingChecklist(projectRoot, taskRef, note)
|
||||
}
|
||||
|
||||
func ensureSpecCodingDocs(workspace, projectRoot string) ([]string, error) {
|
||||
workspace = strings.TrimSpace(workspace)
|
||||
projectRoot = strings.TrimSpace(projectRoot)
|
||||
if workspace == "" || projectRoot == "" {
|
||||
return nil, nil
|
||||
}
|
||||
projectRoot = filepath.Clean(projectRoot)
|
||||
if err := os.MkdirAll(projectRoot, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
templatesDir := filepath.Join(workspace, "skills", "spec-coding", "templates")
|
||||
created := make([]string, 0, len(specCodingDocNames))
|
||||
for _, name := range specCodingDocNames {
|
||||
targetPath := filepath.Join(projectRoot, name)
|
||||
if _, err := os.Stat(targetPath); err == nil {
|
||||
continue
|
||||
} else if !os.IsNotExist(err) {
|
||||
return created, err
|
||||
}
|
||||
templatePath := filepath.Join(templatesDir, name)
|
||||
data, err := os.ReadFile(templatePath)
|
||||
if err != nil {
|
||||
return created, fmt.Errorf("read spec-coding template %s failed: %w", templatePath, err)
|
||||
}
|
||||
if err := os.WriteFile(targetPath, data, 0644); err != nil {
|
||||
return created, err
|
||||
}
|
||||
created = append(created, targetPath)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func upsertSpecCodingTask(projectRoot, currentMessage string) (specCodingTaskRef, error) {
|
||||
projectRoot = strings.TrimSpace(projectRoot)
|
||||
taskSummary := summarizeSpecCodingTask(currentMessage)
|
||||
if projectRoot == "" || taskSummary == "" {
|
||||
return specCodingTaskRef{}, nil
|
||||
}
|
||||
taskID := stableSpecCodingTaskID(taskSummary)
|
||||
taskRef := specCodingTaskRef{ID: taskID, Summary: taskSummary}
|
||||
tasksPath := filepath.Join(projectRoot, "tasks.md")
|
||||
data, err := os.ReadFile(tasksPath)
|
||||
if err != nil {
|
||||
return specCodingTaskRef{}, err
|
||||
}
|
||||
text := string(data)
|
||||
if line, done, ok := findSpecCodingTaskLine(text, taskRef); ok {
|
||||
if done {
|
||||
if shouldReopenSpecCodingTask(currentMessage) {
|
||||
if err := reopenSpecCodingTask(projectRoot, taskRef, currentMessage); err != nil {
|
||||
return specCodingTaskRef{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Contains(line, "<!--") {
|
||||
return specTaskRefFromLine(line), nil
|
||||
}
|
||||
return taskRef, nil
|
||||
}
|
||||
doneLine := renderSpecCodingTaskLine(true, taskRef)
|
||||
openLine := renderSpecCodingTaskLine(false, taskRef)
|
||||
if strings.Contains(text, "- [x] "+taskSummary) || strings.Contains(text, doneLine) {
|
||||
if shouldReopenSpecCodingTask(currentMessage) {
|
||||
if err := reopenSpecCodingTask(projectRoot, taskRef, currentMessage); err != nil {
|
||||
return specCodingTaskRef{}, err
|
||||
}
|
||||
}
|
||||
return taskRef, nil
|
||||
}
|
||||
if strings.Contains(text, "- [ ] "+taskSummary) || strings.Contains(text, openLine) {
|
||||
return taskRef, nil
|
||||
}
|
||||
if shouldReopenSpecCodingTask(currentMessage) {
|
||||
if reopened, err := maybeReopenCompletedTaskFromMessage(projectRoot, currentMessage, text); err != nil {
|
||||
return specCodingTaskRef{}, err
|
||||
} else if reopened.Summary != "" {
|
||||
return reopened, nil
|
||||
}
|
||||
}
|
||||
section := "## Current Coding Tasks\n"
|
||||
entry := openLine + "\n"
|
||||
if strings.Contains(text, section) {
|
||||
text = strings.Replace(text, section, section+entry, 1)
|
||||
} else if idx := strings.Index(text, "## Progress Notes"); idx >= 0 {
|
||||
text = text[:idx] + section + "\n" + entry + "\n" + text[idx:]
|
||||
} else {
|
||||
if !strings.HasSuffix(text, "\n") {
|
||||
text += "\n"
|
||||
}
|
||||
text += "\n" + section + "\n" + entry
|
||||
}
|
||||
if err := os.WriteFile(tasksPath, []byte(text), 0644); err != nil {
|
||||
return specCodingTaskRef{}, err
|
||||
}
|
||||
return taskRef, nil
|
||||
}
|
||||
|
||||
func completeSpecCodingTask(projectRoot string, taskRef specCodingTaskRef, response string) error {
|
||||
projectRoot = strings.TrimSpace(projectRoot)
|
||||
taskRef = normalizeSpecCodingTaskRef(taskRef)
|
||||
if projectRoot == "" || taskRef.Summary == "" {
|
||||
return nil
|
||||
}
|
||||
tasksPath := filepath.Join(projectRoot, "tasks.md")
|
||||
data, err := os.ReadFile(tasksPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
text := string(data)
|
||||
if line, done, ok := findSpecCodingTaskLine(text, taskRef); ok {
|
||||
if !done {
|
||||
text = strings.Replace(text, line, renderSpecCodingTaskLine(true, specTaskRefFromLine(line)), 1)
|
||||
}
|
||||
} else if strings.Contains(text, "- [ ] "+taskRef.Summary) {
|
||||
text = strings.Replace(text, "- [ ] "+taskRef.Summary, renderSpecCodingTaskLine(true, taskRef), 1)
|
||||
}
|
||||
note := fmt.Sprintf("- %s %s -> %s\n", time.Now().Format("2006-01-02 15:04"), taskRef.Summary, summarizeSpecCodingTask(response))
|
||||
if strings.Contains(text, note) {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(text, "## Progress Notes\n") {
|
||||
text = strings.Replace(text, "## Progress Notes\n", "## Progress Notes\n"+note, 1)
|
||||
} else {
|
||||
if !strings.HasSuffix(text, "\n") {
|
||||
text += "\n"
|
||||
}
|
||||
text += "\n## Progress Notes\n" + note
|
||||
}
|
||||
return os.WriteFile(tasksPath, []byte(text), 0644)
|
||||
}
|
||||
|
||||
func maybeReopenCompletedTaskFromMessage(projectRoot, currentMessage, tasksText string) (specCodingTaskRef, error) {
|
||||
completed := extractSpecCodingTasks(tasksText, "- [x] ")
|
||||
if len(completed) == 0 {
|
||||
return specCodingTaskRef{}, nil
|
||||
}
|
||||
if len(completed) == 1 {
|
||||
return completed[0], reopenSpecCodingTask(projectRoot, completed[0], currentMessage)
|
||||
}
|
||||
best := specCodingTaskRef{}
|
||||
bestScore := 0
|
||||
for _, item := range completed {
|
||||
score := scoreSpecCodingTaskMatch(item.Summary, currentMessage)
|
||||
if score > bestScore {
|
||||
best = item
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
if best.Summary == "" || bestScore == 0 {
|
||||
return specCodingTaskRef{}, nil
|
||||
}
|
||||
return best, reopenSpecCodingTask(projectRoot, best, currentMessage)
|
||||
}
|
||||
|
||||
func reopenSpecCodingTask(projectRoot string, taskRef specCodingTaskRef, reason string) error {
|
||||
projectRoot = strings.TrimSpace(projectRoot)
|
||||
taskRef = normalizeSpecCodingTaskRef(taskRef)
|
||||
if projectRoot == "" || taskRef.Summary == "" {
|
||||
return nil
|
||||
}
|
||||
tasksPath := filepath.Join(projectRoot, "tasks.md")
|
||||
data, err := os.ReadFile(tasksPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
text := string(data)
|
||||
if line, done, ok := findSpecCodingTaskLine(text, taskRef); ok {
|
||||
if done {
|
||||
text = strings.Replace(text, line, renderSpecCodingTaskLine(false, specTaskRefFromLine(line)), 1)
|
||||
}
|
||||
} else if strings.Contains(text, "- [x] "+taskRef.Summary) {
|
||||
text = strings.Replace(text, "- [x] "+taskRef.Summary, renderSpecCodingTaskLine(false, taskRef), 1)
|
||||
}
|
||||
note := fmt.Sprintf("- %s reopened %s -> %s\n", time.Now().Format("2006-01-02 15:04"), taskRef.Summary, summarizeSpecCodingTask(reason))
|
||||
if strings.Contains(text, note) {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(text, "## Progress Notes\n") {
|
||||
text = strings.Replace(text, "## Progress Notes\n", "## Progress Notes\n"+note, 1)
|
||||
} else {
|
||||
if !strings.HasSuffix(text, "\n") {
|
||||
text += "\n"
|
||||
}
|
||||
text += "\n## Progress Notes\n" + note
|
||||
}
|
||||
return os.WriteFile(tasksPath, []byte(text), 0644)
|
||||
}
|
||||
|
||||
func summarizeSpecCodingTask(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
text = strings.ReplaceAll(text, "\r\n", "\n")
|
||||
text = strings.ReplaceAll(text, "\n", " ")
|
||||
text = reWhitespace.ReplaceAllString(text, " ")
|
||||
text = strings.TrimSpace(text)
|
||||
if len(text) > 120 {
|
||||
text = strings.TrimSpace(text[:117]) + "..."
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func shouldReopenSpecCodingTask(text string) bool {
|
||||
text = strings.ToLower(strings.TrimSpace(text))
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
hints := []string{
|
||||
"bug", "issue", "problem", "broken", "fix again", "regression", "failing", "failure", "debug", "rework",
|
||||
"有问题", "有 bug", "修一下", "重新修复", "回归失败", "排查", "重做", "返工", "异常", "坏了",
|
||||
}
|
||||
for _, hint := range hints {
|
||||
if strings.Contains(text, hint) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func updateSpecCodingChecklist(projectRoot string, taskRef specCodingTaskRef, response string) error {
|
||||
projectRoot = strings.TrimSpace(projectRoot)
|
||||
taskRef = normalizeSpecCodingTaskRef(taskRef)
|
||||
if projectRoot == "" || taskRef.Summary == "" {
|
||||
return nil
|
||||
}
|
||||
checklistPath := filepath.Join(projectRoot, "checklist.md")
|
||||
data, err := os.ReadFile(checklistPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
text := string(data)
|
||||
checked := make(map[string]bool)
|
||||
checked["Scope implemented"] = true
|
||||
|
||||
evidence := strings.ToLower(taskRef.Summary + "\n" + response)
|
||||
if containsAny(evidence, "test", "tests", "go test", "验证", "校验", "回归", "通过", "passed", "validated") {
|
||||
checked["Tests added or updated where needed"] = true
|
||||
checked["Validation run"] = true
|
||||
}
|
||||
if containsAny(evidence, "edge", "corner", "boundary", "边界", "异常场景", "边缘", "case reviewed") {
|
||||
checked["Edge cases reviewed"] = true
|
||||
}
|
||||
if containsAny(evidence, "doc", "docs", "readme", "prompt", "config", "文档", "配置", "说明") {
|
||||
checked["Docs / prompts / config updated if required"] = true
|
||||
}
|
||||
if !containsAny(evidence, "todo", "follow-up", "remaining", "pending", "未完成", "后续", "待处理", "blocker", "blocked") {
|
||||
checked["No known missing follow-up inside current scope"] = true
|
||||
}
|
||||
|
||||
text = reChecklistItem.ReplaceAllStringFunc(text, func(line string) string {
|
||||
matches := reChecklistItem.FindStringSubmatch(line)
|
||||
if len(matches) != 3 {
|
||||
return line
|
||||
}
|
||||
label := strings.TrimSpace(matches[2])
|
||||
if checked[label] {
|
||||
return "- [x] " + label
|
||||
}
|
||||
return line
|
||||
})
|
||||
|
||||
note := fmt.Sprintf("- %s verified %s -> %s\n", time.Now().Format("2006-01-02 15:04"), taskRef.Summary, summarizeChecklistEvidence(checked))
|
||||
text = upsertChecklistNotes(text, note)
|
||||
return os.WriteFile(checklistPath, []byte(text), 0644)
|
||||
}
|
||||
|
||||
func resetSpecCodingChecklist(projectRoot string, taskRef specCodingTaskRef, reason string) error {
|
||||
projectRoot = strings.TrimSpace(projectRoot)
|
||||
taskRef = normalizeSpecCodingTaskRef(taskRef)
|
||||
if projectRoot == "" || taskRef.Summary == "" {
|
||||
return nil
|
||||
}
|
||||
checklistPath := filepath.Join(projectRoot, "checklist.md")
|
||||
data, err := os.ReadFile(checklistPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
text := reChecklistItem.ReplaceAllString(string(data), "- [ ] $2")
|
||||
note := fmt.Sprintf("- %s reopened %s -> %s\n", time.Now().Format("2006-01-02 15:04"), taskRef.Summary, summarizeSpecCodingTask(reason))
|
||||
text = upsertChecklistNotes(text, note)
|
||||
return os.WriteFile(checklistPath, []byte(text), 0644)
|
||||
}
|
||||
|
||||
func upsertChecklistNotes(text, note string) string {
|
||||
if strings.Contains(text, note) {
|
||||
return text
|
||||
}
|
||||
section := "## Verification Notes\n"
|
||||
if strings.Contains(text, section) {
|
||||
return strings.Replace(text, section, section+note, 1)
|
||||
}
|
||||
if !strings.HasSuffix(text, "\n") {
|
||||
text += "\n"
|
||||
}
|
||||
return text + "\n" + section + note
|
||||
}
|
||||
|
||||
func summarizeChecklistEvidence(checked map[string]bool) string {
|
||||
if len(checked) == 0 {
|
||||
return "no checklist items matched"
|
||||
}
|
||||
order := []string{
|
||||
"Scope implemented",
|
||||
"Edge cases reviewed",
|
||||
"Tests added or updated where needed",
|
||||
"Validation run",
|
||||
"Docs / prompts / config updated if required",
|
||||
"No known missing follow-up inside current scope",
|
||||
}
|
||||
items := make([]string, 0, len(order))
|
||||
for _, label := range order {
|
||||
if checked[label] {
|
||||
items = append(items, label)
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return "no checklist items matched"
|
||||
}
|
||||
return strings.Join(items, "; ")
|
||||
}
|
||||
|
||||
func containsAny(text string, parts ...string) bool {
|
||||
for _, part := range parts {
|
||||
if strings.Contains(text, strings.ToLower(strings.TrimSpace(part))) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func extractSpecCodingTasks(tasksText, prefix string) []specCodingTaskRef {
|
||||
lines := strings.Split(tasksText, "\n")
|
||||
out := make([]specCodingTaskRef, 0, 4)
|
||||
for _, line := range lines {
|
||||
t := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(t, prefix) {
|
||||
continue
|
||||
}
|
||||
ref := specTaskRefFromLine(t)
|
||||
if ref.Summary == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, ref)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stableSpecCodingTaskID(summary string) string {
|
||||
summary = summarizeSpecCodingTask(summary)
|
||||
if summary == "" {
|
||||
return ""
|
||||
}
|
||||
sum := sha1.Sum([]byte(strings.ToLower(summary)))
|
||||
return hex.EncodeToString(sum[:])[:12]
|
||||
}
|
||||
|
||||
func normalizeSpecCodingTaskRef(taskRef specCodingTaskRef) specCodingTaskRef {
|
||||
taskRef.Summary = summarizeSpecCodingTask(taskRef.Summary)
|
||||
taskRef.ID = strings.ToLower(strings.TrimSpace(taskRef.ID))
|
||||
if taskRef.Summary == "" {
|
||||
return specCodingTaskRef{}
|
||||
}
|
||||
if taskRef.ID == "" {
|
||||
taskRef.ID = stableSpecCodingTaskID(taskRef.Summary)
|
||||
}
|
||||
return taskRef
|
||||
}
|
||||
|
||||
func renderSpecCodingTaskLine(done bool, taskRef specCodingTaskRef) string {
|
||||
taskRef = normalizeSpecCodingTaskRef(taskRef)
|
||||
if taskRef.Summary == "" {
|
||||
return ""
|
||||
}
|
||||
state := " "
|
||||
if done {
|
||||
state = "x"
|
||||
}
|
||||
return fmt.Sprintf("- [%s] %s <!-- spec-task-id:%s -->", state, taskRef.Summary, taskRef.ID)
|
||||
}
|
||||
|
||||
func specTaskRefFromLine(line string) specCodingTaskRef {
|
||||
line = strings.TrimSpace(line)
|
||||
line = strings.TrimPrefix(line, "- [ ] ")
|
||||
line = strings.TrimPrefix(line, "- [x] ")
|
||||
ref := specCodingTaskRef{}
|
||||
if matches := reSpecTaskMeta.FindStringSubmatch(line); len(matches) == 2 {
|
||||
ref.ID = strings.ToLower(strings.TrimSpace(matches[1]))
|
||||
line = strings.TrimSpace(reSpecTaskMeta.ReplaceAllString(line, ""))
|
||||
}
|
||||
ref.Summary = summarizeSpecCodingTask(line)
|
||||
return normalizeSpecCodingTaskRef(ref)
|
||||
}
|
||||
|
||||
func findSpecCodingTaskLine(tasksText string, taskRef specCodingTaskRef) (string, bool, bool) {
|
||||
taskRef = normalizeSpecCodingTaskRef(taskRef)
|
||||
if taskRef.Summary == "" {
|
||||
return "", false, false
|
||||
}
|
||||
for _, raw := range strings.Split(tasksText, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if !strings.HasPrefix(line, "- [ ] ") && !strings.HasPrefix(line, "- [x] ") {
|
||||
continue
|
||||
}
|
||||
ref := specTaskRefFromLine(line)
|
||||
if ref.Summary == "" {
|
||||
continue
|
||||
}
|
||||
if ref.ID != "" && ref.ID == taskRef.ID {
|
||||
return line, strings.HasPrefix(line, "- [x] "), true
|
||||
}
|
||||
if ref.Summary == taskRef.Summary {
|
||||
return line, strings.HasPrefix(line, "- [x] "), true
|
||||
}
|
||||
}
|
||||
return "", false, false
|
||||
}
|
||||
|
||||
func scoreSpecCodingTaskMatch(taskSummary, currentMessage string) int {
|
||||
taskSummary = summarizeSpecCodingTask(taskSummary)
|
||||
currentMessage = summarizeSpecCodingTask(currentMessage)
|
||||
if taskSummary == "" || currentMessage == "" {
|
||||
return 0
|
||||
}
|
||||
if strings.Contains(currentMessage, taskSummary) || strings.Contains(taskSummary, currentMessage) {
|
||||
return len(taskSummary)
|
||||
}
|
||||
shorter := taskSummary
|
||||
longer := currentMessage
|
||||
if len([]rune(shorter)) > len([]rune(longer)) {
|
||||
shorter, longer = longer, shorter
|
||||
}
|
||||
runes := []rune(shorter)
|
||||
best := 0
|
||||
for i := 0; i < len(runes); i++ {
|
||||
for j := i + 2; j <= len(runes); j++ {
|
||||
frag := string(runes[i:j])
|
||||
if strings.TrimSpace(frag) == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(longer, frag) && len([]rune(frag)) > best {
|
||||
best = len([]rune(frag))
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
233
pkg/agent/spec_coding_test.go
Normal file
233
pkg/agent/spec_coding_test.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnsureSpecCodingDocsCreatesMissingFilesInProjectRoot(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
projectRoot := t.TempDir()
|
||||
templatesDir := filepath.Join(workspace, "skills", "spec-coding", "templates")
|
||||
if err := os.MkdirAll(templatesDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir templates: %v", err)
|
||||
}
|
||||
for name, body := range map[string]string{
|
||||
"spec.md": "# spec template",
|
||||
"tasks.md": "# tasks template",
|
||||
"checklist.md": "# checklist template",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(templatesDir, name), []byte(body), 0644); err != nil {
|
||||
t.Fatalf("write template %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
created, err := ensureSpecCodingDocs(workspace, projectRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("ensureSpecCodingDocs failed: %v", err)
|
||||
}
|
||||
if len(created) != 3 {
|
||||
t.Fatalf("expected 3 created files, got %d: %#v", len(created), created)
|
||||
}
|
||||
for _, name := range []string{"spec.md", "tasks.md", "checklist.md"} {
|
||||
if _, err := os.Stat(filepath.Join(projectRoot, name)); err != nil {
|
||||
t.Fatalf("expected %s to be created: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSpecCodingDocsDoesNotOverwriteExistingFiles(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
projectRoot := t.TempDir()
|
||||
templatesDir := filepath.Join(workspace, "skills", "spec-coding", "templates")
|
||||
if err := os.MkdirAll(templatesDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir templates: %v", err)
|
||||
}
|
||||
for _, name := range []string{"spec.md", "tasks.md", "checklist.md"} {
|
||||
if err := os.WriteFile(filepath.Join(templatesDir, name), []byte("template"), 0644); err != nil {
|
||||
t.Fatalf("write template %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
existingPath := filepath.Join(projectRoot, "spec.md")
|
||||
if err := os.WriteFile(existingPath, []byte("custom spec"), 0644); err != nil {
|
||||
t.Fatalf("write existing spec: %v", err)
|
||||
}
|
||||
|
||||
created, err := ensureSpecCodingDocs(workspace, projectRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("ensureSpecCodingDocs failed: %v", err)
|
||||
}
|
||||
if len(created) != 2 {
|
||||
t.Fatalf("expected 2 created files, got %d: %#v", len(created), created)
|
||||
}
|
||||
data, err := os.ReadFile(existingPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read existing spec: %v", err)
|
||||
}
|
||||
if string(data) != "custom spec" {
|
||||
t.Fatalf("expected existing spec to be preserved, got %q", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecCodingTaskProgressUpdatesTasksFile(t *testing.T) {
|
||||
projectRoot := t.TempDir()
|
||||
tasksPath := filepath.Join(projectRoot, "tasks.md")
|
||||
initial := "# Task Breakdown (tasks.md)\n\n## Workstreams\n\n### 1.\n- [ ] base\n\n## Progress Notes\n"
|
||||
if err := os.WriteFile(tasksPath, []byte(initial), 0644); err != nil {
|
||||
t.Fatalf("write tasks.md: %v", err)
|
||||
}
|
||||
|
||||
taskSummary, err := upsertSpecCodingTask(projectRoot, "请实现登录功能并补测试")
|
||||
if err != nil {
|
||||
t.Fatalf("upsertSpecCodingTask failed: %v", err)
|
||||
}
|
||||
if taskSummary.Summary == "" || taskSummary.ID == "" {
|
||||
t.Fatalf("expected task summary")
|
||||
}
|
||||
if err := completeSpecCodingTask(projectRoot, taskSummary, "登录功能已完成,测试已补齐"); err != nil {
|
||||
t.Fatalf("completeSpecCodingTask failed: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(tasksPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read tasks.md: %v", err)
|
||||
}
|
||||
text := string(data)
|
||||
if !strings.Contains(text, renderSpecCodingTaskLine(true, taskSummary)) {
|
||||
t.Fatalf("expected task to be checked, got:\n%s", text)
|
||||
}
|
||||
if !strings.Contains(text, "登录功能已完成") {
|
||||
t.Fatalf("expected progress note summary, got:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecCodingTaskReopensCompletedTaskWhenIssueReturns(t *testing.T) {
|
||||
projectRoot := t.TempDir()
|
||||
tasksPath := filepath.Join(projectRoot, "tasks.md")
|
||||
taskSummary := "请实现登录功能并补测试"
|
||||
initial := "# Task Breakdown (tasks.md)\n\n## Current Coding Tasks\n- [x] " + taskSummary + "\n\n## Progress Notes\n"
|
||||
if err := os.WriteFile(tasksPath, []byte(initial), 0644); err != nil {
|
||||
t.Fatalf("write tasks.md: %v", err)
|
||||
}
|
||||
|
||||
got, err := upsertSpecCodingTask(projectRoot, "登录功能还有问题,继续排查并修复")
|
||||
if err != nil {
|
||||
t.Fatalf("upsertSpecCodingTask failed: %v", err)
|
||||
}
|
||||
if got.Summary != taskSummary {
|
||||
t.Fatalf("expected reopened task summary %q, got %q", taskSummary, got.Summary)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(tasksPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read tasks.md: %v", err)
|
||||
}
|
||||
text := string(data)
|
||||
if !strings.Contains(text, renderSpecCodingTaskLine(false, got)) {
|
||||
t.Fatalf("expected task to be reopened, got:\n%s", text)
|
||||
}
|
||||
if !strings.Contains(text, "reopened "+taskSummary) {
|
||||
t.Fatalf("expected reopened progress note, got:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecCodingTaskUsesStableIDAcrossCompleteAndReopen(t *testing.T) {
|
||||
projectRoot := t.TempDir()
|
||||
tasksPath := filepath.Join(projectRoot, "tasks.md")
|
||||
initial := "# Task Breakdown (tasks.md)\n\n## Current Coding Tasks\n\n## Progress Notes\n"
|
||||
if err := os.WriteFile(tasksPath, []byte(initial), 0644); err != nil {
|
||||
t.Fatalf("write tasks.md: %v", err)
|
||||
}
|
||||
|
||||
taskRef, err := upsertSpecCodingTask(projectRoot, "实现支付回调验签并补充回归测试")
|
||||
if err != nil {
|
||||
t.Fatalf("upsertSpecCodingTask failed: %v", err)
|
||||
}
|
||||
if taskRef.ID == "" || taskRef.Summary == "" {
|
||||
t.Fatalf("expected stable task ref, got %#v", taskRef)
|
||||
}
|
||||
if err := completeSpecCodingTask(projectRoot, taskRef, "支付回调验签已完成,测试已补充"); err != nil {
|
||||
t.Fatalf("completeSpecCodingTask failed: %v", err)
|
||||
}
|
||||
|
||||
reopened, err := upsertSpecCodingTask(projectRoot, "支付回调验签回归失败,继续排查修复")
|
||||
if err != nil {
|
||||
t.Fatalf("upsertSpecCodingTask reopen failed: %v", err)
|
||||
}
|
||||
if reopened.ID != taskRef.ID {
|
||||
t.Fatalf("expected reopened task to keep id %q, got %q", taskRef.ID, reopened.ID)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(tasksPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read tasks.md: %v", err)
|
||||
}
|
||||
text := string(data)
|
||||
if strings.Count(text, "<!-- spec-task-id:"+taskRef.ID+" -->") != 1 {
|
||||
t.Fatalf("expected exactly one task line for stable id %q, got:\n%s", taskRef.ID, text)
|
||||
}
|
||||
if !strings.Contains(text, renderSpecCodingTaskLine(false, reopened)) {
|
||||
t.Fatalf("expected reopened task line with same id, got:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecCodingChecklistUpdatesOnTaskCompletion(t *testing.T) {
|
||||
projectRoot := t.TempDir()
|
||||
checklistPath := filepath.Join(projectRoot, "checklist.md")
|
||||
initial := "# Verification Checklist (checklist.md)\n\n- [ ] Scope implemented\n- [ ] Edge cases reviewed\n- [ ] Tests added or updated where needed\n- [ ] Validation run\n- [ ] Docs / prompts / config updated if required\n- [ ] No known missing follow-up inside current scope\n"
|
||||
if err := os.WriteFile(checklistPath, []byte(initial), 0644); err != nil {
|
||||
t.Fatalf("write checklist.md: %v", err)
|
||||
}
|
||||
|
||||
taskRef := specCodingTaskRef{Summary: "实现支付回调验签并补充测试"}
|
||||
if err := updateSpecCodingChecklist(projectRoot, taskRef, "已完成实现,go test 验证通过,并更新文档说明"); err != nil {
|
||||
t.Fatalf("updateSpecCodingChecklist failed: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(checklistPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read checklist.md: %v", err)
|
||||
}
|
||||
text := string(data)
|
||||
for _, needle := range []string{
|
||||
"- [x] Scope implemented",
|
||||
"- [x] Tests added or updated where needed",
|
||||
"- [x] Validation run",
|
||||
"- [x] Docs / prompts / config updated if required",
|
||||
"- [x] No known missing follow-up inside current scope",
|
||||
"## Verification Notes",
|
||||
"verified 实现支付回调验签并补充测试",
|
||||
} {
|
||||
if !strings.Contains(text, needle) {
|
||||
t.Fatalf("expected checklist to contain %q, got:\n%s", needle, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecCodingChecklistResetsWhenTaskReopens(t *testing.T) {
|
||||
projectRoot := t.TempDir()
|
||||
checklistPath := filepath.Join(projectRoot, "checklist.md")
|
||||
initial := "# Verification Checklist (checklist.md)\n\n- [x] Scope implemented\n- [x] Edge cases reviewed\n- [x] Tests added or updated where needed\n- [x] Validation run\n- [x] Docs / prompts / config updated if required\n- [x] No known missing follow-up inside current scope\n"
|
||||
if err := os.WriteFile(checklistPath, []byte(initial), 0644); err != nil {
|
||||
t.Fatalf("write checklist.md: %v", err)
|
||||
}
|
||||
|
||||
taskRef := specCodingTaskRef{Summary: "实现支付回调验签并补充测试"}
|
||||
if err := resetSpecCodingChecklist(projectRoot, taskRef, "回归失败,继续排查"); err != nil {
|
||||
t.Fatalf("resetSpecCodingChecklist failed: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(checklistPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read checklist.md: %v", err)
|
||||
}
|
||||
text := string(data)
|
||||
if strings.Contains(text, "- [x] ") {
|
||||
t.Fatalf("expected all checklist items to reopen, got:\n%s", text)
|
||||
}
|
||||
if !strings.Contains(text, "reopened 实现支付回调验签并补充测试") {
|
||||
t.Fatalf("expected reopen note, got:\n%s", text)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user