template-jj/cmd/planctl/emit.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

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
}