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
757 lines
23 KiB
Go
757 lines
23 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// Emit — diagnostic formatters per design §3.7.
|
|
//
|
|
// Both emitText and emitJSON return the final process exit code by
|
|
// delegating to computeExit. The orchestrator in main.go selects the
|
|
// emitter based on --format and forwards the result.
|
|
|
|
// spec:planctl/D§3.7
|
|
// PlanResult is the per-plan lint output. Moved here from main.go in
|
|
// parent-6; task 5 put it there temporarily before emit.go existed.
|
|
type PlanResult struct {
|
|
PlanDir string
|
|
Diagnostics []Diagnostic
|
|
TaskCount int
|
|
ReqCount int
|
|
DesCount int
|
|
}
|
|
|
|
// spec:planctl/R5.2+R5.3+R5.9+R5.10+D§3.7
|
|
// emitText formats plans as human-readable output to w.
|
|
//
|
|
// - Single-plan (len(plans) == 1): per R5.10, NO `=== ... ===` header,
|
|
// NO inter-plan blank, NO aggregate summary. Diagnostics render in
|
|
// the R5.2 canonical form `<path>:<line>: [<sev>] <code>: <msg>`.
|
|
// A plan with zero diagnostics emits the R5.3 clean-summary line.
|
|
// - Multi-plan (len(plans) > 1): per R5.9, each plan is prefixed with
|
|
// `=== <basename> ===`, plans are separated by a blank line, and
|
|
// the run ends with `<N> plans linted, <E> errors, <W> warnings`
|
|
// (labels always plural per R5.9 even when a count is 1).
|
|
//
|
|
// Directory-level diagnostics (Path == "", typically missing-prd /
|
|
// missing-closeout-file) render with the plan-dir basename substituted
|
|
// for the path.
|
|
// spec:26174-planctl-context-tokens/R3.4+R3.5+R4.1+R4.2+R4.3+R4.5+D§4.3
|
|
// ctx (when non-nil) drives the context-window line: emitted after the plan
|
|
// summary in single-plan mode, after the aggregate summary in multi-plan mode
|
|
// (R4.3 — once, not per-plan). Normal-band / nil ctx emits nothing (R3.5).
|
|
func emitText(w io.Writer, plans []PlanResult, strict bool, ctx *TokenCtx) int {
|
|
multi := len(plans) > 1
|
|
for i, p := range plans {
|
|
if multi {
|
|
if i > 0 {
|
|
fmt.Fprintln(w)
|
|
}
|
|
fmt.Fprintf(w, "=== %s ===\n", p.PlanDir)
|
|
}
|
|
writePlanText(w, p)
|
|
}
|
|
if multi {
|
|
writeAggregateText(w, plans)
|
|
}
|
|
writeContextText(w, ctx)
|
|
return computeExit(plans, strict)
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R3.3+R3.4+R3.5+R4.1+R4.2+D§4.3
|
|
// writeContextText appends the canonical `context:` line for Info/Warn/Error
|
|
// bands, or the unknown-limit form when Limit == 0. Silent for nil ctx or
|
|
// Normal band. Format matches PRD R4.1 exactly.
|
|
func writeContextText(w io.Writer, ctx *TokenCtx) {
|
|
if ctx == nil {
|
|
return
|
|
}
|
|
if ctx.Limit == 0 {
|
|
// Unknown-limit branch is emitted regardless of threshold (R4.2).
|
|
fmt.Fprintf(w, "context: %s tokens (limit unknown)\n", formatTokenCount(ctx.Used))
|
|
return
|
|
}
|
|
sev, msg := ctx.Classify()
|
|
if sev == "" {
|
|
// Normal band: silent (R3.5).
|
|
return
|
|
}
|
|
pct := ctx.Used * 100 / ctx.Limit
|
|
fmt.Fprintf(w, "context: %s / %s tokens (%d%%) — %s\n",
|
|
formatTokenCount(ctx.Used), formatTokenCount(ctx.Limit), pct, msg)
|
|
}
|
|
|
|
// writePlanText emits the body of a single plan: either the R5.3 clean-
|
|
// summary line (zero diagnostics) or the R5.2 diagnostic lines. Shared
|
|
// by single-plan and multi-plan branches of emitText.
|
|
func writePlanText(w io.Writer, p PlanResult) {
|
|
if len(p.Diagnostics) == 0 {
|
|
fmt.Fprintf(w, "%s: clean (%d tasks, %d requirements, %d design sections)\n",
|
|
p.PlanDir, p.TaskCount, p.ReqCount, p.DesCount)
|
|
return
|
|
}
|
|
for _, d := range p.Diagnostics {
|
|
path := d.Path
|
|
if path == "" {
|
|
path = p.PlanDir
|
|
}
|
|
fmt.Fprintf(w, "%s:%d: [%s] %s: %s\n", path, d.Line, severityLabel(d.Severity), d.Code, d.Message)
|
|
}
|
|
}
|
|
|
|
// writeAggregateText emits the final summary line for multi-plan output.
|
|
// Label words are always plural per PRD R5.9 ("1 plans linted, 0 errors,
|
|
// 1 warnings") — verbose, but literal to the PRD wording.
|
|
func writeAggregateText(w io.Writer, plans []PlanResult) {
|
|
errs, warns := countSeverities(plans)
|
|
fmt.Fprintf(w, "%d plans linted, %d errors, %d warnings\n", len(plans), errs, warns)
|
|
}
|
|
|
|
// spec:planctl/R5.6+R5.11+D§3.7
|
|
// emitJSON writes plans as JSONL (one JSON object per line) regardless
|
|
// of single- vs. multi-plan. Each diagnostic becomes one object with
|
|
// keys {plan_dir, path, line, severity, code, message}. A final
|
|
// {"summary":{"plans":N,"errors":E,"warnings":W}} object is emitted
|
|
// after the last diagnostic.
|
|
//
|
|
// Clean plans produce NO per-diagnostic objects (there are no
|
|
// diagnostics), but still contribute to the aggregate summary — so
|
|
// a `planctl lint` run over N clean plans emits exactly one object
|
|
// (the summary).
|
|
// spec:26174-planctl-context-tokens/R5.1+R5.2+R5.3+R5.4+D§4.4
|
|
// ctx (when non-nil) contributes a context_window field to the final summary
|
|
// object. Normal band / nil ctx → no context_window key. Unknown limit (R5.2)
|
|
// → only tokens_used key set. Context never affects the errors/warnings counts
|
|
// in summary (R5.4).
|
|
func emitJSON(w io.Writer, plans []PlanResult, strict bool, ctx *TokenCtx) int {
|
|
enc := json.NewEncoder(w)
|
|
enc.SetEscapeHTML(false)
|
|
for _, p := range plans {
|
|
for _, d := range p.Diagnostics {
|
|
_ = enc.Encode(jsonDiag{
|
|
PlanDir: p.PlanDir,
|
|
Path: d.Path,
|
|
Line: d.Line,
|
|
Severity: severityLabel(d.Severity),
|
|
Code: string(d.Code),
|
|
Message: d.Message,
|
|
})
|
|
}
|
|
}
|
|
errs, warns := countSeverities(plans)
|
|
_ = enc.Encode(jsonSummary{
|
|
Summary: summaryBody{
|
|
Plans: len(plans),
|
|
Errors: errs,
|
|
Warnings: warns,
|
|
},
|
|
ContextWin: buildJSONCtxWin(ctx),
|
|
})
|
|
return computeExit(plans, strict)
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R5.1+R5.2+R5.3+D§4.4
|
|
// buildJSONCtxWin returns a populated jsonCtxWin or nil. nil means "don't emit
|
|
// the key at all" (Normal band / no session data). R5.2 unknown-limit case
|
|
// leaves TokensLimit/Pct/Severity/Recommendation zero-valued so omitempty
|
|
// drops them on serialization.
|
|
func buildJSONCtxWin(ctx *TokenCtx) *jsonCtxWin {
|
|
if ctx == nil {
|
|
return nil
|
|
}
|
|
out := &jsonCtxWin{TokensUsed: ctx.Used}
|
|
if ctx.Limit == 0 {
|
|
return out
|
|
}
|
|
sev, msg := ctx.Classify()
|
|
if sev == "" {
|
|
// Normal band — omit the entire key (R5.3).
|
|
return nil
|
|
}
|
|
pct := int(ctx.Used * 100 / ctx.Limit)
|
|
limit := ctx.Limit
|
|
out.TokensLimit = &limit
|
|
out.Pct = &pct
|
|
out.Severity = sev
|
|
out.Recommendation = msg
|
|
return out
|
|
}
|
|
|
|
// jsonDiag is the JSONL payload for one diagnostic. Field tags pin the
|
|
// exact key order produced by encoding/json and keep the contract
|
|
// grep-stable for downstream consumers.
|
|
type jsonDiag struct {
|
|
PlanDir string `json:"plan_dir"`
|
|
Path string `json:"path"`
|
|
Line int `json:"line"`
|
|
Severity string `json:"severity"`
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// summaryBody carries the aggregate totals (plans/errors/warnings) that
|
|
// terminate a JSONL run.
|
|
type summaryBody struct {
|
|
Plans int `json:"plans"`
|
|
Errors int `json:"errors"`
|
|
Warnings int `json:"warnings"`
|
|
}
|
|
|
|
// jsonSummary wraps summaryBody under the "summary" key so the final
|
|
// JSONL line is distinguishable from the per-diagnostic objects.
|
|
//
|
|
// spec:26174-planctl-context-tokens/R5.1+R5.2+R5.3+D§5.4
|
|
// ContextWin is omitempty so Normal-band / nil-ctx invocations serialize
|
|
// without a context_window key.
|
|
type jsonSummary struct {
|
|
Summary summaryBody `json:"summary"`
|
|
ContextWin *jsonCtxWin `json:"context_window,omitempty"`
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R5.1+R5.2+D§5.4
|
|
// jsonCtxWin is the context_window payload in JSON mode. Pointer fields for
|
|
// TokensLimit and Pct are omitempty: when the limit is unknown per R5.2, they
|
|
// marshal out entirely (rather than as 0).
|
|
type jsonCtxWin struct {
|
|
TokensUsed int64 `json:"tokens_used"`
|
|
TokensLimit *int64 `json:"tokens_limit,omitempty"`
|
|
Pct *int `json:"pct,omitempty"`
|
|
Severity string `json:"severity,omitempty"`
|
|
Recommendation string `json:"recommendation,omitempty"`
|
|
}
|
|
|
|
// severityLabel renders a Severity as its canonical string form. Used
|
|
// both by text emission (bracketed form in R5.2) and JSON emission
|
|
// (severity field value).
|
|
func severityLabel(s Severity) string {
|
|
if s == SevWarning {
|
|
return "warning"
|
|
}
|
|
return "error"
|
|
}
|
|
|
|
// countSeverities sums error- and warning-severity diagnostics across
|
|
// all plans. Used by both emitters' aggregate-summary step.
|
|
func countSeverities(plans []PlanResult) (errs, warns int) {
|
|
for _, p := range plans {
|
|
for _, d := range p.Diagnostics {
|
|
if d.Severity == SevError {
|
|
errs++
|
|
} else if d.Severity == SevWarning {
|
|
warns++
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// ─── v2 emit functions ────────────────────────────────────────────────────────
|
|
|
|
// planRelFile returns the cwd-relative file path used in user-facing output.
|
|
// planDir is the cwd-relative plan directory (e.g. "dev/plans/26174-foo" or ".").
|
|
// filepath.Join normalises "." so single-plan invocations emit "tasks.md" not
|
|
// "./tasks.md", while nested plans emit "dev/plans/<slug>/tasks.md" per R1.3.
|
|
func planRelFile(planDir, file string) string {
|
|
return filepath.Join(planDir, file)
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R1.3+R1.4+D§4+D§7
|
|
// emitNext writes the result of a `planctl next` call.
|
|
// When record is nil (no open tasks): text prints "No open tasks.\n",
|
|
// JSON prints {"open":false}. When record is non-nil: text prints a
|
|
// three-line block (ID+text, optional Tags, File); JSON prints an object
|
|
// per R1.3 with a "parent" key.
|
|
func emitNext(w io.Writer, record *TaskRecord, format string) int {
|
|
if record == nil {
|
|
if format == "json" {
|
|
fmt.Fprintln(w, `{"open":false}`)
|
|
} else {
|
|
fmt.Fprintln(w, "No open tasks.")
|
|
}
|
|
return 0
|
|
}
|
|
if format == "json" {
|
|
enc := json.NewEncoder(w)
|
|
enc.SetEscapeHTML(false)
|
|
type nextJSON struct {
|
|
TaskID string `json:"task_id"`
|
|
Text string `json:"text"`
|
|
Line int `json:"line"`
|
|
File string `json:"file"`
|
|
Tags []string `json:"tags"`
|
|
DesignTags []string `json:"design_tags"`
|
|
Parent string `json:"parent"`
|
|
}
|
|
id := record.ID
|
|
if id == "" {
|
|
id = "(no-id)"
|
|
}
|
|
tags := record.ReqTags
|
|
if tags == nil {
|
|
tags = []string{}
|
|
}
|
|
dtags := record.DesignTags
|
|
if dtags == nil {
|
|
dtags = []string{}
|
|
}
|
|
_ = enc.Encode(nextJSON{
|
|
TaskID: id,
|
|
Text: record.Text,
|
|
Line: record.Line,
|
|
File: planRelFile(record.PlanDir, record.File),
|
|
Tags: tags,
|
|
DesignTags: dtags,
|
|
Parent: taskParent(id),
|
|
})
|
|
return 0
|
|
}
|
|
// Text format.
|
|
id := record.ID
|
|
if id == "" {
|
|
id = "(no-id)"
|
|
}
|
|
fmt.Fprintf(w, "%s %s\n", id, record.Text)
|
|
tagLine := buildTagLine(record.ReqTags, record.DesignTags)
|
|
if tagLine != "" {
|
|
fmt.Fprintf(w, " Tags: %s\n", tagLine)
|
|
}
|
|
fmt.Fprintf(w, " File: %s:%d\n", planRelFile(record.PlanDir, record.File), record.Line)
|
|
return 0
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R2.4+R2.5+R2.6+D§4+D§7
|
|
// emitList writes the result of a `planctl list` call. planResults is a
|
|
// slice of (planDir, records) pairs. In single-plan text mode, no header
|
|
// is emitted. In multi-plan text mode, each plan gets an "=== plan-dir ==="
|
|
// header and a trailing summary line. JSON modes emit per R2.5/R2.6.
|
|
func emitList(w io.Writer, planResults []listPlanResult, format string, showAll bool) int {
|
|
if format == "json" {
|
|
return emitListJSON(w, planResults, showAll)
|
|
}
|
|
multi := len(planResults) > 1
|
|
totalOpen := 0
|
|
for i, pr := range planResults {
|
|
if multi {
|
|
if i > 0 {
|
|
fmt.Fprintln(w)
|
|
}
|
|
fmt.Fprintf(w, "=== %s ===\n", filepath.Base(pr.PlanDir))
|
|
}
|
|
for _, r := range pr.Records {
|
|
if !showAll && r.Checked {
|
|
continue
|
|
}
|
|
check := "[ ]"
|
|
if r.Checked {
|
|
check = "[x]"
|
|
}
|
|
id := r.ID
|
|
if id == "" {
|
|
id = "(no-id)"
|
|
}
|
|
fmt.Fprintf(w, "%s %s %s\n", check, id, r.Text)
|
|
if !r.Checked {
|
|
totalOpen++
|
|
}
|
|
}
|
|
}
|
|
if multi {
|
|
fmt.Fprintf(w, "Total: %d open across %d plans\n", totalOpen, len(planResults))
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R2.5+R2.6+D§4+D§7
|
|
func emitListJSON(w io.Writer, planResults []listPlanResult, showAll bool) int {
|
|
enc := json.NewEncoder(w)
|
|
enc.SetEscapeHTML(false)
|
|
type taskJSON struct {
|
|
TaskID string `json:"task_id"`
|
|
Text string `json:"text"`
|
|
Line int `json:"line"`
|
|
File string `json:"file"`
|
|
Checked bool `json:"checked"`
|
|
Tags []string `json:"tags"`
|
|
DesignTags []string `json:"design_tags"`
|
|
}
|
|
type planJSON struct {
|
|
Plan string `json:"plan"`
|
|
OpenCount int `json:"open_count"`
|
|
TotalCount int `json:"total_count"`
|
|
Tasks []taskJSON `json:"tasks"`
|
|
}
|
|
makePlanJSON := func(pr listPlanResult) planJSON {
|
|
pj := planJSON{Plan: filepath.Base(pr.PlanDir)}
|
|
for _, r := range pr.Records {
|
|
pj.TotalCount++
|
|
if !r.Checked {
|
|
pj.OpenCount++
|
|
}
|
|
if !showAll && r.Checked {
|
|
continue
|
|
}
|
|
id := r.ID
|
|
if id == "" {
|
|
id = "(no-id)"
|
|
}
|
|
tags := r.ReqTags
|
|
if tags == nil {
|
|
tags = []string{}
|
|
}
|
|
dtags := r.DesignTags
|
|
if dtags == nil {
|
|
dtags = []string{}
|
|
}
|
|
pj.Tasks = append(pj.Tasks, taskJSON{
|
|
TaskID: id,
|
|
Text: r.Text,
|
|
Line: r.Line,
|
|
File: planRelFile(r.PlanDir, r.File),
|
|
Checked: r.Checked,
|
|
Tags: tags,
|
|
DesignTags: dtags,
|
|
})
|
|
}
|
|
if pj.Tasks == nil {
|
|
pj.Tasks = []taskJSON{}
|
|
}
|
|
return pj
|
|
}
|
|
if len(planResults) == 1 {
|
|
_ = enc.Encode(makePlanJSON(planResults[0]))
|
|
return 0
|
|
}
|
|
type multiJSON struct {
|
|
Plans []planJSON `json:"plans"`
|
|
TotalOpen int `json:"total_open"`
|
|
TotalTasks int `json:"total_tasks"`
|
|
}
|
|
m := multiJSON{}
|
|
for _, pr := range planResults {
|
|
pj := makePlanJSON(pr)
|
|
m.Plans = append(m.Plans, pj)
|
|
m.TotalOpen += pj.OpenCount
|
|
m.TotalTasks += pj.TotalCount
|
|
}
|
|
if m.Plans == nil {
|
|
m.Plans = []planJSON{}
|
|
}
|
|
_ = enc.Encode(m)
|
|
return 0
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R3.8+R3.9+D§4+D§7
|
|
// emitComplete writes the result of a `planctl complete` call. When dryRun
|
|
// is true, it previews the change without writing. Diagnostics are the
|
|
// post-mutation lint results. oldText/newText are the before/after line text.
|
|
func emitComplete(w io.Writer, taskID string, lineNum int, oldText, newText string, dryRun bool, diags []Diagnostic, format string) int {
|
|
if format == "json" {
|
|
enc := json.NewEncoder(w)
|
|
enc.SetEscapeHTML(false)
|
|
type diagJSON struct {
|
|
Path string `json:"path"`
|
|
Line int `json:"line"`
|
|
Severity string `json:"severity"`
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
type completeJSON struct {
|
|
DryRun bool `json:"dry_run"`
|
|
TaskID string `json:"task_id"`
|
|
Line int `json:"line"`
|
|
OldText string `json:"old_text"`
|
|
NewText string `json:"new_text"`
|
|
Diags []diagJSON `json:"diagnostics"`
|
|
}
|
|
out := completeJSON{
|
|
DryRun: dryRun,
|
|
TaskID: taskID,
|
|
Line: lineNum,
|
|
OldText: oldText,
|
|
NewText: newText,
|
|
}
|
|
for _, d := range diags {
|
|
out.Diags = append(out.Diags, diagJSON{
|
|
Path: d.Path,
|
|
Line: d.Line,
|
|
Severity: severityLabel(d.Severity),
|
|
Code: string(d.Code),
|
|
Message: d.Message,
|
|
})
|
|
}
|
|
if out.Diags == nil {
|
|
out.Diags = []diagJSON{}
|
|
}
|
|
_ = enc.Encode(out)
|
|
// spec:planctl-task-cmds/R3.8 — dry-run always exits 0; the diagnostics
|
|
// field is informational only and does not drive the exit code.
|
|
if dryRun {
|
|
return 0
|
|
}
|
|
return computeExit([]PlanResult{{Diagnostics: diags}}, false)
|
|
}
|
|
if dryRun {
|
|
fmt.Fprintf(w, "Would change line %d: %q → %q\n", lineNum, oldText, newText)
|
|
return 0
|
|
}
|
|
fmt.Fprintf(w, "Completed %s (line %d). %d diagnostics.\n", taskID, lineNum, len(diags))
|
|
for _, d := range diags {
|
|
path := d.Path
|
|
if path == "" {
|
|
path = "."
|
|
}
|
|
fmt.Fprintf(w, " %s:%d: [%s] %s: %s\n", path, d.Line, severityLabel(d.Severity), d.Code, d.Message)
|
|
}
|
|
return computeExit([]PlanResult{{Diagnostics: diags}}, false)
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R4.3+R4.4+R4.5+R4.6+D§4+D§7
|
|
// emitStatus writes the result of a `planctl status` call. Multi-plan text
|
|
// appends an aggregate Summary line per R4.6; multi-plan JSON wraps the
|
|
// plans array with a top-level "summary" object.
|
|
func emitStatus(w io.Writer, statusResults []statusPlanResult, format string) int {
|
|
if format == "json" {
|
|
return emitStatusJSON(w, statusResults)
|
|
}
|
|
multi := len(statusResults) > 1
|
|
for i, sr := range statusResults {
|
|
if multi && i > 0 {
|
|
fmt.Fprintln(w)
|
|
}
|
|
writeStatusBlock(w, sr)
|
|
}
|
|
if multi {
|
|
fmt.Fprintln(w)
|
|
writeStatusSummaryLine(w, statusResults)
|
|
}
|
|
return computeStatusExit(statusResults)
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R4.6+D§4
|
|
// writeStatusSummaryLine emits the aggregate summary for multi-plan status:
|
|
// "Summary: N plans; tasks: T total, O open, D done; statuses: L lint_error, N needs_closeout, D done, N not_started, I in_progress"
|
|
func writeStatusSummaryLine(w io.Writer, results []statusPlanResult) {
|
|
var totalTasks, totalOpen, totalDone int
|
|
counts := map[PlanStatus]int{}
|
|
for _, sr := range results {
|
|
totalTasks += sr.TotalTasks
|
|
totalDone += sr.DoneTasks
|
|
totalOpen += sr.TotalTasks - sr.DoneTasks
|
|
counts[sr.Status]++
|
|
}
|
|
fmt.Fprintf(w, "Summary: %d plans; tasks: %d total, %d open, %d done; statuses: %d lint_error, %d needs_closeout, %d done, %d not_started, %d in_progress\n",
|
|
len(results),
|
|
totalTasks, totalOpen, totalDone,
|
|
counts[StatusLintError], counts[StatusNeedsCloseout], counts[StatusDone], counts[StatusNotStarted], counts[StatusInProgress],
|
|
)
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R4.3+D§4
|
|
func writeStatusBlock(w io.Writer, sr statusPlanResult) {
|
|
fmt.Fprintf(w, "Plan: %s\n", sr.PlanDir)
|
|
open := sr.TotalTasks - sr.DoneTasks
|
|
fmt.Fprintf(w, "Tasks: %d total, %d open, %d done\n", sr.TotalTasks, open, sr.DoneTasks)
|
|
writeLintLine(w, sr)
|
|
// Close line.
|
|
var missing []string
|
|
if !sr.HasCodexLog {
|
|
missing = append(missing, "codex-sessions.md")
|
|
}
|
|
if !sr.HasHandoff {
|
|
missing = append(missing, "handoff*.md")
|
|
}
|
|
if len(missing) == 0 {
|
|
fmt.Fprintln(w, "Close: OK")
|
|
} else {
|
|
fmt.Fprintf(w, "Close: missing %s\n", strings.Join(missing, ", "))
|
|
}
|
|
fmt.Fprintln(w)
|
|
fmt.Fprintf(w, "Status: %s\n", statusLabel(sr.Status))
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R4.2+D§4
|
|
// writeLintLine emits the Lint: line. PASS iff LintErrors == 0; warnings
|
|
// alone do not cause FAIL per R4.2 ("whether lint passes — no errors").
|
|
func writeLintLine(w io.Writer, sr statusPlanResult) {
|
|
if sr.LintErrors > 0 {
|
|
fmt.Fprintf(w, "Lint: FAIL (%d errors, %d warnings)\n", sr.LintErrors, sr.LintWarnings)
|
|
} else {
|
|
fmt.Fprintf(w, "Lint: PASS (0 errors, %d warnings)\n", sr.LintWarnings)
|
|
}
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R4.4+R4.6+D§4
|
|
func emitStatusJSON(w io.Writer, statusResults []statusPlanResult) int {
|
|
enc := json.NewEncoder(w)
|
|
enc.SetEscapeHTML(false)
|
|
type tasksJSON struct {
|
|
Total int `json:"total"`
|
|
Open int `json:"open"`
|
|
Done int `json:"done"`
|
|
}
|
|
type lintJSON struct {
|
|
Errors int `json:"errors"`
|
|
Warnings int `json:"warnings"`
|
|
}
|
|
type closeoutJSON struct {
|
|
Ready bool `json:"ready"`
|
|
Missing []string `json:"missing"`
|
|
}
|
|
type planStatusJSON struct {
|
|
Plan string `json:"plan"`
|
|
Tasks tasksJSON `json:"tasks"`
|
|
Lint lintJSON `json:"lint"`
|
|
Closeout closeoutJSON `json:"closeout"`
|
|
Status string `json:"status"`
|
|
}
|
|
makePlanStatusJSON := func(sr statusPlanResult) planStatusJSON {
|
|
open := sr.TotalTasks - sr.DoneTasks
|
|
var missing []string
|
|
if !sr.HasCodexLog {
|
|
missing = append(missing, "codex-sessions.md")
|
|
}
|
|
if !sr.HasHandoff {
|
|
missing = append(missing, "handoff*.md")
|
|
}
|
|
if missing == nil {
|
|
missing = []string{}
|
|
}
|
|
return planStatusJSON{
|
|
Plan: sr.PlanDir,
|
|
Tasks: tasksJSON{Total: sr.TotalTasks, Open: open, Done: sr.DoneTasks},
|
|
Lint: lintJSON{Errors: sr.LintErrors, Warnings: sr.LintWarnings},
|
|
Closeout: closeoutJSON{Ready: len(missing) == 0, Missing: missing},
|
|
Status: string(sr.Status),
|
|
}
|
|
}
|
|
if len(statusResults) == 1 {
|
|
_ = enc.Encode(makePlanStatusJSON(statusResults[0]))
|
|
return computeStatusExit(statusResults)
|
|
}
|
|
type statusCountsJSON struct {
|
|
LintError int `json:"lint_error"`
|
|
NeedsCloseout int `json:"needs_closeout"`
|
|
Done int `json:"done"`
|
|
NotStarted int `json:"not_started"`
|
|
InProgress int `json:"in_progress"`
|
|
}
|
|
type summaryJSON struct {
|
|
TotalPlans int `json:"total_plans"`
|
|
Tasks tasksJSON `json:"tasks"`
|
|
StatusCounts statusCountsJSON `json:"status_counts"`
|
|
}
|
|
type multiStatusJSON struct {
|
|
Plans []planStatusJSON `json:"plans"`
|
|
Summary summaryJSON `json:"summary"`
|
|
}
|
|
m := multiStatusJSON{}
|
|
var totalTasks, totalOpen, totalDone int
|
|
sCounts := map[PlanStatus]int{}
|
|
for _, sr := range statusResults {
|
|
m.Plans = append(m.Plans, makePlanStatusJSON(sr))
|
|
totalTasks += sr.TotalTasks
|
|
totalDone += sr.DoneTasks
|
|
totalOpen += sr.TotalTasks - sr.DoneTasks
|
|
sCounts[sr.Status]++
|
|
}
|
|
if m.Plans == nil {
|
|
m.Plans = []planStatusJSON{}
|
|
}
|
|
m.Summary = summaryJSON{
|
|
TotalPlans: len(statusResults),
|
|
Tasks: tasksJSON{Total: totalTasks, Open: totalOpen, Done: totalDone},
|
|
StatusCounts: statusCountsJSON{
|
|
LintError: sCounts[StatusLintError],
|
|
NeedsCloseout: sCounts[StatusNeedsCloseout],
|
|
Done: sCounts[StatusDone],
|
|
NotStarted: sCounts[StatusNotStarted],
|
|
InProgress: sCounts[StatusInProgress],
|
|
},
|
|
}
|
|
_ = enc.Encode(m)
|
|
return computeStatusExit(statusResults)
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R4.5+R5.6+D§4
|
|
// computeStatusExit returns the exit code for a set of status results.
|
|
// Exit 0 only when every plan is StatusDone AND has no lint errors (including
|
|
// warnings promoted to errors via --strict). Exit 1 otherwise.
|
|
func computeStatusExit(results []statusPlanResult) int {
|
|
for _, r := range results {
|
|
if r.Status != StatusDone || r.LintErrors > 0 {
|
|
return 1
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// statusLabel returns the human-readable SCREAMING_SNAKE label for a PlanStatus.
|
|
func statusLabel(s PlanStatus) string {
|
|
switch s {
|
|
case StatusLintError:
|
|
return "LINT ERROR"
|
|
case StatusNeedsCloseout:
|
|
return "NEEDS CLOSEOUT"
|
|
case StatusDone:
|
|
return "DONE"
|
|
case StatusNotStarted:
|
|
return "NOT STARTED"
|
|
case StatusInProgress:
|
|
return "IN PROGRESS"
|
|
}
|
|
return string(s)
|
|
}
|
|
|
|
// ─── shared emit helpers ──────────────────────────────────────────────────────
|
|
|
|
// taskParent returns the immediate parent T-id by stripping the last dot
|
|
// segment: taskParent("T2.1") = "T2", taskParent("T2.1.3") = "T2.1".
|
|
// Returns "" for non-T-prefixed ids or bare top-level ids with no dot.
|
|
func taskParent(id string) string {
|
|
if !strings.HasPrefix(id, "T") {
|
|
return ""
|
|
}
|
|
suffix := id[1:]
|
|
dot := strings.LastIndex(suffix, ".")
|
|
if dot < 0 {
|
|
return ""
|
|
}
|
|
return "T" + suffix[:dot]
|
|
}
|
|
|
|
// buildTagLine assembles the Tags display string from req and design tag slices.
|
|
// Returns "" when both are empty.
|
|
func buildTagLine(reqTags, desTags []string) string {
|
|
var parts []string
|
|
if len(reqTags) > 0 {
|
|
parts = append(parts, "_Requirements: "+strings.Join(reqTags, ", ")+"_")
|
|
}
|
|
if len(desTags) > 0 {
|
|
parts = append(parts, "_Design: "+strings.Join(desTags, ", ")+"_")
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
// ─── v2 shared result types ───────────────────────────────────────────────────
|
|
|
|
// listPlanResult carries per-plan data for emitList.
|
|
type listPlanResult struct {
|
|
PlanDir string
|
|
Records []TaskRecord
|
|
}
|
|
|
|
// statusPlanResult carries per-plan data for emitStatus.
|
|
type statusPlanResult struct {
|
|
PlanDir string
|
|
TotalTasks int
|
|
DoneTasks int
|
|
LintErrors int
|
|
LintWarnings int
|
|
HasCodexLog bool
|
|
HasHandoff bool
|
|
Status PlanStatus
|
|
}
|