package main import ( "fmt" "os" "path/filepath" "regexp" "sort" "strings" ) // spec:planctl-task-cmds/R1.2+R1.3+R2.4+R2.5+R3.1+D§2+D§3 // TaskRecord enriches a TaskLine with the task description text, its parsed // numeric T-ID, and the same-line traceability tag refs needed by the v2 // subcommands. The underlying TaskLine (File, Line, Indent, Checked) is // embedded unchanged so v1 consumers are unaffected. type TaskRecord struct { TaskLine // embedded: File, Line, Indent, Checked PlanDir string // basename of the plan directory that owns this task Text string // description stripped of checkbox prefix ID string // "T" + leading N.M, e.g. "T2.1"; "" if no numeric prefix ReqTags []string // R-ids from a same-line _Requirements:_ tag DesignTags []string // D§-ids from a same-line _Design:_ tag } // spec:planctl-task-cmds/R1.2+D§3 // stripCheckboxPrefix removes the leading `^\s*- \[[ x]\] ` marker from a // raw checkbox line, returning the task description text. Lines that do not // match the pattern are returned unchanged. func stripCheckboxPrefix(line string) string { // Match leading whitespace (spaces or tabs), "- [", space or x, "] " i := 0 for i < len(line) && (line[i] == ' ' || line[i] == '\t') { i++ } const marker = "- [" if !strings.HasPrefix(line[i:], marker) { return line } i += len(marker) if i >= len(line) { return line } ch := line[i] if ch != ' ' && ch != 'x' { return line } i++ if i >= len(line) || line[i] != ']' { return line } i++ if i >= len(line) || line[i] != ' ' { return line } i++ return line[i:] } // spec:planctl-task-cmds/R1.3+R3.1+R3.4+D§3+D§6 // taskIDPattern matches a leading N.M numeric prefix (at least one dot // segment) followed by whitespace. The captured group is the numeric part // only; the "T" prefix is added by parseTaskID. var taskIDPattern = regexp.MustCompile(`^(\d+(?:\.\d+)+)\s`) // taskIDRefPattern validates a bare T-ID suffix (digits and dots, fully // anchored). Used by findTaskByID to reject refs like "1.2 extra" that // would otherwise pass taskIDPattern's unanchored prefix match. var taskIDRefPattern = regexp.MustCompile(`^\d+(?:\.\d+)+$`) // spec:planctl-task-cmds/R1.3+R3.1+D§3+D§6 // parseTaskID extracts the leading N.M numeric task ID from task description // text and returns it as "T". Returns "" when the text has no such prefix. func parseTaskID(text string) string { m := taskIDPattern.FindStringSubmatch(text) if m == nil { return "" } return "T" + m[1] } // spec:planctl-task-cmds/R1.2+R2.4+R2.5+D§2+D§3 // buildTaskRecords constructs []TaskRecord from an Index and the tasks.md // ScanResult. Tags are associated by line-number equality (D§3). scan must // not be nil; the result is in the same order as idx.TaskLines. func buildTaskRecords(idx Index, scan *ScanResult, planDir string) []TaskRecord { out := make([]TaskRecord, 0, len(idx.TaskLines)) dirBase := planDir for _, tl := range idx.TaskLines { rawLine := "" if tl.Line >= 1 && tl.Line <= len(scan.Lines) { rawLine = scan.Lines[tl.Line-1] } text := stripCheckboxPrefix(rawLine) id := parseTaskID(text) var reqTags, desTags []string for _, ref := range idx.TaskTags { if ref.Line != tl.Line { continue } switch ref.Kind { case KindRequirements: reqTags = append(reqTags, ref.ID) case KindDesign: desTags = append(desTags, ref.ID) } } out = append(out, TaskRecord{ TaskLine: tl, PlanDir: dirBase, Text: text, ID: id, ReqTags: reqTags, DesignTags: desTags, }) } return out } // spec:planctl-task-cmds/R3.1+D§3 // buildTaskRecordsFromScan is the lightweight variant used by runComplete's // pre-write phase. It builds TaskRecords directly from a ScanResult without // requiring a pre-built Index (avoids loading prd.md / design.md). func buildTaskRecordsFromScan(scan *ScanResult, planDir string) []TaskRecord { taskLines := extractTaskLines(scan) tags, _ := extractTaskTags(scan) fakeIdx := Index{ TaskLines: taskLines, TaskTags: tags, PRDRequirements: map[string]Position{}, DesignSections: map[string]Position{}, } return buildTaskRecords(fakeIdx, scan, planDir) } // spec:planctl-task-cmds/R3.1+R3.4+D§3 // findResult discriminates the three outcomes of findTaskByID. type findResult int const ( findOK findResult = iota findBadFormat // ref doesn't start with T or suffix isn't N.M findNotFound // valid format, no matching record ) // spec:planctl-task-cmds/R3.1+R3.4+D§3 // findTaskByID looks up a TaskRecord by its T-ID ref string (e.g. "T2.1"). // Returns the matched record (or nil), a findResult discriminator, and — // when findNotFound — a sorted slice of all available non-empty IDs. func findTaskByID(records []TaskRecord, ref string) (*TaskRecord, findResult, []string) { // Normalize to uppercase for case-insensitive matching (R3.1). ref = strings.ToUpper(ref) // Validate format: must start with "T" and suffix must match N.M pattern. if !strings.HasPrefix(ref, "T") { return nil, findBadFormat, nil } suffix := ref[1:] if !taskIDRefPattern.MatchString(suffix) { return nil, findBadFormat, nil } for i := range records { if records[i].ID == ref { return &records[i], findOK, nil } } // Collect available IDs for error message. var avail []string seen := map[string]bool{} for _, r := range records { if r.ID != "" && !seen[r.ID] { avail = append(avail, r.ID) seen[r.ID] = true } } sort.Strings(avail) return nil, findNotFound, avail } // spec:planctl-task-cmds/R3.2+R3.7+D§3+D§4 // atomicRewriteTaskLine reads path, replaces "- [ ]" with "- [x]" on the // given 1-indexed lineNum, and writes the result atomically via a temp file // + rename (D§4). Returns the original and new line text. The function // validates that the target line contains "- [ ]" before writing. func atomicRewriteTaskLine(path string, lineNum int) (oldText, newText string, err error) { data, err := os.ReadFile(path) if err != nil { return "", "", fmt.Errorf("planctl: read %s: %w", path, err) } // Preserve trailing newline presence. trailingNewline := len(data) > 0 && data[len(data)-1] == '\n' lines := strings.Split(string(data), "\n") // Strip the trailing empty element that Split produces for a // trailing newline, so we work on content lines only. if trailingNewline && len(lines) > 0 && lines[len(lines)-1] == "" { lines = lines[:len(lines)-1] } if lineNum < 1 || lineNum > len(lines) { return "", "", fmt.Errorf("planctl: line %d out of range (file has %d lines)", lineNum, len(lines)) } target := lines[lineNum-1] if !strings.Contains(target, "- [ ]") { return "", "", fmt.Errorf("planctl: line %d does not contain unchecked task marker: %q", lineNum, target) } changed := strings.Replace(target, "- [ ]", "- [x]", 1) lines[lineNum-1] = changed joined := strings.Join(lines, "\n") if trailingNewline { joined += "\n" } // Preserve original file mode. info, err := os.Stat(path) if err != nil { return "", "", fmt.Errorf("planctl: stat %s: %w", path, err) } mode := info.Mode() tmp, err := os.CreateTemp(filepath.Dir(path), ".planctl-*.tmp") if err != nil { return "", "", fmt.Errorf("planctl: create temp: %w", err) } tmpPath := tmp.Name() defer func() { _ = tmp.Close() if err != nil { _ = os.Remove(tmpPath) } }() if _, werr := tmp.WriteString(joined); werr != nil { err = fmt.Errorf("planctl: write temp: %w", werr) return "", "", err } if cerr := tmp.Close(); cerr != nil { err = fmt.Errorf("planctl: close temp: %w", cerr) return "", "", err } if cherr := os.Chmod(tmpPath, mode); cherr != nil { err = fmt.Errorf("planctl: chmod temp: %w", cherr) return "", "", err } if rerr := os.Rename(tmpPath, path); rerr != nil { err = fmt.Errorf("planctl: rename: %w", rerr) return "", "", err } return target, changed, nil } // spec:planctl-task-cmds/R4.5+D§3 // PlanStatus is the overall plan health label from evalPlanStatus. type PlanStatus string const ( StatusLintError PlanStatus = "lint_error" StatusNeedsCloseout PlanStatus = "needs_closeout" StatusDone PlanStatus = "done" StatusNotStarted PlanStatus = "not_started" StatusInProgress PlanStatus = "in_progress" ) // spec:planctl-task-cmds/R4.5+D§3 // evalPlanStatus derives the PlanStatus for a single plan per the R4.5 // precedence table (first match wins): // 1. Any SevError diagnostic (excluding missing-closeout-file) → lint_error // 2. All tasks checked + missing closeout file(s) → needs_closeout // 3. All tasks checked + closeout present → done // 4. Zero tasks checked → not_started // 5. Default → in_progress // // CodeMissingCloseoutFile is excluded from the SevError check: per the PRD // R4.3 example, a needs_closeout plan shows "Lint: PASS (0 errors)" — the // closeout state is reported separately in the Close line. func evalPlanStatus(diags []Diagnostic, tasks []TaskRecord, plan *Plan) PlanStatus { for _, d := range diags { if d.Severity == SevError && d.Code != CodeMissingCloseoutFile { return StatusLintError } } if len(tasks) > 0 { allChecked := true anyChecked := false for _, t := range tasks { if t.Checked { anyChecked = true } else { allChecked = false } } if allChecked { if plan == nil || !plan.CodexLog || !plan.HandoffFound { return StatusNeedsCloseout } return StatusDone } if !anyChecked { return StatusNotStarted } } else { return StatusNotStarted } return StatusInProgress }