mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 03:16:43 +00:00
461c5a3e3d
The review could not converge on this diff's comments because each round of corrections re-expanded the surface it was reviewing — rounds 16 and 17 found errors inside 15 and 16's fixes. That is a production rate being measured, not a backlog being drained, so the treatment is to write fewer claims rather than review the same ones again. PRUNED, ~135 comment lines: process narration. "An earlier version said X", "found by mutation testing", "codex round N caught this", the scoreboards. Every one of those is already in a commit message, which is where the archaeology belongs; in the source they are claims a future reader has to verify, about a past that no longer exists. KEPT, because they earn it and a reader would otherwise re-derive them: metric semantics, reachability boundaries, what a test does and does not discriminate, why the obvious alternative was rejected, and the hazards that cannot be enforced in code. MOVED TO A TEST, per the rule this run earned the hard way: a comment asserting countable behaviour belongs in the suite. Two test comments in internal/watchevents relied on "this constructor waits for its SUBSCRIBE to be confirmed" — prose, and the same assumption applied to the OTHER bus (which subscribes asynchronously) is what made a namespace test flake. It is now asserted with no polling and no sleep, and the mutation that removes the wait fails it. That rule generalises and is why round 17's find mattered: "counts every unservable resume" was prose, so its falseness could hide a real metric gap. Prose is for claims that cannot be asserted. Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
1258 lines
35 KiB
Go
1258 lines
35 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/fatih/color"
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/cli"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
func statusCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "dashboard",
|
|
Short: "Show project dashboard — progress, attention items, suggested next",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
client, _ := getClient()
|
|
ws := getWorkspace()
|
|
|
|
dashJSON, err := client.GetDashboard(ws)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if formatFlag == "json" {
|
|
return cli.PrintJSON(dashJSON)
|
|
}
|
|
|
|
// Parse the dashboard response
|
|
var dash struct {
|
|
Summary struct {
|
|
TotalItems int `json:"total_items"`
|
|
ByCollection map[string]map[string]int `json:"by_collection"`
|
|
} `json:"summary"`
|
|
ActiveItems []struct {
|
|
Slug string `json:"slug"`
|
|
Title string `json:"title"`
|
|
CollectionSlug string `json:"collection_slug"`
|
|
CollectionIcon string `json:"collection_icon"`
|
|
Priority string `json:"priority"`
|
|
Status string `json:"status"`
|
|
ItemRef string `json:"item_ref"`
|
|
} `json:"active_items"`
|
|
ActivePlans []struct {
|
|
Slug string `json:"slug"`
|
|
Title string `json:"title"`
|
|
Progress int `json:"progress"`
|
|
TaskCount int `json:"task_count"`
|
|
DoneCount int `json:"done_count"`
|
|
} `json:"active_plans"`
|
|
ByRole []struct {
|
|
RoleName string `json:"role_name"`
|
|
RoleSlug string `json:"role_slug"`
|
|
RoleIcon string `json:"role_icon"`
|
|
Tools string `json:"tools"`
|
|
ItemCount int `json:"item_count"`
|
|
Users []string `json:"assigned_users"`
|
|
} `json:"by_role"`
|
|
Attention []struct {
|
|
Type string `json:"type"`
|
|
ItemSlug string `json:"item_slug"`
|
|
ItemTitle string `json:"item_title"`
|
|
Reason string `json:"reason"`
|
|
} `json:"attention"`
|
|
SuggestedNext []struct {
|
|
ItemSlug string `json:"item_slug"`
|
|
ItemTitle string `json:"item_title"`
|
|
Reason string `json:"reason"`
|
|
} `json:"suggested_next"`
|
|
}
|
|
|
|
if err := json.Unmarshal(dashJSON, &dash); err != nil {
|
|
fmt.Println(string(dashJSON))
|
|
return nil
|
|
}
|
|
|
|
bold := color.New(color.Bold)
|
|
dim := color.New(color.Faint)
|
|
headerColor := color.New(color.Bold, color.FgCyan)
|
|
yellow := color.New(color.FgYellow)
|
|
blue := color.New(color.FgBlue)
|
|
green := color.New(color.FgGreen)
|
|
|
|
headerColor.Printf("📊 Project Status (%d items)\n", dash.Summary.TotalItems)
|
|
fmt.Println(dim.Sprint(strings.Repeat("═", 50)))
|
|
|
|
// Collection summary
|
|
if len(dash.Summary.ByCollection) > 0 {
|
|
fmt.Println()
|
|
for collSlug, statuses := range dash.Summary.ByCollection {
|
|
parts := []string{}
|
|
for status, count := range statuses {
|
|
sc := cli.StatusColor(status)
|
|
parts = append(parts, sc.Sprintf("%s: %d", status, count))
|
|
}
|
|
fmt.Printf(" %s %s\n", bold.Sprintf("%-10s", collSlug), strings.Join(parts, ", "))
|
|
}
|
|
}
|
|
|
|
// Active work
|
|
if len(dash.ActiveItems) > 0 {
|
|
fmt.Println()
|
|
bold.Printf("🔨 Active Work (%d)\n", len(dash.ActiveItems))
|
|
for _, ai := range dash.ActiveItems {
|
|
ref := ""
|
|
if ai.ItemRef != "" {
|
|
ref = dim.Sprintf("%-10s ", ai.ItemRef)
|
|
}
|
|
status := cli.ColorizedStatus(ai.Status)
|
|
priority := ""
|
|
if ai.Priority != "" {
|
|
priority = " " + cli.PriorityColor(ai.Priority).Sprint(ai.Priority)
|
|
}
|
|
fmt.Printf(" %s%s %s%s\n", ref, bold.Sprint(ai.Title), status, priority)
|
|
}
|
|
}
|
|
|
|
// Active plans
|
|
if len(dash.ActivePlans) > 0 {
|
|
fmt.Println()
|
|
bold.Println("🏗️ Active Plans")
|
|
for _, plan := range dash.ActivePlans {
|
|
bar := colorProgressBar(plan.Progress, 20, green)
|
|
fmt.Printf(" %s %s %s %s\n",
|
|
bold.Sprint(plan.Title),
|
|
bar,
|
|
color.New(color.FgGreen).Sprintf("%d%%", plan.Progress),
|
|
dim.Sprintf("(%d/%d tasks)", plan.DoneCount, plan.TaskCount),
|
|
)
|
|
}
|
|
}
|
|
|
|
// Role breakdown
|
|
if len(dash.ByRole) > 0 {
|
|
fmt.Println()
|
|
bold.Println("🎭 Roles")
|
|
for _, r := range dash.ByRole {
|
|
icon := r.RoleIcon
|
|
if icon != "" {
|
|
icon += " "
|
|
}
|
|
name := r.RoleName
|
|
if name == "" {
|
|
name = "Unassigned"
|
|
}
|
|
users := ""
|
|
if len(r.Users) > 0 {
|
|
users = " (" + strings.Join(r.Users, ", ") + ")"
|
|
}
|
|
tools := ""
|
|
if r.Tools != "" {
|
|
tools = " [" + r.Tools + "]"
|
|
}
|
|
fmt.Printf(" %s%-14s %d items%s%s\n", icon, name, r.ItemCount, users, tools)
|
|
}
|
|
}
|
|
|
|
// Attention items
|
|
if len(dash.Attention) > 0 {
|
|
fmt.Println()
|
|
bold.Println("⚠️ Needs Attention")
|
|
for _, a := range dash.Attention {
|
|
fmt.Printf(" %s — %s\n", yellow.Sprint(a.ItemTitle), dim.Sprint(a.Reason))
|
|
}
|
|
}
|
|
|
|
// Suggested next
|
|
if len(dash.SuggestedNext) > 0 {
|
|
fmt.Println()
|
|
bold.Println("💡 Suggested Next")
|
|
for _, s := range dash.SuggestedNext {
|
|
fmt.Printf(" %s — %s\n", blue.Sprint(s.ItemTitle), dim.Sprint(s.Reason))
|
|
}
|
|
}
|
|
|
|
fmt.Println()
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func colorProgressBar(pct, width int, filledColor *color.Color) string {
|
|
filled := (pct * width) / 100
|
|
if filled > width {
|
|
filled = width
|
|
}
|
|
dim := color.New(color.Faint)
|
|
return "[" + filledColor.Sprint(strings.Repeat("█", filled)) + dim.Sprint(strings.Repeat("░", width-filled)) + "]"
|
|
}
|
|
|
|
// --- next ---
|
|
|
|
func nextCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "next",
|
|
Short: "Recommend the next task to work on",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
client, _ := getClient()
|
|
ws := getWorkspace()
|
|
|
|
dashJSON, err := client.GetDashboard(ws)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Decode once; both the JSON branch and the human-readable
|
|
// branch use the same suggested_next slice.
|
|
//
|
|
// BUG-987 bug 6: previously the JSON branch dumped the
|
|
// entire dashboard, making `project next --format json`
|
|
// indistinguishable from `project dashboard --format json`.
|
|
// Now it emits ONLY the recommended-next array (with the
|
|
// item-ref + reason fields agents need), matching the human
|
|
// branch's framing.
|
|
var dash struct {
|
|
SuggestedNext []struct {
|
|
ItemSlug string `json:"item_slug"`
|
|
ItemRef string `json:"item_ref,omitempty"`
|
|
ItemTitle string `json:"item_title"`
|
|
Collection string `json:"collection"`
|
|
Reason string `json:"reason"`
|
|
} `json:"suggested_next"`
|
|
}
|
|
|
|
if err := json.Unmarshal(dashJSON, &dash); err != nil {
|
|
return err
|
|
}
|
|
|
|
if formatFlag == "json" {
|
|
return cli.PrintJSON(dash.SuggestedNext)
|
|
}
|
|
|
|
if len(dash.SuggestedNext) == 0 {
|
|
fmt.Println("No suggestions — all tasks may be complete or no active plans found.")
|
|
return nil
|
|
}
|
|
|
|
bold := color.New(color.Bold)
|
|
dim := color.New(color.Faint)
|
|
|
|
bold.Println("💡 Recommended next:")
|
|
for i, s := range dash.SuggestedNext {
|
|
fmt.Printf(" %s %s\n %s\n",
|
|
dim.Sprintf("%d.", i+1),
|
|
bold.Sprint(s.ItemTitle),
|
|
dim.Sprint(s.Reason),
|
|
)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// listCompletedWorkSince fetches items that reached a *positive* terminal
|
|
// since cutoff. Terminal values are resolved per collection via
|
|
// models.CollectionCompletedWorkValues — KEEP IN SYNC with
|
|
// server.listTerminalItemsSince (BUG-1049). Best-effort per collection
|
|
// and status: a list error skips that query rather than failing the
|
|
// whole report.
|
|
func listCompletedWorkSince(client *cli.Client, ws string, cutoff time.Time, limit int) []models.Item {
|
|
colls, err := client.ListCollections(ws)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var out []models.Item
|
|
for _, c := range colls {
|
|
field, values := models.CollectionCompletedWorkValues(c.Schema, c.Settings)
|
|
for _, status := range values {
|
|
params := url.Values{
|
|
field: {status},
|
|
"sort": {"updated_at:desc"},
|
|
"limit": {strconv.Itoa(limit)},
|
|
}
|
|
items, err := client.ListCollectionItems(ws, c.Slug, params)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, item := range items {
|
|
if item.UpdatedAt.After(cutoff) {
|
|
out = append(out, item)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// --- standup ---
|
|
|
|
func standupCmd() *cobra.Command {
|
|
var days int
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "standup",
|
|
Short: "Auto-generate a daily standup report from recent activity",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
client, _ := getClient()
|
|
ws := getWorkspace()
|
|
|
|
// Fetch dashboard data
|
|
dashJSON, err := client.GetDashboard(ws)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Parse dashboard
|
|
var dash struct {
|
|
ActiveItems []struct {
|
|
Slug string `json:"slug"`
|
|
Title string `json:"title"`
|
|
Priority string `json:"priority"`
|
|
Status string `json:"status"`
|
|
ItemRef string `json:"item_ref"`
|
|
} `json:"active_items"`
|
|
// BUG-987 bug 8: previously omitted ItemRef from
|
|
// Attention + SuggestedNext, leaving the JSON output's
|
|
// blockers / suggested_next entries with empty refs.
|
|
// The dashboard handler populates item_ref on both
|
|
// arrays already; we just weren't reading the field.
|
|
Attention []struct {
|
|
Type string `json:"type"`
|
|
ItemSlug string `json:"item_slug"`
|
|
ItemRef string `json:"item_ref"`
|
|
ItemTitle string `json:"item_title"`
|
|
Reason string `json:"reason"`
|
|
} `json:"attention"`
|
|
SuggestedNext []struct {
|
|
ItemSlug string `json:"item_slug"`
|
|
ItemRef string `json:"item_ref"`
|
|
ItemTitle string `json:"item_title"`
|
|
Reason string `json:"reason"`
|
|
} `json:"suggested_next"`
|
|
}
|
|
if err := json.Unmarshal(dashJSON, &dash); err != nil {
|
|
return fmt.Errorf("parsing dashboard: %w", err)
|
|
}
|
|
|
|
cutoff := time.Now().AddDate(0, 0, -days)
|
|
completedItems := listCompletedWorkSince(client, ws, cutoff, 20)
|
|
|
|
// Fetch in-progress items
|
|
inProgressItems, err := client.ListItems(ws, url.Values{
|
|
"status": {"in-progress"},
|
|
"sort": {"updated_at:desc"},
|
|
})
|
|
if err != nil {
|
|
inProgressItems = nil
|
|
}
|
|
|
|
// Build JSON output if requested
|
|
if formatFlag == "json" {
|
|
type standupItem struct {
|
|
Ref string `json:"ref"`
|
|
Title string `json:"title"`
|
|
Status string `json:"status,omitempty"`
|
|
Priority string `json:"priority,omitempty"`
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
type standupJSON struct {
|
|
Date string `json:"date"`
|
|
Days int `json:"days"`
|
|
Completed []standupItem `json:"completed"`
|
|
InProgress []standupItem `json:"in_progress"`
|
|
Blockers []standupItem `json:"blockers"`
|
|
SuggestedNext []standupItem `json:"suggested_next"`
|
|
}
|
|
|
|
output := standupJSON{
|
|
Date: time.Now().Format("2006-01-02"),
|
|
Days: days,
|
|
Completed: []standupItem{},
|
|
InProgress: []standupItem{},
|
|
Blockers: []standupItem{},
|
|
SuggestedNext: []standupItem{},
|
|
}
|
|
|
|
for _, item := range completedItems {
|
|
output.Completed = append(output.Completed, standupItem{
|
|
Ref: cli.ItemRef(item),
|
|
Title: item.Title,
|
|
Status: extractFieldFromJSON(item.Fields, "status"),
|
|
})
|
|
}
|
|
for _, item := range inProgressItems {
|
|
output.InProgress = append(output.InProgress, standupItem{
|
|
Ref: cli.ItemRef(item),
|
|
Title: item.Title,
|
|
Priority: extractFieldFromJSON(item.Fields, "priority"),
|
|
})
|
|
}
|
|
for _, a := range dash.Attention {
|
|
output.Blockers = append(output.Blockers, standupItem{
|
|
Ref: a.ItemRef,
|
|
Title: a.ItemTitle,
|
|
Reason: a.Reason,
|
|
})
|
|
}
|
|
for _, s := range dash.SuggestedNext {
|
|
output.SuggestedNext = append(output.SuggestedNext, standupItem{
|
|
Ref: s.ItemRef,
|
|
Title: s.ItemTitle,
|
|
Reason: s.Reason,
|
|
})
|
|
}
|
|
|
|
return cli.PrintJSON(output)
|
|
}
|
|
|
|
// Human-readable output
|
|
bold := color.New(color.Bold)
|
|
dim := color.New(color.Faint)
|
|
headerColor := color.New(color.Bold, color.FgCyan)
|
|
yellow := color.New(color.FgYellow)
|
|
blue := color.New(color.FgBlue)
|
|
green := color.New(color.FgGreen)
|
|
|
|
dateStr := time.Now().Format("January 2, 2006")
|
|
headerColor.Printf("📋 Standup — %s\n", dateStr)
|
|
fmt.Println(dim.Sprint(strings.Repeat("═", 40)))
|
|
|
|
// Completed
|
|
fmt.Println()
|
|
bold.Println("✅ Completed")
|
|
if len(completedItems) == 0 {
|
|
fmt.Println(" " + dim.Sprint("(none)"))
|
|
} else {
|
|
for _, item := range completedItems {
|
|
ref := cli.ItemRef(item)
|
|
refStr := ""
|
|
if ref != "" {
|
|
refStr = dim.Sprintf("%-10s", ref) + " "
|
|
}
|
|
fmt.Printf(" %s%s\n", refStr, green.Sprint(item.Title))
|
|
}
|
|
}
|
|
|
|
// In Progress
|
|
fmt.Println()
|
|
bold.Println("🔨 In Progress")
|
|
if len(inProgressItems) == 0 && len(dash.ActiveItems) == 0 {
|
|
fmt.Println(" " + dim.Sprint("(none)"))
|
|
} else {
|
|
// Prefer dashboard active items (they include more metadata)
|
|
if len(dash.ActiveItems) > 0 {
|
|
for _, ai := range dash.ActiveItems {
|
|
ref := ""
|
|
if ai.ItemRef != "" {
|
|
ref = dim.Sprintf("%-10s", ai.ItemRef) + " "
|
|
}
|
|
priority := ""
|
|
if ai.Priority != "" {
|
|
priority = " (" + cli.PriorityColor(ai.Priority).Sprint(ai.Priority) + ")"
|
|
}
|
|
fmt.Printf(" %s%s%s\n", ref, bold.Sprint(ai.Title), priority)
|
|
}
|
|
} else {
|
|
for _, item := range inProgressItems {
|
|
ref := cli.ItemRef(item)
|
|
refStr := ""
|
|
if ref != "" {
|
|
refStr = dim.Sprintf("%-10s", ref) + " "
|
|
}
|
|
priorityStr := extractFieldFromJSON(item.Fields, "priority")
|
|
priority := ""
|
|
if priorityStr != "" {
|
|
priority = " (" + cli.PriorityColor(priorityStr).Sprint(priorityStr) + ")"
|
|
}
|
|
fmt.Printf(" %s%s%s\n", refStr, bold.Sprint(item.Title), priority)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Blockers
|
|
fmt.Println()
|
|
bold.Println("⚠️ Blockers")
|
|
if len(dash.Attention) == 0 {
|
|
fmt.Println(" " + dim.Sprint("(none)"))
|
|
} else {
|
|
for _, a := range dash.Attention {
|
|
fmt.Printf(" %s — %s\n", yellow.Sprint(a.ItemTitle), dim.Sprint(a.Reason))
|
|
}
|
|
}
|
|
|
|
// Up Next
|
|
fmt.Println()
|
|
bold.Println("💡 Up Next")
|
|
if len(dash.SuggestedNext) == 0 {
|
|
fmt.Println(" " + dim.Sprint("(none)"))
|
|
} else {
|
|
for _, s := range dash.SuggestedNext {
|
|
reason := ""
|
|
if s.Reason != "" {
|
|
reason = " (" + dim.Sprint(s.Reason) + ")"
|
|
}
|
|
fmt.Printf(" %s%s\n", blue.Sprint(s.ItemTitle), reason)
|
|
}
|
|
}
|
|
|
|
fmt.Println()
|
|
return nil
|
|
},
|
|
}
|
|
|
|
cmd.Flags().IntVar(&days, "days", 1, "number of days to look back for completed items")
|
|
return cmd
|
|
}
|
|
|
|
// --- changelog ---
|
|
|
|
func reportCmd() *cobra.Command {
|
|
var window string
|
|
var collections string
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "report",
|
|
Short: "Windowed project report — throughput, net flow, completions, status mix",
|
|
Long: `Show a time-windowed project report: items created vs completed per bucket,
|
|
net flow, completed-by-collection, and a current status-distribution snapshot.
|
|
|
|
--window day|week|2wk|month (default week; day buckets hourly, others daily).
|
|
--collections restricts to a comma-separated list of collection slugs.`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
client, _ := getClient()
|
|
ws := getWorkspace()
|
|
|
|
repJSON, err := client.GetReport(ws, window, collections)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if formatFlag == "json" {
|
|
return cli.PrintJSON(repJSON)
|
|
}
|
|
|
|
var rep struct {
|
|
Window string `json:"window"`
|
|
Granularity string `json:"granularity"`
|
|
RangeStart string `json:"range_start"`
|
|
RangeEnd string `json:"range_end"`
|
|
Collections []string `json:"collections"`
|
|
Buckets []struct {
|
|
Bucket string `json:"bucket"`
|
|
Created int `json:"created"`
|
|
Completed int `json:"completed"`
|
|
} `json:"buckets"`
|
|
Totals struct {
|
|
Created int `json:"created"`
|
|
Completed int `json:"completed"`
|
|
NetFlow int `json:"net_flow"`
|
|
} `json:"totals"`
|
|
CompletedByCollection []struct {
|
|
Collection string `json:"collection"`
|
|
Count int `json:"count"`
|
|
} `json:"completed_by_collection"`
|
|
StatusDistribution []struct {
|
|
Collection string `json:"collection"`
|
|
Status string `json:"status"`
|
|
Count int `json:"count"`
|
|
} `json:"status_distribution"`
|
|
CycleTime struct {
|
|
SampleSize int `json:"sample_size"`
|
|
MedianHours float64 `json:"median_hours"`
|
|
P90Hours float64 `json:"p90_hours"`
|
|
} `json:"cycle_time"`
|
|
WIP struct {
|
|
OpenCount int `json:"open_count"`
|
|
MedianAgeHours float64 `json:"median_age_hours"`
|
|
AgingBuckets []struct {
|
|
Label string `json:"label"`
|
|
Count int `json:"count"`
|
|
} `json:"aging_buckets"`
|
|
} `json:"wip"`
|
|
}
|
|
if err := json.Unmarshal(repJSON, &rep); err != nil {
|
|
fmt.Println(string(repJSON))
|
|
return nil
|
|
}
|
|
|
|
bold := color.New(color.Bold)
|
|
dim := color.New(color.Faint)
|
|
green := color.New(color.FgGreen)
|
|
red := color.New(color.FgRed)
|
|
|
|
bold.Printf("📊 Report — %s", rep.Window)
|
|
if len(rep.Collections) > 0 {
|
|
dim.Printf(" (%s)", strings.Join(rep.Collections, ", "))
|
|
}
|
|
fmt.Println()
|
|
dim.Printf(" %s → %s\n\n", rep.RangeStart, rep.RangeEnd)
|
|
|
|
net := fmt.Sprintf("%+d", rep.Totals.NetFlow)
|
|
fmt.Printf(" Created: ")
|
|
green.Printf("%d", rep.Totals.Created)
|
|
fmt.Printf(" Completed: ")
|
|
green.Printf("%d", rep.Totals.Completed)
|
|
fmt.Printf(" Net flow: ")
|
|
if rep.Totals.NetFlow >= 0 {
|
|
bold.Print(net)
|
|
} else {
|
|
red.Print(net)
|
|
}
|
|
fmt.Print("\n\n")
|
|
|
|
// Per-bucket throughput (skip all-zero buckets to keep it compact).
|
|
any := false
|
|
for _, b := range rep.Buckets {
|
|
if b.Created == 0 && b.Completed == 0 {
|
|
continue
|
|
}
|
|
if !any {
|
|
bold.Println(" Throughput (created / completed)")
|
|
any = true
|
|
}
|
|
fmt.Printf(" %-13s ", b.Bucket)
|
|
green.Printf("+%d", b.Created)
|
|
fmt.Print(" / ")
|
|
red.Printf("-%d", b.Completed)
|
|
fmt.Println()
|
|
}
|
|
if !any {
|
|
dim.Println(" No activity in this window.")
|
|
}
|
|
|
|
if len(rep.CompletedByCollection) > 0 {
|
|
fmt.Println()
|
|
bold.Println(" Completed by collection")
|
|
for _, c := range rep.CompletedByCollection {
|
|
fmt.Printf(" %-16s %d\n", c.Collection, c.Count)
|
|
}
|
|
}
|
|
|
|
if len(rep.StatusDistribution) > 0 {
|
|
fmt.Println()
|
|
bold.Println(" Status distribution")
|
|
for _, s := range rep.StatusDistribution {
|
|
fmt.Printf(" %-16s %-14s %d\n", s.Collection, s.Status, s.Count)
|
|
}
|
|
}
|
|
|
|
fmtHours := func(h float64) string {
|
|
if h >= 48 {
|
|
return fmt.Sprintf("%.1fd", h/24)
|
|
}
|
|
return fmt.Sprintf("%.1fh", h)
|
|
}
|
|
if rep.CycleTime.SampleSize > 0 {
|
|
fmt.Println()
|
|
bold.Println(" Cycle time (created → completed)")
|
|
fmt.Printf(" median %s p90 %s (n=%d)\n",
|
|
fmtHours(rep.CycleTime.MedianHours), fmtHours(rep.CycleTime.P90Hours), rep.CycleTime.SampleSize)
|
|
}
|
|
if rep.WIP.OpenCount > 0 {
|
|
fmt.Println()
|
|
bold.Printf(" Work in progress: %d open", rep.WIP.OpenCount)
|
|
dim.Printf(" (median age %s)\n", fmtHours(rep.WIP.MedianAgeHours))
|
|
for _, b := range rep.WIP.AgingBuckets {
|
|
if b.Count > 0 {
|
|
fmt.Printf(" %-7s %d\n", b.Label, b.Count)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&window, "window", "week", "Report window: day|week|2wk|month")
|
|
cmd.Flags().StringVar(&collections, "collections", "", "Comma-separated collection slugs to include (default: all visible)")
|
|
return cmd
|
|
}
|
|
|
|
func changelogCmd() *cobra.Command {
|
|
var days int
|
|
var since string
|
|
var parentRef string
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "changelog",
|
|
Short: "Generate release notes from completed items",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
client, _ := getClient()
|
|
ws := getWorkspace()
|
|
|
|
// Determine cutoff date
|
|
var cutoff time.Time
|
|
if since != "" {
|
|
parsed, err := time.Parse("2006-01-02", since)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid --since date (expected YYYY-MM-DD): %w", err)
|
|
}
|
|
cutoff = parsed
|
|
} else {
|
|
cutoff = time.Now().AddDate(0, 0, -days)
|
|
}
|
|
|
|
allItems := listCompletedWorkSince(client, ws, cutoff, 100)
|
|
|
|
// Filter by parent if specified
|
|
filterParent := parentRef
|
|
if filterParent != "" {
|
|
var filtered []models.Item
|
|
for _, item := range allItems {
|
|
// Check parent link (populated by API enrichment)
|
|
if strings.EqualFold(item.ParentLinkID, filterParent) ||
|
|
strings.EqualFold(item.ParentRef, filterParent) ||
|
|
strings.EqualFold(item.ParentTitle, filterParent) {
|
|
filtered = append(filtered, item)
|
|
}
|
|
}
|
|
allItems = filtered
|
|
}
|
|
|
|
// Group by collection
|
|
type collectionGroup struct {
|
|
Name string
|
|
Icon string
|
|
Items []models.Item
|
|
}
|
|
groupOrder := []string{}
|
|
groups := map[string]*collectionGroup{}
|
|
|
|
for _, item := range allItems {
|
|
key := item.CollectionSlug
|
|
if key == "" {
|
|
key = "other"
|
|
}
|
|
if _, exists := groups[key]; !exists {
|
|
name := item.CollectionName
|
|
if name == "" {
|
|
name = key
|
|
}
|
|
groups[key] = &collectionGroup{
|
|
Name: name,
|
|
Icon: item.CollectionIcon,
|
|
}
|
|
groupOrder = append(groupOrder, key)
|
|
}
|
|
groups[key].Items = append(groups[key].Items, item)
|
|
}
|
|
|
|
// Determine period label
|
|
periodLabel := fmt.Sprintf("last %d days", days)
|
|
if since != "" {
|
|
periodLabel = "since " + since
|
|
}
|
|
if filterParent != "" {
|
|
periodLabel += " (parent: " + filterParent + ")"
|
|
}
|
|
|
|
// JSON output
|
|
if formatFlag == "json" {
|
|
type changelogItem struct {
|
|
Ref string `json:"ref"`
|
|
Title string `json:"title"`
|
|
Status string `json:"status"`
|
|
}
|
|
type changelogGroup struct {
|
|
Collection string `json:"collection"`
|
|
Icon string `json:"icon,omitempty"`
|
|
Count int `json:"count"`
|
|
Items []changelogItem `json:"items"`
|
|
}
|
|
type changelogJSON struct {
|
|
Period string `json:"period"`
|
|
Since string `json:"since"`
|
|
Total int `json:"total"`
|
|
Groups []changelogGroup `json:"groups"`
|
|
}
|
|
|
|
output := changelogJSON{
|
|
Period: periodLabel,
|
|
Since: cutoff.Format("2006-01-02"),
|
|
Total: len(allItems),
|
|
Groups: []changelogGroup{},
|
|
}
|
|
|
|
for _, key := range groupOrder {
|
|
g := groups[key]
|
|
cg := changelogGroup{
|
|
Collection: g.Name,
|
|
Icon: g.Icon,
|
|
Count: len(g.Items),
|
|
Items: []changelogItem{},
|
|
}
|
|
for _, item := range g.Items {
|
|
cg.Items = append(cg.Items, changelogItem{
|
|
Ref: cli.ItemRef(item),
|
|
Title: item.Title,
|
|
Status: extractFieldFromJSON(item.Fields, "status"),
|
|
})
|
|
}
|
|
output.Groups = append(output.Groups, cg)
|
|
}
|
|
|
|
return cli.PrintJSON(output)
|
|
}
|
|
|
|
// Markdown output
|
|
if formatFlag == "markdown" {
|
|
fmt.Printf("# Changelog — %s\n\n", periodLabel)
|
|
|
|
if len(allItems) == 0 {
|
|
fmt.Println("No completed items in this period.")
|
|
return nil
|
|
}
|
|
|
|
for _, key := range groupOrder {
|
|
g := groups[key]
|
|
icon := g.Icon
|
|
if icon == "" {
|
|
icon = collectionDefaultIcon(key)
|
|
}
|
|
fmt.Printf("## %s %s (%d)\n\n", icon, g.Name, len(g.Items))
|
|
for _, item := range g.Items {
|
|
ref := cli.ItemRef(item)
|
|
if ref != "" {
|
|
fmt.Printf("- **%s** %s\n", ref, item.Title)
|
|
} else {
|
|
fmt.Printf("- %s\n", item.Title)
|
|
}
|
|
}
|
|
fmt.Println()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Human-readable table output (default)
|
|
bold := color.New(color.Bold)
|
|
dim := color.New(color.Faint)
|
|
headerColor := color.New(color.Bold, color.FgCyan)
|
|
green := color.New(color.FgGreen)
|
|
|
|
headerColor.Printf("📦 Changelog — %s\n", periodLabel)
|
|
fmt.Println(dim.Sprint(strings.Repeat("═", 40)))
|
|
|
|
if len(allItems) == 0 {
|
|
fmt.Println()
|
|
fmt.Println(dim.Sprint(" No completed items in this period."))
|
|
fmt.Println()
|
|
return nil
|
|
}
|
|
|
|
for _, key := range groupOrder {
|
|
g := groups[key]
|
|
icon := g.Icon
|
|
if icon == "" {
|
|
icon = collectionDefaultIcon(key)
|
|
}
|
|
fmt.Println()
|
|
bold.Printf("%s %s (%d)\n", icon, g.Name, len(g.Items))
|
|
for _, item := range g.Items {
|
|
ref := cli.ItemRef(item)
|
|
refStr := ""
|
|
if ref != "" {
|
|
refStr = dim.Sprintf("%-10s", ref) + " "
|
|
}
|
|
fmt.Printf(" %s%s\n", refStr, green.Sprint(item.Title))
|
|
}
|
|
}
|
|
|
|
fmt.Println()
|
|
return nil
|
|
},
|
|
}
|
|
|
|
cmd.Flags().IntVar(&days, "days", 7, "show items completed in last N days")
|
|
cmd.Flags().StringVar(&since, "since", "", "only show items completed after this date (YYYY-MM-DD)")
|
|
cmd.Flags().StringVar(&parentRef, "parent", "", "only show items under a specific parent (ref, slug, or title)")
|
|
|
|
return cmd
|
|
}
|
|
|
|
func activityCmd() *cobra.Command {
|
|
var limit int
|
|
var actor string
|
|
var since string
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "activity",
|
|
Short: "Show recent workspace activity — what agents and users changed (non-streaming)",
|
|
Long: `Show a bounded, non-streaming snapshot of workspace activity.
|
|
|
|
Answers "what did other agents/users do since I last worked?" — the query
|
|
counterpart to the live 'pad project watch' stream. Backed by the same
|
|
enriched activity feed the web UI uses (item refs, titles, change details).`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
client, _ := getClient()
|
|
ws := getWorkspace()
|
|
|
|
params := url.Values{}
|
|
if limit > 0 {
|
|
params.Set("limit", strconv.Itoa(limit))
|
|
}
|
|
if actor != "" {
|
|
params.Set("actor", actor)
|
|
}
|
|
if since != "" {
|
|
// Validate locally for a friendly error before the round-trip;
|
|
// the server applies the filter (a.created_at >= since) so LIMIT
|
|
// counts post-filter rows.
|
|
if _, err := time.Parse("2006-01-02", since); err != nil {
|
|
return fmt.Errorf("invalid --since date (expected YYYY-MM-DD): %w", err)
|
|
}
|
|
params.Set("since", since)
|
|
}
|
|
|
|
activities, err := client.ListActivity(ws, params)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if formatFlag == "json" {
|
|
if activities == nil {
|
|
activities = []models.Activity{}
|
|
}
|
|
return cli.PrintJSON(activities)
|
|
}
|
|
|
|
if len(activities) == 0 {
|
|
fmt.Println("No activity found.")
|
|
return nil
|
|
}
|
|
|
|
if formatFlag == "markdown" {
|
|
// Columns mirror what the terminal form shows: timestamp, actor,
|
|
// action, the item it touched, and the field-level changes.
|
|
rows := make([][]string, 0, len(activities))
|
|
for _, a := range activities {
|
|
actorName := a.ActorName
|
|
if actorName == "" {
|
|
actorName = a.Actor
|
|
}
|
|
if actorName == "" {
|
|
actorName = "unknown"
|
|
}
|
|
|
|
target := a.ItemTitle
|
|
if a.ItemRef != "" {
|
|
target = a.ItemRef
|
|
if a.ItemTitle != "" {
|
|
target += " " + a.ItemTitle
|
|
}
|
|
}
|
|
|
|
changes := ""
|
|
if a.Metadata != "" {
|
|
var meta struct {
|
|
Changes string `json:"changes"`
|
|
}
|
|
if json.Unmarshal([]byte(a.Metadata), &meta) == nil {
|
|
changes = meta.Changes
|
|
}
|
|
}
|
|
|
|
rows = append(rows, []string{
|
|
a.CreatedAt.Format("2006-01-02 15:04"),
|
|
actorName,
|
|
a.Action,
|
|
target,
|
|
changes,
|
|
})
|
|
}
|
|
cli.RenderMarkdownTable(os.Stdout,
|
|
[]string{"When", "Actor", "Action", "Item", "Changes"}, rows)
|
|
return nil
|
|
}
|
|
|
|
dim := color.New(color.Faint)
|
|
bold := color.New(color.Bold)
|
|
cyan := color.New(color.FgCyan)
|
|
|
|
for _, a := range activities {
|
|
timeStr := dim.Sprint(a.CreatedAt.Format("2006-01-02 15:04"))
|
|
|
|
actorName := a.ActorName
|
|
if actorName == "" {
|
|
actorName = a.Actor
|
|
}
|
|
if actorName == "" {
|
|
actorName = "unknown"
|
|
}
|
|
|
|
// Target: item ref + title when the activity references an item,
|
|
// otherwise a workspace-level (audit) event.
|
|
target := ""
|
|
if a.ItemRef != "" {
|
|
target = " " + cyan.Sprint(a.ItemRef)
|
|
if a.ItemTitle != "" {
|
|
target += " " + a.ItemTitle
|
|
}
|
|
} else if a.ItemTitle != "" {
|
|
target = " " + a.ItemTitle
|
|
}
|
|
|
|
fmt.Printf("%s %s %s%s\n", timeStr, bold.Sprint(actorName), a.Action, target)
|
|
|
|
// Surface field-level change details when present.
|
|
if a.Metadata != "" {
|
|
var meta struct {
|
|
Changes string `json:"changes"`
|
|
}
|
|
if json.Unmarshal([]byte(a.Metadata), &meta) == nil && meta.Changes != "" {
|
|
fmt.Printf(" %s\n", dim.Sprint(meta.Changes))
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
},
|
|
}
|
|
|
|
cmd.Flags().IntVar(&limit, "limit", 20, "maximum number of activity entries to return")
|
|
cmd.Flags().StringVar(&actor, "actor", "", "filter by actor category: user or agent")
|
|
cmd.Flags().StringVar(&since, "since", "", "only show activity on or after this date (YYYY-MM-DD)")
|
|
|
|
return cmd
|
|
}
|
|
|
|
func watchCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "watch",
|
|
Short: "Stream real-time workspace activity (like kubectl get events --watch)",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
_, cfg := getClient()
|
|
ws := getWorkspace()
|
|
|
|
sseURL := cfg.BaseURL() + "/api/v1/events?workspace=" + url.QueryEscape(ws)
|
|
|
|
// Set up context with signal handling for graceful shutdown
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
dim := color.New(color.Faint)
|
|
bold := color.New(color.Bold)
|
|
greenColor := color.New(color.FgGreen)
|
|
blueColor := color.New(color.FgBlue)
|
|
grayColor := color.New(color.Faint)
|
|
purpleColor := color.New(color.FgMagenta)
|
|
|
|
fmt.Printf("👁 Watching %s... (Ctrl+C to stop)\n\n", bold.Sprint(ws))
|
|
|
|
// Use an HTTP client with no timeout for SSE streaming
|
|
httpClient := &http.Client{}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", sseURL, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("create request: %w", err)
|
|
}
|
|
req.Header.Set("Accept", "text/event-stream")
|
|
req.Header.Set("Cache-Control", "no-cache")
|
|
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return nil // graceful shutdown
|
|
}
|
|
return fmt.Errorf("connect to event stream: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusTooManyRequests {
|
|
// The server is at its streaming-connection limit
|
|
// (BUG-2726). Distinguished from other non-200s because
|
|
// the remedy is different and the generic message —
|
|
// "event stream returned 429: {json}" — sends the reader
|
|
// looking for a bug rather than at a limit.
|
|
//
|
|
// This command exits rather than backing off, unlike the
|
|
// unattended `pad watch --stream` monitor: it is
|
|
// interactive and a human can decide. Retry-After says
|
|
// how long the server suggests waiting.
|
|
wait := resp.Header.Get("Retry-After")
|
|
if wait == "" {
|
|
wait = "a few seconds"
|
|
} else {
|
|
wait += "s"
|
|
}
|
|
// All THREE knobs named, because all three can refuse
|
|
// this endpoint — including the per-workspace one, which
|
|
// is the likeliest cause on a busy workspace.
|
|
return fmt.Errorf("the server is at its streaming-connection limit; retry in %s, "+
|
|
"or raise PAD_SSE_MAX_PER_WORKSPACE (this stream only), "+
|
|
"PAD_SSE_MAX_CONNECTIONS or PAD_SSE_MAX_PER_USER "+
|
|
"(both cover this stream and the agent watch stream together)", wait)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("event stream returned %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
// Read SSE stream line by line
|
|
scanner := bufio.NewScanner(resp.Body)
|
|
var currentEvent string
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
|
|
if strings.HasPrefix(line, "event: ") {
|
|
currentEvent = strings.TrimPrefix(line, "event: ")
|
|
continue
|
|
}
|
|
|
|
if strings.HasPrefix(line, "data: ") {
|
|
data := strings.TrimPrefix(line, "data: ")
|
|
|
|
// Skip the initial "connected" event
|
|
if currentEvent == "connected" {
|
|
currentEvent = ""
|
|
continue
|
|
}
|
|
|
|
// Parse the event data
|
|
var evt struct {
|
|
ItemID string `json:"item_id"`
|
|
Title string `json:"title"`
|
|
Collection string `json:"collection"`
|
|
Actor string `json:"actor"`
|
|
Source string `json:"source"`
|
|
Timestamp int64 `json:"timestamp"`
|
|
}
|
|
if err := json.Unmarshal([]byte(data), &evt); err != nil {
|
|
currentEvent = ""
|
|
continue
|
|
}
|
|
|
|
// Format timestamp
|
|
ts := time.Now()
|
|
if evt.Timestamp > 0 {
|
|
ts = time.UnixMilli(evt.Timestamp)
|
|
}
|
|
timeStr := dim.Sprintf("%s", ts.Format("15:04:05"))
|
|
|
|
// Determine emoji and color based on event type
|
|
var emoji string
|
|
var actionColor *color.Color
|
|
var action string
|
|
var prep string
|
|
|
|
switch currentEvent {
|
|
case "item_created":
|
|
emoji = "✨"
|
|
actionColor = greenColor
|
|
action = "Created"
|
|
prep = "in"
|
|
case "item_updated":
|
|
emoji = "✏️ "
|
|
actionColor = blueColor
|
|
action = "Updated"
|
|
prep = "in"
|
|
case "item_archived":
|
|
emoji = "🗑️"
|
|
actionColor = grayColor
|
|
action = "Archived"
|
|
prep = "from"
|
|
case "item_restored":
|
|
emoji = "♻️ "
|
|
actionColor = greenColor
|
|
action = "Restored"
|
|
prep = "in"
|
|
case "comment_created":
|
|
emoji = "💬"
|
|
actionColor = blueColor
|
|
action = "Comment on"
|
|
prep = "in"
|
|
case "comment_deleted":
|
|
emoji = "💬"
|
|
actionColor = grayColor
|
|
action = "Comment removed from"
|
|
prep = "in"
|
|
case "workspace_updated":
|
|
emoji = "⚙️ "
|
|
actionColor = blueColor
|
|
action = "Workspace updated"
|
|
prep = ""
|
|
default:
|
|
emoji = "•"
|
|
actionColor = dim
|
|
action = currentEvent
|
|
prep = "in"
|
|
}
|
|
|
|
// Format actor with color
|
|
actorStr := evt.Actor
|
|
if actorStr == "" {
|
|
actorStr = evt.Source
|
|
}
|
|
if actorStr == "" {
|
|
actorStr = "unknown"
|
|
}
|
|
|
|
// Color agent actors in purple
|
|
var actorFormatted string
|
|
if actorStr == "agent" || actorStr == "cli" || evt.Source == "agent" || evt.Source == "cli" || evt.Source == "skill" {
|
|
actorFormatted = purpleColor.Sprint(actorStr)
|
|
} else {
|
|
actorFormatted = actorStr
|
|
}
|
|
|
|
// Build the output line
|
|
title := bold.Sprint(evt.Title)
|
|
if evt.Title == "" && currentEvent == "workspace_updated" {
|
|
fmt.Printf("%s %s %s by %s\n",
|
|
timeStr, emoji, actionColor.Sprint(action), actorFormatted)
|
|
} else if evt.Collection != "" {
|
|
fmt.Printf("%s %s %s %q %s %s by %s\n",
|
|
timeStr, emoji, actionColor.Sprint(action), title, prep, evt.Collection, actorFormatted)
|
|
} else {
|
|
fmt.Printf("%s %s %s %q by %s\n",
|
|
timeStr, emoji, actionColor.Sprint(action), title, actorFormatted)
|
|
}
|
|
|
|
currentEvent = ""
|
|
continue
|
|
}
|
|
|
|
// Keepalive comments (lines starting with ":") — ignore
|
|
// silently. We use a direct byte comparison rather than
|
|
// strings.HasPrefix to dodge a long-standing staticcheck
|
|
// SA4017 false positive on this specific branch (the two
|
|
// sibling HasPrefix calls earlier in the loop don't trip
|
|
// it; only this one does, which strongly suggests an SSA-
|
|
// analysis quirk). Behaviour is identical for a single-
|
|
// byte ASCII prefix.
|
|
if len(line) > 0 && line[0] == ':' {
|
|
continue
|
|
}
|
|
|
|
// Empty line is the event separator — already handled above
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
if ctx.Err() != nil {
|
|
fmt.Println("\nStopped watching.")
|
|
return nil
|
|
}
|
|
return fmt.Errorf("reading event stream: %w", err)
|
|
}
|
|
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// --- agent roles ---
|