Commit e8223db099e4

Vincent Demeester <vincent@sbr.pm>
2026-02-19 16:30:37
refactor(review-tool): rename claude to ai source
Renamed claude source to ai, reading solely from ~/.local/share/ai for sessions, research, plans, and learnings. Extracted rich metadata (project, repository, directory, tool, host, tags) from AI session files. Added post-fetch folder filtering with --folder/-d and --exclude-folder/-D flags for include/exclude by project, repository, or directory metadata. Fixed flaky TestParse_PastDays_Boundaries with dynamic dates.
1 parent e7c5b54
tools/review-tool/cmd/review-tool/main.go
@@ -9,15 +9,18 @@ import (
 
 	"github.com/vdemeester/home/tools/review-tool/internal/activity"
 	"github.com/vdemeester/home/tools/review-tool/internal/config"
+	"github.com/vdemeester/home/tools/review-tool/internal/filter"
 	"github.com/vdemeester/home/tools/review-tool/internal/output"
 	"github.com/vdemeester/home/tools/review-tool/internal/sources"
 	"github.com/vdemeester/home/tools/review-tool/internal/timerange"
 )
 
 var (
-	cfgFile      string
-	outputFormat string
-	sourcesFlag  []string
+	cfgFile        string
+	outputFormat   string
+	sourcesFlag    []string
+	includeFolders []string
+	excludeFolders []string
 )
 
 func main() {
@@ -52,6 +55,16 @@ func run(args []string) error {
 				i++
 				sourcesFlag = append(sourcesFlag, args[i])
 			}
+		case arg == "-d" || arg == "--folder":
+			if i+1 < len(args) {
+				i++
+				includeFolders = append(includeFolders, args[i])
+			}
+		case arg == "-D" || arg == "--exclude-folder":
+			if i+1 < len(args) {
+				i++
+				excludeFolders = append(excludeFolders, args[i])
+			}
 		case arg == "config":
 			return runConfigCmd()
 		case arg == "sources":
@@ -86,17 +99,27 @@ Arguments:
                         "past 3 days", "this month", "2026-01-01..2026-01-15"
 
 Flags:
-  -c, --config string   Config file (default: ~/.config/review-tool/config.yaml)
-  -f, --format string   Output format: markdown, json, yaml/llm (default from config)
-  -s, --source string   Sources to include (can be repeated)
-  -h, --help            Show this help message
+  -c, --config string          Config file (default: ~/.config/review-tool/config.yaml)
+  -f, --format string          Output format: markdown, json, yaml/llm (default from config)
+  -s, --source string          Sources to include (can be repeated)
+  -d, --folder string          Include only items matching folder (can be repeated)
+  -D, --exclude-folder string  Exclude items matching folder (can be repeated)
+  -h, --help                   Show this help message
+
+Folder filtering:
+  Matches against project, repository, and directory metadata (substring,
+  case-insensitive). Parenthetical descriptions are stripped before matching.
 
 Examples:
-  review-tool                       # Last week's activity in markdown
-  review-tool today                 # Today's activity
-  review-tool "past 3 days"         # Last 3 days
-  review-tool -f json "this week"   # JSON output
-  review-tool -s github -s org      # Only GitHub and org sources`)
+  review-tool                                          # Last week's activity
+  review-tool today                                    # Today's activity
+  review-tool "past 3 days"                            # Last 3 days
+  review-tool -f json "this week"                      # JSON output
+  review-tool -s github -s org                         # Only GitHub and org
+  review-tool -d tektoncd                              # Only tektoncd projects
+  review-tool -d tektoncd -d chisel                    # tektoncd OR chisel
+  review-tool -D home                                  # Exclude homelab
+  review-tool -d tektoncd -D tektoncd/plumbing         # tektoncd except plumbing`)
 }
 
 func runReview(timeRangeStr string) error {
@@ -128,6 +151,13 @@ func runReview(timeRangeStr string) error {
 		activities = registry.FetchAll(ctx, tr.Start, tr.End)
 	}
 
+	// Apply folder filters
+	f := &filter.Filter{
+		IncludeFolders: includeFolders,
+		ExcludeFolders: excludeFolders,
+	}
+	f.Apply(activities)
+
 	// Build report
 	report := &activity.ReviewReport{
 		GeneratedAt: time.Now(),
@@ -168,7 +198,7 @@ func runConfigCmd() error {
 	fmt.Printf("  GitHub:  enabled=%v\n", cfg.GitHub.Enabled)
 	fmt.Printf("  Org:     enabled=%v, files=%d\n", cfg.Org.Enabled, len(cfg.Org.Files))
 	fmt.Printf("  Jira:    enabled=%v\n", cfg.Jira.Enabled)
-	fmt.Printf("  Claude:  enabled=%v\n", cfg.Claude.Enabled)
+	fmt.Printf("  AI:      enabled=%v, dir=%s\n", cfg.AI.Enabled, cfg.AI.Dir)
 
 	fmt.Printf("\nOutput: format=%s, summary=%v\n", cfg.Output.DefaultFormat, cfg.Output.IncludeSummary)
 
tools/review-tool/internal/activity/activity.go
@@ -10,7 +10,7 @@ const (
 	CategoryGitHub Category = "github"
 	CategoryOrg    Category = "org"
 	CategoryJira   Category = "jira"
-	CategoryClaude Category = "claude"
+	CategoryAI Category = "ai"
 )
 
 // ActivityItem represents a single unit of work or event.
tools/review-tool/internal/config/config.go
@@ -15,7 +15,7 @@ type Config struct {
 	GitHub GitHubConfig `yaml:"github"`
 	Org    OrgConfig    `yaml:"org"`
 	Jira   JiraConfig   `yaml:"jira"`
-	Claude ClaudeConfig `yaml:"claude"`
+	AI AIConfig `yaml:"ai"`
 	Output OutputConfig `yaml:"output"`
 }
 
@@ -56,13 +56,14 @@ type JiraConfig struct {
 	Username string   `yaml:"username"`
 }
 
-// ClaudeConfig configures the Claude history source.
-type ClaudeConfig struct {
+// AIConfig configures the AI session history source (~/.local/share/ai).
+type AIConfig struct {
 	Enabled          bool   `yaml:"enabled"`
-	HistoryDir       string `yaml:"history_dir"`
+	Dir              string `yaml:"dir"`
 	IncludeSessions  bool   `yaml:"include_sessions"`
 	IncludeLearnings bool   `yaml:"include_learnings"`
 	IncludeResearch  bool   `yaml:"include_research"`
+	IncludePlans     bool   `yaml:"include_plans"`
 }
 
 // OutputConfig configures output formatting.
@@ -131,12 +132,13 @@ func DefaultConfig() *Config {
 		Jira: JiraConfig{
 			Enabled: false,
 		},
-		Claude: ClaudeConfig{
+		AI: AIConfig{
 			Enabled:          true,
-			HistoryDir:       filepath.Join(home, ".config", "claude", "history"),
+			Dir:              filepath.Join(home, ".local", "share", "ai"),
 			IncludeSessions:  true,
 			IncludeLearnings: true,
 			IncludeResearch:  true,
+			IncludePlans:     true,
 		},
 		Output: OutputConfig{
 			DefaultFormat:  "markdown",
@@ -156,8 +158,8 @@ func (c *Config) expandPaths() {
 	// Expand org archive dir
 	c.Org.ArchiveDir = expandHome(c.Org.ArchiveDir, home)
 
-	// Expand Claude history dir
-	c.Claude.HistoryDir = expandHome(c.Claude.HistoryDir, home)
+	// Expand AI dir
+	c.AI.Dir = expandHome(c.AI.Dir, home)
 }
 
 func expandHome(path, home string) string {
tools/review-tool/internal/filter/filter.go
@@ -0,0 +1,121 @@
+// Package filter provides post-fetch activity filtering.
+package filter
+
+import (
+	"strings"
+
+	"github.com/vdemeester/home/tools/review-tool/internal/activity"
+)
+
+// Filter defines include/exclude rules for activity items.
+type Filter struct {
+	// IncludeFolders keeps only items whose project/repository/directory
+	// matches one of these values (substring, case-insensitive).
+	// Empty means no include constraint.
+	IncludeFolders []string
+	// ExcludeFolders removes items whose project/repository/directory
+	// matches any of these values (substring, case-insensitive).
+	ExcludeFolders []string
+}
+
+// IsEmpty returns true if no filters are configured.
+func (f *Filter) IsEmpty() bool {
+	return len(f.IncludeFolders) == 0 && len(f.ExcludeFolders) == 0
+}
+
+// Apply filters a map of activities in place, removing non-matching items.
+func (f *Filter) Apply(activities map[string]*activity.Activity) {
+	if f.IsEmpty() {
+		return
+	}
+	for name, act := range activities {
+		activities[name] = &activity.Activity{
+			Source: act.Source,
+			Items:  f.FilterItems(act.Items),
+			Error:  act.Error,
+		}
+	}
+}
+
+// FilterItems returns only the items that pass the filter.
+func (f *Filter) FilterItems(items []activity.ActivityItem) []activity.ActivityItem {
+	if f.IsEmpty() {
+		return items
+	}
+	var result []activity.ActivityItem
+	for _, item := range items {
+		if f.Match(item) {
+			result = append(result, item)
+		}
+	}
+	return result
+}
+
+// Match returns true if an item passes the filter.
+func (f *Filter) Match(item activity.ActivityItem) bool {
+	folders := itemFolders(item)
+
+	// Exclude check: if any folder matches an exclude pattern, reject
+	if len(f.ExcludeFolders) > 0 && matchesAny(folders, f.ExcludeFolders) {
+		return false
+	}
+
+	// Include check: at least one folder must match an include pattern
+	// Items with no folder metadata pass include filters (they can't be classified)
+	if len(f.IncludeFolders) > 0 {
+		if len(folders) == 0 {
+			return false
+		}
+		return matchesAny(folders, f.IncludeFolders)
+	}
+
+	return true
+}
+
+// itemFolders extracts all folder-like values from an item's metadata.
+// It looks at project, repository, and directory fields.
+func itemFolders(item activity.ActivityItem) []string {
+	var folders []string
+	for _, key := range []string{"project", "repository", "directory"} {
+		if val, ok := item.Metadata[key]; ok && val != "" {
+			folders = append(folders, normalizePath(val))
+		}
+	}
+	return folders
+}
+
+// matchesAny returns true if any folder matches any pattern.
+// Matching is substring-based and case-insensitive.
+func matchesAny(folders []string, patterns []string) bool {
+	for _, folder := range folders {
+		for _, pattern := range patterns {
+			if containsFold(folder, normalizePath(pattern)) {
+				return true
+			}
+		}
+	}
+	return false
+}
+
+// normalizePath cleans a path for matching:
+// - strips trailing descriptions in parens: "~/src/home (homelab)" → "~/src/home"
+// - strips trailing whitespace
+// - strips trailing slashes
+func normalizePath(path string) string {
+	path = strings.TrimSpace(path)
+
+	// Strip parenthetical suffix: "~/src/home (homelab monorepo)" → "~/src/home"
+	if idx := strings.Index(path, " ("); idx > 0 {
+		path = path[:idx]
+	}
+
+	// Strip trailing slashes
+	path = strings.TrimRight(path, "/")
+
+	return path
+}
+
+// containsFold is a case-insensitive substring check.
+func containsFold(s, substr string) bool {
+	return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
+}
tools/review-tool/internal/filter/filter_test.go
@@ -0,0 +1,177 @@
+package filter
+
+import (
+	"testing"
+
+	"github.com/vdemeester/home/tools/review-tool/internal/activity"
+)
+
+func item(meta map[string]string) activity.ActivityItem {
+	return activity.ActivityItem{
+		Title:    "test",
+		Metadata: meta,
+	}
+}
+
+func TestFilter_IncludeFolder(t *testing.T) {
+	f := &Filter{IncludeFolders: []string{"tektoncd"}}
+
+	tests := []struct {
+		name  string
+		item  activity.ActivityItem
+		match bool
+	}{
+		{"exact repo match", item(map[string]string{"repository": "tektoncd/pipeline"}), true},
+		{"project match", item(map[string]string{"project": "tektoncd/plumbing"}), true},
+		{"directory match", item(map[string]string{"directory": "~/src/tektoncd/pipeline"}), true},
+		{"no match", item(map[string]string{"project": "~/src/home"}), false},
+		{"no metadata", item(map[string]string{}), false},
+		{"case insensitive", item(map[string]string{"project": "TektonCD/Pipeline"}), true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := f.Match(tt.item); got != tt.match {
+				t.Errorf("Match() = %v, want %v", got, tt.match)
+			}
+		})
+	}
+}
+
+func TestFilter_ExcludeFolder(t *testing.T) {
+	f := &Filter{ExcludeFolders: []string{"home"}}
+
+	tests := []struct {
+		name  string
+		item  activity.ActivityItem
+		match bool
+	}{
+		{"excludes home project", item(map[string]string{"project": "~/src/home (homelab)"}), false},
+		{"excludes home dir", item(map[string]string{"directory": "~/src/home"}), false},
+		{"keeps tektoncd", item(map[string]string{"project": "tektoncd/pipeline"}), true},
+		{"no metadata passes", item(map[string]string{}), true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := f.Match(tt.item); got != tt.match {
+				t.Errorf("Match() = %v, want %v", got, tt.match)
+			}
+		})
+	}
+}
+
+func TestFilter_IncludeAndExclude(t *testing.T) {
+	// Include tektoncd but exclude tektoncd/plumbing
+	f := &Filter{
+		IncludeFolders: []string{"tektoncd"},
+		ExcludeFolders: []string{"tektoncd/plumbing"},
+	}
+
+	tests := []struct {
+		name  string
+		item  activity.ActivityItem
+		match bool
+	}{
+		{"includes pipeline", item(map[string]string{"project": "tektoncd/pipeline"}), true},
+		{"excludes plumbing", item(map[string]string{"project": "tektoncd/plumbing"}), false},
+		{"excludes non-tektoncd", item(map[string]string{"project": "~/src/home"}), false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := f.Match(tt.item); got != tt.match {
+				t.Errorf("Match() = %v, want %v", got, tt.match)
+			}
+		})
+	}
+}
+
+func TestFilter_NormalizePath(t *testing.T) {
+	tests := []struct {
+		input string
+		want  string
+	}{
+		{"~/src/home (homelab)", "~/src/home"},
+		{"~/src/home (homelab monorepo)", "~/src/home"},
+		{"tektoncd/pipeline", "tektoncd/pipeline"},
+		{"  ~/src/home/  ", "~/src/home"},
+		{"home", "home"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.input, func(t *testing.T) {
+			if got := normalizePath(tt.input); got != tt.want {
+				t.Errorf("normalizePath(%q) = %q, want %q", tt.input, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestFilter_Apply(t *testing.T) {
+	f := &Filter{IncludeFolders: []string{"tektoncd"}}
+
+	activities := map[string]*activity.Activity{
+		"ai": {
+			Source: "ai",
+			Items: []activity.ActivityItem{
+				item(map[string]string{"project": "tektoncd/pipeline"}),
+				item(map[string]string{"project": "~/src/home"}),
+				item(map[string]string{"project": "tektoncd/plumbing"}),
+			},
+		},
+		"github": {
+			Source: "github",
+			Items: []activity.ActivityItem{
+				item(map[string]string{"repository": "tektoncd/pipeline"}),
+				item(map[string]string{"repository": "vdemeester/home"}),
+			},
+		},
+	}
+
+	f.Apply(activities)
+
+	if len(activities["ai"].Items) != 2 {
+		t.Errorf("ai items = %d, want 2", len(activities["ai"].Items))
+	}
+	if len(activities["github"].Items) != 1 {
+		t.Errorf("github items = %d, want 1", len(activities["github"].Items))
+	}
+}
+
+func TestFilter_Empty(t *testing.T) {
+	f := &Filter{}
+
+	if !f.IsEmpty() {
+		t.Error("expected empty filter")
+	}
+
+	// Empty filter should pass everything
+	i := item(map[string]string{"project": "anything"})
+	if !f.Match(i) {
+		t.Error("empty filter should match everything")
+	}
+}
+
+func TestFilter_MultipleIncludeFolders(t *testing.T) {
+	// Either tektoncd or chisel
+	f := &Filter{IncludeFolders: []string{"tektoncd", "chisel"}}
+
+	tests := []struct {
+		name  string
+		item  activity.ActivityItem
+		match bool
+	}{
+		{"tektoncd matches", item(map[string]string{"project": "tektoncd/pipeline"}), true},
+		{"chisel matches", item(map[string]string{"project": "github.com/vdemeester/chisel"}), true},
+		{"home excluded", item(map[string]string{"project": "~/src/home"}), false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := f.Match(tt.item); got != tt.match {
+				t.Errorf("Match() = %v, want %v", got, tt.match)
+			}
+		})
+	}
+}
tools/review-tool/internal/output/markdown.go
@@ -37,7 +37,7 @@ func WriteMarkdown(w io.Writer, report *activity.ReviewReport) error {
 	}
 
 	// Activities by source
-	sourceOrder := []string{"github", "org", "jira", "claude"}
+	sourceOrder := []string{"github", "org", "jira", "ai"}
 	for _, source := range sourceOrder {
 		act, ok := report.Activities[source]
 		if !ok || len(act.Items) == 0 {
@@ -124,7 +124,7 @@ func formatSourceName(source string) string {
 		"github": "GitHub",
 		"org":    "Org-mode",
 		"jira":   "Jira",
-		"claude": "Claude Sessions",
+		"ai": "AI Sessions",
 	}
 	if name, ok := names[source]; ok {
 		return name
tools/review-tool/internal/output/markdown_test.go
@@ -117,7 +117,7 @@ func TestFormatSourceName(t *testing.T) {
 		{"github", "GitHub"},
 		{"org", "Org-mode"},
 		{"jira", "Jira"},
-		{"claude", "Claude Sessions"},
+		{"ai", "AI Sessions"},
 		{"unknown", "Unknown"},
 	}
 
tools/review-tool/internal/output/yaml.go
@@ -39,7 +39,7 @@ func WriteYAML(w io.Writer, report *activity.ReviewReport) error {
 
 	// Activities by source
 	fmt.Fprintln(w, "activities:")
-	sourceOrder := []string{"github", "org", "jira", "claude"}
+	sourceOrder := []string{"github", "org", "jira", "ai"}
 	for _, source := range sourceOrder {
 		act, ok := report.Activities[source]
 		if !ok || len(act.Items) == 0 {
tools/review-tool/internal/sources/claude.go → tools/review-tool/internal/sources/ai.go
@@ -25,54 +25,64 @@ var (
 	// Filename patterns: 2026-01-21-something.md or 20260121-something.md
 	filenameDateRe        = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})`)
 	filenameDateCompactRe = regexp.MustCompile(`^(\d{8})`)
+	// **Key:** value or **Key**: value (with optional trailing **)
+	// Captures key and value from markdown bold metadata lines
+	mdMetadataRe = regexp.MustCompile(`^\*\*([A-Za-z][A-Za-z ]*?)\*?\*?:\*?\*?\s*(.+)$`)
 )
 
-// ClaudeSource fetches activity from Claude session history.
-type ClaudeSource struct {
-	cfg *config.ClaudeConfig
+// AISource fetches activity from AI session history (~/.local/share/ai).
+type AISource struct {
+	cfg *config.AIConfig
 }
 
-// NewClaudeSource creates a new Claude history source.
-func NewClaudeSource(cfg *config.ClaudeConfig) *ClaudeSource {
-	return &ClaudeSource{cfg: cfg}
+// NewAISource creates a new AI history source.
+func NewAISource(cfg *config.AIConfig) *AISource {
+	return &AISource{cfg: cfg}
 }
 
 // Name returns the source identifier.
-func (c *ClaudeSource) Name() string {
-	return "claude"
+func (s *AISource) Name() string {
+	return "ai"
 }
 
-// Validate checks if history directory exists.
-func (c *ClaudeSource) Validate() error {
-	if _, err := os.Stat(c.cfg.HistoryDir); err != nil {
+// Validate checks if the base directory exists.
+func (s *AISource) Validate() error {
+	if _, err := os.Stat(s.cfg.Dir); err != nil {
 		return err
 	}
 	return nil
 }
 
-// Fetch retrieves Claude session activities within the time range.
-func (c *ClaudeSource) Fetch(ctx context.Context, start, end time.Time) (*activity.Activity, error) {
+// Fetch retrieves AI session activities within the time range.
+func (s *AISource) Fetch(ctx context.Context, start, end time.Time) (*activity.Activity, error) {
 	act := &activity.Activity{
-		Source: "claude",
+		Source: "ai",
 		Items:  []activity.ActivityItem{},
 	}
 
-	if c.cfg.IncludeSessions {
-		items, err := c.scanDirectory(filepath.Join(c.cfg.HistoryDir, "sessions"), "session", start, end)
+	if s.cfg.IncludeSessions {
+		items, err := s.scanDirectory(filepath.Join(s.cfg.Dir, "sessions"), "session", start, end)
 		if err == nil {
 			act.Items = append(act.Items, items...)
 		}
 	}
 
-	if c.cfg.IncludeLearnings {
-		items, err := c.scanDirectory(filepath.Join(c.cfg.HistoryDir, "learnings"), "learning", start, end)
+	if s.cfg.IncludeLearnings {
+		items, err := s.scanDirectory(filepath.Join(s.cfg.Dir, "learnings"), "learning", start, end)
 		if err == nil {
 			act.Items = append(act.Items, items...)
 		}
 	}
 
-	if c.cfg.IncludeResearch {
-		items, err := c.scanDirectory(filepath.Join(c.cfg.HistoryDir, "research"), "research", start, end)
+	if s.cfg.IncludeResearch {
+		items, err := s.scanDirectory(filepath.Join(s.cfg.Dir, "research"), "research", start, end)
+		if err == nil {
+			act.Items = append(act.Items, items...)
+		}
+	}
+
+	if s.cfg.IncludePlans {
+		items, err := s.scanDirectory(filepath.Join(s.cfg.Dir, "plans"), "plan", start, end)
 		if err == nil {
 			act.Items = append(act.Items, items...)
 		}
@@ -81,11 +91,18 @@ func (c *ClaudeSource) Fetch(ctx context.Context, start, end time.Time) (*activi
 	return act, nil
 }
 
-func (c *ClaudeSource) scanDirectory(baseDir, itemType string, start, end time.Time) ([]activity.ActivityItem, error) {
+func (s *AISource) scanDirectory(baseDir, itemType string, start, end time.Time) ([]activity.ActivityItem, error) {
 	var items []activity.ActivityItem
 
+	// Resolve symlinks so filepath.Walk can traverse symlinked directories
+	// (e.g. home-manager managed paths via nix store)
+	resolvedDir, err := filepath.EvalSymlinks(baseDir)
+	if err != nil {
+		resolvedDir = baseDir // Fall back to original if resolution fails
+	}
+
 	// Walk through YYYY-MM subdirectories
-	err := filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error {
+	err = filepath.Walk(resolvedDir, func(path string, info os.FileInfo, err error) error {
 		if err != nil {
 			return nil // Skip errors, continue walking
 		}
@@ -105,7 +122,7 @@ func (c *ClaudeSource) scanDirectory(baseDir, itemType string, start, end time.T
 			return nil
 		}
 
-		item, err := c.parseFile(path, itemType, ext)
+		item, err := s.parseFile(path, itemType, ext, info.ModTime())
 		if err != nil {
 			return nil // Skip files that fail to parse
 		}
@@ -122,7 +139,7 @@ func (c *ClaudeSource) scanDirectory(baseDir, itemType string, start, end time.T
 	return items, err
 }
 
-func (c *ClaudeSource) parseFile(path, itemType, ext string) (*activity.ActivityItem, error) {
+func (s *AISource) parseFile(path, itemType, ext string, modTime time.Time) (*activity.ActivityItem, error) {
 	file, err := os.Open(path)
 	if err != nil {
 		return nil, err
@@ -131,17 +148,17 @@ func (c *ClaudeSource) parseFile(path, itemType, ext string) (*activity.Activity
 
 	var title string
 	var dateStr string
+	metadata := map[string]string{"path": path}
 	filename := filepath.Base(path)
 
 	scanner := bufio.NewScanner(file)
 	lineCount := 0
-	for scanner.Scan() && lineCount < 30 {
+	for scanner.Scan() && lineCount < 40 {
 		line := scanner.Text()
 		lineCount++
 
 		// Parse based on file type
 		if ext == ".org" {
-			// Org-mode: #+title: and #+date:
 			if matches := orgTitleRe.FindStringSubmatch(line); len(matches) > 1 && title == "" {
 				title = strings.TrimSpace(matches[1])
 			}
@@ -149,19 +166,37 @@ func (c *ClaudeSource) parseFile(path, itemType, ext string) (*activity.Activity
 				dateStr = matches[1]
 			}
 		} else {
-			// Markdown: # Title and **Date:**
+			// Markdown title
 			if matches := mdTitleRe.FindStringSubmatch(line); len(matches) > 1 && title == "" {
 				title = strings.TrimSpace(matches[1])
 			}
-			if matches := mdDateRe.FindStringSubmatch(line); len(matches) > 1 && dateStr == "" {
-				dateStr = matches[1]
+			// Extract **Key:** value metadata
+			if matches := mdMetadataRe.FindStringSubmatch(line); len(matches) > 2 {
+				key := strings.ToLower(strings.TrimSpace(matches[1]))
+				val := cleanMetadataValue(matches[2])
+				switch key {
+				case "date":
+					if dateStr == "" {
+						// Extract YYYY-MM-DD from various date formats
+						if d := extractDate(val); d != "" {
+							dateStr = d
+						}
+					}
+				case "project":
+					metadata["project"] = val
+				case "repository", "repositories", "repo":
+					metadata["repository"] = val
+				case "directory", "working directory":
+					metadata["directory"] = val
+				case "tool":
+					metadata["tool"] = val
+				case "tags":
+					metadata["tags"] = val
+				case "host":
+					metadata["host"] = val
+				}
 			}
 		}
-
-		// Early exit if we have both
-		if title != "" && dateStr != "" {
-			break
-		}
 	}
 
 	// Fallback: extract date from filename
@@ -169,7 +204,6 @@ func (c *ClaudeSource) parseFile(path, itemType, ext string) (*activity.Activity
 		if matches := filenameDateRe.FindStringSubmatch(filename); len(matches) > 1 {
 			dateStr = matches[1]
 		} else if matches := filenameDateCompactRe.FindStringSubmatch(filename); len(matches) > 1 {
-			// Convert 20260121 to 2026-01-21
 			d := matches[1]
 			dateStr = d[:4] + "-" + d[4:6] + "-" + d[6:8]
 		}
@@ -180,8 +214,8 @@ func (c *ClaudeSource) parseFile(path, itemType, ext string) (*activity.Activity
 		title = cleanFilename(filename)
 	}
 
-	// Parse date
-	ts := time.Now() // fallback
+	// Parse date: content > filename > file modification time
+	ts := modTime
 	if dateStr != "" {
 		if parsed, err := time.Parse("2006-01-02", dateStr); err == nil {
 			ts = parsed
@@ -192,14 +226,37 @@ func (c *ClaudeSource) parseFile(path, itemType, ext string) (*activity.Activity
 		ID:        path,
 		Title:     title,
 		Type:      itemType,
-		Category:  activity.CategoryClaude,
+		Category:  activity.CategoryAI,
 		Timestamp: ts,
-		Metadata: map[string]string{
-			"path": path,
-		},
+		Metadata:  metadata,
 	}, nil
 }
 
+// cleanMetadataValue strips markdown formatting from a metadata value.
+// Removes backticks, trailing parenthetical descriptions, etc.
+func cleanMetadataValue(val string) string {
+	val = strings.TrimSpace(val)
+	// Strip backticks
+	val = strings.ReplaceAll(val, "`", "")
+	return val
+}
+
+// extractDate pulls a YYYY-MM-DD date from various formats:
+// "2026-01-21", "January 21, 2026", "February 18, 2026"
+func extractDate(val string) string {
+	// Direct YYYY-MM-DD
+	if m := filenameDateRe.FindString(val); m != "" {
+		return m
+	}
+	// Try standard Go date parsing for verbose formats
+	for _, layout := range []string{"January 2, 2006", "January 02, 2006", "Jan 2, 2006"} {
+		if t, err := time.Parse(layout, strings.TrimSpace(val)); err == nil {
+			return t.Format("2006-01-02")
+		}
+	}
+	return ""
+}
+
 func cleanFilename(filename string) string {
 	// Remove extension
 	name := strings.TrimSuffix(filename, filepath.Ext(filename))
tools/review-tool/internal/sources/claude_integration_test.go → tools/review-tool/internal/sources/ai_integration_test.go
@@ -10,24 +10,24 @@ import (
 	"github.com/vdemeester/home/tools/review-tool/internal/config"
 )
 
-func TestClaudeSource_RealHistory(t *testing.T) {
-	// This test uses the real Claude history directory
+func TestAISource_RealHistory(t *testing.T) {
 	home, _ := os.UserHomeDir()
-	historyDir := filepath.Join(home, ".config", "claude", "history")
+	aiDir := filepath.Join(home, ".local", "share", "ai")
 
-	if _, err := os.Stat(historyDir); err != nil {
-		t.Skipf("Claude history not found at %s", historyDir)
+	if _, err := os.Stat(aiDir); err != nil {
+		t.Skipf("AI history not found at %s", aiDir)
 	}
 
-	cfg := &config.ClaudeConfig{
+	cfg := &config.AIConfig{
 		Enabled:          true,
-		HistoryDir:       historyDir,
+		Dir:              aiDir,
 		IncludeSessions:  true,
 		IncludeLearnings: true,
 		IncludeResearch:  true,
+		IncludePlans:     true,
 	}
 
-	source := NewClaudeSource(cfg)
+	source := NewAISource(cfg)
 
 	// Last 7 days
 	now := time.Now()
@@ -49,6 +49,6 @@ func TestClaudeSource_RealHistory(t *testing.T) {
 
 	// Should find at least some items in the last 7 days
 	if len(act.Items) == 0 {
-		t.Error("Expected to find some Claude history items")
+		t.Error("Expected to find some AI history items")
 	}
 }
tools/review-tool/internal/sources/claude_test.go → tools/review-tool/internal/sources/ai_test.go
@@ -10,7 +10,7 @@ import (
 )
 
 // Helper to create temp directory with test files
-func setupTestHistoryDir(t *testing.T) string {
+func setupTestAIDir(t *testing.T) string {
 	t.Helper()
 	dir := t.TempDir()
 
@@ -81,18 +81,22 @@ Research on local CI systems.
 	return dir
 }
 
-func TestClaudeSource_ParsesSessionWithMetadata(t *testing.T) {
-	historyDir := setupTestHistoryDir(t)
+func TestAISource_ParsesSessionWithMetadata(t *testing.T) {
+	aiDir := setupTestAIDir(t)
 
-	cfg := &config.ClaudeConfig{
+	cfg := &config.AIConfig{
 		Enabled:          true,
-		HistoryDir:       historyDir,
+		Dir:              aiDir,
 		IncludeSessions:  true,
 		IncludeLearnings: false,
 		IncludeResearch:  false,
 	}
 
-	source := NewClaudeSource(cfg)
+	source := NewAISource(cfg)
+
+	if source.Name() != "ai" {
+		t.Errorf("Name() = %q, want %q", source.Name(), "ai")
+	}
 
 	// Query for January 2026
 	start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
@@ -107,6 +111,10 @@ func TestClaudeSource_ParsesSessionWithMetadata(t *testing.T) {
 		t.Fatal("expected at least one session item")
 	}
 
+	if act.Source != "ai" {
+		t.Errorf("Source = %q, want %q", act.Source, "ai")
+	}
+
 	// Find the terraform session
 	var found bool
 	for _, item := range act.Items {
@@ -127,18 +135,18 @@ func TestClaudeSource_ParsesSessionWithMetadata(t *testing.T) {
 	}
 }
 
-func TestClaudeSource_ParsesDateFromFilename(t *testing.T) {
-	historyDir := setupTestHistoryDir(t)
+func TestAISource_ParsesDateFromFilename(t *testing.T) {
+	aiDir := setupTestAIDir(t)
 
-	cfg := &config.ClaudeConfig{
+	cfg := &config.AIConfig{
 		Enabled:          true,
-		HistoryDir:       historyDir,
+		Dir:              aiDir,
 		IncludeSessions:  true,
 		IncludeLearnings: false,
 		IncludeResearch:  false,
 	}
 
-	source := NewClaudeSource(cfg)
+	source := NewAISource(cfg)
 
 	start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
 	end := time.Date(2026, 1, 31, 23, 59, 59, 0, time.UTC)
@@ -165,18 +173,18 @@ func TestClaudeSource_ParsesDateFromFilename(t *testing.T) {
 	}
 }
 
-func TestClaudeSource_ParsesLearnings(t *testing.T) {
-	historyDir := setupTestHistoryDir(t)
+func TestAISource_ParsesLearnings(t *testing.T) {
+	aiDir := setupTestAIDir(t)
 
-	cfg := &config.ClaudeConfig{
+	cfg := &config.AIConfig{
 		Enabled:          true,
-		HistoryDir:       historyDir,
+		Dir:              aiDir,
 		IncludeSessions:  false,
 		IncludeLearnings: true,
 		IncludeResearch:  false,
 	}
 
-	source := NewClaudeSource(cfg)
+	source := NewAISource(cfg)
 
 	start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
 	end := time.Date(2026, 1, 31, 23, 59, 59, 0, time.UTC)
@@ -199,18 +207,18 @@ func TestClaudeSource_ParsesLearnings(t *testing.T) {
 	}
 }
 
-func TestClaudeSource_ParsesResearchOrgMode(t *testing.T) {
-	historyDir := setupTestHistoryDir(t)
+func TestAISource_ParsesResearchOrgMode(t *testing.T) {
+	aiDir := setupTestAIDir(t)
 
-	cfg := &config.ClaudeConfig{
+	cfg := &config.AIConfig{
 		Enabled:          true,
-		HistoryDir:       historyDir,
+		Dir:              aiDir,
 		IncludeSessions:  false,
 		IncludeLearnings: false,
 		IncludeResearch:  true,
 	}
 
-	source := NewClaudeSource(cfg)
+	source := NewAISource(cfg)
 
 	start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
 	end := time.Date(2026, 1, 31, 23, 59, 59, 0, time.UTC)
@@ -233,18 +241,18 @@ func TestClaudeSource_ParsesResearchOrgMode(t *testing.T) {
 	}
 }
 
-func TestClaudeSource_FiltersOutsideDateRange(t *testing.T) {
-	historyDir := setupTestHistoryDir(t)
+func TestAISource_FiltersOutsideDateRange(t *testing.T) {
+	aiDir := setupTestAIDir(t)
 
-	cfg := &config.ClaudeConfig{
+	cfg := &config.AIConfig{
 		Enabled:          true,
-		HistoryDir:       historyDir,
+		Dir:              aiDir,
 		IncludeSessions:  true,
 		IncludeLearnings: true,
 		IncludeResearch:  true,
 	}
 
-	source := NewClaudeSource(cfg)
+	source := NewAISource(cfg)
 
 	// Query for December 2025 - should return nothing
 	start := time.Date(2025, 12, 1, 0, 0, 0, 0, time.UTC)
@@ -259,3 +267,180 @@ func TestClaudeSource_FiltersOutsideDateRange(t *testing.T) {
 		t.Errorf("expected 0 items outside date range, got %d", len(act.Items))
 	}
 }
+
+func TestAISource_Plans(t *testing.T) {
+	dir := t.TempDir()
+
+	// Plans are flat (no YYYY-MM subdirectories)
+	plansDir := filepath.Join(dir, "plans")
+	if err := os.MkdirAll(plansDir, 0o755); err != nil {
+		t.Fatalf("failed to create dir: %v", err)
+	}
+
+	content := `# Migration Plan
+
+**Date:** 2026-01-22
+
+## Overview
+Plan for migrating services.
+`
+	if err := os.WriteFile(filepath.Join(plansDir, "migration-plan.md"), []byte(content), 0o644); err != nil {
+		t.Fatalf("failed to write file: %v", err)
+	}
+
+	cfg := &config.AIConfig{
+		Enabled:      true,
+		Dir:          dir,
+		IncludePlans: true,
+	}
+
+	source := NewAISource(cfg)
+
+	start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := time.Date(2026, 1, 31, 23, 59, 59, 0, time.UTC)
+
+	act, err := source.Fetch(t.Context(), start, end)
+	if err != nil {
+		t.Fatalf("Fetch() error = %v", err)
+	}
+
+	if len(act.Items) != 1 {
+		t.Fatalf("expected 1 plan item, got %d", len(act.Items))
+	}
+
+	item := act.Items[0]
+	if item.Type != "plan" {
+		t.Errorf("Type = %q, want %q", item.Type, "plan")
+	}
+	if item.Title != "Migration Plan" {
+		t.Errorf("Title = %q, want %q", item.Title, "Migration Plan")
+	}
+}
+
+func TestAISource_ExtractsMetadata(t *testing.T) {
+	dir := t.TempDir()
+	sessionsDir := filepath.Join(dir, "sessions", "2026-01")
+	if err := os.MkdirAll(sessionsDir, 0o755); err != nil {
+		t.Fatal(err)
+	}
+
+	content := `# Fix Pipeline Release
+
+**Date:** 2026-01-21
+**Project:** tektoncd/pipeline
+**Repository:** tektoncd/pipeline
+**Directory:** ~/src/tektoncd/pipeline
+**Tool:** pi
+**Host:** kyushu
+**Tags:** #tekton #ci
+
+## Summary
+Fixed release pipeline.
+`
+	if err := os.WriteFile(filepath.Join(sessionsDir, "2026-01-21-fix-pipeline.md"), []byte(content), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	cfg := &config.AIConfig{
+		Enabled:         true,
+		Dir:             dir,
+		IncludeSessions: true,
+	}
+	source := NewAISource(cfg)
+
+	start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := time.Date(2026, 1, 31, 23, 59, 59, 0, time.UTC)
+
+	act, err := source.Fetch(t.Context(), start, end)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(act.Items) != 1 {
+		t.Fatalf("expected 1 item, got %d", len(act.Items))
+	}
+
+	item := act.Items[0]
+	checks := map[string]string{
+		"project":    "tektoncd/pipeline",
+		"repository": "tektoncd/pipeline",
+		"directory":  "~/src/tektoncd/pipeline",
+		"tool":       "pi",
+		"host":       "kyushu",
+		"tags":       "#tekton #ci",
+	}
+	for key, want := range checks {
+		if got := item.Metadata[key]; got != want {
+			t.Errorf("Metadata[%q] = %q, want %q", key, got, want)
+		}
+	}
+}
+
+func TestAISource_ExtractsMetadataVariants(t *testing.T) {
+	dir := t.TempDir()
+	sessionsDir := filepath.Join(dir, "sessions", "2026-01")
+	if err := os.MkdirAll(sessionsDir, 0o755); err != nil {
+		t.Fatal(err)
+	}
+
+	// Test variant formats: **Key**: value (colon outside bold)
+	content := `# Session Title
+
+**Date**: 2026-01-20
+**Project**: tektoncd/plumbing
+**Working Directory**: /home/user/src/tektoncd/plumbing
+**Tool**: pi (Claude)
+
+## Summary
+Did some work.
+`
+	if err := os.WriteFile(filepath.Join(sessionsDir, "2026-01-20-variant.md"), []byte(content), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	cfg := &config.AIConfig{
+		Enabled:         true,
+		Dir:             dir,
+		IncludeSessions: true,
+	}
+	source := NewAISource(cfg)
+
+	start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := time.Date(2026, 1, 31, 23, 59, 59, 0, time.UTC)
+
+	act, err := source.Fetch(t.Context(), start, end)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(act.Items) != 1 {
+		t.Fatalf("expected 1 item, got %d", len(act.Items))
+	}
+
+	item := act.Items[0]
+	if item.Metadata["project"] != "tektoncd/plumbing" {
+		t.Errorf("project = %q, want %q", item.Metadata["project"], "tektoncd/plumbing")
+	}
+	if item.Metadata["directory"] != "/home/user/src/tektoncd/plumbing" {
+		t.Errorf("directory = %q, want %q", item.Metadata["directory"], "/home/user/src/tektoncd/plumbing")
+	}
+	if item.Metadata["tool"] != "pi (Claude)" {
+		t.Errorf("tool = %q, want %q", item.Metadata["tool"], "pi (Claude)")
+	}
+	if item.Timestamp.Day() != 20 {
+		t.Errorf("day = %d, want 20", item.Timestamp.Day())
+	}
+}
+
+func TestAISource_ValidateMissingDir(t *testing.T) {
+	cfg := &config.AIConfig{
+		Enabled: true,
+		Dir:     "/nonexistent/dir/that/doesnt/exist",
+	}
+
+	source := NewAISource(cfg)
+
+	if err := source.Validate(); err == nil {
+		t.Error("expected error for missing directory")
+	}
+}
tools/review-tool/internal/sources/source.go
@@ -40,8 +40,8 @@ func NewRegistry(cfg *config.Config) *Registry {
 	if cfg.Jira.Enabled {
 		r.sources["jira"] = NewJiraSource(&cfg.Jira)
 	}
-	if cfg.Claude.Enabled {
-		r.sources["claude"] = NewClaudeSource(&cfg.Claude)
+	if cfg.AI.Enabled {
+		r.sources["ai"] = NewAISource(&cfg.AI)
 	}
 
 	return r
tools/review-tool/internal/timerange/timerange_integration_test.go
@@ -14,14 +14,27 @@ func TestParse_PastDays_Boundaries(t *testing.T) {
 	t.Logf("Start: %v", tr.Start)
 	t.Logf("End: %v", tr.End)
 
-	// Check that a date like 2026-01-20 (parsed as UTC midnight) would be included
-	testDate, _ := time.Parse("2006-01-02", "2026-01-20")
-	t.Logf("Test date (2026-01-20 parsed): %v", testDate)
+	// A date 3 days ago (formatted as YYYY-MM-DD, parsed as UTC midnight)
+	// must fall within "past 7 days".
+	threeDaysAgo := time.Now().AddDate(0, 0, -3)
+	dateStr := threeDaysAgo.Format("2006-01-02")
+	testDate, _ := time.Parse("2006-01-02", dateStr)
+	t.Logf("Test date (%s parsed): %v", dateStr, testDate)
 
 	inRange := !testDate.Before(tr.Start) && !testDate.After(tr.End)
-	t.Logf("2026-01-20 in range? %v", inRange)
+	t.Logf("%s in range? %v", dateStr, inRange)
 
 	if !inRange {
-		t.Error("Expected 2026-01-20 to be in range for past 7 days")
+		t.Errorf("Expected %s to be in range for past 7 days", dateStr)
+	}
+
+	// A date 10 days ago should NOT be in range
+	tenDaysAgo := time.Now().AddDate(0, 0, -10)
+	dateStr2 := tenDaysAgo.Format("2006-01-02")
+	testDate2, _ := time.Parse("2006-01-02", dateStr2)
+
+	inRange2 := !testDate2.Before(tr.Start) && !testDate2.After(tr.End)
+	if inRange2 {
+		t.Errorf("Expected %s to NOT be in range for past 7 days", dateStr2)
 	}
 }