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*`) 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, "", 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 }