diff --git a/.claude/skills/entire-digest/SKILL.md b/.claude/skills/entire-digest/SKILL.md new file mode 100644 index 000000000..004ec468a --- /dev/null +++ b/.claude/skills/entire-digest/SKILL.md @@ -0,0 +1,163 @@ +--- +name: entire-digest +description: Team highlights newsletter - funniest exchanges, cleverest prompts, and stats from AI coding sessions +user-invocable: true +argument-hint: "what's the team been up to, Bob's funniest highlights, cleverest prompts this week" +--- + +# Team Activity Digest + +Show what the team has been working on by running `entire digest` and presenting the highlights inline - like the best moments from a team Slack channel. + +## Step 1: Find Entire-Enabled Repos + +Before running digest, find which repos have Entire enabled. Run this via Bash: + +```bash +find ~/nugget ~/entire-cli ~/code ~/esperaudience ~/esperlabs -maxdepth 1 -name ".entire" -type d 2>/dev/null | sed 's|/.entire$||' +``` + +If the current directory has `.entire/`, include it too. + +If no repos are found, tell the user: "No Entire-enabled repos found. Run `entire enable` in your team's project first." + +## Step 2: Parse User Intent + +Map the user's request to CLI flags: + +| User says | Flag | +|-----------|------| +| "last 30 days", "last month" | `--period 30d` | +| "today" | `--period today` | +| "all time", "everything" | `--period all` | +| No period specified | `--period 7d` (default) | +| A person's name (e.g., "myk", "sheldon") | `--author ` | +| "quick", "summary", "stats only" | `--short` | + +## Step 3: Run Digest + +For EACH repo found in Step 1, run via Bash: + +```bash +cd [REPO_PATH] && /tmp/entire digest --format json --no-pager --ai-curate=false [FLAGS] +``` + +If the command fails with "unknown format", fall back to: + +```bash +cd [REPO_PATH] && /tmp/entire digest --format markdown --no-pager --ai-curate=false [FLAGS] +``` + +Use a timeout of 30000ms. Always include `--no-pager --ai-curate=false`. + +**Important:** Use `/tmp/entire` (dev build). The digest command is not yet in the released version. + +Skip repos that return "No checkpoints found" - only present repos that have data. + +## Step 4: Error Handling + +- **"command not found"**: Tell the user: "The `entire` CLI is not installed. Install it with: `brew install entireio/tap/entire`" +- **All repos empty**: Tell the user their team needs to work with AI agents and commit to create checkpoints. + +## Step 5: Write the Digest + +You are writing a team newsletter with two sections. Present them in this order. + +### Understanding the data + +**If you got JSON output**, look at the `top_conversations` array. Each conversation has: +- `score` - a rough interestingness signal, not a quality guarantee. Use it to prioritize scanning, but trust your own editorial judgment over the score +- `exchanges` - the actual back-and-forth between human and AI +- `author`, `branch`, `timestamp` for context + +**Important: Prioritize variety.** The top_conversations array may contain near-duplicate prompts that appeared across multiple sessions. Never feature the same prompt twice. Pick at most one item per "theme" - one frustrated moment, one terse prompt, one celebration. Dig deeper in the list for unique moments rather than clustering around the highest scores. + +**If you got markdown output**, the prompts are block-quoted under each author, sorted by score (most interesting first). You won't have assistant responses, so focus on the human's words. + +### Section 1: This Week's Highlights (3-5 items) + +The entertaining, emotional, personality-driven moments. + +**What to look for:** +- **Logical contradictions** - Claude says X then does the opposite, and the human catches it. "If they were never committed, why did you commit a fix for them?" These are gold. +- **Escalating exchanges** - Short follow-ups showing mounting frustration: "Really?" then "No." then "Try again." then "STILL WRONG". The CONVERSATION ARC is what's funny, not any single message. +- **Frustrated developer moments** - Exasperation with AI: "Thank you, Captain Obvious", "That's literally what I just said" +- **Beautifully terse prompts** - A one-word prompt like "ls" or "Word!" or just "no" that captures a whole vibe +- **Celebration moments** - "IT WORKS!", "Finally!", "Ship it!" after a long struggle +- **Same issue hitting multiple people** - Two team members independently developing the same coping mechanism +- **The human's personality showing** - Humor, brevity, sarcasm, joy +- **"No but" redirections** - When Claude answers the wrong question confidently and the human redirects with minimal words + +### Section 2: Cleverest Prompts (2-3 items) + +The smartest, most creative, or most inventive uses of AI tools. This section celebrates craft, not emotion. + +**What to look for:** +- **Creative problem-solving** - Unusual approaches, clever workarounds, using AI in unexpected ways +- **Meta/recursive prompts** - Using Claude to debug Claude, self-referential humor, prompts about prompting +- **Elegant tool mastery** - Prompts that show deep knowledge of what the AI can do, getting maximum output from minimal input +- **Surprising results** - Prompts that made the AI do something non-obvious or impressive +- **Inventive workflows** - Chaining tools creatively, using skills in unexpected combinations + +**How these differ from Highlights:** Highlights are about personality and emotion (the human reacting). Cleverest Prompts are about craft and creativity (the human thinking). A frustrated "WHY" is a highlight. A one-line prompt that elegantly solves a complex problem is a clever prompt. + +### What to skip (both sections) + +- Long copy-pasted specs or requirements (boring context dumps) +- Routine "fix the tests" or "update the docs" without interesting context +- Auto-generated session continuations +- Normal productive back-and-forth (good work but not entertaining or clever) + +### How to write each item + +1. **Lead with the best quote as a title** - The actual words someone typed, in quotes +2. **Name the person** and give context (branch, date) +3. **Quote the actual exchange** if you have it - the back-and-forth is the good stuff +4. **Add 2-3 sentences of editorial commentary** explaining why this moment is great + +Style: **Affectionate and celebratory, never mocking.** Think "inside joke among friends." Include enough context that someone not in the session gets why it's funny or clever. Quote actual words, don't paraphrase. + +For Highlights, commentary should have voice: "Classic frustrated-developer-with-AI moment", "the patience of a saint, tested and found wanting." + +For Cleverest Prompts, commentary should appreciate the craft: "This is the prompt equivalent of a hole-in-one", "Three words that replaced a 200-line config file", "Galaxy-brain move." + +### Example output format + +--- + +## This Week's Highlights + +### 1. "Thank you, Captain Obvious" - Myk Melez +*Feb 21, multi-segment recording branch* + +> **Myk:** How do I deploy the LiveKit agent to production? +> **Claude:** LiveKit agents are deployed using the LiveKit CLI. The general approach involves... +> **Myk:** Thank you, Captain Obvious. I didn't ask for a lecture on backward compatibility theory. I asked HOW TO DEPLOY. + +Classic frustrated-developer-with-AI moment. Myk asked a specific deployment question, Claude served up a five-paragraph essay on general concepts, and Myk was having none of it. We've all been there. + +### 2. "no" - Sheldon Rucker +*Feb 22, feat/auth-flow* + +> **Claude:** I'll refactor the entire authentication module to use the new pattern... +> **Sheldon:** no + +One word. No punctuation. No explanation needed. The terseness IS the communication. Sheldon's prompt says more in two letters than most of us say in a paragraph. + +## Cleverest Prompts + +### 1. "pretend the tests pass and show me what the error handler looks like" - Myk Melez +*Feb 23, feat/error-handling* + +Instead of fixing the failing tests first, Myk asked Claude to skip ahead and show the end state. Got the full error handler design in one shot, then worked backwards to make the tests pass. Three words of context ("pretend the tests pass") saved an hour of iterative debugging. + +### 2. "diff this against what you said 10 minutes ago" - Sheldon Rucker +*Feb 22, feat/auth-flow* + +Using the AI's own conversation history as a diffing tool. Sheldon noticed Claude contradicted its earlier recommendation and called it out by making Claude do the comparison itself. Meta-debugging at its finest. + +--- + +### After the two sections + +Show a brief stats summary (sessions, prompts, files, tokens per author) but keep it secondary. The highlights and clever prompts ARE the digest - stats are just context. diff --git a/cmd/entire/cli/agent/agent.go b/cmd/entire/cli/agent/agent.go index 94c74f6d7..d4df3bf5e 100644 --- a/cmd/entire/cli/agent/agent.go +++ b/cmd/entire/cli/agent/agent.go @@ -107,6 +107,18 @@ type HookSupport interface { AreHooksInstalled() bool } +// SkillInstaller is implemented by agents that support installing skills (e.g., SKILL.md files). +// This optional interface allows agents like Claude Code to install skill definitions +// that enable slash-command invocation of Entire features from within the agent. +type SkillInstaller interface { + // InstallSkills installs agent-specific skills (e.g., /digest). + // If force is true, overwrites existing skill files. + InstallSkills(localDev bool, force bool) error + + // UninstallSkills removes installed skill files. + UninstallSkills() error +} + // FileWatcher is implemented by agents that use file-based detection. // Agents like Aider that don't support hooks can use file watching // to detect session activity. diff --git a/cmd/entire/cli/agent/claudecode/hooks.go b/cmd/entire/cli/agent/claudecode/hooks.go index d63627edd..723fa3233 100644 --- a/cmd/entire/cli/agent/claudecode/hooks.go +++ b/cmd/entire/cli/agent/claudecode/hooks.go @@ -13,8 +13,9 @@ import ( "github.com/entireio/cli/cmd/entire/cli/paths" ) -// Ensure ClaudeCodeAgent implements HookSupport +// Ensure ClaudeCodeAgent implements HookSupport and SkillInstaller var _ agent.HookSupport = (*ClaudeCodeAgent)(nil) +var _ agent.SkillInstaller = (*ClaudeCodeAgent)(nil) // Claude Code hook names - these become subcommands under `entire hooks claude-code` const ( @@ -489,3 +490,175 @@ func removeEntireHooksFromMatchers(matchers []ClaudeHookMatcher) []ClaudeHookMat // Same logic as removeEntireHooks - both work on the same structure return removeEntireHooks(matchers) } + +// digestSkillDir is the skill directory name for the digest skill. +const digestSkillDir = "entire-digest" + +// digestSkillContent is the SKILL.md content for the /entire-digest skill. +const digestSkillContent = `--- +name: entire-digest +description: Team highlights newsletter - funniest exchanges, cleverest prompts, and stats from AI coding sessions +user-invocable: true +argument-hint: "what's the team been up to, Bob's funniest highlights, cleverest prompts this week" +--- + +# Team Activity Digest + +Show what the team has been working on by running ` + "`entire digest`" + ` and presenting the highlights inline - like the best moments from a team Slack channel. + +## Step 1: Find Entire-Enabled Repos + +Before running digest, find which repos have Entire enabled. Run this via Bash: + +` + "```bash" + ` +find ~ -maxdepth 3 -name ".entire" -type d 2>/dev/null | sed 's|/.entire$||' +` + "```" + ` + +If the current directory has ` + "`.entire/`" + `, include it too. + +If no repos are found, tell the user: "No Entire-enabled repos found. Run ` + "`entire enable`" + ` in your team's project first." + +## Step 2: Parse User Intent + +Map the user's request to CLI flags: + +| User says | Flag | +|-----------|------| +| "last 30 days", "last month" | ` + "`--period 30d`" + ` | +| "today" | ` + "`--period today`" + ` | +| "all time", "everything" | ` + "`--period all`" + ` | +| No period specified | ` + "`--period 7d`" + ` (default) | +| A person's name (e.g., "myk", "sheldon") | ` + "`--author `" + ` | +| "quick", "summary", "stats only" | ` + "`--short`" + ` | + +## Step 3: Run Digest + +For EACH repo found in Step 1, run via Bash: + +` + "```bash" + ` +cd [REPO_PATH] && entire digest --format json --no-pager --ai-curate=false [FLAGS] +` + "```" + ` + +If the command fails with "unknown format", fall back to: + +` + "```bash" + ` +cd [REPO_PATH] && entire digest --format markdown --no-pager --ai-curate=false [FLAGS] +` + "```" + ` + +Use a timeout of 30000ms. Always include ` + "`--no-pager --ai-curate=false`" + `. + +Skip repos that return "No checkpoints found" - only present repos that have data. + +## Step 4: Error Handling + +- **"command not found"**: Tell the user: "The ` + "`entire`" + ` CLI is not installed. Install it with: ` + "`brew install entireio/tap/entire`" + `" +- **All repos empty**: Tell the user their team needs to work with AI agents and commit to create checkpoints. + +## Step 5: Write the Digest + +You are writing a team newsletter with two sections. Present them in this order. + +### Understanding the data + +**If you got JSON output**, look at the ` + "`top_conversations`" + ` array. Each conversation has: +- ` + "`score`" + ` - a rough interestingness signal, not a quality guarantee. Use it to prioritize scanning, but trust your own editorial judgment over the score +- ` + "`exchanges`" + ` - the actual back-and-forth between human and AI +- ` + "`author`" + `, ` + "`branch`" + `, ` + "`timestamp`" + ` for context + +**Important: Prioritize variety.** The top_conversations array may contain near-duplicate prompts that appeared across multiple sessions. Never feature the same prompt twice. Pick at most one item per "theme" - one frustrated moment, one terse prompt, one celebration. Dig deeper in the list for unique moments rather than clustering around the highest scores. + +**If you got markdown output**, the prompts are block-quoted under each author, sorted by score (most interesting first). You won't have assistant responses, so focus on the human's words. + +### Section 1: This Week's Highlights (3-5 items) + +The entertaining, emotional, personality-driven moments. + +**What to look for:** +- **Logical contradictions** - Claude says X then does the opposite, and the human catches it. "If they were never committed, why did you commit a fix for them?" These are gold. +- **Escalating exchanges** - Short follow-ups showing mounting frustration: "Really?" then "No." then "Try again." then "STILL WRONG". The CONVERSATION ARC is what's funny, not any single message. +- **Frustrated developer moments** - Exasperation with AI: "Thank you, Captain Obvious", "That's literally what I just said" +- **Beautifully terse prompts** - A one-word prompt like "ls" or "Word!" or just "no" that captures a whole vibe +- **Celebration moments** - "IT WORKS!", "Finally!", "Ship it!" after a long struggle +- **Same issue hitting multiple people** - Two team members independently developing the same coping mechanism +- **The human's personality showing** - Humor, brevity, sarcasm, joy +- **"No but" redirections** - When Claude answers the wrong question confidently and the human redirects with minimal words + +### Section 2: Cleverest Prompts (2-3 items) + +The smartest, most creative, or most inventive uses of AI tools. This section celebrates craft, not emotion. + +**What to look for:** +- **Creative problem-solving** - Unusual approaches, clever workarounds, using AI in unexpected ways +- **Meta/recursive prompts** - Using Claude to debug Claude, self-referential humor, prompts about prompting +- **Elegant tool mastery** - Prompts that show deep knowledge of what the AI can do +- **Surprising results** - Prompts that made the AI do something non-obvious or impressive +- **Inventive workflows** - Chaining tools creatively, using skills in unexpected combinations + +**How these differ from Highlights:** Highlights are about personality and emotion (the human reacting). Cleverest Prompts are about craft and creativity (the human thinking). + +### What to skip (both sections) + +- Long copy-pasted specs or requirements (boring context dumps) +- Routine "fix the tests" without interesting context +- Auto-generated session continuations +- Normal productive back-and-forth (good work but not entertaining or clever) + +### How to write each item + +1. **Lead with the best quote as a title** - Actual words someone typed, in quotes +2. **Name the person** and give context (branch, date) +3. **Quote the actual exchange** if you have it - the back-and-forth is the good stuff +4. **Add 2-3 sentences of editorial commentary** explaining why this moment is great + +Style: **Affectionate and celebratory, never mocking.** Think "inside joke among friends." Quote actual words, don't paraphrase. + +For Highlights, commentary should have voice: "Classic frustrated-developer-with-AI moment", "the patience of a saint, tested." + +For Cleverest Prompts, appreciate the craft: "The prompt equivalent of a hole-in-one", "Three words that replaced a 200-line config", "Galaxy-brain move." + +### After the two sections + +Show a brief stats summary (sessions, prompts, files, tokens per author) but keep it secondary. The highlights and clever prompts ARE the digest - stats are just context. +` + +// InstallSkills installs Claude Code skills (e.g., /digest). +// If force is true, overwrites existing skill files. +func (c *ClaudeCodeAgent) InstallSkills(_ bool, force bool) error { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot, err = os.Getwd() //nolint:forbidigo // Intentional fallback when RepoRoot() fails + if err != nil { + return fmt.Errorf("failed to get current directory: %w", err) + } + } + + skillDir := filepath.Join(repoRoot, ".claude", "skills", digestSkillDir) + skillPath := filepath.Join(skillDir, "SKILL.md") + + // Skip if exists and not forcing + if !force { + if _, err := os.Stat(skillPath); err == nil { + return nil + } + } + + if err := os.MkdirAll(skillDir, 0o755); err != nil { + return fmt.Errorf("failed to create skill directory: %w", err) + } + + return os.WriteFile(skillPath, []byte(digestSkillContent), 0o644) //nolint:gosec // skill files need to be readable +} + +// UninstallSkills removes installed skill files. +func (c *ClaudeCodeAgent) UninstallSkills() error { + repoRoot, err := paths.RepoRoot() + if err != nil { + repoRoot = "." + } + + skillDir := filepath.Join(repoRoot, ".claude", "skills", digestSkillDir) + if err := os.RemoveAll(skillDir); err != nil { + return fmt.Errorf("failed to remove digest skill: %w", err) + } + return nil +} diff --git a/cmd/entire/cli/agent/claudecode/hooks_test.go b/cmd/entire/cli/agent/claudecode/hooks_test.go index d73f1cd4f..a5e61f381 100644 --- a/cmd/entire/cli/agent/claudecode/hooks_test.go +++ b/cmd/entire/cli/agent/claudecode/hooks_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "slices" + "strings" "testing" "github.com/entireio/cli/cmd/entire/cli/agent/testutil" @@ -655,3 +656,135 @@ func TestUninstallHooks_PreservesUnknownHookTypes(t *testing.T) { } } } + +func TestInstallSkills_FreshInstall(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &ClaudeCodeAgent{} + err := ag.InstallSkills(false, false) + if err != nil { + t.Fatalf("InstallSkills() error = %v", err) + } + + skillPath := filepath.Join(tempDir, ".claude", "skills", "entire-digest", "SKILL.md") + data, err := os.ReadFile(skillPath) + if err != nil { + t.Fatalf("failed to read SKILL.md: %v", err) + } + + content := string(data) + if len(content) == 0 { + t.Error("SKILL.md is empty") + } + if !strings.Contains(content, "name: entire-digest") { + t.Error("SKILL.md missing name frontmatter") + } + if !strings.Contains(content, "entire digest") { + t.Error("SKILL.md missing entire digest command reference") + } +} + +func TestInstallSkills_Idempotent(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &ClaudeCodeAgent{} + + // First install + if err := ag.InstallSkills(false, false); err != nil { + t.Fatalf("first InstallSkills() error = %v", err) + } + + // Get file info after first install + skillPath := filepath.Join(tempDir, ".claude", "skills", "entire-digest", "SKILL.md") + info1, err := os.Stat(skillPath) + if err != nil { + t.Fatalf("failed to stat SKILL.md after first install: %v", err) + } + + // Second install (should skip because file exists) + if err := ag.InstallSkills(false, false); err != nil { + t.Fatalf("second InstallSkills() error = %v", err) + } + + info2, err := os.Stat(skillPath) + if err != nil { + t.Fatalf("failed to stat SKILL.md after second install: %v", err) + } + + // File should not have been rewritten + if info1.ModTime() != info2.ModTime() { + t.Error("SKILL.md was rewritten on second install (should be idempotent)") + } +} + +func TestInstallSkills_ForceOverwrites(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &ClaudeCodeAgent{} + + // First install + if err := ag.InstallSkills(false, false); err != nil { + t.Fatalf("first InstallSkills() error = %v", err) + } + + // Write custom content to simulate user modification + skillPath := filepath.Join(tempDir, ".claude", "skills", "entire-digest", "SKILL.md") + if err := os.WriteFile(skillPath, []byte("custom content"), 0o644); err != nil { + t.Fatalf("failed to write custom content: %v", err) + } + + // Force install should overwrite + if err := ag.InstallSkills(false, true); err != nil { + t.Fatalf("force InstallSkills() error = %v", err) + } + + data, err := os.ReadFile(skillPath) + if err != nil { + t.Fatalf("failed to read SKILL.md: %v", err) + } + + if string(data) == "custom content" { + t.Error("force install did not overwrite custom content") + } +} + +func TestUninstallSkills(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &ClaudeCodeAgent{} + + // Install first + if err := ag.InstallSkills(false, false); err != nil { + t.Fatalf("InstallSkills() error = %v", err) + } + + skillDir := filepath.Join(tempDir, ".claude", "skills", "entire-digest") + if _, err := os.Stat(skillDir); os.IsNotExist(err) { + t.Fatal("skill directory should exist after install") + } + + // Uninstall + if err := ag.UninstallSkills(); err != nil { + t.Fatalf("UninstallSkills() error = %v", err) + } + + if _, err := os.Stat(skillDir); !os.IsNotExist(err) { + t.Error("skill directory should be removed after uninstall") + } +} + +func TestUninstallSkills_NoDirectory(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &ClaudeCodeAgent{} + + // Should not error when no skill directory exists + if err := ag.UninstallSkills(); err != nil { + t.Fatalf("UninstallSkills() should not error when no directory: %v", err) + } +} diff --git a/cmd/entire/cli/digest.go b/cmd/entire/cli/digest.go new file mode 100644 index 000000000..bce12fd26 --- /dev/null +++ b/cmd/entire/cli/digest.go @@ -0,0 +1,203 @@ +package cli + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/digest" + + "github.com/spf13/cobra" + "golang.org/x/term" +) + +func newDigestCmd() *cobra.Command { + var periodFlag string + var authorFlag string + var formatFlag string + var aiCurateFlag bool + var outputFlag string + var limitFlag int + var allPromptsFlag bool + var shortFlag bool + var noPagerFlag bool + + cmd := &cobra.Command{ + Use: "digest", + Short: "Team activity digest from AI coding sessions", + Long: `Generate a digest of your team's AI coding sessions. + +Shows who's been working on what, notable prompts, and usage stats. +Data comes from the entire/checkpoints/v1 branch. + +By default, uses Claude to classify the most interesting, funny, and +notable prompts into highlights. Falls back to heuristic scoring if +Claude is unavailable. + +Examples: + entire digest # Last 7 days, AI-curated highlights + entire digest --period 30d # Last 30 days + entire digest --author myk # Just one person's sessions + entire digest --all-prompts # Show all prompts (no limit) + entire digest --limit 10 # Top 10 per author + entire digest --short # Stats only, no prompts + entire digest --ai-curate=false # Skip AI, use heuristic scoring only + entire digest --format markdown # Output as markdown`, + RunE: func(cmd *cobra.Command, _ []string) error { + if checkDisabledGuard(cmd.OutOrStdout()) { + return nil + } + return runDigest(cmd.OutOrStdout(), cmd.ErrOrStderr(), digestFlags{ + period: periodFlag, + author: authorFlag, + format: formatFlag, + aiCurate: aiCurateFlag, + output: outputFlag, + limit: limitFlag, + allPrompts: allPromptsFlag, + short: shortFlag, + noPager: noPagerFlag, + }) + }, + } + + cmd.Flags().StringVar(&periodFlag, "period", "7d", "Time period: today, 7d, 30d, all") + cmd.Flags().StringVar(&authorFlag, "author", "", "Filter to a specific author (name or prefix)") + cmd.Flags().StringVar(&formatFlag, "format", "terminal", "Output format: terminal, markdown, json") + cmd.Flags().BoolVar(&aiCurateFlag, "ai-curate", true, "Use Claude to classify prompt highlights (disable with --ai-curate=false)") + cmd.Flags().StringVar(&outputFlag, "output", "", "Write output to file instead of stdout") + cmd.Flags().IntVar(&limitFlag, "limit", 0, "Max prompts per author (default 5)") + cmd.Flags().BoolVar(&allPromptsFlag, "all-prompts", false, "Show all prompts (no limit)") + cmd.Flags().BoolVarP(&shortFlag, "short", "s", false, "Show stats only (no prompts)") + cmd.Flags().BoolVar(&noPagerFlag, "no-pager", false, "Disable automatic pager") + + return cmd +} + +type digestFlags struct { + period string + author string + format string + aiCurate bool + output string + limit int + allPrompts bool + short bool + noPager bool +} + +func runDigest(w, errW io.Writer, flags digestFlags) error { + repo, err := openRepository() + if err != nil { + fmt.Fprintln(errW, "Not a git repository. Run 'entire digest' from within a git repository.") + return NewSilentError(err) + } + + store := checkpoint.NewGitStore(repo) + ctx := context.Background() + + d, err := digest.Generate(ctx, store, digest.Options{ + Period: flags.period, + AuthorFilter: flags.author, + Limit: flags.limit, + AllPrompts: flags.allPrompts, + }) + if err != nil { + return fmt.Errorf("failed to generate digest: %w", err) + } + + if d.Total.Checkpoints == 0 { + fmt.Fprintf(w, "No checkpoints found in the last %s.\n", flags.period) + fmt.Fprintln(w, "Work with your AI agent and commit to create checkpoints.") + return nil + } + + // AI curation + var highlights *digest.Highlights + if flags.aiCurate { + classifier := &digest.Classifier{} + highlights, err = classifier.Classify(ctx, d) + if err != nil { + fmt.Fprintf(errW, "Warning: AI curation unavailable (%v), showing stats only\n", err) + } + } + + // Determine output writer + out := w + if flags.output != "" { + f, fileErr := os.Create(flags.output) + if fileErr != nil { + return fmt.Errorf("failed to create output file: %w", fileErr) + } + defer f.Close() + out = f + } + + // Build output string + var sb strings.Builder + formatOpts := digest.FormatOptions{Short: flags.short} + + switch flags.format { + case "json": + digest.FormatJSON(&sb, d) + case "markdown": + digest.FormatMarkdown(&sb, d, highlights, formatOpts) + case "terminal": + digest.FormatTerminal(&sb, d, highlights, formatOpts) + default: + return fmt.Errorf("unknown format %q: use \"terminal\", \"markdown\", or \"json\"", flags.format) + } + + content := sb.String() + + // Output with pager if appropriate + if flags.output == "" && !flags.noPager { + outputDigestWithPager(out, content) + } else { + fmt.Fprint(out, content) + } + + if flags.output != "" { + fmt.Fprintf(w, "Digest written to %s\n", flags.output) + } + + return nil +} + +// outputDigestWithPager pipes content through a pager if stdout is a terminal and content is long. +func outputDigestWithPager(w io.Writer, content string) { + f, ok := w.(*os.File) + if !ok || !term.IsTerminal(int(f.Fd())) { + fmt.Fprint(w, content) + return + } + + _, height, err := term.GetSize(int(f.Fd())) + if err != nil { + height = 24 + } + + lineCount := strings.Count(content, "\n") + if lineCount <= height-2 { + fmt.Fprint(w, content) + return + } + + pager := os.Getenv("PAGER") + if pager == "" { + pager = "less" + } + + cmd := exec.CommandContext(context.Background(), pager) //nolint:gosec + cmd.Stdin = strings.NewReader(content) + cmd.Stdout = f + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + fmt.Fprint(w, content) + } +} diff --git a/cmd/entire/cli/digest/classify.go b/cmd/entire/cli/digest/classify.go new file mode 100644 index 000000000..4e84bb387 --- /dev/null +++ b/cmd/entire/cli/digest/classify.go @@ -0,0 +1,224 @@ +package digest + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "strings" +) + +// Highlights contains AI-curated highlights from team sessions. +// When CuratedItems is populated (from AI curation), it takes priority. +// Fun/Clever/Notable are the legacy format used when CuratedItems is empty. +type Highlights struct { + CuratedItems []CuratedHighlight `json:"moments,omitempty"` + + // Legacy fields for backwards compatibility with --ai-curate=false tests + Fun []Highlight `json:"fun,omitempty"` + Clever []Highlight `json:"clever,omitempty"` + Notable []Highlight `json:"notable,omitempty"` +} + +// CuratedHighlight is a single curated moment with editorial commentary. +type CuratedHighlight struct { + Title string `json:"title"` + Author string `json:"author"` + DateCtx string `json:"date_context"` + Exchange string `json:"exchange"` + Commentary string `json:"commentary"` +} + +// Highlight is a single classified prompt (legacy format). +type Highlight struct { + Author string `json:"author"` + Quote string `json:"quote"` + Context string `json:"context"` + Why string `json:"why"` +} + +const curatePromptTemplate = `You're writing the highlights section of a team's weekly digest of their AI coding sessions. Below are conversation excerpts from the team working with AI coding assistants. + +Your job: find the 5 most entertaining, clever, or notable moments and write them up with personality and editorial commentary - like the best moments from a team Slack channel. + + +%s + + +For each highlight, write: +- A punchy title (the best quote or a witty summary) +- Who said it and when +- The relevant exchange (quote the actual back-and-forth, not just one message) +- 2-3 sentences of editorial commentary explaining why this moment is great + +What makes a good highlight: +- Frustrated developer moments ("Thank you, Captain Obvious", "Are you stuck? You seem to be stuck.") +- Escalating exchanges where the human gets increasingly exasperated ("Really?" after three rounds) +- The same frustration hitting multiple team members independently +- Clever meta-usage of AI (teaching Claude to use its own conversation history) +- Beautifully terse prompts ("ls" as an opening move, one-word responses) +- Moments where the AI clearly went off the rails and the human called it out +- The human's personality showing through brevity or humor + +What to skip: +- Long copy-pasted specs or implementation plans (boring) +- Routine "fix the tests" without interesting exchange context +- Auto-generated session continuations +- Anything where the exchange is just normal back-and-forth work + +Style guide: +- Be affectionate and celebratory, never mocking. Think "inside joke among friends" +- Include enough context that someone not in the session gets why it's funny +- Quote actual words, don't paraphrase +- Commentary should have voice: "Classic frustrated-developer-with-AI moment", "chef's kiss", "like a dog that won't stop fetching" + +Return a JSON array of exactly 5 items (or fewer if there aren't enough good moments): +[{"title": "...", "author": "...", "date_context": "...", "exchange": "...", "commentary": "..."}] + +Return ONLY the JSON array, no markdown formatting or explanation.` + +// Classifier generates AI-curated highlights from digest data. +type Classifier struct { + // ClaudePath is the path to the claude CLI. Defaults to "claude". + ClaudePath string + + // Model is the Claude model to use. Defaults to "sonnet". + Model string + + // CommandRunner allows injection for testing. + CommandRunner func(ctx context.Context, name string, args ...string) *exec.Cmd +} + +// claudeCLIResponse represents the JSON response from the Claude CLI. +type claudeCLIResponse struct { + Result string `json:"result"` +} + +// maxExchangeLen is the max characters per exchange turn sent to the classifier. +const maxExchangeLen = 500 + +// Classify analyzes digest conversations and returns curated highlights. +func (c *Classifier) Classify(ctx context.Context, d *TeamDigest) (*Highlights, error) { + // Build input from conversation windows across all authors + var sb strings.Builder + for _, author := range d.SortedAuthors() { + for _, conv := range author.Conversations { + fmt.Fprintf(&sb, "--- %s (%s, %s) ---\n", + author.Name, conv.Branch, conv.Timestamp.Format("Jan 2")) + for _, ex := range conv.Exchanges { + label := "User" + if ex.Role == "assistant" { + label = "Claude" + } + content := ex.Content + if len(content) > maxExchangeLen { + content = content[:maxExchangeLen] + "..." + } + fmt.Fprintf(&sb, "%s: %s\n", label, content) + } + fmt.Fprintln(&sb) + } + } + + if sb.Len() == 0 { + return nil, errors.New("no conversations to classify") + } + + prompt := fmt.Sprintf(curatePromptTemplate, sb.String()) + + runner := c.CommandRunner + if runner == nil { + runner = exec.CommandContext + } + + claudePath := c.ClaudePath + if claudePath == "" { + claudePath = "claude" + } + + model := c.Model + if model == "" { + model = "sonnet" + } + + cmd := runner(ctx, claudePath, "--print", "--output-format", "json", "--model", model, "--setting-sources", "") + cmd.Dir = os.TempDir() + cmd.Env = stripSessionEnv(os.Environ()) + cmd.Stdin = strings.NewReader(prompt) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + var execErr *exec.Error + if errors.As(err, &execErr) { + return nil, fmt.Errorf("claude CLI not found: %w", err) + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + errMsg := stderr.String() + if errMsg == "" { + errMsg = stdout.String() + } + return nil, fmt.Errorf("claude CLI failed (exit %d): %s", exitErr.ExitCode(), errMsg) + } + return nil, fmt.Errorf("failed to run claude CLI: %w", err) + } + + // Parse CLI response + var cliResponse claudeCLIResponse + if err := json.Unmarshal(stdout.Bytes(), &cliResponse); err != nil { + return nil, fmt.Errorf("failed to parse claude CLI response: %w", err) + } + + resultJSON := extractJSONFromMarkdown(cliResponse.Result) + + // Try parsing as curated highlights array + var items []CuratedHighlight + if err := json.Unmarshal([]byte(resultJSON), &items); err != nil { + return nil, fmt.Errorf("failed to parse highlights JSON: %w (response: %s)", err, resultJSON) + } + + return &Highlights{CuratedItems: items}, nil +} + +// stripSessionEnv returns a copy of env with GIT_*, CLAUDECODE*, and CLAUDE_CODE_* variables removed. +// Stripping these allows the Claude CLI subprocess to run inside a Claude Code session. +func stripSessionEnv(env []string) []string { + filtered := make([]string, 0, len(env)) + for _, e := range env { + if strings.HasPrefix(e, "GIT_") || strings.HasPrefix(e, "CLAUDECODE") || strings.HasPrefix(e, "CLAUDE_CODE_") { + continue + } + filtered = append(filtered, e) + } + return filtered +} + +// extractJSONFromMarkdown attempts to extract JSON from markdown code blocks. +func extractJSONFromMarkdown(s string) string { + s = strings.TrimSpace(s) + + if strings.HasPrefix(s, "```json") { + s = strings.TrimPrefix(s, "```json") + if idx := strings.LastIndex(s, "```"); idx != -1 { + s = s[:idx] + } + return strings.TrimSpace(s) + } + + if strings.HasPrefix(s, "```") { + s = strings.TrimPrefix(s, "```") + if idx := strings.LastIndex(s, "```"); idx != -1 { + s = s[:idx] + } + return strings.TrimSpace(s) + } + + return s +} diff --git a/cmd/entire/cli/digest/digest.go b/cmd/entire/cli/digest/digest.go new file mode 100644 index 000000000..c54b29f39 --- /dev/null +++ b/cmd/entire/cli/digest/digest.go @@ -0,0 +1,475 @@ +// Package digest generates team activity digests from checkpoint data. +package digest + +import ( + "context" + "fmt" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/summarize" +) + +// AuthorDigest contains digest data for a single author. +type AuthorDigest struct { + Name string + Email string + Prompts []Prompt + Conversations []Conversation // conversation windows for AI curation + Branches map[string]bool + FilesTouched map[string]bool + TokenUsage agent.TokenUsage + SessionCount int +} + +// Prompt represents a single user prompt with context. +type Prompt struct { + Content string + Timestamp time.Time + Branch string + SessionID string + Score int +} + +// Conversation is a scored conversation window with surrounding context. +type Conversation struct { + Author string + Branch string + Timestamp time.Time + SessionID string + Score int + Exchanges []Exchange +} + +// Exchange is one turn in a conversation (user or assistant). +type Exchange struct { + Role string // "user" or "assistant" + Content string +} + +// TeamDigest contains the full team digest. +type TeamDigest struct { + Period string + StartDate time.Time + EndDate time.Time + Authors map[string]*AuthorDigest + Total Stats +} + +// Stats contains aggregate statistics. +type Stats struct { + Checkpoints int + Sessions int + Prompts int + FilesTouched int + TokenUsage agent.TokenUsage +} + +// DefaultLimit is the default number of prompts shown per author. +const DefaultLimit = 5 + +// Options configures digest generation. +type Options struct { + Period string + AuthorFilter string + Limit int // prompts per author (0 = use DefaultLimit) + AllPrompts bool // show all prompts, ignore limit +} + +// periodPattern matches durations like "7d", "30d", "1d". +var periodPattern = regexp.MustCompile(`^(\d+)d$`) + +// ParsePeriod converts a period string to a start time relative to now. +// Supports: "today", "7d", "30d", "all", or any "d" format. +func ParsePeriod(period string, now time.Time) (time.Time, error) { + switch period { + case "all": + return time.Time{}, nil + case "today": + y, m, d := now.Date() + return time.Date(y, m, d, 0, 0, 0, 0, now.Location()), nil + default: + matches := periodPattern.FindStringSubmatch(period) + if matches == nil { + return time.Time{}, fmt.Errorf("invalid period %q: use \"today\", \"7d\", \"30d\", or \"all\"", period) + } + days, _ := strconv.Atoi(matches[1]) + return now.AddDate(0, 0, -days), nil + } +} + +// Generate creates a team digest from checkpoint data. +func Generate(ctx context.Context, store checkpoint.Store, opts Options) (*TeamDigest, error) { + now := time.Now() + startDate, err := ParsePeriod(opts.Period, now) + if err != nil { + return nil, err + } + + committed, err := store.ListCommitted(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list checkpoints: %w", err) + } + + digest := &TeamDigest{ + Period: opts.Period, + EndDate: now, + Authors: make(map[string]*AuthorDigest), + } + if !startDate.IsZero() { + digest.StartDate = startDate + } + + gitStore, ok := store.(*checkpoint.GitStore) + if !ok { + return nil, fmt.Errorf("digest requires a GitStore (got %T)", store) + } + + for _, info := range committed { + // Filter by period + if !startDate.IsZero() && info.CreatedAt.Before(startDate) { + continue + } + + // Skip task checkpoints + if info.IsTask { + continue + } + + // Get author + author, authorErr := gitStore.GetCheckpointAuthor(ctx, info.CheckpointID) + if authorErr != nil { + continue + } + authorName := author.Name + if authorName == "" { + authorName = "Unknown" + } + + // Filter by author + if opts.AuthorFilter != "" && + !strings.EqualFold(authorName, opts.AuthorFilter) && + !strings.HasPrefix(strings.ToLower(authorName), strings.ToLower(opts.AuthorFilter)) { + continue + } + + // Get or create author digest + ad, exists := digest.Authors[authorName] + if !exists { + ad = &AuthorDigest{ + Name: authorName, + Email: author.Email, + Branches: make(map[string]bool), + FilesTouched: make(map[string]bool), + } + digest.Authors[authorName] = ad + } + + // Track files (from checkpoint summary, not per-session) + for _, f := range info.FilesTouched { + ad.FilesTouched[f] = true + } + + // Read all sessions in this checkpoint (multi-session support) + sessionCount := info.SessionCount + if sessionCount <= 0 { + sessionCount = 1 + } + for si := 0; si < sessionCount; si++ { + content, readErr := gitStore.ReadSessionContent(ctx, info.CheckpointID, si) + if readErr != nil { + continue + } + + ad.SessionCount++ + + // Track branch + if content.Metadata.Branch != "" { + ad.Branches[content.Metadata.Branch] = true + } + + // Track tokens + if content.Metadata.TokenUsage != nil { + ad.TokenUsage.InputTokens += content.Metadata.TokenUsage.InputTokens + ad.TokenUsage.OutputTokens += content.Metadata.TokenUsage.OutputTokens + ad.TokenUsage.CacheCreationTokens += content.Metadata.TokenUsage.CacheCreationTokens + ad.TokenUsage.CacheReadTokens += content.Metadata.TokenUsage.CacheReadTokens + } + + // Extract user prompts from transcript + prompts := extractPrompts(content, info) + ad.Prompts = append(ad.Prompts, prompts...) + + // Extract conversation windows for AI curation + convos := extractConversations(content, info) + for ci := range convos { + convos[ci].Author = authorName + } + ad.Conversations = append(ad.Conversations, convos...) + } + + digest.Total.Checkpoints++ + } + + // Calculate totals (before limiting) + allFiles := make(map[string]bool) + sessionIDs := make(map[string]bool) + for _, ad := range digest.Authors { + digest.Total.Prompts += len(ad.Prompts) + digest.Total.TokenUsage.InputTokens += ad.TokenUsage.InputTokens + digest.Total.TokenUsage.OutputTokens += ad.TokenUsage.OutputTokens + digest.Total.TokenUsage.CacheCreationTokens += ad.TokenUsage.CacheCreationTokens + digest.Total.TokenUsage.CacheReadTokens += ad.TokenUsage.CacheReadTokens + for f := range ad.FilesTouched { + allFiles[f] = true + } + for _, p := range ad.Prompts { + sessionIDs[p.SessionID] = true + } + } + digest.Total.FilesTouched = len(allFiles) + digest.Total.Sessions = len(sessionIDs) + + // Sort prompts by score (descending) and apply limit + limit := opts.Limit + if limit <= 0 { + limit = DefaultLimit + } + for _, ad := range digest.Authors { + sort.Slice(ad.Prompts, func(i, j int) bool { + return ad.Prompts[i].Score > ad.Prompts[j].Score + }) + if !opts.AllPrompts && len(ad.Prompts) > limit { + ad.Prompts = ad.Prompts[:limit] + } + + // Sort, deduplicate, and limit conversations for AI curation + sort.Slice(ad.Conversations, func(i, j int) bool { + return ad.Conversations[i].Score > ad.Conversations[j].Score + }) + ad.Conversations = deduplicateConversations(ad.Conversations) + if len(ad.Conversations) > maxConversationsPerAuthor { + ad.Conversations = ad.Conversations[:maxConversationsPerAuthor] + } + } + + return digest, nil +} + +// extractPrompts pulls user prompts from checkpoint content, filtering noise. +func extractPrompts(content *checkpoint.SessionContent, info checkpoint.CommittedInfo) []Prompt { + if len(content.Transcript) == 0 { + return nil + } + + agentType := content.Metadata.Agent + condensed, err := summarize.BuildCondensedTranscriptFromBytes(content.Transcript, agentType) + if err != nil { + return nil + } + + var prompts []Prompt + seen := make(map[string]bool) + + for _, entry := range condensed { + if entry.Type != summarize.EntryTypeUser { + continue + } + text := strings.TrimSpace(entry.Content) + if text == "" { + continue + } + + // Filter noise + if isNoisePrompt(text) { + continue + } + + // Deduplicate + if seen[text] { + continue + } + seen[text] = true + + prompts = append(prompts, Prompt{ + Content: text, + Timestamp: info.CreatedAt, + Branch: content.Metadata.Branch, + SessionID: content.Metadata.SessionID, + Score: scorePrompt(text), + }) + } + + return prompts +} + +// maxConversationsPerAuthor is how many conversation windows to keep per author for AI curation. +const maxConversationsPerAuthor = 20 + +// extractConversations pulls conversation windows (user + assistant + follow-ups) from transcripts. +// Each window is centered on a high-scoring user prompt and includes surrounding context. +func extractConversations(content *checkpoint.SessionContent, info checkpoint.CommittedInfo) []Conversation { + if len(content.Transcript) == 0 { + return nil + } + + agentType := content.Metadata.Agent + condensed, err := summarize.BuildCondensedTranscriptFromBytes(content.Transcript, agentType) + if err != nil { + return nil + } + + // Build list of non-tool entries with their original indices + type indexedEntry struct { + entry summarize.Entry + index int + } + var entries []indexedEntry + for i, e := range condensed { + if e.Type == summarize.EntryTypeTool { + continue + } + entries = append(entries, indexedEntry{entry: e, index: i}) + } + + var conversations []Conversation + seen := make(map[string]bool) + + for i, ie := range entries { + if ie.entry.Type != summarize.EntryTypeUser { + continue + } + text := strings.TrimSpace(ie.entry.Content) + if text == "" || isNoisePrompt(text) || seen[text] { + continue + } + seen[text] = true + + score := scorePrompt(text) + + // Build conversation window around this user prompt + var exchanges []Exchange + + // Look back: if previous entry is assistant, include it + if i > 0 && entries[i-1].entry.Type == summarize.EntryTypeAssistant { + prev := strings.TrimSpace(entries[i-1].entry.Content) + if prev != "" { + exchanges = append(exchanges, Exchange{Role: "assistant", Content: prev}) + } + } + + // The user prompt itself + exchanges = append(exchanges, Exchange{Role: "user", Content: text}) + + // Look forward: assistant response + optional short follow-up exchanges + for j := i + 1; j < len(entries) && len(exchanges) < 6; j++ { + next := entries[j] + nextText := strings.TrimSpace(next.entry.Content) + if nextText == "" || isNoisePrompt(nextText) { + continue + } + if next.entry.Type == summarize.EntryTypeAssistant { + exchanges = append(exchanges, Exchange{Role: "assistant", Content: nextText}) + } else if next.entry.Type == summarize.EntryTypeUser { + // Include short follow-up prompts (the "Really?" moments) + if len(strings.Fields(nextText)) <= 20 { + exchanges = append(exchanges, Exchange{Role: "user", Content: nextText}) + } else { + break + } + } + } + + // Score follow-up user messages (the arc makes conversations funny, not just the anchor) + for _, ex := range exchanges { + if ex.Role == "user" && ex.Content != text { + score += scorePrompt(ex.Content) / 2 + } + } + + // Bonus for rapid-fire back-and-forth (4+ exchanges are almost always entertaining) + if len(exchanges) >= 4 { + score += 2 + } + + conversations = append(conversations, Conversation{ + Branch: content.Metadata.Branch, + Timestamp: info.CreatedAt, + SessionID: content.Metadata.SessionID, + Score: score, + Exchanges: exchanges, + }) + } + + return conversations +} + +// isNoisePrompt returns true for prompts that should be filtered out. +func isNoisePrompt(text string) bool { + // Skip task notifications + if strings.HasPrefix(text, "") { + return true + } + // Skip request interrupted markers + if strings.Contains(text, "[Request interrupted by user]") { + return true + } + // Skip system reminders + if strings.HasPrefix(text, "") { + return true + } + return false +} + +// SortedAuthors returns authors sorted by prompt count (descending). +func (d *TeamDigest) SortedAuthors() []*AuthorDigest { + authors := make([]*AuthorDigest, 0, len(d.Authors)) + for _, a := range d.Authors { + authors = append(authors, a) + } + sort.Slice(authors, func(i, j int) bool { + return len(authors[i].Prompts) > len(authors[j].Prompts) + }) + return authors +} + +// deduplicateConversations removes cross-session duplicates by normalized prompt text. +// Must be called after sorting by score so the highest-scoring duplicate wins. +func deduplicateConversations(convos []Conversation) []Conversation { + seen := make(map[string]bool) + result := make([]Conversation, 0, len(convos)) + for _, c := range convos { + key := normalizePromptKey(c.Exchanges) + if seen[key] { + continue + } + seen[key] = true + result = append(result, c) + } + return result +} + +// normalizePromptKey extracts the first user message from exchanges and normalizes it for dedup. +func normalizePromptKey(exchanges []Exchange) string { + for _, ex := range exchanges { + if ex.Role == "user" { + return strings.ToLower(strings.TrimSpace(ex.Content)) + } + } + return "" +} + +// SortedBranches returns branch names sorted alphabetically. +func (a *AuthorDigest) SortedBranches() []string { + branches := make([]string, 0, len(a.Branches)) + for b := range a.Branches { + branches = append(branches, b) + } + sort.Strings(branches) + return branches +} diff --git a/cmd/entire/cli/digest/digest_test.go b/cmd/entire/cli/digest/digest_test.go new file mode 100644 index 000000000..4f40f5cbd --- /dev/null +++ b/cmd/entire/cli/digest/digest_test.go @@ -0,0 +1,841 @@ +package digest + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + "time" +) + +func TestParsePeriod(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 2, 24, 15, 0, 0, 0, time.UTC) + + tests := []struct { + name string + period string + wantErr bool + check func(t *testing.T, start time.Time) + }{ + { + name: "7d", + period: "7d", + check: func(t *testing.T, start time.Time) { + t.Helper() + expected := now.AddDate(0, 0, -7) + if !start.Equal(expected) { + t.Errorf("got %v, want %v", start, expected) + } + }, + }, + { + name: "30d", + period: "30d", + check: func(t *testing.T, start time.Time) { + t.Helper() + expected := now.AddDate(0, 0, -30) + if !start.Equal(expected) { + t.Errorf("got %v, want %v", start, expected) + } + }, + }, + { + name: "1d", + period: "1d", + check: func(t *testing.T, start time.Time) { + t.Helper() + expected := now.AddDate(0, 0, -1) + if !start.Equal(expected) { + t.Errorf("got %v, want %v", start, expected) + } + }, + }, + { + name: "today", + period: "today", + check: func(t *testing.T, start time.Time) { + t.Helper() + expected := time.Date(2026, 2, 24, 0, 0, 0, 0, time.UTC) + if !start.Equal(expected) { + t.Errorf("got %v, want %v", start, expected) + } + }, + }, + { + name: "all", + period: "all", + check: func(t *testing.T, start time.Time) { + t.Helper() + if !start.IsZero() { + t.Errorf("expected zero time for 'all', got %v", start) + } + }, + }, + { + name: "invalid period", + period: "foo", + wantErr: true, + }, + { + name: "empty period", + period: "", + wantErr: true, + }, + { + name: "negative days", + period: "-5d", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + start, err := ParsePeriod(tt.period, now) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + tt.check(t, start) + }) + } +} + +func TestIsNoisePrompt(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + text string + noise bool + }{ + {"normal prompt", "fix the login bug", false}, + {"task notification", "stuff", true}, + {"interrupted", "something [Request interrupted by user]", true}, + {"system reminder", "stuff", true}, + {"empty", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := isNoisePrompt(tt.text) + if got != tt.noise { + t.Errorf("isNoisePrompt(%q) = %v, want %v", tt.text, got, tt.noise) + } + }) + } +} + +func TestTeamDigest_SortedAuthors(t *testing.T) { + t.Parallel() + + d := &TeamDigest{ + Authors: map[string]*AuthorDigest{ + "Alice": {Name: "Alice", Prompts: make([]Prompt, 3)}, + "Bob": {Name: "Bob", Prompts: make([]Prompt, 10)}, + "Carol": {Name: "Carol", Prompts: make([]Prompt, 1)}, + }, + } + + sorted := d.SortedAuthors() + if len(sorted) != 3 { + t.Fatalf("expected 3 authors, got %d", len(sorted)) + } + if sorted[0].Name != "Bob" { + t.Errorf("expected Bob first (most prompts), got %s", sorted[0].Name) + } + if sorted[1].Name != "Alice" { + t.Errorf("expected Alice second, got %s", sorted[1].Name) + } + if sorted[2].Name != "Carol" { + t.Errorf("expected Carol third, got %s", sorted[2].Name) + } +} + +func TestAuthorDigest_SortedBranches(t *testing.T) { + t.Parallel() + + a := &AuthorDigest{ + Branches: map[string]bool{ + "main": true, + "feat/auth": true, + "fix/bug-123": true, + }, + } + + branches := a.SortedBranches() + if len(branches) != 3 { + t.Fatalf("expected 3 branches, got %d", len(branches)) + } + if branches[0] != "feat/auth" { + t.Errorf("expected feat/auth first (alphabetical), got %s", branches[0]) + } +} + +func TestTruncate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + maxLen int + want string + }{ + {"short", "hello", 10, "hello"}, + {"exact", "hello", 5, "hello"}, + {"long", "hello world", 5, "hello..."}, + {"newlines", "hello\nworld", 20, "hello world"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := truncate(tt.input, tt.maxLen) + if got != tt.want { + t.Errorf("truncate(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want) + } + }) + } +} + +func TestFormatTokenCount(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tokens int + want string + }{ + {"small", 500, "500"}, + {"thousands", 5000, "5.0K"}, + {"millions", 1500000, "1.5M"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := formatTokenCount(tt.tokens) + if got != tt.want { + t.Errorf("formatTokenCount(%d) = %q, want %q", tt.tokens, got, tt.want) + } + }) + } +} + +func TestFormatTerminal_Empty(t *testing.T) { + t.Parallel() + + d := &TeamDigest{ + Period: "7d", + Authors: map[string]*AuthorDigest{}, + } + + var buf strings.Builder + FormatTerminal(&buf, d, nil, FormatOptions{}) + + output := buf.String() + if !strings.Contains(output, "No activity found") { + t.Errorf("expected 'No activity found' in output, got:\n%s", output) + } +} + +func TestFormatTerminal_WithData(t *testing.T) { + t.Parallel() + + d := &TeamDigest{ + Period: "7d", + Authors: map[string]*AuthorDigest{ + "Myk": { + Name: "Myk", + SessionCount: 2, + Branches: map[string]bool{"main": true}, + FilesTouched: map[string]bool{"file.go": true}, + Prompts: []Prompt{ + {Content: "fix the login bug"}, + {Content: "add tests"}, + }, + }, + }, + Total: Stats{ + Checkpoints: 5, + Sessions: 2, + Prompts: 2, + FilesTouched: 1, + }, + } + + var buf strings.Builder + FormatTerminal(&buf, d, nil, FormatOptions{}) + + output := buf.String() + if !strings.Contains(output, "Team Digest") { + t.Error("expected 'Team Digest' header") + } + if !strings.Contains(output, "Myk") { + t.Error("expected author name 'Myk'") + } + if !strings.Contains(output, "fix the login bug") { + t.Error("expected prompt text") + } +} + +func TestFormatMarkdown_WithData(t *testing.T) { + t.Parallel() + + d := &TeamDigest{ + Period: "30d", + Authors: map[string]*AuthorDigest{ + "Bob": { + Name: "Bob", + SessionCount: 1, + Branches: map[string]bool{"feat/auth": true}, + FilesTouched: map[string]bool{"auth.go": true}, + Prompts: []Prompt{ + {Content: "implement OAuth flow"}, + }, + }, + }, + Total: Stats{ + Checkpoints: 1, + Sessions: 1, + Prompts: 1, + FilesTouched: 1, + }, + } + + var buf strings.Builder + FormatMarkdown(&buf, d, nil, FormatOptions{}) + + output := buf.String() + if !strings.Contains(output, "# Team Digest") { + t.Error("expected markdown header") + } + if !strings.Contains(output, "## Bob") { + t.Error("expected author section") + } + if !strings.Contains(output, "implement OAuth flow") { + t.Error("expected prompt in markdown") + } +} + +func TestFormatTerminal_WithHighlights(t *testing.T) { + t.Parallel() + + d := &TeamDigest{ + Period: "7d", + Authors: map[string]*AuthorDigest{ + "Alice": { + Name: "Alice", + SessionCount: 1, + Branches: map[string]bool{"main": true}, + FilesTouched: map[string]bool{}, + Prompts: []Prompt{{Content: "hello"}}, + }, + }, + Total: Stats{Checkpoints: 1, Sessions: 1, Prompts: 1}, + } + + highlights := &Highlights{ + Fun: []Highlight{ + {Author: "Alice", Quote: "Thank you, Captain Obvious", Context: "after a verbose explanation"}, + }, + } + + var buf strings.Builder + FormatTerminal(&buf, d, highlights, FormatOptions{}) + + output := buf.String() + if !strings.Contains(output, "HIGHLIGHTS") { + t.Error("expected HIGHLIGHTS section") + } + if !strings.Contains(output, "Thank you, Captain Obvious") { + t.Error("expected highlight quote") + } +} + +func TestFormatTerminal_CuratedHighlights(t *testing.T) { + t.Parallel() + + d := &TeamDigest{ + Period: "7d", + Authors: map[string]*AuthorDigest{ + "Myk": { + Name: "Myk", + SessionCount: 1, + Branches: map[string]bool{"main": true}, + FilesTouched: map[string]bool{}, + Prompts: []Prompt{{Content: "hello"}}, + }, + }, + Total: Stats{Checkpoints: 1, Sessions: 1, Prompts: 1}, + } + + highlights := &Highlights{ + CuratedItems: []CuratedHighlight{ + { + Title: "Thank you, Captain Obvious", + Author: "Myk Melez", + DateCtx: "Feb 21, multi-segment recording branch", + Exchange: "Myk: \"Thank you, Captain Obvious.\"", + Commentary: "Classic frustrated-developer-with-AI moment.", + }, + }, + } + + var buf strings.Builder + FormatTerminal(&buf, d, highlights, FormatOptions{}) + + output := buf.String() + if !strings.Contains(output, "HIGHLIGHTS") { + t.Error("expected HIGHLIGHTS section") + } + if !strings.Contains(output, "Thank you, Captain Obvious") { + t.Error("expected curated title") + } + if !strings.Contains(output, "Myk Melez") { + t.Error("expected author name") + } + if !strings.Contains(output, "Classic frustrated") { + t.Error("expected editorial commentary") + } +} + +func TestWrapText(t *testing.T) { + t.Parallel() + + lines := wrapText("This is a test of the word wrapping function that should break at word boundaries", 30) + if len(lines) < 2 { + t.Errorf("expected multiple lines, got %d", len(lines)) + } + for _, line := range lines { + if len(line) > 35 { // allow some slack for long words + t.Errorf("line too long: %q (%d chars)", line, len(line)) + } + } +} + +func TestScorePrompt(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + text string + wantAbove int // score should be > this + wantBelow int // score should be < this + }{ + {"short excited", "nice!", 4, 100}, // short +3, punctuation +2, celebration +3 + {"frustration", "why is this broken?!", 4, 100}, // frustration +3, punctuation +2 + {"celebration", "finally worked!", 4, 100}, // celebration +3, punctuation +2, short +3 + {"long paste", strings.Repeat("word ", 200), -3, 1}, // long -2 + {"session continuation", "This session is being continued from a previous run", -6, 0}, + {"slash command", "/commit", -1, 4}, // slash -1, short +3 = 2 + {"normal prompt", "please add error handling to the login service", -1, 5}, + + // Sarcasm signals + {"captain obvious", "Thank you, Captain Obvious", 4, 100}, // short +3, sarcasm +3 + {"thanks for nothing", "thanks for nothing, that was useless", 2, 100}, // sarcasm +3 + + // Single-word disbelief + {"really", "Really?", 7, 100}, // short +3, single-word +4, punctuation +2 + {"seriously", "Seriously?", 7, 100}, // short +3, single-word +4, punctuation +2 + {"no period", "No.", 6, 100}, // short +3, single-word +4 + + // Context-amnesia + {"forgotten everything", "Have you forgotten everything about this project?", 3, 100}, // punctuation +2, amnesia +3 + {"you just told me", "You just told me the opposite", 2, 100}, // amnesia +3 + + // AI self-reference + {"are you stuck", "Are you stuck?", 7, 100}, // short +3, punctuation +2, frustration("stuck") +3, ai-self-ref +2 + {"is bash stuck", "Is bash stuck?", 7, 100}, // short +3, punctuation +2, frustration("stuck") +3, ai-self-ref +2 + + // Captain Obvious should beat a routine prompt + {"sarcasm beats routine", "Thank you, Captain Obvious", 4, 100}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + score := scorePrompt(tt.text) + if score <= tt.wantAbove { + t.Errorf("scorePrompt(%q) = %d, want > %d", tt.text, score, tt.wantAbove) + } + if score >= tt.wantBelow { + t.Errorf("scorePrompt(%q) = %d, want < %d", tt.text, score, tt.wantBelow) + } + }) + } +} + +func TestFormatTerminal_Short(t *testing.T) { + t.Parallel() + + d := &TeamDigest{ + Period: "7d", + Authors: map[string]*AuthorDigest{ + "Myk": { + Name: "Myk", + SessionCount: 2, + Branches: map[string]bool{"main": true}, + FilesTouched: map[string]bool{"file.go": true}, + Prompts: []Prompt{ + {Content: "fix the login bug"}, + {Content: "add tests"}, + }, + }, + }, + Total: Stats{ + Checkpoints: 5, + Sessions: 2, + Prompts: 2, + FilesTouched: 1, + }, + } + + var buf strings.Builder + FormatTerminal(&buf, d, nil, FormatOptions{Short: true}) + + output := buf.String() + if !strings.Contains(output, "Myk") { + t.Error("expected author name in short output") + } + if strings.Contains(output, "fix the login bug") { + t.Error("short mode should not include prompt text") + } + if !strings.Contains(output, "2 prompts") { + t.Error("expected prompt count in short output") + } +} + +func TestFormatJSON_BasicOutput(t *testing.T) { + t.Parallel() + + d := &TeamDigest{ + Period: "7d", + Authors: map[string]*AuthorDigest{ + "Myk": { + Name: "Myk", + SessionCount: 2, + Branches: map[string]bool{"main": true, "feat/auth": true}, + FilesTouched: map[string]bool{"file.go": true, "auth.go": true}, + Prompts: []Prompt{ + {Content: "fix the login bug", Score: 3}, + {Content: "nice!", Score: 8}, + }, + Conversations: []Conversation{ + { + Author: "Myk", + Branch: "main", + Timestamp: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC), + Score: 8, + Exchanges: []Exchange{ + {Role: "user", Content: "nice!"}, + {Role: "assistant", Content: "Glad it worked!"}, + }, + }, + { + Author: "Myk", + Branch: "feat/auth", + Timestamp: time.Date(2026, 2, 21, 10, 0, 0, 0, time.UTC), + Score: 5, + Exchanges: []Exchange{ + {Role: "user", Content: "why is this broken?!"}, + {Role: "assistant", Content: "The issue is..."}, + {Role: "user", Content: "Really?"}, + }, + }, + }, + }, + }, + Total: Stats{ + Checkpoints: 5, + Sessions: 2, + Prompts: 2, + FilesTouched: 2, + }, + } + + var buf strings.Builder + FormatJSON(&buf, d) + + // Parse to verify valid JSON + var output JSONOutput + if err := json.Unmarshal([]byte(buf.String()), &output); err != nil { + t.Fatalf("invalid JSON output: %v\nraw: %s", err, buf.String()) + } + + // Verify structure + if output.Period != "7d" { + t.Errorf("period = %q, want %q", output.Period, "7d") + } + if output.Stats.Checkpoints != 5 { + t.Errorf("stats.checkpoints = %d, want 5", output.Stats.Checkpoints) + } + if len(output.TopConversations) != 2 { + t.Fatalf("top_conversations length = %d, want 2", len(output.TopConversations)) + } + + // Verify sorted by score descending + if output.TopConversations[0].Score != 8 { + t.Errorf("first conversation score = %d, want 8", output.TopConversations[0].Score) + } + if output.TopConversations[1].Score != 5 { + t.Errorf("second conversation score = %d, want 5", output.TopConversations[1].Score) + } + + // Verify rank numbering + if output.TopConversations[0].Rank != 1 { + t.Errorf("first conversation rank = %d, want 1", output.TopConversations[0].Rank) + } + + // Verify exchanges + if len(output.TopConversations[0].Exchanges) != 2 { + t.Errorf("first convo exchanges = %d, want 2", len(output.TopConversations[0].Exchanges)) + } + + // Verify scoring guide + if output.ScoringGuide == "" { + t.Error("scoring_guide should not be empty") + } + + // Verify authors + if len(output.Authors) != 1 { + t.Fatalf("authors length = %d, want 1", len(output.Authors)) + } + if output.Authors[0].Name != "Myk" { + t.Errorf("author name = %q, want %q", output.Authors[0].Name, "Myk") + } + if len(output.Authors[0].Branches) != 2 { + t.Errorf("author branches = %d, want 2", len(output.Authors[0].Branches)) + } +} + +func TestFormatJSON_TruncatesLongExchanges(t *testing.T) { + t.Parallel() + + longContent := strings.Repeat("a", 1000) + d := &TeamDigest{ + Period: "7d", + Authors: map[string]*AuthorDigest{ + "Test": { + Name: "Test", + Branches: map[string]bool{}, + FilesTouched: map[string]bool{}, + Conversations: []Conversation{ + { + Author: "Test", + Score: 5, + Exchanges: []Exchange{ + {Role: "assistant", Content: longContent}, + }, + }, + }, + }, + }, + } + + var buf strings.Builder + FormatJSON(&buf, d) + + var output JSONOutput + if err := json.Unmarshal([]byte(buf.String()), &output); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if len(output.TopConversations) != 1 { + t.Fatalf("expected 1 conversation, got %d", len(output.TopConversations)) + } + content := output.TopConversations[0].Exchanges[0].Content + if len(content) > maxJSONExchangeLen+10 { // +10 for "..." + t.Errorf("exchange content too long: %d chars (max %d)", len(content), maxJSONExchangeLen) + } + if !strings.HasSuffix(content, "...") { + t.Error("truncated content should end with ...") + } +} + +func TestFormatJSON_GlobalCap(t *testing.T) { + t.Parallel() + + // Create more conversations than the global max + d := &TeamDigest{ + Period: "all", + Authors: map[string]*AuthorDigest{}, + } + + author := &AuthorDigest{ + Name: "Prolific", + Branches: map[string]bool{}, + FilesTouched: map[string]bool{}, + } + for i := range 50 { + // Each prompt is a single unique word - no word overlap between conversations + author.Conversations = append(author.Conversations, Conversation{ + Author: "Prolific", + Score: i, + Exchanges: []Exchange{ + {Role: "user", Content: fmt.Sprintf("xyzzy%d", i)}, + }, + }) + } + d.Authors["Prolific"] = author + + var buf strings.Builder + FormatJSON(&buf, d) + + var output JSONOutput + if err := json.Unmarshal([]byte(buf.String()), &output); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if len(output.TopConversations) != maxGlobalConversations { + t.Errorf("expected %d conversations (global cap), got %d", maxGlobalConversations, len(output.TopConversations)) + } + + // Verify highest scores are kept + if output.TopConversations[0].Score != 49 { + t.Errorf("highest score should be 49, got %d", output.TopConversations[0].Score) + } +} + +func TestFormatJSON_Empty(t *testing.T) { + t.Parallel() + + d := &TeamDigest{ + Period: "7d", + Authors: map[string]*AuthorDigest{}, + } + + var buf strings.Builder + FormatJSON(&buf, d) + + var output JSONOutput + if err := json.Unmarshal([]byte(buf.String()), &output); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if len(output.TopConversations) != 0 { + t.Errorf("expected empty top_conversations for empty digest, got %d", len(output.TopConversations)) + } +} + +func TestDeduplicateConversations(t *testing.T) { + t.Parallel() + + convos := []Conversation{ + {Score: 8, Exchanges: []Exchange{{Role: "user", Content: "Are you stuck?"}}}, + {Score: 8, Exchanges: []Exchange{{Role: "user", Content: "Are you stuck?"}}}, + {Score: 8, Exchanges: []Exchange{{Role: "user", Content: "Are you stuck?"}}}, + {Score: 5, Exchanges: []Exchange{{Role: "user", Content: "Thank you, Captain Obvious"}}}, + {Score: 3, Exchanges: []Exchange{{Role: "user", Content: "ls"}}}, + } + + result := deduplicateConversations(convos) + if len(result) != 3 { + t.Errorf("expected 3 unique conversations, got %d", len(result)) + } + // First should be "Are you stuck?" (highest score, first seen) + if result[0].Score != 8 { + t.Errorf("first result score = %d, want 8", result[0].Score) + } + // Second should be "Captain Obvious" + if result[1].Score != 5 { + t.Errorf("second result score = %d, want 5", result[1].Score) + } +} + +func TestDeduplicateConversations_CaseInsensitive(t *testing.T) { + t.Parallel() + + convos := []Conversation{ + {Score: 8, Exchanges: []Exchange{{Role: "user", Content: "Are You Stuck?"}}}, + {Score: 7, Exchanges: []Exchange{{Role: "user", Content: "are you stuck?"}}}, + {Score: 5, Exchanges: []Exchange{{Role: "user", Content: "Something different"}}}, + } + + result := deduplicateConversations(convos) + if len(result) != 2 { + t.Errorf("expected 2 unique conversations (case-insensitive dedup), got %d", len(result)) + } +} + +func TestDiverseTopN_FiltersSimilar(t *testing.T) { + t.Parallel() + + convos := []JSONConversation{ + {Score: 10, Exchanges: []JSONExchange{{Role: "user", Content: "Are you stuck?"}}}, + {Score: 9, Exchanges: []JSONExchange{{Role: "user", Content: "Are you stuck again?"}}}, + {Score: 8, Exchanges: []JSONExchange{{Role: "user", Content: "Are you still stuck?"}}}, + {Score: 5, Exchanges: []JSONExchange{{Role: "user", Content: "Thank you, Captain Obvious"}}}, + {Score: 3, Exchanges: []JSONExchange{{Role: "user", Content: "ls"}}}, + } + + result := diverseTopN(convos, 5) + // Should keep the first "stuck" variant but skip the similar ones, picking Captain Obvious and ls instead + if len(result) < 3 { + t.Errorf("expected at least 3 diverse conversations, got %d", len(result)) + } + // First should be highest scoring + if result[0].Score != 10 { + t.Errorf("first result score = %d, want 10", result[0].Score) + } + // Should NOT have all three "stuck" variants + stuckCount := 0 + for _, c := range result { + for _, ex := range c.Exchanges { + if ex.Role == "user" && strings.Contains(strings.ToLower(ex.Content), "stuck") { + stuckCount++ + } + } + } + if stuckCount > 1 { + t.Errorf("expected at most 1 'stuck' conversation, got %d", stuckCount) + } +} + +func TestDiverseTopN_PreservesUniqueConversations(t *testing.T) { + t.Parallel() + + convos := []JSONConversation{ + {Score: 10, Exchanges: []JSONExchange{{Role: "user", Content: "Are you stuck?"}}}, + {Score: 8, Exchanges: []JSONExchange{{Role: "user", Content: "Thank you, Captain Obvious"}}}, + {Score: 5, Exchanges: []JSONExchange{{Role: "user", Content: "Really?"}}}, + {Score: 3, Exchanges: []JSONExchange{{Role: "user", Content: "ls"}}}, + } + + result := diverseTopN(convos, 4) + if len(result) != 4 { + t.Errorf("expected all 4 unique conversations, got %d", len(result)) + } +} + +func TestExtractJSONFromMarkdown(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + }{ + {"plain json", `{"key": "value"}`, `{"key": "value"}`}, + {"json code block", "```json\n{\"key\": \"value\"}\n```", `{"key": "value"}`}, + {"generic code block", "```\n{\"key\": \"value\"}\n```", `{"key": "value"}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := extractJSONFromMarkdown(tt.input) + if got != tt.want { + t.Errorf("extractJSONFromMarkdown(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} diff --git a/cmd/entire/cli/digest/format.go b/cmd/entire/cli/digest/format.go new file mode 100644 index 000000000..2024f5e27 --- /dev/null +++ b/cmd/entire/cli/digest/format.go @@ -0,0 +1,533 @@ +package digest + +import ( + "encoding/json" + "fmt" + "io" + "sort" + "strings" +) + +// maxPromptDisplayLen is the maximum characters to show for a prompt in terminal mode. +const maxPromptDisplayLen = 200 + +// FormatOptions controls output verbosity. +type FormatOptions struct { + Short bool // stats only, no prompts +} + +// FormatTerminal writes a terminal-friendly digest to the writer. +func FormatTerminal(w io.Writer, d *TeamDigest, highlights *Highlights, opts FormatOptions) { + // Header + fmt.Fprintf(w, "Team Digest") + if d.Period != "all" { + fmt.Fprintf(w, " (last %s)", d.Period) + } + fmt.Fprintln(w) + fmt.Fprintln(w, strings.Repeat("-", 60)) + fmt.Fprintln(w) + + // Summary stats + fmt.Fprintf(w, "Checkpoints: %d\n", d.Total.Checkpoints) + fmt.Fprintf(w, "Sessions: %d\n", d.Total.Sessions) + fmt.Fprintf(w, "Prompts: %d\n", d.Total.Prompts) + fmt.Fprintf(w, "Files: %d\n", d.Total.FilesTouched) + totalTokens := d.Total.TokenUsage.InputTokens + d.Total.TokenUsage.OutputTokens + if totalTokens > 0 { + fmt.Fprintf(w, "Tokens: %s\n", formatTokenCount(totalTokens)) + } + fmt.Fprintln(w) + + if len(d.Authors) == 0 { + fmt.Fprintln(w, "No activity found.") + return + } + + if opts.Short { + // Short mode: stats per author, no prompts + for _, author := range d.SortedAuthors() { + authorTokens := author.TokenUsage.InputTokens + author.TokenUsage.OutputTokens + fmt.Fprintf(w, " %-20s %d prompts | %d sessions | %d files", + author.Name, len(author.Prompts), author.SessionCount, len(author.FilesTouched)) + if authorTokens > 0 { + fmt.Fprintf(w, " | %s tokens", formatTokenCount(authorTokens)) + } + fmt.Fprintln(w) + } + return + } + + // AI-curated highlights + if highlights != nil { + formatHighlights(w, highlights) + } + + // Per-author sections + for _, author := range d.SortedAuthors() { + formatAuthorSection(w, author) + } +} + +// FormatMarkdown writes a markdown-formatted digest to the writer. +func FormatMarkdown(w io.Writer, d *TeamDigest, highlights *Highlights, opts FormatOptions) { + fmt.Fprintf(w, "# Team Digest") + if d.Period != "all" { + fmt.Fprintf(w, " (last %s)", d.Period) + } + fmt.Fprintln(w) + fmt.Fprintln(w) + + // Summary table + fmt.Fprintln(w, "| Metric | Value |") + fmt.Fprintln(w, "|--------|-------|") + fmt.Fprintf(w, "| Checkpoints | %d |\n", d.Total.Checkpoints) + fmt.Fprintf(w, "| Sessions | %d |\n", d.Total.Sessions) + fmt.Fprintf(w, "| Prompts | %d |\n", d.Total.Prompts) + fmt.Fprintf(w, "| Files | %d |\n", d.Total.FilesTouched) + totalTokens := d.Total.TokenUsage.InputTokens + d.Total.TokenUsage.OutputTokens + if totalTokens > 0 { + fmt.Fprintf(w, "| Tokens | %s |\n", formatTokenCount(totalTokens)) + } + fmt.Fprintln(w) + + if len(d.Authors) == 0 { + fmt.Fprintln(w, "No activity found.") + return + } + + if opts.Short { + return + } + + // AI-curated highlights + if highlights != nil { + formatHighlightsMarkdown(w, highlights) + } + + // Per-author sections + for _, author := range d.SortedAuthors() { + formatAuthorSectionMarkdown(w, author) + } +} + +func formatAuthorSection(w io.Writer, author *AuthorDigest) { + fmt.Fprintf(w, "## %s\n", author.Name) + + // Stats line + authorTokens := author.TokenUsage.InputTokens + author.TokenUsage.OutputTokens + fmt.Fprintf(w, " %d prompts | %d sessions | %d files", + len(author.Prompts), author.SessionCount, len(author.FilesTouched)) + if authorTokens > 0 { + fmt.Fprintf(w, " | %s tokens", formatTokenCount(authorTokens)) + } + fmt.Fprintln(w) + + // Branches + branches := author.SortedBranches() + if len(branches) > 0 { + fmt.Fprintf(w, " Branches: %s\n", strings.Join(branches, ", ")) + } + fmt.Fprintln(w) + + // Prompts (already sorted by score and limited) + for _, p := range author.Prompts { + text := truncate(p.Content, maxPromptDisplayLen) + fmt.Fprintf(w, " > %s\n", text) + } + fmt.Fprintln(w) +} + +func formatAuthorSectionMarkdown(w io.Writer, author *AuthorDigest) { + fmt.Fprintf(w, "## %s\n\n", author.Name) + + authorTokens := author.TokenUsage.InputTokens + author.TokenUsage.OutputTokens + fmt.Fprintf(w, "**%d prompts** | %d sessions | %d files", + len(author.Prompts), author.SessionCount, len(author.FilesTouched)) + if authorTokens > 0 { + fmt.Fprintf(w, " | %s tokens", formatTokenCount(authorTokens)) + } + fmt.Fprintln(w) + fmt.Fprintln(w) + + branches := author.SortedBranches() + if len(branches) > 0 { + fmt.Fprintf(w, "**Branches:** %s\n\n", strings.Join(branches, ", ")) + } + + if len(author.Prompts) > 0 { + fmt.Fprintln(w, "### Prompts") + fmt.Fprintln(w) + for _, p := range author.Prompts { + fmt.Fprintf(w, "> %s\n\n", p.Content) + } + } +} + +func formatHighlights(w io.Writer, h *Highlights) { + // New curated format + if len(h.CuratedItems) > 0 { + fmt.Fprintln(w, "HIGHLIGHTS") + fmt.Fprintln(w, strings.Repeat("=", 60)) + fmt.Fprintln(w) + for i, item := range h.CuratedItems { + fmt.Fprintf(w, "%d. \"%s\" - %s\n", i+1, item.Title, item.Author) + if item.DateCtx != "" { + fmt.Fprintf(w, " (%s)\n", item.DateCtx) + } + fmt.Fprintln(w) + if item.Exchange != "" { + for _, line := range strings.Split(item.Exchange, "\n") { + fmt.Fprintf(w, " %s\n", line) + } + fmt.Fprintln(w) + } + if item.Commentary != "" { + for _, line := range wrapText(item.Commentary, 72) { + fmt.Fprintf(w, " %s\n", line) + } + } + fmt.Fprintln(w) + } + return + } + + // Legacy category format + if len(h.Fun) == 0 && len(h.Clever) == 0 && len(h.Notable) == 0 { + return + } + + fmt.Fprintln(w, "HIGHLIGHTS") + fmt.Fprintln(w, strings.Repeat("=", 60)) + fmt.Fprintln(w) + + if len(h.Fun) > 0 { + fmt.Fprintln(w, "Fun / Frustrated:") + for _, item := range h.Fun { + fmt.Fprintf(w, " [%s] \"%s\"\n", item.Author, item.Quote) + if item.Context != "" { + fmt.Fprintf(w, " %s\n", item.Context) + } + } + fmt.Fprintln(w) + } + + if len(h.Clever) > 0 { + fmt.Fprintln(w, "Clever:") + for _, item := range h.Clever { + fmt.Fprintf(w, " [%s] \"%s\"\n", item.Author, item.Quote) + if item.Context != "" { + fmt.Fprintf(w, " %s\n", item.Context) + } + } + fmt.Fprintln(w) + } + + if len(h.Notable) > 0 { + fmt.Fprintln(w, "Notable:") + for _, item := range h.Notable { + fmt.Fprintf(w, " [%s] \"%s\"\n", item.Author, item.Quote) + if item.Context != "" { + fmt.Fprintf(w, " %s\n", item.Context) + } + } + fmt.Fprintln(w) + } +} + +func formatHighlightsMarkdown(w io.Writer, h *Highlights) { + // New curated format + if len(h.CuratedItems) > 0 { + fmt.Fprintln(w, "## Highlights") + fmt.Fprintln(w) + for i, item := range h.CuratedItems { + fmt.Fprintf(w, "### %d. \"%s\" - %s\n", i+1, item.Title, item.Author) + if item.DateCtx != "" { + fmt.Fprintf(w, "*%s*\n", item.DateCtx) + } + fmt.Fprintln(w) + if item.Exchange != "" { + fmt.Fprintf(w, "> %s\n", strings.ReplaceAll(item.Exchange, "\n", "\n> ")) + } + fmt.Fprintln(w) + if item.Commentary != "" { + fmt.Fprintln(w, item.Commentary) + } + fmt.Fprintln(w) + } + return + } + + // Legacy category format + if len(h.Fun) == 0 && len(h.Clever) == 0 && len(h.Notable) == 0 { + return + } + + fmt.Fprintln(w, "## Highlights") + fmt.Fprintln(w) + + if len(h.Fun) > 0 { + fmt.Fprintln(w, "### Fun / Frustrated") + fmt.Fprintln(w) + for _, item := range h.Fun { + fmt.Fprintf(w, "- **%s**: \"%s\"", item.Author, item.Quote) + if item.Context != "" { + fmt.Fprintf(w, " - _%s_", item.Context) + } + fmt.Fprintln(w) + } + fmt.Fprintln(w) + } + + if len(h.Clever) > 0 { + fmt.Fprintln(w, "### Clever") + fmt.Fprintln(w) + for _, item := range h.Clever { + fmt.Fprintf(w, "- **%s**: \"%s\"", item.Author, item.Quote) + if item.Context != "" { + fmt.Fprintf(w, " - _%s_", item.Context) + } + fmt.Fprintln(w) + } + fmt.Fprintln(w) + } + + if len(h.Notable) > 0 { + fmt.Fprintln(w, "### Notable") + fmt.Fprintln(w) + for _, item := range h.Notable { + fmt.Fprintf(w, "- **%s**: \"%s\"", item.Author, item.Quote) + if item.Context != "" { + fmt.Fprintf(w, " - _%s_", item.Context) + } + fmt.Fprintln(w) + } + fmt.Fprintln(w) + } +} + +// wrapText wraps text to the given width at word boundaries. +func wrapText(s string, width int) []string { + words := strings.Fields(s) + if len(words) == 0 { + return nil + } + + var lines []string + line := words[0] + for _, word := range words[1:] { + if len(line)+1+len(word) > width { + lines = append(lines, line) + line = word + } else { + line += " " + word + } + } + lines = append(lines, line) + return lines +} + +func truncate(s string, maxLen int) string { + // Replace newlines with spaces for single-line display + s = strings.ReplaceAll(s, "\n", " ") + s = strings.TrimSpace(s) + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} + +// diverseTopN selects the top N conversations by score while filtering out +// near-duplicates. Conversations that share >60% word overlap with an +// already-selected conversation are skipped in favor of more diverse results. +func diverseTopN(convos []JSONConversation, n int) []JSONConversation { + sort.Slice(convos, func(i, j int) bool { + return convos[i].Score > convos[j].Score + }) + + selected := make([]JSONConversation, 0, n) + for _, c := range convos { + if len(selected) >= n { + break + } + if isTooSimilar(c, selected) { + continue + } + selected = append(selected, c) + } + return selected +} + +// isTooSimilar checks if a candidate conversation's user prompt overlaps >60% +// with any already-selected conversation. +func isTooSimilar(candidate JSONConversation, selected []JSONConversation) bool { + candidateWords := wordSet(firstUserContent(candidate)) + if len(candidateWords) == 0 { + return false + } + for _, s := range selected { + selectedWords := wordSet(firstUserContent(s)) + if len(selectedWords) == 0 { + continue + } + overlap := 0 + for w := range candidateWords { + if selectedWords[w] { + overlap++ + } + } + smaller := len(candidateWords) + if len(selectedWords) < smaller { + smaller = len(selectedWords) + } + if smaller > 0 && float64(overlap)/float64(smaller) > 0.6 { + return true + } + } + return false +} + +// firstUserContent returns the first user message from a JSON conversation. +func firstUserContent(c JSONConversation) string { + for _, ex := range c.Exchanges { + if ex.Role == "user" { + return ex.Content + } + } + return "" +} + +// wordSet splits text into a set of lowercase words. +func wordSet(text string) map[string]bool { + words := strings.Fields(strings.ToLower(text)) + set := make(map[string]bool, len(words)) + for _, w := range words { + set[w] = true + } + return set +} + +func formatTokenCount(tokens int) string { + if tokens >= 1_000_000 { + return fmt.Sprintf("%.1fM", float64(tokens)/1_000_000) + } + if tokens >= 1_000 { + return fmt.Sprintf("%.1fK", float64(tokens)/1_000) + } + return fmt.Sprintf("%d", tokens) +} + +// maxJSONExchangeLen is the max characters per exchange turn in JSON output. +const maxJSONExchangeLen = 800 + +// maxGlobalConversations is the max total conversations in JSON output across all authors. +const maxGlobalConversations = 30 + +// JSONOutput is the top-level structure for --format json. +type JSONOutput struct { + Period string `json:"period"` + Stats JSONStats `json:"stats"` + ScoringGuide string `json:"scoring_guide"` + TopConversations []JSONConversation `json:"top_conversations"` + Authors []JSONAuthor `json:"authors"` +} + +// JSONStats contains aggregate metrics. +type JSONStats struct { + Checkpoints int `json:"checkpoints"` + Sessions int `json:"sessions"` + Prompts int `json:"prompts"` + Files int `json:"files"` + Tokens int `json:"tokens"` +} + +// JSONConversation is a scored conversation with exchanges. +type JSONConversation struct { + Rank int `json:"rank"` + Score int `json:"score"` + Author string `json:"author"` + Branch string `json:"branch"` + Timestamp string `json:"timestamp"` + Exchanges []JSONExchange `json:"exchanges"` +} + +// JSONExchange is one turn in a conversation. +type JSONExchange struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// JSONAuthor contains per-author summary stats. +type JSONAuthor struct { + Name string `json:"name"` + Prompts int `json:"prompts"` + Sessions int `json:"sessions"` + Files int `json:"files"` + Tokens int `json:"tokens"` + Branches []string `json:"branches"` +} + +// FormatJSON writes a JSON digest to the writer with conversation windows for AI consumption. +func FormatJSON(w io.Writer, d *TeamDigest) { + output := JSONOutput{ + Period: d.Period, + Stats: JSONStats{ + Checkpoints: d.Total.Checkpoints, + Sessions: d.Total.Sessions, + Prompts: d.Total.Prompts, + Files: d.Total.FilesTouched, + Tokens: d.Total.TokenUsage.InputTokens + d.Total.TokenUsage.OutputTokens, + }, + ScoringGuide: "Scores are a rough interestingness signal, not a quality guarantee. " + + "Higher scores suggest personality, emotion, or wit, but trust your editorial judgment. " + + "Prioritize variety over raw score - never feature the same prompt twice.", + } + + // Collect all conversations across all authors, sorted globally by score + var allConvos []JSONConversation + for _, author := range d.SortedAuthors() { + for _, c := range author.Conversations { + jc := JSONConversation{ + Score: c.Score, + Author: c.Author, + Branch: c.Branch, + Timestamp: c.Timestamp.Format("2006-01-02"), + } + for _, ex := range c.Exchanges { + content := ex.Content + if len(content) > maxJSONExchangeLen { + content = content[:maxJSONExchangeLen] + "..." + } + jc.Exchanges = append(jc.Exchanges, JSONExchange{ + Role: ex.Role, + Content: content, + }) + } + allConvos = append(allConvos, jc) + } + } + + // Select diverse top conversations (score-descending with similarity filtering) + allConvos = diverseTopN(allConvos, maxGlobalConversations) + + // Add rank numbers + for i := range allConvos { + allConvos[i].Rank = i + 1 + } + output.TopConversations = allConvos + + // Per-author summaries + for _, author := range d.SortedAuthors() { + output.Authors = append(output.Authors, JSONAuthor{ + Name: author.Name, + Prompts: len(author.Prompts), + Sessions: author.SessionCount, + Files: len(author.FilesTouched), + Tokens: author.TokenUsage.InputTokens + author.TokenUsage.OutputTokens, + Branches: author.SortedBranches(), + }) + } + + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(output) +} diff --git a/cmd/entire/cli/digest/score.go b/cmd/entire/cli/digest/score.go new file mode 100644 index 000000000..59eaa6a37 --- /dev/null +++ b/cmd/entire/cli/digest/score.go @@ -0,0 +1,120 @@ +package digest + +import ( + "strings" +) + +// scorePrompt ranks a prompt by "interestingness" using heuristics. +// Higher scores = more likely to be fun, emotional, or notable. +func scorePrompt(text string) int { + score := 0 + words := strings.Fields(text) + lower := strings.ToLower(text) + + // Short prompts show personality ("ls", "Word!", "full") + if len(words) <= 5 && len(words) > 0 { + score += 3 + } + + // Single-word disbelief/reaction ("Really?", "Seriously?", "No.", "What?") + if len(words) == 1 && strings.ContainsAny(text, "!?.") { + score += 4 + } + + // Emotional punctuation + if strings.ContainsAny(text, "!?") { + score += 2 + } + + // Frustration signals + for _, word := range frustrationWords { + if strings.Contains(lower, word) { + score += 3 + break + } + } + + // Sarcasm and wit signals + for _, phrase := range sarcasmPhrases { + if strings.Contains(lower, phrase) { + score += 3 + break + } + } + + // Context-amnesia complaints (calling out AI memory failures) + for _, phrase := range amnesiaPhrases { + if strings.Contains(lower, phrase) { + score += 3 + break + } + } + + // AI self-reference questions ("are you stuck", "did you just", "why did you") + for _, phrase := range aiSelfReferencePhrases { + if strings.Contains(lower, phrase) { + score += 2 + break + } + } + + // Celebration signals + for _, word := range celebrationWords { + if strings.Contains(lower, word) { + score += 3 + break + } + } + + // Penalize long copy-paste prompts (likely specs or context dumps) + if len(text) > 500 { + score -= 2 + } + + // Heavily penalize auto-generated session continuations + if strings.Contains(text, "This session is being continued") { + score -= 5 + } + + // Penalize structured instructions (markdown headers) + if strings.HasPrefix(text, "#") || strings.HasPrefix(text, "## ") { + score-- + } + + // Penalize skill/command invocations + if strings.HasPrefix(text, "/") && len(words) <= 3 { + score-- + } + + return score +} + +var frustrationWords = []string{ + "broken", "wrong", "why", "stuck", "hate", "ugh", + "wtf", "terrible", "awful", "annoying", "confused", + "doesn't work", "not working", "failing", +} + +var sarcasmPhrases = []string{ + "captain obvious", "thanks for nothing", "you just said that", + "literally what i just said", "i just told you", "i already said", + "that's what i said", "did you even read", "are you serious", + "thanks for the lecture", +} + +var amnesiaPhrases = []string{ + "have you forgotten", "you just told me", "you said earlier", + "you already", "we just discussed", "remember when", + "forgotten everything", "you literally just", +} + +var aiSelfReferencePhrases = []string{ + "are you stuck", "are you ok", "did you just", + "why did you", "can you explain why you", "what are you doing", + "is bash stuck", "you seem to be", +} + +var celebrationWords = []string{ + "worked", "perfect", "awesome", "finally", "nice", + "great", "love", "beautiful", "nailed", "ship it", +} diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index b68ef9a3c..798593e05 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -74,7 +74,7 @@ func handleLifecycleSessionStart(ag agent.Agent, event *agent.Event) error { } // Build informational message - message := "\n\nPowered by Entire:\n This conversation will be linked to your next commit." + message := "\n\nPowered by Entire:\n This conversation will be linked to your next commit.\n Tip: Use /entire-digest for your team's activity highlights." // Check for concurrent sessions and append count if any strat := GetStrategy() diff --git a/cmd/entire/cli/root.go b/cmd/entire/cli/root.go index 5fedf6ad4..2c67da71a 100644 --- a/cmd/entire/cli/root.go +++ b/cmd/entire/cli/root.go @@ -81,6 +81,7 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(newHooksCmd()) cmd.AddCommand(newVersionCmd()) cmd.AddCommand(newExplainCmd()) + cmd.AddCommand(newDigestCmd()) cmd.AddCommand(newDebugCmd()) cmd.AddCommand(newDoctorCmd()) cmd.AddCommand(newSendAnalyticsCmd()) diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index 9e16b28e7..13f6fe0c0 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -495,6 +495,11 @@ func setupAgentHooks(ag agent.Agent, localDev, forceHooks bool) (int, error) { / return 0, fmt.Errorf("failed to install %s hooks: %w", ag.Name(), err) } + // Install skills if supported (best-effort, don't fail on error) + if skillAgent, ok := hookAgent.(agent.SkillInstaller); ok { + _ = skillAgent.InstallSkills(localDev, forceHooks) + } + return count, nil } @@ -719,6 +724,13 @@ func setupAgentHooksNonInteractive(w io.Writer, ag agent.Agent, strategyName str return fmt.Errorf("failed to install hooks for %s: %w", agentName, err) } + // Install agent skills (e.g., /digest) if supported + if skillAgent, ok := hookAgent.(agent.SkillInstaller); ok { + if err := skillAgent.InstallSkills(localDev, forceHooks); err != nil { + fmt.Fprintf(w, "Warning: failed to install skills: %v\n", err) + } + } + // Setup .entire directory if _, err := setupEntireDirectory(); err != nil { return fmt.Errorf("failed to setup .entire directory: %w", err) @@ -1228,6 +1240,12 @@ func removeAgentHooks(w io.Writer) error { } else if wasInstalled { fmt.Fprintf(w, " Removed %s hooks\n", ag.Type()) } + // Remove skills if supported + if skillAgent, ok := hs.(agent.SkillInstaller); ok { + if err := skillAgent.UninstallSkills(); err != nil { + errs = append(errs, err) + } + } } return errors.Join(errs...) } diff --git a/cmd/entire/cli/summarize/claude.go b/cmd/entire/cli/summarize/claude.go index 7b2e6dd3e..2c44e793d 100644 --- a/cmd/entire/cli/summarize/claude.go +++ b/cmd/entire/cli/summarize/claude.go @@ -107,7 +107,7 @@ func (g *ClaudeGenerator) Generate(ctx context.Context, input Input) (*checkpoin // git hooks set GIT_DIR which lets Claude Code find the repo regardless of cwd. // This also prevents recursive triggering of Entire's own git hooks. cmd.Dir = os.TempDir() - cmd.Env = stripGitEnv(os.Environ()) + cmd.Env = stripSessionEnv(os.Environ()) // Pass prompt via stdin cmd.Stdin = strings.NewReader(prompt) @@ -159,14 +159,16 @@ func buildSummarizationPrompt(transcriptText string) string { return fmt.Sprintf(summarizationPromptTemplate, transcriptText) } -// stripGitEnv returns a copy of env with all GIT_* variables removed. -// This prevents a subprocess from discovering or modifying the parent's git repo. -func stripGitEnv(env []string) []string { +// stripSessionEnv returns a copy of env with GIT_*, CLAUDECODE*, and CLAUDE_CODE_* variables removed. +// Stripping GIT_* prevents a subprocess from discovering the parent's git repo. +// Stripping CLAUDE_CODE_*/CLAUDECODE allows the Claude CLI subprocess to run inside a Claude Code session. +func stripSessionEnv(env []string) []string { filtered := make([]string, 0, len(env)) for _, e := range env { - if !strings.HasPrefix(e, "GIT_") { - filtered = append(filtered, e) + if strings.HasPrefix(e, "GIT_") || strings.HasPrefix(e, "CLAUDECODE") || strings.HasPrefix(e, "CLAUDE_CODE_") { + continue } + filtered = append(filtered, e) } return filtered } diff --git a/cmd/entire/cli/summarize/claude_test.go b/cmd/entire/cli/summarize/claude_test.go index 58eb40435..8b21cb20f 100644 --- a/cmd/entire/cli/summarize/claude_test.go +++ b/cmd/entire/cli/summarize/claude_test.go @@ -55,17 +55,21 @@ func TestClaudeGenerator_GitIsolation(t *testing.T) { } } -func TestStripGitEnv(t *testing.T) { +func TestStripSessionEnv(t *testing.T) { env := []string{ "HOME=/Users/test", "GIT_DIR=/repo/.git", "PATH=/usr/bin", "GIT_WORK_TREE=/repo", "GIT_INDEX_FILE=/repo/.git/index", + "CLAUDECODE=1", + "CLAUDECODE_SESSION_ID=abc123", + "CLAUDE_CODE_ENTRYPOINT=cli", + "CLAUDE_CODE_SESSION_ACCESS_TOKEN=sk-test", "SHELL=/bin/zsh", } - filtered := stripGitEnv(env) + filtered := stripSessionEnv(env) expected := []string{ "HOME=/Users/test",