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
952 lines
28 KiB
Go
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)
|
|
}
|