← back to Cli Printing Press
feat(generator): add PM workflow and behavioral insight templates
f55405b82af4534bdeeef7ffef585f8abd2fea79 · 2026-03-26 09:57:31 -0700 · Matt Van Horn
Phases 3-4 of the Creative Vision Engine:
- PM Workflow Templates: stale (items not updated in N days), orphans (missing
assignee/project), load (workload distribution per assignee)
- Insight Templates: health (composite 0-100 workspace score from stale ratio,
orphan ratio, and velocity), similar (FTS5-based duplicate detection)
- Generator: renders workflow and insight templates based on VisionTemplateSet
- root.go.tmpl: conditionally registers workflow/insight commands via HasWorkflows()/HasInsights()
The generator now automatically produces Rung 4 (domain analytics) and Rung 5
(behavioral insights) commands for project management APIs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Files touched
M internal/generator/generator.goA internal/generator/templates/insights/health_score.go.tmplA internal/generator/templates/insights/similar.go.tmplM internal/generator/templates/root.go.tmplA internal/generator/templates/workflows/pm_load.go.tmplA internal/generator/templates/workflows/pm_orphans.go.tmplA internal/generator/templates/workflows/pm_stale.go.tmplM internal/generator/vision_templates.go
Diff
commit f55405b82af4534bdeeef7ffef585f8abd2fea79
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date: Thu Mar 26 09:57:31 2026 -0700
feat(generator): add PM workflow and behavioral insight templates
Phases 3-4 of the Creative Vision Engine:
- PM Workflow Templates: stale (items not updated in N days), orphans (missing
assignee/project), load (workload distribution per assignee)
- Insight Templates: health (composite 0-100 workspace score from stale ratio,
orphan ratio, and velocity), similar (FTS5-based duplicate detection)
- Generator: renders workflow and insight templates based on VisionTemplateSet
- root.go.tmpl: conditionally registers workflow/insight commands via HasWorkflows()/HasInsights()
The generator now automatically produces Rung 4 (domain analytics) and Rung 5
(behavioral insights) commands for project management APIs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
internal/generator/generator.go | 18 +++
.../templates/insights/health_score.go.tmpl | 169 +++++++++++++++++++++
.../generator/templates/insights/similar.go.tmpl | 168 ++++++++++++++++++++
internal/generator/templates/root.go.tmpl | 9 ++
.../generator/templates/workflows/pm_load.go.tmpl | 117 ++++++++++++++
.../templates/workflows/pm_orphans.go.tmpl | 144 ++++++++++++++++++
.../generator/templates/workflows/pm_stale.go.tmpl | 150 ++++++++++++++++++
internal/generator/vision_templates.go | 5 +-
8 files changed, 779 insertions(+), 1 deletion(-)
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index bb32848b..dc2dd807 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -259,6 +259,24 @@ func (g *Generator) Generate() error {
}
}
+ // Render domain-specific workflow templates
+ for _, tmpl := range g.VisionSet.Workflows {
+ outName := strings.TrimSuffix(filepath.Base(tmpl), ".tmpl")
+ outPath := filepath.Join("internal", "cli", outName)
+ if err := g.renderTemplate(tmpl, outPath, g.Spec); err != nil {
+ fmt.Fprintf(os.Stderr, "warning: skipping workflow template %s: %v\n", tmpl, err)
+ }
+ }
+
+ // Render insight templates
+ for _, tmpl := range g.VisionSet.Insights {
+ outName := strings.TrimSuffix(filepath.Base(tmpl), ".tmpl")
+ outPath := filepath.Join("internal", "cli", outName)
+ if err := g.renderTemplate(tmpl, outPath, g.Spec); err != nil {
+ fmt.Fprintf(os.Stderr, "warning: skipping insight template %s: %v\n", tmpl, err)
+ }
+ }
+
rootData := struct {
*spec.APISpec
VisionSet VisionTemplateSet
diff --git a/internal/generator/templates/insights/health_score.go.tmpl b/internal/generator/templates/insights/health_score.go.tmpl
new file mode 100644
index 00000000..7d6e83cb
--- /dev/null
+++ b/internal/generator/templates/insights/health_score.go.tmpl
@@ -0,0 +1,169 @@
+// Code generated by CLI Printing Press. DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/{{.Owner}}/{{.Name}}-cli/internal/store"
+ "github.com/spf13/cobra"
+)
+
+func newHealthCmd(flags *rootFlags) *cobra.Command {
+ var dbPath string
+
+ cmd := &cobra.Command{
+ Use: "health",
+ Short: "Compute a composite workspace health score (0-100)",
+ Long: `Analyze locally synced data to produce a health score based on:
+ - Stale ratio: percentage of items not updated in 30+ days (weight: 35%)
+ - Orphan ratio: percentage of items missing assignee or project (weight: 30%)
+ - Recent velocity: items created or updated in the last 7 days (weight: 35%)
+
+A score of 100 means all items are fresh, assigned, and actively worked on.`,
+ Example: ` # Show workspace health score
+ {{.Name}}-cli health
+
+ # Output as JSON
+ {{.Name}}-cli health --json`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if dbPath == "" {
+ home, _ := os.UserHomeDir()
+ dbPath = filepath.Join(home, ".config", "{{.Name}}-cli", "store.db")
+ }
+
+ db, err := store.Open(dbPath)
+ if err != nil {
+ return fmt.Errorf("opening local database: %w\nRun '{{.Name}}-cli sync' first.", err)
+ }
+ defer db.Close()
+
+ rows, err := db.Query(`SELECT data FROM resources`)
+ if err != nil {
+ return fmt.Errorf("querying items: %w", err)
+ }
+ defer rows.Close()
+
+ now := time.Now()
+ staleCutoff := now.AddDate(0, 0, -30)
+ recentCutoff := now.AddDate(0, 0, -7)
+
+ totalItems := 0
+ staleCount := 0
+ orphanCount := 0
+ recentCount := 0
+
+ for rows.Next() {
+ var data []byte
+ if err := rows.Scan(&data); err != nil {
+ continue
+ }
+ var obj map[string]any
+ if err := json.Unmarshal(data, &obj); err != nil {
+ continue
+ }
+ totalItems++
+
+ // Check staleness
+ isStale := false
+ for _, key := range []string{"updatedAt", "updated_at", "updatedDate"} {
+ if v, ok := obj[key]; ok {
+ if t, err := time.Parse(time.RFC3339, fmt.Sprintf("%v", v)); err == nil {
+ if t.Before(staleCutoff) {
+ isStale = true
+ }
+ if t.After(recentCutoff) {
+ recentCount++
+ }
+ break
+ }
+ }
+ }
+ if isStale {
+ staleCount++
+ }
+
+ // Check orphan status
+ hasAssignee := false
+ for _, key := range []string{"assigneeId", "assignee_id", "assignee", "ownerId", "owner_id"} {
+ if v, ok := obj[key]; ok && v != nil && fmt.Sprintf("%v", v) != "" {
+ hasAssignee = true
+ break
+ }
+ }
+ hasProject := false
+ for _, key := range []string{"projectId", "project_id", "project"} {
+ if v, ok := obj[key]; ok && v != nil && fmt.Sprintf("%v", v) != "" {
+ hasProject = true
+ break
+ }
+ }
+ if !hasAssignee || !hasProject {
+ orphanCount++
+ }
+ }
+
+ if totalItems == 0 {
+ if flags.asJSON {
+ enc := json.NewEncoder(cmd.OutOrStdout())
+ enc.SetIndent("", " ")
+ return enc.Encode(map[string]any{"health": 0, "error": "no items found"})
+ }
+ fmt.Fprintln(cmd.OutOrStdout(), "No items found. Run sync first.")
+ return nil
+ }
+
+ // Compute subscores (0-100 each)
+ staleRatio := float64(staleCount) / float64(totalItems)
+ staleScore := int((1.0 - staleRatio) * 100)
+
+ orphanRatio := float64(orphanCount) / float64(totalItems)
+ orphanScore := int((1.0 - orphanRatio) * 100)
+
+ velocityRatio := float64(recentCount) / float64(totalItems)
+ if velocityRatio > 1.0 {
+ velocityRatio = 1.0
+ }
+ velocityScore := int(velocityRatio * 100)
+
+ // Weighted average
+ healthScore := int(float64(staleScore)*0.35 + float64(orphanScore)*0.30 + float64(velocityScore)*0.35)
+ if healthScore > 100 {
+ healthScore = 100
+ }
+ if healthScore < 0 {
+ healthScore = 0
+ }
+
+ if flags.asJSON {
+ result := map[string]any{
+ "health": healthScore,
+ "total_items": totalItems,
+ "stale_score": staleScore,
+ "stale_count": staleCount,
+ "orphan_score": orphanScore,
+ "orphan_count": orphanCount,
+ "velocity_score": velocityScore,
+ "recent_count": recentCount,
+ }
+ enc := json.NewEncoder(cmd.OutOrStdout())
+ enc.SetIndent("", " ")
+ return enc.Encode(result)
+ }
+
+ fmt.Fprintf(cmd.OutOrStdout(), "Health: %d/100\n\n", healthScore)
+ fmt.Fprintf(cmd.OutOrStdout(), " Freshness: %3d/100 (%d of %d items stale, 30+ days)\n", staleScore, staleCount, totalItems)
+ fmt.Fprintf(cmd.OutOrStdout(), " Ownership: %3d/100 (%d of %d items missing assignee/project)\n", orphanScore, orphanCount, totalItems)
+ fmt.Fprintf(cmd.OutOrStdout(), " Velocity: %3d/100 (%d of %d items updated in last 7 days)\n", velocityScore, recentCount, totalItems)
+ return nil
+ },
+ }
+
+ cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: ~/.config/{{.Name}}-cli/store.db)")
+
+ return cmd
+}
diff --git a/internal/generator/templates/insights/similar.go.tmpl b/internal/generator/templates/insights/similar.go.tmpl
new file mode 100644
index 00000000..fe1dc82a
--- /dev/null
+++ b/internal/generator/templates/insights/similar.go.tmpl
@@ -0,0 +1,168 @@
+// Code generated by CLI Printing Press. DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/{{.Owner}}/{{.Name}}-cli/internal/store"
+ "github.com/spf13/cobra"
+)
+
+func newSimilarCmd(flags *rootFlags) *cobra.Command {
+ var dbPath string
+ var limit int
+
+ cmd := &cobra.Command{
+ Use: "similar <item-id>",
+ Short: "Find potential duplicate items using full-text search",
+ Long: `Given an item ID, extract its title or name and use the FTS5 full-text
+search index to find other items with similar content. Helps identify duplicate
+work items, tickets, or tasks.`,
+ Example: ` # Find items similar to a given item
+ {{.Name}}-cli similar abc123
+
+ # Limit results
+ {{.Name}}-cli similar abc123 --limit 5
+
+ # Output as JSON
+ {{.Name}}-cli similar abc123 --json`,
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ itemID := args[0]
+
+ if dbPath == "" {
+ home, _ := os.UserHomeDir()
+ dbPath = filepath.Join(home, ".config", "{{.Name}}-cli", "store.db")
+ }
+
+ db, err := store.Open(dbPath)
+ if err != nil {
+ return fmt.Errorf("opening local database: %w\nRun '{{.Name}}-cli sync' first.", err)
+ }
+ defer db.Close()
+
+ // Get the source item's content
+ var sourceData []byte
+ err = db.QueryRow(`SELECT data FROM resources WHERE id = ?`, itemID).Scan(&sourceData)
+ if err != nil {
+ return fmt.Errorf("item %q not found in local store: %w", itemID, err)
+ }
+
+ var sourceObj map[string]any
+ if err := json.Unmarshal(sourceData, &sourceObj); err != nil {
+ return fmt.Errorf("parsing item data: %w", err)
+ }
+
+ // Extract searchable text from the source item
+ searchText := ""
+ for _, key := range []string{"title", "name", "subject", "summary", "description"} {
+ if v, ok := sourceObj[key]; ok && v != nil {
+ text := fmt.Sprintf("%v", v)
+ if text != "" {
+ searchText = text
+ break
+ }
+ }
+ }
+
+ if searchText == "" {
+ return fmt.Errorf("item %q has no searchable title or name", itemID)
+ }
+
+ // Use FTS5 to find similar items (exclude the source item)
+ query := `SELECT r.id, r.resource_type, r.data, rank
+ FROM resources_fts fts
+ JOIN resources r ON r.id = fts.id
+ WHERE resources_fts MATCH ?
+ AND r.id != ?
+ ORDER BY rank
+ LIMIT ?`
+
+ rows, err := db.Query(query, searchText, itemID, limit)
+ if err != nil {
+ return fmt.Errorf("searching for similar items: %w", err)
+ }
+ defer rows.Close()
+
+ type similarItem struct {
+ ID string `json:"id"`
+ ResourceType string `json:"resource_type"`
+ Title string `json:"title,omitempty"`
+ Rank float64 `json:"relevance_rank"`
+ }
+
+ var items []similarItem
+ for rows.Next() {
+ var id, resType string
+ var data []byte
+ var rank float64
+ if err := rows.Scan(&id, &resType, &data, &rank); err != nil {
+ continue
+ }
+
+ var obj map[string]any
+ if err := json.Unmarshal(data, &obj); err != nil {
+ continue
+ }
+
+ title := ""
+ for _, key := range []string{"title", "name", "subject", "summary"} {
+ if v, ok := obj[key]; ok {
+ title = fmt.Sprintf("%v", v)
+ break
+ }
+ }
+
+ items = append(items, similarItem{
+ ID: id,
+ ResourceType: resType,
+ Title: title,
+ Rank: rank,
+ })
+ }
+
+ if flags.asJSON {
+ result := map[string]any{
+ "source_id": itemID,
+ "search_text": searchText,
+ "similar": items,
+ "result_count": len(items),
+ }
+ enc := json.NewEncoder(cmd.OutOrStdout())
+ enc.SetIndent("", " ")
+ return enc.Encode(result)
+ }
+
+ sourceTitle := searchText
+ if len(sourceTitle) > 60 {
+ sourceTitle = sourceTitle[:57] + "..."
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "Items similar to %s (%q):\n\n", itemID, sourceTitle)
+
+ if len(items) == 0 {
+ fmt.Fprintln(cmd.OutOrStdout(), "No similar items found.")
+ return nil
+ }
+
+ fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-15s %s\n", "ID", "TYPE", "TITLE")
+ fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-15s %s\n", "----", "----", "-----")
+ for _, item := range items {
+ titleDisplay := item.Title
+ if len(titleDisplay) > 50 {
+ titleDisplay = titleDisplay[:47] + "..."
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-15s %s\n", item.ID, item.ResourceType, titleDisplay)
+ }
+ return nil
+ },
+ }
+
+ cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: ~/.config/{{.Name}}-cli/store.db)")
+ cmd.Flags().IntVar(&limit, "limit", 10, "Maximum number of similar items to show")
+
+ return cmd
+}
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index b7ce9e90..a99289c4 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -79,6 +79,15 @@ func Execute() error {
{{- end}}
{{- if .VisionSet.Store}}
rootCmd.AddCommand(newWorkflowCmd(&flags))
+{{- end}}
+{{- if .VisionSet.HasWorkflows}}
+ rootCmd.AddCommand(newStaleCmd(&flags))
+ rootCmd.AddCommand(newOrphansCmd(&flags))
+ rootCmd.AddCommand(newLoadCmd(&flags))
+{{- end}}
+{{- if .VisionSet.HasInsights}}
+ rootCmd.AddCommand(newHealthCmd(&flags))
+ rootCmd.AddCommand(newSimilarCmd(&flags))
{{- end}}
rootCmd.AddCommand(newVersionCliCmd())
diff --git a/internal/generator/templates/workflows/pm_load.go.tmpl b/internal/generator/templates/workflows/pm_load.go.tmpl
new file mode 100644
index 00000000..0b6a6e47
--- /dev/null
+++ b/internal/generator/templates/workflows/pm_load.go.tmpl
@@ -0,0 +1,117 @@
+// Code generated by CLI Printing Press. DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+
+ "github.com/{{.Owner}}/{{.Name}}-cli/internal/store"
+ "github.com/spf13/cobra"
+)
+
+func newLoadCmd(flags *rootFlags) *cobra.Command {
+ var dbPath string
+
+ cmd := &cobra.Command{
+ Use: "load",
+ Short: "Show workload distribution per assignee",
+ Long: `Analyze locally synced data to show how many items are assigned to each
+person. Helps identify overloaded team members and unbalanced workload.`,
+ Example: ` # Show workload distribution
+ {{.Name}}-cli load
+
+ # Output as JSON
+ {{.Name}}-cli load --json`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if dbPath == "" {
+ home, _ := os.UserHomeDir()
+ dbPath = filepath.Join(home, ".config", "{{.Name}}-cli", "store.db")
+ }
+
+ db, err := store.Open(dbPath)
+ if err != nil {
+ return fmt.Errorf("opening local database: %w\nRun '{{.Name}}-cli sync' first.", err)
+ }
+ defer db.Close()
+
+ rows, err := db.Query(`SELECT id, resource_type, data FROM resources`)
+ if err != nil {
+ return fmt.Errorf("querying items: %w", err)
+ }
+ defer rows.Close()
+
+ type loadEntry struct {
+ Assignee string `json:"assignee"`
+ Count int `json:"count"`
+ }
+
+ counts := make(map[string]int)
+ total := 0
+ for rows.Next() {
+ var id, resType string
+ var data []byte
+ if err := rows.Scan(&id, &resType, &data); err != nil {
+ continue
+ }
+ _ = id
+ _ = resType
+
+ var obj map[string]any
+ if err := json.Unmarshal(data, &obj); err != nil {
+ continue
+ }
+
+ assignee := ""
+ for _, key := range []string{"assigneeId", "assignee_id", "assignee", "ownerId", "owner_id", "owner"} {
+ if v, ok := obj[key]; ok && v != nil {
+ assignee = fmt.Sprintf("%v", v)
+ break
+ }
+ }
+ if assignee == "" || assignee == "<nil>" {
+ assignee = "(unassigned)"
+ }
+
+ counts[assignee]++
+ total++
+ }
+
+ var entries []loadEntry
+ for k, v := range counts {
+ entries = append(entries, loadEntry{Assignee: k, Count: v})
+ }
+ sort.Slice(entries, func(i, j int) bool { return entries[i].Count > entries[j].Count })
+
+ if flags.asJSON {
+ result := map[string]any{
+ "total": total,
+ "distribution": entries,
+ }
+ enc := json.NewEncoder(cmd.OutOrStdout())
+ enc.SetIndent("", " ")
+ return enc.Encode(result)
+ }
+
+ if len(entries) == 0 {
+ fmt.Fprintln(cmd.OutOrStdout(), "No items found. Run sync first.")
+ return nil
+ }
+
+ fmt.Fprintf(cmd.OutOrStdout(), "Workload Distribution (%d total items):\n\n", total)
+ fmt.Fprintf(cmd.OutOrStdout(), "%-40s %s\n", "ASSIGNEE", "COUNT")
+ fmt.Fprintf(cmd.OutOrStdout(), "%-40s %s\n", "--------", "-----")
+ for _, entry := range entries {
+ fmt.Fprintf(cmd.OutOrStdout(), "%-40s %d\n", entry.Assignee, entry.Count)
+ }
+ return nil
+ },
+ }
+
+ cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: ~/.config/{{.Name}}-cli/store.db)")
+
+ return cmd
+}
diff --git a/internal/generator/templates/workflows/pm_orphans.go.tmpl b/internal/generator/templates/workflows/pm_orphans.go.tmpl
new file mode 100644
index 00000000..37a0eda7
--- /dev/null
+++ b/internal/generator/templates/workflows/pm_orphans.go.tmpl
@@ -0,0 +1,144 @@
+// Code generated by CLI Printing Press. DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/{{.Owner}}/{{.Name}}-cli/internal/store"
+ "github.com/spf13/cobra"
+)
+
+func newOrphansCmd(flags *rootFlags) *cobra.Command {
+ var dbPath string
+
+ cmd := &cobra.Command{
+ Use: "orphans",
+ Short: "Find items missing key fields like assignee or project",
+ Long: `Scan locally synced data for items that are missing important fields
+such as assignee, project, priority, or labels. Useful for triaging unowned work.`,
+ Example: ` # Find orphaned items
+ {{.Name}}-cli orphans
+
+ # Output as JSON
+ {{.Name}}-cli orphans --json`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if dbPath == "" {
+ home, _ := os.UserHomeDir()
+ dbPath = filepath.Join(home, ".config", "{{.Name}}-cli", "store.db")
+ }
+
+ db, err := store.Open(dbPath)
+ if err != nil {
+ return fmt.Errorf("opening local database: %w\nRun '{{.Name}}-cli sync' first.", err)
+ }
+ defer db.Close()
+
+ // Fields that indicate an item is properly assigned/categorized
+ checkFields := []struct {
+ jsonPaths []string
+ label string
+ }{
+ {[]string{"$.assigneeId", "$.assignee_id", "$.assignee", "$.ownerId", "$.owner_id"}, "assignee"},
+ {[]string{"$.projectId", "$.project_id", "$.project"}, "project"},
+ {[]string{"$.priority", "$.priorityId", "$.priority_id"}, "priority"},
+ {[]string{"$.labels", "$.labelIds", "$.label_ids", "$.tags"}, "labels"},
+ }
+
+ rows, err := db.Query(`SELECT id, resource_type, data FROM resources ORDER BY resource_type, id`)
+ if err != nil {
+ return fmt.Errorf("querying items: %w", err)
+ }
+ defer rows.Close()
+
+ type orphanItem struct {
+ ID string `json:"id"`
+ ResourceType string `json:"resource_type"`
+ Title string `json:"title,omitempty"`
+ MissingFields []string `json:"missing_fields"`
+ }
+
+ var items []orphanItem
+ for rows.Next() {
+ var id, resType string
+ var data []byte
+ if err := rows.Scan(&id, &resType, &data); err != nil {
+ continue
+ }
+ var obj map[string]any
+ if err := json.Unmarshal(data, &obj); err != nil {
+ continue
+ }
+
+ var missing []string
+ for _, cf := range checkFields {
+ found := false
+ for _, path := range cf.jsonPaths {
+ // Strip "$." prefix for direct map lookup
+ key := strings.TrimPrefix(path, "$.")
+ if v, ok := obj[key]; ok {
+ if v != nil && fmt.Sprintf("%v", v) != "" && fmt.Sprintf("%v", v) != "[]" && fmt.Sprintf("%v", v) != "<nil>" {
+ found = true
+ break
+ }
+ }
+ }
+ if !found {
+ missing = append(missing, cf.label)
+ }
+ }
+
+ if len(missing) == 0 {
+ continue
+ }
+
+ title := ""
+ for _, key := range []string{"title", "name", "subject", "summary"} {
+ if v, ok := obj[key]; ok {
+ title = fmt.Sprintf("%v", v)
+ break
+ }
+ }
+
+ items = append(items, orphanItem{
+ ID: id,
+ ResourceType: resType,
+ Title: title,
+ MissingFields: missing,
+ })
+ }
+
+ if flags.asJSON {
+ enc := json.NewEncoder(cmd.OutOrStdout())
+ enc.SetIndent("", " ")
+ return enc.Encode(items)
+ }
+
+ if len(items) == 0 {
+ fmt.Fprintln(cmd.OutOrStdout(), "No orphaned items found. All items have key fields populated.")
+ return nil
+ }
+
+ fmt.Fprintf(cmd.OutOrStdout(), "Found %d orphaned items:\n\n", len(items))
+ fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-15s %-30s %s\n", "ID", "TYPE", "MISSING", "TITLE")
+ fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-15s %-30s %s\n", "----", "----", "-------", "-----")
+ for _, item := range items {
+ titleDisplay := item.Title
+ if len(titleDisplay) > 40 {
+ titleDisplay = titleDisplay[:37] + "..."
+ }
+ missingStr := strings.Join(item.MissingFields, ", ")
+ fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-15s %-30s %s\n", item.ID, item.ResourceType, missingStr, titleDisplay)
+ }
+ return nil
+ },
+ }
+
+ cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: ~/.config/{{.Name}}-cli/store.db)")
+
+ return cmd
+}
diff --git a/internal/generator/templates/workflows/pm_stale.go.tmpl b/internal/generator/templates/workflows/pm_stale.go.tmpl
new file mode 100644
index 00000000..565550af
--- /dev/null
+++ b/internal/generator/templates/workflows/pm_stale.go.tmpl
@@ -0,0 +1,150 @@
+// Code generated by CLI Printing Press. DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/{{.Owner}}/{{.Name}}-cli/internal/store"
+ "github.com/spf13/cobra"
+)
+
+func newStaleCmd(flags *rootFlags) *cobra.Command {
+ var days int
+ var team string
+ var dbPath string
+
+ cmd := &cobra.Command{
+ Use: "stale",
+ Short: "Find items with no updates in N days",
+ Long: `Scan locally synced data for items that have not been updated within
+the specified number of days. Useful for identifying forgotten or blocked work.`,
+ Example: ` # Find items not updated in 30 days (default)
+ {{.Name}}-cli stale
+
+ # Find items not updated in 14 days
+ {{.Name}}-cli stale --days 14
+
+ # Filter by team
+ {{.Name}}-cli stale --days 7 --team backend
+
+ # Output as JSON
+ {{.Name}}-cli stale --days 30 --json`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if dbPath == "" {
+ home, _ := os.UserHomeDir()
+ dbPath = filepath.Join(home, ".config", "{{.Name}}-cli", "store.db")
+ }
+
+ db, err := store.Open(dbPath)
+ if err != nil {
+ return fmt.Errorf("opening local database: %w\nRun '{{.Name}}-cli sync' first.", err)
+ }
+ defer db.Close()
+
+ cutoff := time.Now().AddDate(0, 0, -days).Format(time.RFC3339)
+
+ // Query resources table for items not updated since cutoff
+ query := `SELECT id, resource_type, data FROM resources
+ WHERE json_extract(data, '$.updatedAt') < ?
+ OR json_extract(data, '$.updated_at') < ?`
+ qArgs := []any{cutoff, cutoff}
+
+ if team != "" {
+ query += ` AND (json_extract(data, '$.teamId') = ? OR json_extract(data, '$.team_id') = ? OR json_extract(data, '$.team') = ?)`
+ qArgs = append(qArgs, team, team, team)
+ }
+
+ query += ` ORDER BY json_extract(data, '$.updatedAt') ASC, json_extract(data, '$.updated_at') ASC`
+
+ rows, err := db.Query(query, qArgs...)
+ if err != nil {
+ return fmt.Errorf("querying stale items: %w", err)
+ }
+ defer rows.Close()
+
+ type staleItem struct {
+ ID string `json:"id"`
+ ResourceType string `json:"resource_type"`
+ Title string `json:"title,omitempty"`
+ UpdatedAt string `json:"updated_at,omitempty"`
+ DaysSince int `json:"days_since"`
+ }
+
+ var items []staleItem
+ now := time.Now()
+ for rows.Next() {
+ var id, resType string
+ var data []byte
+ if err := rows.Scan(&id, &resType, &data); err != nil {
+ continue
+ }
+ var obj map[string]any
+ if err := json.Unmarshal(data, &obj); err != nil {
+ continue
+ }
+
+ title := ""
+ for _, key := range []string{"title", "name", "subject", "summary"} {
+ if v, ok := obj[key]; ok {
+ title = fmt.Sprintf("%v", v)
+ break
+ }
+ }
+
+ updatedAt := ""
+ daysSince := days
+ for _, key := range []string{"updatedAt", "updated_at", "updatedDate"} {
+ if v, ok := obj[key]; ok {
+ updatedAt = fmt.Sprintf("%v", v)
+ if t, err := time.Parse(time.RFC3339, updatedAt); err == nil {
+ daysSince = int(now.Sub(t).Hours() / 24)
+ }
+ break
+ }
+ }
+
+ items = append(items, staleItem{
+ ID: id,
+ ResourceType: resType,
+ Title: title,
+ UpdatedAt: updatedAt,
+ DaysSince: daysSince,
+ })
+ }
+
+ if flags.asJSON {
+ enc := json.NewEncoder(cmd.OutOrStdout())
+ enc.SetIndent("", " ")
+ return enc.Encode(items)
+ }
+
+ if len(items) == 0 {
+ fmt.Fprintln(cmd.OutOrStdout(), "No stale items found.")
+ return nil
+ }
+
+ fmt.Fprintf(cmd.OutOrStdout(), "Found %d stale items (no update in %d+ days):\n\n", len(items), days)
+ fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-15s %-8s %s\n", "ID", "TYPE", "DAYS", "TITLE")
+ fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-15s %-8s %s\n", "----", "----", "----", "-----")
+ for _, item := range items {
+ titleDisplay := item.Title
+ if len(titleDisplay) > 50 {
+ titleDisplay = titleDisplay[:47] + "..."
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-15s %-8d %s\n", item.ID, item.ResourceType, item.DaysSince, titleDisplay)
+ }
+ return nil
+ },
+ }
+
+ cmd.Flags().IntVar(&days, "days", 30, "Number of days without update to consider stale")
+ cmd.Flags().StringVar(&team, "team", "", "Filter by team identifier")
+ cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: ~/.config/{{.Name}}-cli/store.db)")
+
+ return cmd
+}
diff --git a/internal/generator/vision_templates.go b/internal/generator/vision_templates.go
index 56cbf2dd..eb99418f 100644
--- a/internal/generator/vision_templates.go
+++ b/internal/generator/vision_templates.go
@@ -113,7 +113,10 @@ func SelectVisionTemplates(plan *vision.VisionaryPlan) VisionTemplateSet {
}
if plan.Insight.HasInsight() {
- set.Insights = []string{"insight_report.go.tmpl"}
+ set.Insights = []string{
+ "insights/health_score.go.tmpl",
+ "insights/similar.go.tmpl",
+ }
}
return set
← f5369dd0 feat(generator): add Non-Obvious Insight system, domain arch
·
back to Cli Printing Press
·
feat(generator): add schema builder with data gravity scorin 63b89f6d →