[object Object]

← 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

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 →