template-jj/cmd/planctl/main.go
sid e913ce1880
Some checks failed
planctl / build-test (1.22, macos-latest) (push) Has been cancelled
planctl / build-test (1.22, ubuntu-latest) (push) Has been cancelled
planctl / build-test (1.22, windows-latest) (push) Has been cancelled
planctl / build-test (stable, macos-latest) (push) Has been cancelled
planctl / build-test (stable, ubuntu-latest) (push) Has been cancelled
planctl / build-test (stable, windows-latest) (push) Has been cancelled
planctl / bench (push) Has been cancelled
merge: integrate planctl v2 task-cmds + context-window token awareness
2026-04-26 09:42:58 -06:00

952 lines
28 KiB
Go

// planctl — lint tool and task-state manager for spec-driven plan directories.
//
// v1: lint subcommand only (read-only).
// v2: next, list, complete, status subcommands added.
package main
import (
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
const version = "0.1.0-dev"
// spec:planctl/R5.7+R5.8+D§3.6
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
// spec:planctl/R5.7+R5.8+R6.1+D§3.6
// run is the testable entry point: it takes argv (minus argv[0]) and writers
// for stdout/stderr, and returns the process exit code. Exit codes follow
// PRD R5.4 / R5.5: 0 = clean or warnings-only, 1 = errors, 2 = tool error
// or bad invocation.
func run(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
printUsage(stderr)
return 2
}
// Top-level flags that short-circuit before subcommand dispatch.
switch args[0] {
case "--help", "-h":
printUsage(stdout)
return 0
case "--version":
fmt.Fprintf(stdout, "planctl %s\n", version)
return 0
}
// Reject unknown top-level flags (anything starting with "-" that isn't
// --help/--version above) — a flag without a subcommand is a usage error.
if strings.HasPrefix(args[0], "-") {
fmt.Fprintf(stderr, "planctl: unknown flag %q\n", args[0])
printUsage(stderr)
return 2
}
subcommand := args[0]
rest := args[1:]
// spec:26174-planctl-context-tokens/R3.4+D§1+D§2
// Resolve session token context once per invocation, at the top of run()
// so it's available to every subcommand handler (current and future) and
// so it can't be skipped by an early subcommand failure. ReadTokenCtx
// returns nil when no session data is available; all emitters handle nil.
ctx := ReadTokenCtx()
switch subcommand {
case "lint":
return runLint(rest, stdout, stderr, ctx)
case "next":
// spec:planctl-task-cmds/R5.5+D§1
return runNext(rest, stdout, stderr)
case "list":
// spec:planctl-task-cmds/R5.5+D§1
return runList(rest, stdout, stderr)
case "complete":
// spec:planctl-task-cmds/R5.5+D§1
return runComplete(rest, stdout, stderr)
case "status":
// spec:planctl-task-cmds/R5.5+D§1+D§8
return runStatus(rest, stdout, stderr)
default:
// spec:planctl/R5.8
fmt.Fprintf(stderr, "planctl: unknown subcommand %q\n", subcommand)
printUsage(stderr)
return 2
}
}
// spec:planctl/R3.1+R3.2+R5.3+R5.4+R5.5+R5.6+D§1+D§3.5+D§3.7
// runLint drives the lint subcommand end-to-end: parse flags, resolve
// plan directories, load each plan, run the rule pipeline, and route
// the results into the text or JSON emitter based on --format. The
// emitter owns the final exit-code return via computeExit.
//
// v1 scope per PRD R6.2: strictly read-only — no writes to any plan
// file.
func runLint(args []string, stdout, stderr io.Writer, ctx *TokenCtx) int {
flags, err := parseLintFlags(args)
if err != nil {
fmt.Fprintln(stderr, err.Error())
return 2
}
cwd, err := os.Getwd()
if err != nil {
fmt.Fprintf(stderr, "planctl: getwd: %v\n", err)
return 2
}
plans, err := resolvePlans(flags.planDir, cwd)
if err != nil {
fmt.Fprintln(stderr, err.Error())
return 2
}
results := make([]PlanResult, 0, len(plans))
for _, dir := range plans {
p, err := loadPlan(dir)
if err != nil {
fmt.Fprintln(stderr, err.Error())
return 2
}
results = append(results, lintPlan(p, flags))
}
if flags.format == "json" {
return emitJSON(stdout, results, flags.strict, ctx)
}
return emitText(stdout, results, flags.strict, ctx)
}
// spec:planctl/R5.3+D§3.5
// loadPlan reads the three canonical markdown files (prd.md, design.md,
// tasks.md) from dir and records the two close-out markers
// (codex-sessions.md, any handoff*.md). Missing markdown files yield
// nil ScanResult pointers rather than errors — R2.8 (no design), R3.1
// (no prd fatal), and R3.4 (zero-task tasks.md) are downstream-rule
// concerns, handled in lintPlan / the rule checkers.
//
// Scan paths are rewritten to the plan-relative form ("prd.md", not the
// absolute path) so diagnostics quote compact file names per R5.2.
func loadPlan(dir string) (*Plan, error) {
absDir, err := filepath.Abs(dir)
if err != nil {
return nil, err
}
p := &Plan{Dir: absDir}
files := []struct {
name string
dst **ScanResult
}{
{"prd.md", &p.PRD},
{"design.md", &p.Design},
{"tasks.md", &p.Tasks},
}
for _, f := range files {
path := filepath.Join(absDir, f.name)
if _, err := os.Stat(path); err != nil {
continue
}
r, err := Scan(path)
if err != nil {
return nil, fmt.Errorf("planctl: scan %s: %w", path, err)
}
r.Path = f.name
*f.dst = &r
}
if _, err := os.Stat(filepath.Join(absDir, "codex-sessions.md")); err == nil {
p.CodexLog = true
}
if matches, _ := filepath.Glob(filepath.Join(absDir, "handoff*.md")); len(matches) > 0 {
p.HandoffFound = true
}
return p, nil
}
// spec:planctl/R3.1+R3.2+R5.3+D§1+D§3.7
// lintPlan runs BuildIndex + the four rule checkers on p and returns a
// PlanResult whose PlanDir is the plan-dir basename (so multi-plan
// output can attribute diagnostics without the full path).
//
// PRD-absent short-circuit (R3.1 / R3.2): emit ONLY the missing-prd
// fatal plus checkCloseoutFiles (which is PRD-independent — it only
// needs task lines and the two close-out booleans). R2 / R4 checks are
// skipped because they have no basis without declared R-ids.
func lintPlan(p *Plan, flags lintFlags) PlanResult {
planDirName := filepath.Base(p.Dir)
if p.PRD == nil {
idx := BuildIndex(p)
diags := newMissingPRDResult(planDirName)
diags = append(diags, checkCloseoutFiles(p, idx)...)
backfillPlanDir(diags, planDirName)
sortDiagnostics(diags)
return PlanResult{
PlanDir: planDirName,
Diagnostics: diags,
TaskCount: len(idx.TaskLines),
}
}
idx := BuildIndex(p)
hasDesign := p.Design != nil
var diags []Diagnostic
diags = append(diags, checkTagSyntax(idx)...)
diags = append(diags, checkCrossRef(idx, hasDesign)...)
diags = append(diags, checkCloseoutFiles(p, idx)...)
diags = append(diags, checkEars(idx, flags.noEars)...)
backfillPlanDir(diags, planDirName)
sortDiagnostics(diags)
return PlanResult{
PlanDir: planDirName,
Diagnostics: diags,
TaskCount: len(idx.TaskLines),
ReqCount: len(idx.PRDRequirements),
DesCount: len(idx.DesignSections),
}
}
// backfillPlanDir stamps every diag's PlanDir with planDir when unset.
// Rule checkers don't know the plan-dir basename; the orchestrator does.
func backfillPlanDir(diags []Diagnostic, planDir string) {
for i := range diags {
if diags[i].PlanDir == "" {
diags[i].PlanDir = planDir
}
}
}
// spec:planctl/R5.4+R5.5+D§3.7
// computeExit returns the process exit code for a set of linted plans
// per PRD R5.4 / R5.5:
//
// - 0 — no error-severity diagnostics (warnings alone are informational)
// - 1 — any error, or any warning when strict=true
//
// Exit code 2 is reserved for discovery / I/O failures at the outer
// layer (runLint returns 2 directly in those paths) and is never
// produced here.
func computeExit(plans []PlanResult, strict bool) int {
for _, pr := range plans {
for _, d := range pr.Diagnostics {
if d.Severity == SevError {
return 1
}
if d.Severity == SevWarning && strict {
return 1
}
}
}
return 0
}
// spec:planctl/R5.1+D§3.5
// planDirPattern matches a plan-dir basename: five digits (YYWWD), a
// dash, and a lowercase kebab slug starting with a letter. Used both
// for Case B (is `d` itself a plan dir?) and Case C (which `dev/plans/*`
// entries are plans?).
var planDirPattern = regexp.MustCompile(`^\d{5}-[a-z][a-z0-9-]+$`)
// spec:planctl/R5.6+R5.8+D§3.6+D§4
// lintFlags captures the parsed `planctl lint` invocation — the
// accepted flag surface is part of the stable public CLI contract per
// design §4.
type lintFlags struct {
format string // "text" | "json", default "text"
strict bool
noEars bool
color string // "auto" | "always" | "never", accepted but a no-op in v1 per D-1
planDir string // optional positional argument
}
// spec:planctl/R5.6+R5.8+D§3.6+D§4
// parseLintFlags parses the `lint` subcommand's args (subcommand name
// already stripped). Multiple invocations of a flag use the last value
// (natural consequence of sequential assignment). Unknown flags return
// an error that the caller translates to exit 2 per R5.8.
//
// Both `--flag value` and `--flag=value` forms are accepted for the two
// value-taking flags (--format, --color) to stay friendly with common
// CLI invocation styles.
func parseLintFlags(args []string) (lintFlags, error) {
f := lintFlags{format: "text", color: "auto"}
for i := 0; i < len(args); i++ {
a := args[i]
switch {
case a == "--strict":
f.strict = true
case a == "--no-ears":
f.noEars = true
case strings.HasPrefix(a, "--format="):
v, err := validateFormat(strings.TrimPrefix(a, "--format="))
if err != nil {
return lintFlags{}, err
}
f.format = v
case a == "--format":
i++
if i >= len(args) {
return lintFlags{}, fmt.Errorf("planctl: --format requires a value")
}
v, err := validateFormat(args[i])
if err != nil {
return lintFlags{}, err
}
f.format = v
case strings.HasPrefix(a, "--color="):
v, err := validateColor(strings.TrimPrefix(a, "--color="))
if err != nil {
return lintFlags{}, err
}
f.color = v
case a == "--color":
i++
if i >= len(args) {
return lintFlags{}, fmt.Errorf("planctl: --color requires a value")
}
v, err := validateColor(args[i])
if err != nil {
return lintFlags{}, err
}
f.color = v
case strings.HasPrefix(a, "-"):
return lintFlags{}, fmt.Errorf("planctl: unknown flag %q", a)
default:
if f.planDir != "" {
return lintFlags{}, fmt.Errorf("planctl: unexpected extra argument %q", a)
}
f.planDir = a
}
}
return f, nil
}
// validateFormat gates --format=<v>.
func validateFormat(v string) (string, error) {
if v == "text" || v == "json" {
return v, nil
}
return "", fmt.Errorf("planctl: invalid --format value %q (want text or json)", v)
}
// validateColor gates --color=<v>. All three values are accepted; the
// flag itself is a no-op in v1 per D-1 (TTY detection deferred to v2).
func validateColor(v string) (string, error) {
if v == "auto" || v == "always" || v == "never" {
return v, nil
}
return "", fmt.Errorf("planctl: invalid --color value %q (want auto, always, or never)", v)
}
// spec:planctl/R5.1+D§3.5
// resolvePlans implements the R5.1 discovery decision tree (design §3.5):
//
// - Case A: explicit != "". Stat the path; return the single absolute
// path, or an error that the caller translates to exit 2.
// - Case B/C: a unified upward walk from cwd. At each ancestor `d` we
// check Case B first (is d itself a plan dir?) then Case C (does
// d/dev/plans/ exist with at least one plan child?). First hit wins;
// empty Case C enumeration falls through to continue walking.
// - Case D: walk reached filesystem root with no hit → 3-option error.
//
// Case C excludes anything named `archive` and never recurses into it.
// Results are returned sorted lexicographically, which also sorts
// chronologically thanks to the YYWWD prefix.
//
// NOTE: this is strictly an upward walk; we do NOT `filepath.WalkDir`
// downward looking for dev/plans/ (by design §3.5 — recursion would be
// slow on large trees and would pick up accidentally-nested plan dirs).
func resolvePlans(explicit string, cwd string) ([]string, error) {
if explicit != "" {
abs, err := filepath.Abs(explicit)
if err != nil {
return nil, fmt.Errorf("planctl: resolve %q: %w", explicit, err)
}
info, err := os.Stat(abs)
if err != nil {
return nil, fmt.Errorf("planctl: plan directory %q does not exist", explicit)
}
if !info.IsDir() {
return nil, fmt.Errorf("planctl: path %q is not a directory", explicit)
}
return []string{abs}, nil
}
absCwd, err := filepath.Abs(cwd)
if err != nil {
return nil, fmt.Errorf("planctl: resolve cwd %q: %w", cwd, err)
}
for d := absCwd; ; {
if plan := planDirAt(d); plan != "" {
return []string{plan}, nil
}
plansDir := filepath.Join(d, "dev", "plans")
if info, err := os.Stat(plansDir); err == nil && info.IsDir() {
planDirs, err := enumeratePlansChildren(plansDir)
if err != nil {
return nil, fmt.Errorf("planctl: enumerate %q: %w", plansDir, err)
}
if len(planDirs) > 0 {
return planDirs, nil
}
}
parent := filepath.Dir(d)
if parent == d {
break
}
d = parent
}
return nil, fmt.Errorf("planctl: no plan directory found; pass a path explicitly, cd into a plan directory, or cd to a repo containing dev/plans/")
}
// spec:planctl/R5.1+D§3.5
// planDirAt returns d if d is itself a plan directory: basename matches
// planDirPattern, d's parent's basename is "plans", and the grandparent's
// basename is "dev". Returns "" otherwise.
//
// The triple-check is how we distinguish a real plan dir (`repo/dev/plans/
// 26172-planctl/`) from a dir that just happens to have a plan-dir-like
// name elsewhere in the tree.
func planDirAt(d string) string {
if !planDirPattern.MatchString(filepath.Base(d)) {
return ""
}
parent := filepath.Dir(d)
if filepath.Base(parent) != "plans" {
return ""
}
if filepath.Base(filepath.Dir(parent)) != "dev" {
return ""
}
return d
}
// spec:planctl/R5.1+D§3.5
// enumeratePlansChildren reads plansDir and returns direct-child plan
// directories as absolute paths in lexicographic order. Entries named
// "archive" are excluded (and NOT recursed into, satisfying R5.1's
// "excluding dev/plans/archive/" clause). Non-directories and entries
// not matching planDirPattern are skipped.
func enumeratePlansChildren(plansDir string) ([]string, error) {
entries, err := os.ReadDir(plansDir)
if err != nil {
return nil, err
}
var out []string
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
if name == "archive" {
continue
}
if !planDirPattern.MatchString(name) {
continue
}
out = append(out, filepath.Join(plansDir, name))
}
sort.Strings(out)
return out, nil
}
// spec:planctl-task-cmds/R5.3+R5.5+D§1
// printUsage writes the top-level --help output.
func printUsage(w io.Writer) {
fmt.Fprint(w, `planctl — lint tool and task-state manager for spec-driven plan directories
Usage:
planctl <subcommand> [flags] [plan-dir]
planctl --help
planctl --version
Subcommands:
lint Lint a plan directory for traceability and EARS compliance.
next Return the next unchecked task.
list List task state (open tasks by default; --all for all).
complete Mark a task done by its T-ID (e.g. T2.1).
status Show overall plan health and progress.
Use "planctl <subcommand> --help" for per-subcommand flags and exit codes.
Flags (top-level):
--format=text|json Output format. Default: text.
--strict Treat warnings as errors (lint / status).
--no-ears Suppress ears-violation warnings (lint only).
--color=auto|always|never Accepted for forward-compatibility; no-op in v1.
--help, -h Show this help and exit.
--version Print version and exit.
Classification codes (stable public contract — grep-safe):
tag-syntax Malformed traceability tag body.
tag-unclosed Italic-underscore delimiter missing.
orphan-requirement tasks.md cites an R-id not declared in prd.md.
orphan-design tasks.md cites a D§-id not declared in design.md.
uncovered-requirement prd.md R-id is never cited by a task tag.
uncovered-design design.md D§-id is never cited by a task tag.
missing-prd Plan directory has no prd.md (fatal).
missing-closeout-file Fully [x] tasks.md lacks codex-sessions.md or handoff*.md.
ears-violation PRD acceptance criterion does not begin with an EARS keyword.
Exit codes:
0 clean / open task found / status done
1 errors found (or warnings with --strict); status not done
2 tool error, missing file, or bad invocation
See dev/plans/26172-planctl/ for the v1 specification.
See dev/plans/26174-planctl-task-cmds/ for the v2 specification.
`)
}
// ─── v2 subcommand implementations ───────────────────────────────────────────
// spec:planctl-task-cmds/R1.1+R1.2+R1.5+R1.6+R1.7+D§2.1
// runNext implements `planctl next [--format=text|json] [plan-dir]`.
func runNext(args []string, stdout, stderr io.Writer) int {
var format, planDir string
for i := 0; i < len(args); i++ {
a := args[i]
switch {
case a == "--help" || a == "-h":
fmt.Fprint(stdout, `planctl next — Return the next unchecked task
Usage:
planctl next [--format=text|json] [plan-dir]
Flags:
--format=text|json Output format. Default: text.
--help, -h Show this help and exit.
Exit codes:
0 Open task found (or no open tasks remain)
2 Tool error (tasks.md missing, bad invocation)
`)
return 0
case strings.HasPrefix(a, "--format="):
v, err := validateFormat(strings.TrimPrefix(a, "--format="))
if err != nil {
fmt.Fprintln(stderr, err.Error())
return 2
}
format = v
case strings.HasPrefix(a, "-"):
fmt.Fprintf(stderr, "planctl next: unknown flag %q\n", a)
return 2
default:
if planDir != "" {
fmt.Fprintf(stderr, "planctl next: unexpected extra argument %q\n", a)
return 2
}
planDir = a
}
}
if format == "" {
format = "text"
}
cwd, err := os.Getwd()
if err != nil {
fmt.Fprintf(stderr, "planctl: getwd: %v\n", err)
return 2
}
plans, err := resolvePlans(planDir, cwd)
if err != nil {
fmt.Fprintln(stderr, err.Error())
return 2
}
for _, dir := range plans {
relDir := filepath.Base(dir)
p, err := loadPlan(dir)
if err != nil {
fmt.Fprintln(stderr, err.Error())
return 2
}
if p.Tasks == nil {
fmt.Fprintf(stderr, "planctl: %s: tasks.md is missing\n", filepath.Base(dir))
return 2
}
idx := BuildIndex(p)
records := buildTaskRecords(idx, p.Tasks, relDir)
for i := range records {
if !records[i].Checked {
return emitNext(stdout, &records[i], format)
}
}
}
return emitNext(stdout, nil, format)
}
// spec:planctl-task-cmds/R2.1+R2.2+R2.3+R2.7+D§2.1
// runList implements `planctl list [--format=text|json] [--all] [plan-dir]`.
func runList(args []string, stdout, stderr io.Writer) int {
var format, planDir string
var showAll bool
for i := 0; i < len(args); i++ {
a := args[i]
switch {
case a == "--help" || a == "-h":
fmt.Fprint(stdout, `planctl list — List task state
Usage:
planctl list [--format=text|json] [--all] [plan-dir]
Flags:
--format=text|json Output format. Default: text.
--all Show all tasks (checked and unchecked). Default: open only.
--help, -h Show this help and exit.
Exit codes:
0 Success
2 Tool error (tasks.md missing, bad invocation)
`)
return 0
case a == "--all":
showAll = true
case strings.HasPrefix(a, "--format="):
v, err := validateFormat(strings.TrimPrefix(a, "--format="))
if err != nil {
fmt.Fprintln(stderr, err.Error())
return 2
}
format = v
case strings.HasPrefix(a, "-"):
fmt.Fprintf(stderr, "planctl list: unknown flag %q\n", a)
return 2
default:
if planDir != "" {
fmt.Fprintf(stderr, "planctl list: unexpected extra argument %q\n", a)
return 2
}
planDir = a
}
}
if format == "" {
format = "text"
}
cwd, err := os.Getwd()
if err != nil {
fmt.Fprintf(stderr, "planctl: getwd: %v\n", err)
return 2
}
plans, err := resolvePlans(planDir, cwd)
if err != nil {
fmt.Fprintln(stderr, err.Error())
return 2
}
var results []listPlanResult
for _, dir := range plans {
relDir := filepath.Base(dir)
p, err := loadPlan(dir)
if err != nil {
fmt.Fprintln(stderr, err.Error())
return 2
}
if p.Tasks == nil {
fmt.Fprintf(stderr, "planctl: %s: tasks.md is missing\n", filepath.Base(dir))
return 2
}
idx := BuildIndex(p)
records := buildTaskRecords(idx, p.Tasks, relDir)
results = append(results, listPlanResult{PlanDir: dir, Records: records})
}
return emitList(stdout, results, format, showAll)
}
// spec:planctl-task-cmds/R3.1+R3.2+R3.3+R3.4+R3.5+R3.6+R3.7+R3.8+D§2.2+D§3
// runComplete implements `planctl complete [--format=text|json] [--dry-run] <task-ref> [plan-dir]`.
func runComplete(args []string, stdout, stderr io.Writer) int {
var format, planDir, taskRef string
var dryRun bool
for i := 0; i < len(args); i++ {
a := args[i]
switch {
case a == "--help" || a == "-h":
fmt.Fprint(stdout, `planctl complete — Mark a task done by T-ID
Usage:
planctl complete [--format=text|json] [--dry-run] <task-ref> [plan-dir]
The caller is responsible for snapshotting the working tree (e.g. jj describe)
before invoking complete. planctl complete never invokes jj or git internally.
Flags:
--format=text|json Output format. Default: text.
--dry-run Preview the change without writing.
--help, -h Show this help and exit.
Exit codes:
0 Task marked complete (or already complete, or --dry-run preview)
1 Task not found or bad task-ref format
2 Tool error (tasks.md missing, bad invocation, multiple plans)
`)
return 0
case a == "--dry-run":
dryRun = true
case strings.HasPrefix(a, "--format="):
v, err := validateFormat(strings.TrimPrefix(a, "--format="))
if err != nil {
fmt.Fprintln(stderr, err.Error())
return 2
}
format = v
case strings.HasPrefix(a, "-"):
fmt.Fprintf(stderr, "planctl complete: unknown flag %q\n", a)
return 2
default:
if taskRef == "" {
taskRef = a
} else if planDir == "" {
planDir = a
} else {
fmt.Fprintf(stderr, "planctl complete: unexpected extra argument %q\n", a)
return 2
}
}
}
if format == "" {
format = "text"
}
if taskRef == "" {
fmt.Fprintln(stderr, "planctl complete: <task-ref> is required (e.g. T2.1)")
return 2
}
cwd, err := os.Getwd()
if err != nil {
fmt.Fprintf(stderr, "planctl: getwd: %v\n", err)
return 2
}
plans, err := resolvePlans(planDir, cwd)
if err != nil {
fmt.Fprintln(stderr, err.Error())
return 2
}
// spec:planctl-task-cmds/D§5
if len(plans) > 1 {
fmt.Fprintf(stderr, "planctl complete: found %d plan directories; specify the plan directory explicitly\n", len(plans))
return 2
}
p, err := loadPlan(plans[0])
if err != nil {
fmt.Fprintln(stderr, err.Error())
return 2
}
if p.Tasks == nil {
fmt.Fprintf(stderr, "planctl: %s: tasks.md is missing\n", filepath.Base(plans[0]))
return 2
}
records := buildTaskRecordsFromScan(p.Tasks, filepath.Base(plans[0]))
rec, result, avail := findTaskByID(records, taskRef)
switch result {
case findBadFormat:
fmt.Fprintf(stderr, "planctl complete: task ref must be in T<N.M> format (e.g. T2.1), got %q\n", taskRef)
return 1
case findNotFound:
fmt.Fprintf(stderr, "planctl complete: task %s not found; available: %s\n", taskRef, strings.Join(avail, ", "))
return 1
}
if rec.Checked {
fmt.Fprintf(stdout, "Task %s is already complete.\n", taskRef)
return 0
}
tasksPath := filepath.Join(plans[0], "tasks.md")
// Compute the hypothetical new text for dry-run.
oldLine := ""
if rec.Line >= 1 && rec.Line <= len(p.Tasks.Lines) {
oldLine = p.Tasks.Lines[rec.Line-1]
}
newLine := strings.Replace(oldLine, "- [ ]", "- [x]", 1)
if dryRun {
// spec:planctl-task-cmds/R3.8
// For JSON dry-run, compute the lint diagnostics the mutation would
// trigger: copy the plan dir, apply the rewrite to the copy, lint it.
var hypotheticalDiags []Diagnostic
if format == "json" {
if tmpDir, cpErr := copyPlanDir(plans[0]); cpErr == nil {
defer func() { _ = os.RemoveAll(tmpDir) }()
if _, _, mErr := atomicRewriteTaskLine(filepath.Join(tmpDir, "tasks.md"), rec.Line); mErr == nil {
if p2, lErr := loadPlan(tmpDir); lErr == nil {
hypotheticalDiags = lintPlan(p2, lintFlags{}).Diagnostics
}
}
}
}
return emitComplete(stdout, taskRef, rec.Line, oldLine, newLine, true, hypotheticalDiags, format)
}
oldText, newText, err := atomicRewriteTaskLine(tasksPath, rec.Line)
if err != nil {
fmt.Fprintln(stderr, err.Error())
return 2
}
// Reload and lint post-write.
p2, err := loadPlan(plans[0])
if err != nil {
fmt.Fprintln(stderr, err.Error())
return 2
}
result2 := lintPlan(p2, lintFlags{})
return emitComplete(stdout, taskRef, rec.Line, oldText, newText, false, result2.Diagnostics, format)
}
// spec:planctl-task-cmds/R4.1+R4.2+R4.6+R4.7+D§2.3
// runStatus implements `planctl status [--format=text|json] [--strict] [plan-dir]`.
func runStatus(args []string, stdout, stderr io.Writer) int {
var format, planDir string
var strict bool
for i := 0; i < len(args); i++ {
a := args[i]
switch {
case a == "--help" || a == "-h":
fmt.Fprint(stdout, `planctl status — Show overall plan health and progress
Usage:
planctl status [--format=text|json] [--strict] [plan-dir]
Flags:
--format=text|json Output format. Default: text.
--strict Treat lint warnings as errors.
--help, -h Show this help and exit.
Exit codes:
0 Plan status is "done"
1 Plan has open tasks, lint errors, or missing closeout files
2 Tool error (bad invocation)
`)
return 0
case a == "--strict":
strict = true
case strings.HasPrefix(a, "--format="):
v, err := validateFormat(strings.TrimPrefix(a, "--format="))
if err != nil {
fmt.Fprintln(stderr, err.Error())
return 2
}
format = v
case strings.HasPrefix(a, "-"):
fmt.Fprintf(stderr, "planctl status: unknown flag %q\n", a)
return 2
default:
if planDir != "" {
fmt.Fprintf(stderr, "planctl status: unexpected extra argument %q\n", a)
return 2
}
planDir = a
}
}
if format == "" {
format = "text"
}
cwd, err := os.Getwd()
if err != nil {
fmt.Fprintf(stderr, "planctl: getwd: %v\n", err)
return 2
}
plans, err := resolvePlans(planDir, cwd)
if err != nil {
fmt.Fprintln(stderr, err.Error())
return 2
}
var results []statusPlanResult
for _, dir := range plans {
relDir := filepath.Base(dir)
p, err := loadPlan(dir)
if err != nil {
fmt.Fprintln(stderr, err.Error())
return 2
}
pr := lintPlan(p, lintFlags{strict: strict})
idx := BuildIndex(p)
records := buildTaskRecords(idx, func() *ScanResult {
if p.Tasks != nil {
return p.Tasks
}
return &ScanResult{}
}(), relDir)
var errs, warns int
for _, d := range pr.Diagnostics {
if d.Code == CodeMissingCloseoutFile {
continue // closeout state is tracked via HasCodexLog/HasHandoff, not lint counts
}
if d.Severity == SevError {
errs++
} else if d.Severity == SevWarning {
if strict {
errs++
} else {
warns++
}
}
}
status := evalPlanStatus(pr.Diagnostics, records, p)
results = append(results, statusPlanResult{
PlanDir: filepath.Base(dir),
TotalTasks: pr.TaskCount,
DoneTasks: countCheckedTasks(records),
LintErrors: errs,
LintWarnings: warns,
HasCodexLog: p.CodexLog,
HasHandoff: p.HandoffFound,
Status: status,
})
}
return emitStatus(stdout, results, format)
}
// countCheckedTasks returns the number of checked (completed) tasks.
func countCheckedTasks(records []TaskRecord) int {
n := 0
for _, r := range records {
if r.Checked {
n++
}
}
return n
}
// spec:planctl-task-cmds/R3.8+D§4
// copyPlanDir copies a flat plan directory into a fresh OS temp directory
// (one level, no subdirs) so dry-run can apply a hypothetical mutation and
// lint the result without touching the original.
func copyPlanDir(planDir string) (string, error) {
tmp, err := os.MkdirTemp("", "planctl-dryrun-*")
if err != nil {
return "", err
}
entries, err := os.ReadDir(planDir)
if err != nil {
_ = os.RemoveAll(tmp)
return "", err
}
for _, e := range entries {
if e.IsDir() {
continue
}
data, readErr := os.ReadFile(filepath.Join(planDir, e.Name()))
if readErr != nil {
_ = os.RemoveAll(tmp)
return "", readErr
}
info, statErr := e.Info()
if statErr != nil {
_ = os.RemoveAll(tmp)
return "", statErr
}
if writeErr := os.WriteFile(filepath.Join(tmp, e.Name()), data, info.Mode()); writeErr != nil {
_ = os.RemoveAll(tmp)
return "", writeErr
}
}
return tmp, nil
}
// relOrBase returns the path of absDir relative to cwd. Falls back to
// filepath.Base(absDir) if Rel fails (e.g. different drive on Windows).
func relOrBase(cwd, absDir string) string {
if rel, err := filepath.Rel(cwd, absDir); err == nil {
return rel
}
return filepath.Base(absDir)
}