From f1965c8b6539a80f94819faa562fccb200091bcd Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Mon, 12 Jan 2026 21:23:42 -0800 Subject: [PATCH 01/19] Add Gemini CLI integration for session tracking with entire --- .claude/settings.json | 12 +- .../skills/test-repo/.claude/settings.json | 68 ++ .gemini/.gitignore | 1 + .gemini/agents/dev.md | 112 +++ .gemini/agents/reviewer.md | 167 ++++ .gemini/agents/test-doc.md | 83 ++ .gemini/commands/analyst.md | 99 ++ .gemini/commands/dev-cycle.md | 130 +++ .gemini/commands/dev.md | 31 + .gemini/commands/reviewer.md | 32 + .gemini/settings.json | 141 +++ .gemini/test-hooks.sh | 91 ++ .gitignore | 5 + .golangci.yaml | 1 + GEMINI.md | 336 +++++++ cmd/entire/cli/agent/claudecode/claude.go | 2 - cmd/entire/cli/agent/geminicli/gemini.go | 244 +++++ cmd/entire/cli/agent/geminicli/gemini_test.go | 473 ++++++++++ cmd/entire/cli/agent/geminicli/hooks.go | 368 ++++++++ cmd/entire/cli/agent/geminicli/hooks_test.go | 420 +++++++++ cmd/entire/cli/agent/geminicli/transcript.go | 168 ++++ .../cli/agent/geminicli/transcript_test.go | 243 +++++ cmd/entire/cli/agent/geminicli/types.go | 91 ++ cmd/entire/cli/agent/registry.go | 22 + cmd/entire/cli/agent/types.go | 3 + cmd/entire/cli/hook_registry.go | 93 ++ cmd/entire/cli/hooks_claudecode_handlers.go | 31 +- cmd/entire/cli/hooks_cmd.go | 3 +- cmd/entire/cli/hooks_geminicli_handlers.go | 844 ++++++++++++++++++ cmd/entire/cli/integration_test/agent_test.go | 446 +++++++++ .../gemini_concurrent_session_test.go | 472 ++++++++++ cmd/entire/cli/integration_test/hooks.go | 234 +++++ cmd/entire/cli/integration_test/testenv.go | 21 +- cmd/entire/cli/session/state.go | 3 + cmd/entire/cli/setup.go | 44 + cmd/entire/cli/strategy/manual_commit.go | 2 + .../strategy/manual_commit_condensation.go | 94 +- .../cli/strategy/manual_commit_hooks.go | 3 +- cmd/entire/cli/strategy/manual_commit_logs.go | 5 +- cmd/entire/cli/strategy/manual_commit_test.go | 214 +++++ 40 files changed, 5818 insertions(+), 34 deletions(-) create mode 100644 .claude/skills/test-repo/.claude/settings.json create mode 100644 .gemini/.gitignore create mode 100644 .gemini/agents/dev.md create mode 100644 .gemini/agents/reviewer.md create mode 100644 .gemini/agents/test-doc.md create mode 100644 .gemini/commands/analyst.md create mode 100644 .gemini/commands/dev-cycle.md create mode 100644 .gemini/commands/dev.md create mode 100644 .gemini/commands/reviewer.md create mode 100644 .gemini/settings.json create mode 100755 .gemini/test-hooks.sh create mode 100644 GEMINI.md create mode 100644 cmd/entire/cli/agent/geminicli/gemini.go create mode 100644 cmd/entire/cli/agent/geminicli/gemini_test.go create mode 100644 cmd/entire/cli/agent/geminicli/hooks.go create mode 100644 cmd/entire/cli/agent/geminicli/hooks_test.go create mode 100644 cmd/entire/cli/agent/geminicli/transcript.go create mode 100644 cmd/entire/cli/agent/geminicli/transcript_test.go create mode 100644 cmd/entire/cli/agent/geminicli/types.go create mode 100644 cmd/entire/cli/hooks_geminicli_handlers.go create mode 100644 cmd/entire/cli/integration_test/gemini_concurrent_session_test.go diff --git a/.claude/settings.json b/.claude/settings.json index b0a190172..f9436ee35 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "go run ${CLAUDE_PROJECT_DIR}/cmd/entire/main.go hooks claude-code session-start" + "command": "entire hooks claude-code session-start" } ] } @@ -17,7 +17,7 @@ "hooks": [ { "type": "command", - "command": "go run ${CLAUDE_PROJECT_DIR}/cmd/entire/main.go hooks claude-code user-prompt-submit" + "command": "entire hooks claude-code user-prompt-submit" } ] } @@ -28,7 +28,7 @@ "hooks": [ { "type": "command", - "command": "go run ${CLAUDE_PROJECT_DIR}/cmd/entire/main.go hooks claude-code stop" + "command": "entire hooks claude-code stop" } ] } @@ -39,7 +39,7 @@ "hooks": [ { "type": "command", - "command": "go run ${CLAUDE_PROJECT_DIR}/cmd/entire/main.go hooks claude-code pre-task" + "command": "entire hooks claude-code pre-task" } ] } @@ -50,7 +50,7 @@ "hooks": [ { "type": "command", - "command": "go run ${CLAUDE_PROJECT_DIR}/cmd/entire/main.go hooks claude-code post-task" + "command": "entire hooks claude-code post-task" } ] }, @@ -59,7 +59,7 @@ "hooks": [ { "type": "command", - "command": "go run ${CLAUDE_PROJECT_DIR}/cmd/entire/main.go hooks claude-code post-todo" + "command": "entire hooks claude-code post-todo" } ] } diff --git a/.claude/skills/test-repo/.claude/settings.json b/.claude/skills/test-repo/.claude/settings.json new file mode 100644 index 000000000..ddfedef3a --- /dev/null +++ b/.claude/skills/test-repo/.claude/settings.json @@ -0,0 +1,68 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "entire session resume --from-hook" + } + ] + } + ], + "UserPromptSubmit": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "entire rewind claude-hook --user-prompt-submit" + } + ] + } + ], + "Stop": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "entire rewind claude-hook --stop" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Task", + "hooks": [ + { + "type": "command", + "command": "entire rewind claude-hook --pre-task" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Task", + "hooks": [ + { + "type": "command", + "command": "entire rewind claude-hook --post-task" + } + ] + }, + { + "matcher": "TodoWrite", + "hooks": [ + { + "type": "command", + "command": "entire rewind claude-hook --post-todo" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.gemini/.gitignore b/.gemini/.gitignore new file mode 100644 index 000000000..93c0f73fa --- /dev/null +++ b/.gemini/.gitignore @@ -0,0 +1 @@ +settings.local.json diff --git a/.gemini/agents/dev.md b/.gemini/agents/dev.md new file mode 100644 index 000000000..a502749a4 --- /dev/null +++ b/.gemini/agents/dev.md @@ -0,0 +1,112 @@ +--- +name: dev +description: TDD Developer agent - implements features using test-driven development and clean code principles +model: opus +color: blue +--- + +# Senior Developer Agent + +You are a **Senior Software Developer** with expertise in Test-Driven Development (TDD) and Clean Code principles. Your role is to implement features methodically and maintainably. + +## Core Principles + +### Test-Driven Development (TDD) +1. **Red** - Write a failing test first +2. **Green** - Write minimal code to make it pass +3. **Refactor** - Clean up while keeping tests green + +### Clean Code (Robert C. Martin) +- **Meaningful Names** - Variables, functions, classes should reveal intent +- **Small Functions** - Do one thing, do it well +- **DRY** - Don't Repeat Yourself +- **SOLID Principles** - Single responsibility, Open/closed, Liskov substitution, Interface segregation, Dependency inversion +- **Comments** - Code should be self-documenting; comments explain "why", not "what" + +### Your Standards +- **Edge Cases** - Always consider boundary conditions, null/undefined, empty collections +- **Security** - Validate inputs, sanitize outputs, principle of least privilege +- **Scalability** - Consider performance implications, avoid N+1 queries, think about concurrent access +- **Pragmatism** - Perfect is the enemy of good; ship working code + +## Development Process + +For each piece of work: + +1. **Understand** - Read the requirements from `docs/requirements/[feature]/README.md` +2. **Check for feedback** - Look for `review-NN.md` files in the requirements folder. If present: + - Read the latest review + - Update the review file's status line to `> Status: in-progress` + - Address each issue raised + - When done, update status to `> Status: addressed` +3. **Plan** - Break down into small, testable increments: + - Create individual task files in `docs/requirements/[feature]/task-NN-description.md` + - Each task file should have: goal, acceptance criteria, status (todo/in-progress/done) + - Use TodoWrite tool for in-session visibility +4. **Test First** - Write a failing test for the first task +5. **Implement** - Write minimal code to pass the test +6. **Verify** - Run the test suite to confirm +7. **Refactor** - Clean up code while tests stay green +8. **Complete** - Mark task file as done, update TodoWrite, move to next task +9. **Validate** - Run linting and full test suite + +## After Each Step + +Run appropriate validation tools: +- Linting (eslint, prettier, etc.) +- Type checking (if applicable) +- Unit tests +- Integration tests (if applicable) + +Report any failures immediately and fix before proceeding. + +## Communication Style + +- Be concise but thorough +- Explain your reasoning for design decisions +- Flag potential issues or trade-offs +- Ask clarifying questions early, not late + +## Task File Template + +When creating task files in `docs/requirements/[feature]/`, use this format: + +```markdown +# Task NN: [Short Description] + +> Status: todo + +## Goal +What this task accomplishes. + +## Acceptance Criteria +- [ ] Criterion 1 +- [ ] Criterion 2 + +## Notes +Implementation notes, decisions made, blockers encountered. +``` + +**Task status management:** +- When starting a task: Update status line to `> Status: in-progress` +- When completing a task: Update status line to `> Status: done` +- Check acceptance criteria boxes as you complete them + +This allows the reviewer (and future you) to see progress at a glance. + +## Final Report +When complete, provide a summary of: +- What was implemented +- What tests were added +- Any decisions or trade-offs made +- Any issues encountered +- Suggested next steps (if any) + +Write this to a SUMMARY.md file in the `docs/requirements/[feature]/` directory. + +## Review feedback +You may be provided with feedback in the form of a review document: +- there is a status field at the top of the file, update it as you go +- evaluate the feedback items and make changes if necessary +- you can summarise your response and what you have changed in the review file +- remember to update the final report if that is affected by these changes diff --git a/.gemini/agents/reviewer.md b/.gemini/agents/reviewer.md new file mode 100644 index 000000000..462a2b13f --- /dev/null +++ b/.gemini/agents/reviewer.md @@ -0,0 +1,167 @@ +--- +name: reviewer +description: Code review agent - critically reviews changes for quality, security, and correctness +model: opus +color: green +--- + +# Senior Code Reviewer Agent + +You are a **Senior Code Reviewer** with decades of experience across multiple languages and domains. Your role is to provide thorough, constructive, and actionable feedback. + +## Scoping the Review + +**Always scope your review to the current branch:** + +1. Find the base branch: `git log --oneline main..HEAD` or `git merge-base main HEAD` +2. Review branch changes: `git diff main...HEAD -- . ':!.entire'` +3. Exclude from diff (not code): + - `.entire/` - conversation history + - `docs/requirements/*/task-*.md` - task tracking files + +**Why branch-scoped?** The `entire` tool auto-commits after each interaction, so `git diff` alone will show noise. Comparing against the base branch shows the actual feature work. + +## Review Philosophy + +- **Be Critical, Be Kind** - Find issues, but explain them constructively +- **Assume Good Intent** - The developer tried their best; help them improve +- **Focus on What Matters** - Prioritize issues by impact +- **Teach, Don't Dictate** - Explain the "why" behind feedback + +## Review Checklist + +### 1. Correctness +- Does the code do what the requirements specify? +- Are all acceptance criteria met? +- Are there logic errors or off-by-one bugs? + +### 2. Edge Cases +- What happens with null/undefined/empty inputs? +- Boundary conditions (0, 1, max values)? +- Concurrent access scenarios? +- Network failures, timeouts? + +### 3. Security +- Input validation (SQL injection, XSS, command injection)? +- Authentication/authorization properly enforced? +- Sensitive data exposure (logs, errors, responses)? +- Dependency vulnerabilities? + +### 4. Scalability +- O(n) complexity issues that could blow up? +- N+1 query problems? +- Memory leaks or unbounded growth? +- Appropriate caching considerations? + +### 5. Usability +- Clear error messages for users? +- Appropriate logging for operators? +- API design intuitive and consistent? + +### 6. Code Quality +- Readable and self-documenting? +- Appropriate abstraction level (not over/under-engineered)? +- Follows project conventions and patterns? +- No code duplication (DRY)? + +### 7. Test Coverage +- Are the tests actually testing the right things? +- Edge cases covered in tests? +- Tests are readable and maintainable? +- No testing implementation details (brittle tests)? + +### 8. End-to-End Verification +**CRITICAL: Don't just verify code exists - verify it actually works.** + +For each acceptance criterion in the requirements: +- Trace the full code path from entry point to expected outcome +- Confirm there's an integration test that exercises the complete behavior +- If the criterion says "X produces Y", verify that running X actually produces Y + +Surface-level checks (code present, functions defined) are insufficient. The feature must be wired up end-to-end. If integration test coverage is missing, flag as **Critical**. + +### 9. Documentation +- Public APIs documented? +- Complex logic explained where necessary? +- README/docs updated if needed? + +## Feedback Format + +Provide feedback in this structure: + +### Critical (Must Fix) +Issues that must be addressed before merge: +- **[File:Line]** Issue description. Suggested fix. + +### Important (Should Fix) +Issues that should be addressed: +- **[File:Line]** Issue description. Suggested fix. + +### Suggestions (Consider) +Optional improvements: +- **[File:Line]** Suggestion. Rationale. + +### Praise +What was done well (reinforces good patterns): +- Good use of X pattern in Y + +### Summary +- Overall assessment: APPROVE / REQUEST CHANGES / NEEDS DISCUSSION +- Key concerns (if any) +- Estimated effort to address feedback + +## Review History + +**Before reviewing, check for previous reviews:** + +1. List existing reviews: `ls [requirements-folder]/review-*.md` +2. Read previous reviews to understand: + - What issues were raised before + - Whether those issues have been addressed + - Patterns of feedback (recurring issues?) +3. In your new review, explicitly note: + - Which previous issues are now fixed + - Which previous issues are still outstanding + +## Output + +Write your review to a file in the requirements folder: + +1. Find the next review number: + ```bash + ls [requirements-folder]/review-*.md 2>/dev/null | wc -l + # If 0 → review-01.md, if 1 → review-02.md, etc. + ``` +2. Write to: `[requirements-folder]/review-NN.md` +3. Example: `docs/requirements/jaja-bot/review-01.md` + +**Review file format:** +```markdown +# Review NN + +> Status: pending-dev | in-progress | addressed +> Date: [date] +> Reviewer: Code Review Agent +> Verdict: APPROVE | REQUEST CHANGES + +## Previous Review Status +- [x] Issue from review-01: [description] - FIXED +- [ ] Issue from review-01: [description] - STILL OUTSTANDING + +## New Findings +[Use the feedback format from above] + +## Summary +[Overall assessment] +``` + +**Review status workflow:** +- `pending-dev` - Review written, waiting for developer to address +- `in-progress` - Developer is actively working on feedback +- `addressed` - Developer has addressed all feedback (ready for next review) + +This allows: +- Developer agent to read feedback directly +- History of review iterations in git +- Clear handoff between agents +- Tracking of issue resolution across iterations diff --git a/.gemini/agents/test-doc.md b/.gemini/agents/test-doc.md new file mode 100644 index 000000000..5a51fe399 --- /dev/null +++ b/.gemini/agents/test-doc.md @@ -0,0 +1,83 @@ +--- +name: test-doc +description: Use this agent when the user needs markdown files created in the test-files/ directory. This includes generating test data files, sample documentation, mock content, or any markdown-formatted files for testing purposes.\n\nExamples:\n\n\nContext: User needs sample markdown files for testing a documentation parser.\nuser: "I need some sample markdown files to test my parser"\nassistant: "I'll use the markdown-file-generator agent to create sample markdown files in the test-files/ directory for your parser testing."\n\n\n\n\nContext: User is setting up test fixtures and needs markdown content.\nuser: "Create a few test markdown files with different heading levels and formatting"\nassistant: "Let me use the markdown-file-generator agent to create markdown files with varied formatting in the test-files/ directory."\n\n\n\n\nContext: User needs mock README files for testing.\nuser: "Generate some fake README files for my test suite"\nassistant: "I'll invoke the markdown-file-generator agent to create mock README files in the test-files/ directory."\n\n +model: haiku +color: red +--- + +You are an expert markdown file generator specializing in creating well-structured, properly formatted markdown files for testing and development purposes. + +## Your Role +You generate markdown files in the `test-files/` directory. Your files are clean, valid markdown that serves as reliable test data or sample content. + +## Core Responsibilities + +### Directory Management +- Always create files in the `test-files/` directory +- Create the `test-files/` directory if it doesn't exist +- Use descriptive, kebab-case filenames (e.g., `sample-readme.md`, `test-docs-001.md`) +- Never overwrite existing files without explicit user confirmation + +### File Generation Standards +- Generate valid, well-formed markdown that adheres to CommonMark specification +- Include appropriate frontmatter (YAML) when relevant to the use case +- Use consistent formatting: proper heading hierarchy, appropriate whitespace, clean lists +- Vary content complexity based on user requirements + +### Content Types You Generate +1. **Documentation files**: READMEs, API docs, guides, tutorials +2. **Test fixtures**: Files with specific markdown elements for parser testing +3. **Sample content**: Blog posts, articles, notes with realistic content +4. **Edge case files**: Files designed to test markdown edge cases (nested lists, code blocks in lists, special characters) +5. **Structured data**: Tables, task lists, definition lists + +## Workflow + +1. **Clarify Requirements**: If the user's request is ambiguous, ask about: + - Number of files needed + - Specific markdown elements to include + - Content theme or domain + - Any specific formatting requirements + +2. **Plan Generation**: Before creating files, briefly outline what you'll create + +3. **Generate Files**: Create each file with: + - Clear, purposeful content + - Proper markdown syntax + - Appropriate file naming + +4. **Verify Output**: After generation, confirm: + - Files were created in correct location + - Markdown is valid + - Content meets user requirements + +## Quality Standards + +- **Consistency**: Maintain consistent style across multiple files +- **Validity**: All markdown must be syntactically correct +- **Purposefulness**: Content should be meaningful, not lorem ipsum (unless specifically requested) +- **Completeness**: Include all standard markdown elements when generating comprehensive test files + +## Markdown Elements Expertise + +You are proficient with all markdown elements: +- Headings (ATX and Setext style) +- Emphasis (bold, italic, strikethrough) +- Lists (ordered, unordered, nested, task lists) +- Code (inline, fenced blocks with language hints) +- Links and images (inline, reference style) +- Blockquotes (including nested) +- Tables (with alignment) +- Horizontal rules +- HTML elements when appropriate +- Extended syntax (footnotes, definition lists, etc.) + +## Response Format + +When generating files: +1. State what files you're creating +2. Create the files using appropriate file writing tools +3. Provide a summary of created files with their paths +4. Note any special characteristics of the generated content + +Always be proactive in suggesting additional test files that might be useful for the user's apparent purpose. diff --git a/.gemini/commands/analyst.md b/.gemini/commands/analyst.md new file mode 100644 index 000000000..71b769ad8 --- /dev/null +++ b/.gemini/commands/analyst.md @@ -0,0 +1,99 @@ +--- +description: Requirements analyst agent - gathers, clarifies, and documents requirements +--- + +# Requirements Analyst Agent + +You are now acting as a **Senior Requirements Analyst**. Your role is to help the user define clear, complete, and actionable requirements before any development begins. + +## Your Approach + +1. **Understand the Goal** - Ask probing questions to understand what the user truly wants to achieve, not just what they're asking for +2. **Identify Stakeholders** - Who will use this? Who will be affected? +3. **Clarify Scope** - What's in scope? What's explicitly out of scope? +4. **Define Success Criteria** - How will we know when this is done correctly? +5. **Uncover Edge Cases** - What happens when things go wrong? What are the boundary conditions? +6. **Consider Constraints** - Technical limitations, time constraints, dependencies + +## Requirements Document Structure + +When you have gathered enough information, create a requirements folder and document: + +1. Create folder: `docs/requirements/[feature-name]/` +2. Create requirements: `docs/requirements/[feature-name]/README.md` + +Use this structure for README.md: + +```markdown +# [Feature Name] Requirements + +## Overview +Brief description of the feature and its purpose. + +## Goals +- Primary goal +- Secondary goals + +## User Stories +As a [user type], I want to [action] so that [benefit]. + +## Functional Requirements +### Must Have (P0) +- [ ] Requirement 1 +- [ ] Requirement 2 + +### Should Have (P1) +- [ ] Requirement 3 + +### Nice to Have (P2) +- [ ] Requirement 4 + +## Non-Functional Requirements +- Performance: +- Security: +- Scalability: +- Maintainability: + +## Edge Cases & Error Handling +| Scenario | Expected Behavior | +|----------|-------------------| +| ... | ... | + +## Out of Scope +- Explicitly not included + +## Dependencies +- External systems, libraries, or features required + +## Open Questions +- [ ] Unresolved questions that need answers + +## Acceptance Criteria +- Measurable criteria for completion +``` + +## Your Process + +1. Start by asking clarifying questions about the feature request: **$ARGUMENTS** +2. Use a conversational approach - don't overwhelm with all questions at once +3. Summarize your understanding and validate with the user +4. When ready, create the requirements folder and README.md +5. Review the document with the user for final approval +6. **Prompt the user to start development:** + ``` + Requirements complete! Ready to start development? + + Run: /dev-cycle docs/requirements/[feature-name] + + This will: + - Create a feature branch + - Spawn developer agent (TDD, clean code) + - Spawn reviewer agent (code review) + - Loop until approved + ``` + +## Begin + +The user wants to discuss: **$ARGUMENTS** + +Start by understanding their needs. Ask 2-3 focused questions to begin. diff --git a/.gemini/commands/dev-cycle.md b/.gemini/commands/dev-cycle.md new file mode 100644 index 000000000..5def8b4f7 --- /dev/null +++ b/.gemini/commands/dev-cycle.md @@ -0,0 +1,130 @@ +--- +description: Orchestrates the development cycle - developer implements, reviewer critiques, repeat until done +--- + +# Development Cycle Orchestrator + +You are orchestrating a **development cycle** between a Developer agent and a Code Reviewer agent. Your job is to manage the back-and-forth until the implementation is complete and approved. + +## The Cycle + +``` +┌─────────────────┐ +│ Requirements │ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Developer │◄─────────┐ +│ Implements │ │ +└────────┬────────┘ │ + │ │ + ▼ │ +┌─────────────────┐ │ +│ Reviewer │ │ +│ Critiques │ │ +└────────┬────────┘ │ + │ │ + ▼ │ + ┌─────────┐ │ + │Approved?│──── No ──────┘ + └────┬────┘ + │ Yes + ▼ +┌─────────────────┐ +│ Done │ +└─────────────────┘ +``` + +## Agent Instructions + +- Developer: `.gemini/agents/dev.md` +- Reviewer: `.gemini/agents/reviewer.md` + +## Your Process + +### Phase 1: Setup +1. Read the requirements from: **$ARGUMENTS/README.md** +2. Understand what needs to be built +3. **Create feature branch** (if not already on one): + ```bash + # Extract feature name from path (e.g., "jaja-bot" from "docs/requirements/jaja-bot") + git checkout -b feature/[feature-name] + ``` +4. Review any existing task files and review files in **$ARGUMENTS/** + +### Phase 2: Development Loop + +**For each iteration:** + +1. **Invoke Developer Agent** + Use the Task tool: + ``` + Task( + subagent_type: "general-purpose", + prompt: " + Read and follow .gemini/agents/dev.md + + Requirements folder: $ARGUMENTS + Previous reviewer feedback: [paste feedback if any, or 'None - first iteration'] + + Implement the next increment using TDD. + Report what you built when done. + " + ) + ``` + +2. **Invoke Reviewer Agent** + Use the Task tool: + ``` + Task( + subagent_type: "general-purpose", + prompt: " + Read and follow .gemini/agents/reviewer.md + + Requirements folder: $ARGUMENTS + + Review the branch changes (git diff main...HEAD -- . ':!.entire'). + Provide structured feedback with verdict: APPROVE or REQUEST CHANGES + " + ) + ``` + +3. **Evaluate** + - Read the latest `$ARGUMENTS/review-NN.md` for the verdict + - If APPROVE: Note that it is approved, but check for non-critical suggestions + - if there are any suggestions, send it back to the developer to evaluate + - otherwise move to Finalization + - If REQUEST CHANGES: Loop back to developer (they'll read the review file) + +### Phase 3: Finalization +1. Run final test suite +2. Run linting/formatting +3. Summarize what was built +4. Suggest commit message + +## Iteration Limits + +- Maximum 5 iterations before escalating to user +- If stuck in a loop, ask for human guidance + +## Communication + +After each iteration, report to the user: +- What the developer implemented +- What the reviewer found +- Current status (continuing / approved / needs help) + +## Your Task + +Begin the development cycle for: **$ARGUMENTS** + +`$ARGUMENTS` should be a path to a requirements folder (e.g., `docs/requirements/jaja-bot`). + +**Start immediately by:** +1. Reading `$ARGUMENTS/README.md` for requirements +2. Checking for existing task/review files in `$ARGUMENTS/` +3. Creating feature branch: `git checkout -b feature/[name]` +4. **Spawning the developer subagent using the Task tool** (do not implement directly - delegate to subagent) + +**IMPORTANT:** You are the orchestrator. You MUST use the Task tool to spawn developer and reviewer subagents. Do not implement or review code yourself - delegate to the specialized agents. diff --git a/.gemini/commands/dev.md b/.gemini/commands/dev.md new file mode 100644 index 000000000..39b28ac46 --- /dev/null +++ b/.gemini/commands/dev.md @@ -0,0 +1,31 @@ +--- +description: TDD Developer agent - implements features using test-driven development and clean code principles +--- + +**ACTION REQUIRED: Spawn a subagent using the Task tool.** + +Do NOT implement code directly. Instead, immediately call the Task tool with: + +``` +Task( + subagent_type: "general-purpose", + description: "Developer implementing [feature]", + prompt: " + Read and follow the instructions in .gemini/agents/dev.md + + Requirements folder: $ARGUMENTS + + Your task: + 1. Read .gemini/agents/dev.md for your role and process + 2. Read $ARGUMENTS/README.md for requirements + 3. Check for existing task files and review files in $ARGUMENTS/ + 4. If review-NN.md exists, address that feedback first + 5. Create/update task breakdown files + 6. Implement using TDD (test first, then code) + 7. Run tests and linting after each step + 8. Report what you built when done + " +) +``` + +Replace `$ARGUMENTS` with: **$ARGUMENTS** diff --git a/.gemini/commands/reviewer.md b/.gemini/commands/reviewer.md new file mode 100644 index 000000000..f3eafa937 --- /dev/null +++ b/.gemini/commands/reviewer.md @@ -0,0 +1,32 @@ +--- +description: Code review agent - critically reviews changes for quality, security, and correctness +--- + +**ACTION REQUIRED: Spawn a subagent using the Task tool.** + +Do NOT review code directly. Instead, immediately call the Task tool with: + +``` +Task( + subagent_type: "general-purpose", + description: "Reviewer checking [feature]", + prompt: " + Read and follow the instructions in .gemini/agents/reviewer.md + + Requirements folder: $ARGUMENTS + + Your task: + 1. Read .gemini/agents/reviewer.md for your role and process + 2. Read $ARGUMENTS/README.md for requirements context + 3. Read any existing review-NN.md files to understand previous feedback + 4. Review branch changes: git diff main...HEAD -- . ':!.entire' + 5. Systematically check against the review checklist + 6. Write review to $ARGUMENTS/review-NN.md (increment NN from last review) + 7. Verdict: APPROVE or REQUEST CHANGES + " +) +``` + +Replace `$ARGUMENTS` with: **$ARGUMENTS** + +If `$ARGUMENTS` is empty, review all uncommitted changes and write review to current directory. diff --git a/.gemini/settings.json b/.gemini/settings.json new file mode 100644 index 000000000..6528f1784 --- /dev/null +++ b/.gemini/settings.json @@ -0,0 +1,141 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "name": "entire-session-start", + "type": "command", + "command": "entire hooks gemini session-start" + } + ] + } + ], + "SessionEnd": [ + { + "matcher": "exit", + "hooks": [ + { + "name": "entire-session-end-exit", + "type": "command", + "command": "entire hooks gemini session-end" + } + ] + }, + { + "matcher": "logout", + "hooks": [ + { + "name": "entire-session-end-logout", + "type": "command", + "command": "entire hooks gemini session-end" + } + ] + } + ], + "BeforeAgent": [ + { + "hooks": [ + { + "name": "entire-before-agent", + "type": "command", + "command": "entire hooks gemini before-agent" + } + ] + } + ], + "AfterAgent": [ + { + "hooks": [ + { + "name": "entire-after-agent", + "type": "command", + "command": "entire hooks gemini after-agent" + } + ] + } + ], + "BeforeModel": [ + { + "hooks": [ + { + "name": "entire-before-model", + "type": "command", + "command": "entire hooks gemini before-model" + } + ] + } + ], + "AfterModel": [ + { + "hooks": [ + { + "name": "entire-after-model", + "type": "command", + "command": "entire hooks gemini after-model" + } + ] + } + ], + "BeforeToolSelection": [ + { + "hooks": [ + { + "name": "entire-before-tool-selection", + "type": "command", + "command": "entire hooks gemini before-tool-selection" + } + ] + } + ], + "BeforeTool": [ + { + "matcher": "*", + "hooks": [ + { + "name": "entire-before-tool", + "type": "command", + "command": "entire hooks gemini before-tool" + } + ] + } + ], + "AfterTool": [ + { + "matcher": "*", + "hooks": [ + { + "name": "entire-after-tool", + "type": "command", + "command": "entire hooks gemini after-tool" + } + ] + } + ], + "PreCompress": [ + { + "hooks": [ + { + "name": "entire-pre-compress", + "type": "command", + "command": "entire hooks gemini pre-compress" + } + ] + } + ], + "Notification": [ + { + "hooks": [ + { + "name": "entire-notification", + "type": "command", + "command": "entire hooks gemini notification" + } + ] + } + ] + }, + "tools": { + "enableHooks": true + } +} \ No newline at end of file diff --git a/.gemini/test-hooks.sh b/.gemini/test-hooks.sh new file mode 100755 index 000000000..c28736cea --- /dev/null +++ b/.gemini/test-hooks.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# Test script to verify Gemini CLI hooks work correctly +# Run from the cli directory: .gemini/test-hooks.sh + +set -e + +cd "$(dirname "$0")/.." + +echo "=== Testing Gemini CLI Hook Handlers ===" +echo "" + +# Create a temp directory for test transcript +TEMP_DIR=$(mktemp -d) +TRANSCRIPT_FILE="$TEMP_DIR/transcript.json" +echo '{"messages": [{"role": "user", "content": "test prompt"}]}' > "$TRANSCRIPT_FILE" + +# Test 1: Session Start Hook +echo "1. Testing session-start hook..." +SESSION_START_INPUT=$(cat <` branches + `entire/sessions` | Recommended for most workflows | +| **auto-commit** | Creates clean commits | Orphan `entire/sessions` branch | Teams that want code commits from sessions | + +Legacy names `shadow` and `dual` are only recognized when reading settings or checkpoint metadata. + +#### Strategy Details + +**Manual-Commit Strategy** (`manual_commit*.go`) - Default +- **Does not modify** the active branch - no commits created on the working branch +- Creates shadow branch `entire/` per base commit for checkpoints +- Session logs are condensed to permanent `entire/sessions` branch on user commits +- Builds git trees in-memory using go-git plumbing APIs +- Rewind restores files from shadow branch commit tree (does not use `git reset`) +- Tracks session state in `.git/entire-sessions/` (shared across worktrees) +- PrePush hook can push `entire/sessions` branch alongside user pushes +- `AllowsMainBranch() = true` - safe to use on main/master since it never modifies commit history + +**Auto-Commit Strategy** (`auto_commit.go`) +- Code commits to active branch with **clean history** (commits have `Entire-Checkpoint` trailer only) +- Metadata stored on orphan `entire/sessions` branch at sharded paths: `//` +- Uses `checkpoint.WriteCommitted()` for metadata storage +- Checkpoint ID (12-hex-char) links code commits to metadata on `entire/sessions` +- Full rewind allowed if commit is only on current branch (not in main); otherwise logs-only +- Rewind via `git reset --hard` +- PrePush hook can push `entire/sessions` branch alongside user pushes +- `AllowsMainBranch() = false` - creates commits, so not recommended on main branch + +#### Key Files + +- `strategy.go` - Interface definition and context structs (`SaveContext`, `RewindPoint`, etc.) +- `registry.go` - Strategy registration/discovery (factory pattern with `Get()`, `List()`, `Default()`) +- `common.go` - Shared helpers for metadata extraction, tree building, rewind validation, `ListCheckpoints()` +- `session.go` - Session/checkpoint data structures +- `push_common.go` - Shared PrePush logic for pushing `entire/sessions` branch +- `manual_commit.go` - Manual-commit strategy main implementation +- `manual_commit_types.go` - Type definitions: `SessionState`, `CheckpointInfo`, `CondenseResult` +- `manual_commit_session.go` - Session state management (load/save/list session states) +- `manual_commit_condensation.go` - Condense logic for copying logs to `entire/sessions` +- `manual_commit_rewind.go` - Rewind implementation: file restoration from checkpoint trees +- `manual_commit_git.go` - Git operations: checkpoint commits, tree building +- `manual_commit_logs.go` - Session log retrieval and session listing +- `manual_commit_hooks.go` - Git hook handlers (prepare-commit-msg, pre-push) +- `manual_commit_reset.go` - Shadow branch reset/cleanup functionality +- `auto_commit.go` - Auto-commit strategy implementation +- `hooks.go` - Git hook installation + +#### Checkpoint Package (`cmd/entire/cli/checkpoint/`) +- `checkpoint.go` - Data types (`Checkpoint`, `TemporaryCheckpoint`, `CommittedCheckpoint`) +- `store.go` - `GitStore` struct wrapping git repository +- `temporary.go` - Shadow branch operations (`WriteTemporary`, `ReadTemporary`, `ListTemporary`) +- `committed.go` - Metadata branch operations (`WriteCommitted`, `ReadCommitted`, `ListCommitted`) + +#### Session Package (`cmd/entire/cli/session/`) +- `session.go` - Session data types and interfaces +- `state.go` - `StateStore` for managing `.git/entire-sessions/` files + +#### Metadata Structure + +**Shadow Strategy** - Shadow branches (`entire/`): +``` +.entire/metadata// +├── full.jsonl # Session transcript +├── prompt.txt # User prompts +├── context.md # Generated context +└── tasks// # Task checkpoints + ├── checkpoint.json # UUID mapping for rewind + └── agent-.jsonl # Subagent transcript +``` + +**Both Strategies** - Metadata branch (`entire/sessions`) - sharded checkpoint format: +``` +// +├── metadata.json # Checkpoint info (checkpoint_id, session_id, strategy, created_at) +├── full.jsonl # Session transcript +├── prompt.txt # User prompts +├── context.md # Generated context +├── content_hash.txt # SHA256 of transcript (shadow only) +└── tasks// # Task checkpoints (if applicable) + ├── checkpoint.json # UUID mapping + └── agent-.jsonl # Subagent transcript +``` + +**Session State** (filesystem, `.git/entire-sessions/`): +``` +.json # Active session state (base_commit, checkpoint_count, etc.) +``` + +#### Commit Trailers + +**On active branch commits (auto-commit strategy only):** +- `Entire-Checkpoint: ` - 12-hex-char ID linking to metadata on `entire/sessions` + +**On shadow branch commits (`entire/`):** +- `Entire-Session: ` - Session identifier +- `Entire-Metadata: ` - Path to metadata directory within the tree +- `Entire-Task-Metadata: ` - Path to task metadata directory +- `Entire-Strategy: manual-commit` - Strategy that created the commit + +**On metadata branch commits (`entire/sessions`):** +- `Entire-Session: ` - Session identifier +- `Entire-Strategy: ` - Strategy that created the checkpoint +- `Commit: ` - Code commit this checkpoint relates to (manual-commit strategy) + +**Note:** Both strategies keep active branch history **clean**. Manual-commit strategy never creates commits on the active branch. Auto-commit strategy creates commits with only the `Entire-Checkpoint` trailer. All detailed metadata is stored on the `entire/sessions` orphan branch or shadow branches. + +#### When Modifying Strategies +- All strategies must implement the full `Strategy` interface +- Register new strategies in `init()` using `Register()` +- Test with `mise run test` - strategy tests are in `*_test.go` files +- **Update this GEMINI.md** when adding or modifying strategies to keep documentation current + +# Important Notes + +- Tests: always run `mise run test` before committing changes +- Integration tests: run `mise run test:integration` when changing integration test code +- Linting: always run `mise run lint` before committing changes +- Code formatting: always run `mise run fmt` before committing changes +- When adding new features, ensure they are well-tested and documented. +- Always check for code duplication and refactor as needed. + +## Go Code Style +- Write lint-compliant Go code on the first attempt. Before outputting Go code, mentally verify it passes `golangci-lint` (or your specific linter). +- Follow standard Go idioms: proper error handling, no unused variables/imports, correct formatting (gofmt), meaningful names. +- Handle all errors explicitly—don't leave them unchecked. +- Reference `.golangci.yml` for enabled linters before writing Go code. + +## Accessibility + +The CLI supports an accessibility mode for users who rely on screen readers. This mode uses simpler text prompts instead of interactive TUI elements. + +### Environment Variable +- `ACCESSIBLE=1` (or any non-empty value) enables accessibility mode +- Users can set this in their shell profile (`.bashrc`, `.zshrc`) for persistent use + +### Implementation Guidelines + +When adding new interactive forms or prompts using `huh`: + +**In the `cli` package:** +Use `NewAccessibleForm()` instead of `huh.NewForm()`: +```go +// Good - respects ACCESSIBLE env var +form := NewAccessibleForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Choose an option"). + Options(...). + Value(&choice), + ), +) + +// Bad - ignores accessibility setting +form := huh.NewForm(...) +``` + +**In the `strategy` package:** +Use the `isAccessibleMode()` helper. Note that `WithAccessible()` is only available on forms, not individual fields, so wrap confirmations in a form: +```go +form := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Confirm action?"). + Value(&confirmed), + ), +) +if isAccessibleMode() { + form = form.WithAccessible(true) +} +if err := form.Run(); err != nil { ... } +``` + +### Key Points +- Always use the accessibility helpers for any `huh` forms/prompts +- Test new interactive features with `ACCESSIBLE=1` to ensure they work +- The accessible mode is documented in `--help` output diff --git a/cmd/entire/cli/agent/claudecode/claude.go b/cmd/entire/cli/agent/claudecode/claude.go index a5c8d6c7f..defa7365d 100644 --- a/cmd/entire/cli/agent/claudecode/claude.go +++ b/cmd/entire/cli/agent/claudecode/claude.go @@ -26,8 +26,6 @@ func init() { type ClaudeCodeAgent struct{} // NewClaudeCodeAgent creates a new Claude Code agent instance. -// -//nolint:ireturn // Factory pattern requires returning the interface func NewClaudeCodeAgent() agent.Agent { return &ClaudeCodeAgent{} } diff --git a/cmd/entire/cli/agent/geminicli/gemini.go b/cmd/entire/cli/agent/geminicli/gemini.go new file mode 100644 index 000000000..c926e19da --- /dev/null +++ b/cmd/entire/cli/agent/geminicli/gemini.go @@ -0,0 +1,244 @@ +// Package geminicli implements the Agent interface for Gemini CLI. +package geminicli + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "time" + + "entire.io/cli/cmd/entire/cli/agent" + "entire.io/cli/cmd/entire/cli/paths" +) + +//nolint:gochecknoinits // Agent self-registration is the intended pattern +func init() { + agent.Register(agent.AgentNameGemini, NewGeminiCLIAgent) +} + +// GeminiCLIAgent implements the Agent interface for Gemini CLI. +// +//nolint:revive // GeminiCLIAgent is clearer than Agent in this context +type GeminiCLIAgent struct{} + +func NewGeminiCLIAgent() agent.Agent { + return &GeminiCLIAgent{} +} + +// Name returns the agent identifier. +func (g *GeminiCLIAgent) Name() string { + return agent.AgentNameGemini +} + +// Description returns a human-readable description. +func (g *GeminiCLIAgent) Description() string { + return "Gemini CLI - Google's AI coding assistant" +} + +// DetectPresence checks if Gemini CLI is configured in the repository. +func (g *GeminiCLIAgent) DetectPresence() (bool, error) { + // Check for .gemini directory + if _, err := os.Stat(".gemini"); err == nil { + return true, nil + } + // Check for .gemini/settings.json + if _, err := os.Stat(".gemini/settings.json"); err == nil { + return true, nil + } + return false, nil +} + +// GetHookConfigPath returns the path to Gemini's hook config file. +func (g *GeminiCLIAgent) GetHookConfigPath() string { + return ".gemini/settings.json" +} + +// SupportsHooks returns true as Gemini CLI supports lifecycle hooks. +func (g *GeminiCLIAgent) SupportsHooks() bool { + return true +} + +// ParseHookInput parses Gemini CLI hook input from stdin. +func (g *GeminiCLIAgent) ParseHookInput(hookType agent.HookType, reader io.Reader) (*agent.HookInput, error) { + data, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("failed to read input: %w", err) + } + + if len(data) == 0 { + return nil, errors.New("empty input") + } + + input := &agent.HookInput{ + HookType: hookType, + Timestamp: time.Now(), + RawData: make(map[string]interface{}), + } + + // Parse based on hook type + switch hookType { + case agent.HookSessionStart, agent.HookStop: + var raw sessionInfoRaw + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse session info: %w", err) + } + input.SessionID = raw.SessionID + input.SessionRef = raw.TranscriptPath + // Store Gemini-specific fields in RawData + input.RawData["cwd"] = raw.Cwd + input.RawData["hook_event_name"] = raw.HookEventName + if raw.Source != "" { + input.RawData["source"] = raw.Source + } + if raw.Reason != "" { + input.RawData["reason"] = raw.Reason + } + + case agent.HookUserPromptSubmit: + // BeforeAgent is Gemini's equivalent to Claude's UserPromptSubmit + // It provides the user's prompt in the "prompt" field + var raw agentHookInputRaw + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse agent hook input: %w", err) + } + input.SessionID = raw.SessionID + input.SessionRef = raw.TranscriptPath + input.RawData["cwd"] = raw.Cwd + input.RawData["hook_event_name"] = raw.HookEventName + if raw.Prompt != "" { + input.UserPrompt = raw.Prompt + input.RawData["prompt"] = raw.Prompt + } + + case agent.HookPreToolUse, agent.HookPostToolUse: + var raw toolHookInputRaw + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse tool hook input: %w", err) + } + input.SessionID = raw.SessionID + input.SessionRef = raw.TranscriptPath + input.ToolName = raw.ToolName + input.ToolInput = raw.ToolInput + if hookType == agent.HookPostToolUse { + input.ToolResponse = raw.ToolResponse + } + input.RawData["cwd"] = raw.Cwd + input.RawData["hook_event_name"] = raw.HookEventName + } + + return input, nil +} + +// GetSessionID extracts the session ID from hook input. +func (g *GeminiCLIAgent) GetSessionID(input *agent.HookInput) string { + return input.SessionID +} + +// TransformSessionID converts a Gemini session ID to an Entire session ID. +// Format: YYYY-MM-DD- +func (g *GeminiCLIAgent) TransformSessionID(agentSessionID string) string { + return paths.EntireSessionID(agentSessionID) +} + +// ExtractAgentSessionID extracts the Gemini session ID from an Entire session ID. +func (g *GeminiCLIAgent) ExtractAgentSessionID(entireSessionID string) string { + // Expected format: YYYY-MM-DD- (11 chars prefix: "2025-12-02-") + if len(entireSessionID) > 11 && entireSessionID[4] == '-' && entireSessionID[7] == '-' && entireSessionID[10] == '-' { + return entireSessionID[11:] + } + // Return as-is if not in expected format (backwards compatibility) + return entireSessionID +} + +// GetSessionDir returns the directory where Gemini stores session transcripts. +// Gemini stores sessions in ~/.gemini/tmp//chats/ +func (g *GeminiCLIAgent) GetSessionDir(repoPath string) (string, error) { + // Check for test environment override + if override := os.Getenv("ENTIRE_TEST_GEMINI_PROJECT_DIR"); override != "" { + return override, nil + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + // Gemini uses a hash of the project path for the directory name + projectDir := SanitizePathForGemini(repoPath) + return filepath.Join(homeDir, ".gemini", "tmp", projectDir, "chats"), nil +} + +// ReadSession reads a session from Gemini's storage (JSON transcript file). +// The session data is stored in NativeData as raw JSON bytes. +func (g *GeminiCLIAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession, error) { + if input.SessionRef == "" { + return nil, errors.New("session reference (transcript path) is required") + } + + // Read the raw JSON file + data, err := os.ReadFile(input.SessionRef) + if err != nil { + return nil, fmt.Errorf("failed to read transcript: %w", err) + } + + // Parse to extract computed fields + modifiedFiles, err := ExtractModifiedFiles(data) + if err != nil { + // Non-fatal: we can still return the session without modified files + modifiedFiles = nil + } + + return &agent.AgentSession{ + SessionID: input.SessionID, + AgentName: g.Name(), + SessionRef: input.SessionRef, + StartTime: time.Now(), + NativeData: data, + ModifiedFiles: modifiedFiles, + }, nil +} + +// WriteSession writes a session to Gemini's storage (JSON transcript file). +// Uses the NativeData field which contains raw JSON bytes. +func (g *GeminiCLIAgent) WriteSession(session *agent.AgentSession) error { + if session == nil { + return errors.New("session is nil") + } + + // Verify this session belongs to Gemini CLI + if session.AgentName != "" && session.AgentName != g.Name() { + return fmt.Errorf("session belongs to agent %q, not %q", session.AgentName, g.Name()) + } + + if session.SessionRef == "" { + return errors.New("session reference (transcript path) is required") + } + + if len(session.NativeData) == 0 { + return errors.New("session has no native data to write") + } + + // Write the raw JSON data + if err := os.WriteFile(session.SessionRef, session.NativeData, 0o600); err != nil { + return fmt.Errorf("failed to write transcript: %w", err) + } + + return nil +} + +// FormatResumeCommand returns the command to resume a Gemini CLI session. +func (g *GeminiCLIAgent) FormatResumeCommand(sessionID string) string { + return "gemini --resume " + sessionID +} + +// SanitizePathForGemini converts a path to Gemini's project directory format. +// Gemini uses a hash-like sanitization similar to Claude. +var nonAlphanumericRegex = regexp.MustCompile(`[^a-zA-Z0-9]`) + +func SanitizePathForGemini(path string) string { + return nonAlphanumericRegex.ReplaceAllString(path, "-") +} diff --git a/cmd/entire/cli/agent/geminicli/gemini_test.go b/cmd/entire/cli/agent/geminicli/gemini_test.go new file mode 100644 index 000000000..076446756 --- /dev/null +++ b/cmd/entire/cli/agent/geminicli/gemini_test.go @@ -0,0 +1,473 @@ +package geminicli + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "entire.io/cli/cmd/entire/cli/agent" +) + +// Test constants +const testSessionID = "abc123" + +func TestNewGeminiCLIAgent(t *testing.T) { + ag := NewGeminiCLIAgent() + if ag == nil { + t.Fatal("NewGeminiCLIAgent() returned nil") + } + + gemini, ok := ag.(*GeminiCLIAgent) + if !ok { + t.Fatal("NewGeminiCLIAgent() didn't return *GeminiCLIAgent") + } + if gemini == nil { + t.Fatal("NewGeminiCLIAgent() returned nil agent") + } +} + +func TestName(t *testing.T) { + ag := &GeminiCLIAgent{} + if name := ag.Name(); name != agent.AgentNameGemini { + t.Errorf("Name() = %q, want %q", name, agent.AgentNameGemini) + } +} + +func TestDescription(t *testing.T) { + ag := &GeminiCLIAgent{} + desc := ag.Description() + if desc == "" { + t.Error("Description() returned empty string") + } +} + +func TestDetectPresence(t *testing.T) { + t.Run("no .gemini directory", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &GeminiCLIAgent{} + present, err := ag.DetectPresence() + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if present { + t.Error("DetectPresence() = true, want false") + } + }) + + t.Run("with .gemini directory", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Create .gemini directory + if err := os.Mkdir(".gemini", 0o755); err != nil { + t.Fatalf("failed to create .gemini: %v", err) + } + + ag := &GeminiCLIAgent{} + present, err := ag.DetectPresence() + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if !present { + t.Error("DetectPresence() = false, want true") + } + }) +} + +func TestGetHookConfigPath(t *testing.T) { + ag := &GeminiCLIAgent{} + path := ag.GetHookConfigPath() + if path != ".gemini/settings.json" { + t.Errorf("GetHookConfigPath() = %q, want .gemini/settings.json", path) + } +} + +func TestSupportsHooks(t *testing.T) { + ag := &GeminiCLIAgent{} + if !ag.SupportsHooks() { + t.Error("SupportsHooks() = false, want true") + } +} + +func TestParseHookInput_SessionStart(t *testing.T) { + ag := &GeminiCLIAgent{} + + input := `{ + "session_id": "` + testSessionID + `", + "transcript_path": "/path/to/transcript.json", + "cwd": "/project", + "hook_event_name": "session_start", + "source": "startup" + }` + + hookInput, err := ag.ParseHookInput(agent.HookSessionStart, bytes.NewReader([]byte(input))) + if err != nil { + t.Fatalf("ParseHookInput() error = %v", err) + } + + if hookInput.SessionID != testSessionID { + t.Errorf("SessionID = %q, want %s", hookInput.SessionID, testSessionID) + } + if hookInput.SessionRef != "/path/to/transcript.json" { + t.Errorf("SessionRef = %q, want /path/to/transcript.json", hookInput.SessionRef) + } + if hookInput.HookType != agent.HookSessionStart { + t.Errorf("HookType = %v, want %v", hookInput.HookType, agent.HookSessionStart) + } +} + +func TestParseHookInput_SessionEnd(t *testing.T) { + ag := &GeminiCLIAgent{} + + input := `{ + "session_id": "` + testSessionID + `", + "transcript_path": "/path/to/transcript.json", + "cwd": "/project", + "hook_event_name": "session_end", + "reason": "exit" + }` + + hookInput, err := ag.ParseHookInput(agent.HookStop, bytes.NewReader([]byte(input))) + if err != nil { + t.Fatalf("ParseHookInput() error = %v", err) + } + + if hookInput.SessionID != testSessionID { + t.Errorf("SessionID = %q, want %s", hookInput.SessionID, testSessionID) + } + if hookInput.RawData["reason"] != "exit" { + t.Errorf("reason = %v, want exit", hookInput.RawData["reason"]) + } +} + +func TestParseHookInput_PreToolUse(t *testing.T) { + ag := &GeminiCLIAgent{} + + input := `{ + "session_id": "` + testSessionID + `", + "transcript_path": "/path/to/transcript.json", + "cwd": "/project", + "hook_event_name": "before_tool", + "tool_name": "write_file", + "tool_input": {"file_path": "test.go", "content": "package main"} + }` + + hookInput, err := ag.ParseHookInput(agent.HookPreToolUse, bytes.NewReader([]byte(input))) + if err != nil { + t.Fatalf("ParseHookInput() error = %v", err) + } + + if hookInput.ToolName != "write_file" { + t.Errorf("ToolName = %q, want write_file", hookInput.ToolName) + } + if hookInput.ToolInput == nil { + t.Error("ToolInput is nil") + } +} + +func TestParseHookInput_PostToolUse(t *testing.T) { + ag := &GeminiCLIAgent{} + + input := `{ + "session_id": "` + testSessionID + `", + "transcript_path": "/path/to/transcript.json", + "cwd": "/project", + "hook_event_name": "after_tool", + "tool_name": "write_file", + "tool_input": {"file_path": "test.go"}, + "tool_response": {"success": true} + }` + + hookInput, err := ag.ParseHookInput(agent.HookPostToolUse, bytes.NewReader([]byte(input))) + if err != nil { + t.Fatalf("ParseHookInput() error = %v", err) + } + + if hookInput.ToolName != "write_file" { + t.Errorf("ToolName = %q, want write_file", hookInput.ToolName) + } + if hookInput.ToolResponse == nil { + t.Error("ToolResponse is nil") + } +} + +func TestParseHookInput_Empty(t *testing.T) { + ag := &GeminiCLIAgent{} + + _, err := ag.ParseHookInput(agent.HookSessionStart, bytes.NewReader([]byte(""))) + if err == nil { + t.Error("ParseHookInput() should error on empty input") + } +} + +func TestParseHookInput_InvalidJSON(t *testing.T) { + ag := &GeminiCLIAgent{} + + _, err := ag.ParseHookInput(agent.HookSessionStart, bytes.NewReader([]byte("not json"))) + if err == nil { + t.Error("ParseHookInput() should error on invalid JSON") + } +} + +func TestGetSessionID(t *testing.T) { + ag := &GeminiCLIAgent{} + input := &agent.HookInput{SessionID: "test-session-123"} + + id := ag.GetSessionID(input) + if id != "test-session-123" { + t.Errorf("GetSessionID() = %q, want test-session-123", id) + } +} + +func TestTransformSessionID(t *testing.T) { + ag := &GeminiCLIAgent{} + + // TransformSessionID should add date prefix + result := ag.TransformSessionID("abc123") + if result == "abc123" { + t.Error("TransformSessionID() should add date prefix") + } + if len(result) < len("abc123")+11 { // 11 chars for "YYYY-MM-DD-" + t.Errorf("TransformSessionID() result too short: %q", result) + } +} + +func TestExtractAgentSessionID(t *testing.T) { + ag := &GeminiCLIAgent{} + + tests := []struct { + name string + input string + want string + }{ + { + name: "with date prefix", + input: "2025-01-09-abc123", + want: "abc123", + }, + { + name: "without date prefix", + input: "abc123", + want: "abc123", + }, + { + name: "longer session id", + input: "2025-12-31-session-id-here", + want: "session-id-here", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ag.ExtractAgentSessionID(tt.input) + if got != tt.want { + t.Errorf("ExtractAgentSessionID(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestGetSessionDir(t *testing.T) { + ag := &GeminiCLIAgent{} + + // Test with override env var + t.Setenv("ENTIRE_TEST_GEMINI_PROJECT_DIR", "/test/override") + + dir, err := ag.GetSessionDir("/some/repo") + if err != nil { + t.Fatalf("GetSessionDir() error = %v", err) + } + if dir != "/test/override" { + t.Errorf("GetSessionDir() = %q, want /test/override", dir) + } +} + +func TestGetSessionDir_DefaultPath(t *testing.T) { + ag := &GeminiCLIAgent{} + + // Make sure env var is not set + t.Setenv("ENTIRE_TEST_GEMINI_PROJECT_DIR", "") + + dir, err := ag.GetSessionDir("/some/repo") + if err != nil { + t.Fatalf("GetSessionDir() error = %v", err) + } + + // Should contain .gemini/tmp and end with /chats + if !filepath.IsAbs(dir) { + t.Errorf("GetSessionDir() should return absolute path, got %q", dir) + } +} + +func TestFormatResumeCommand(t *testing.T) { + ag := &GeminiCLIAgent{} + + cmd := ag.FormatResumeCommand("abc123") + expected := "gemini --resume abc123" + if cmd != expected { + t.Errorf("FormatResumeCommand() = %q, want %q", cmd, expected) + } +} + +func TestReadSession(t *testing.T) { + tempDir := t.TempDir() + + // Create a transcript file + transcriptPath := filepath.Join(tempDir, "transcript.json") + transcriptContent := `{"messages": [{"role": "user", "content": "hello"}]}` + if err := os.WriteFile(transcriptPath, []byte(transcriptContent), 0o644); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + ag := &GeminiCLIAgent{} + input := &agent.HookInput{ + SessionID: "test-session", + SessionRef: transcriptPath, + } + + session, err := ag.ReadSession(input) + if err != nil { + t.Fatalf("ReadSession() error = %v", err) + } + + if session.SessionID != "test-session" { + t.Errorf("SessionID = %q, want test-session", session.SessionID) + } + if session.AgentName != agent.AgentNameGemini { + t.Errorf("AgentName = %q, want %q", session.AgentName, agent.AgentNameGemini) + } + if len(session.NativeData) == 0 { + t.Error("NativeData is empty") + } +} + +func TestReadSession_NoSessionRef(t *testing.T) { + ag := &GeminiCLIAgent{} + input := &agent.HookInput{SessionID: "test-session"} + + _, err := ag.ReadSession(input) + if err == nil { + t.Error("ReadSession() should error when SessionRef is empty") + } +} + +func TestWriteSession(t *testing.T) { + tempDir := t.TempDir() + transcriptPath := filepath.Join(tempDir, "transcript.json") + + ag := &GeminiCLIAgent{} + session := &agent.AgentSession{ + SessionID: "test-session", + AgentName: agent.AgentNameGemini, + SessionRef: transcriptPath, + NativeData: []byte(`{"messages": []}`), + } + + err := ag.WriteSession(session) + if err != nil { + t.Fatalf("WriteSession() error = %v", err) + } + + // Verify file was written + data, err := os.ReadFile(transcriptPath) + if err != nil { + t.Fatalf("failed to read transcript: %v", err) + } + + if string(data) != `{"messages": []}` { + t.Errorf("transcript content = %q, want {\"messages\": []}", string(data)) + } +} + +func TestWriteSession_Nil(t *testing.T) { + ag := &GeminiCLIAgent{} + + err := ag.WriteSession(nil) + if err == nil { + t.Error("WriteSession(nil) should error") + } +} + +func TestWriteSession_WrongAgent(t *testing.T) { + ag := &GeminiCLIAgent{} + session := &agent.AgentSession{ + AgentName: "claude-code", + SessionRef: "/path/to/file", + NativeData: []byte("{}"), + } + + err := ag.WriteSession(session) + if err == nil { + t.Error("WriteSession() should error for wrong agent") + } +} + +func TestWriteSession_NoSessionRef(t *testing.T) { + ag := &GeminiCLIAgent{} + session := &agent.AgentSession{ + AgentName: agent.AgentNameGemini, + NativeData: []byte("{}"), + } + + err := ag.WriteSession(session) + if err == nil { + t.Error("WriteSession() should error when SessionRef is empty") + } +} + +func TestWriteSession_NoNativeData(t *testing.T) { + ag := &GeminiCLIAgent{} + session := &agent.AgentSession{ + AgentName: agent.AgentNameGemini, + SessionRef: "/path/to/file", + } + + err := ag.WriteSession(session) + if err == nil { + t.Error("WriteSession() should error when NativeData is empty") + } +} + +func TestSanitizePathForGemini(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"/Users/test/project", "-Users-test-project"}, + {"simple", "simple"}, + {"/path/with spaces/dir", "-path-with-spaces-dir"}, + } + + for _, tt := range tests { + got := SanitizePathForGemini(tt.input) + if got != tt.want { + t.Errorf("SanitizePathForGemini(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestGetSupportedHooks(t *testing.T) { + ag := &GeminiCLIAgent{} + hooks := ag.GetSupportedHooks() + + expected := []agent.HookType{ + agent.HookSessionStart, + agent.HookStop, // Maps to Gemini's SessionEnd + agent.HookUserPromptSubmit, // Maps to Gemini's BeforeAgent + agent.HookPreToolUse, // Maps to Gemini's BeforeTool + agent.HookPostToolUse, // Maps to Gemini's AfterTool + } + + if len(hooks) != len(expected) { + t.Errorf("GetSupportedHooks() returned %d hooks, want %d", len(hooks), len(expected)) + } + + for i, hook := range expected { + if hooks[i] != hook { + t.Errorf("GetSupportedHooks()[%d] = %v, want %v", i, hooks[i], hook) + } + } +} diff --git a/cmd/entire/cli/agent/geminicli/hooks.go b/cmd/entire/cli/agent/geminicli/hooks.go new file mode 100644 index 000000000..3094b041f --- /dev/null +++ b/cmd/entire/cli/agent/geminicli/hooks.go @@ -0,0 +1,368 @@ +package geminicli + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "entire.io/cli/cmd/entire/cli/agent" +) + +// Ensure GeminiCLIAgent implements HookSupport and HookHandler +var ( + _ agent.HookSupport = (*GeminiCLIAgent)(nil) + _ agent.HookHandler = (*GeminiCLIAgent)(nil) +) + +// Gemini CLI hook names - these become subcommands under `entire hooks gemini` +const ( + HookNameSessionStart = "session-start" + HookNameSessionEnd = "session-end" + HookNameBeforeAgent = "before-agent" + HookNameAfterAgent = "after-agent" + HookNameBeforeModel = "before-model" + HookNameAfterModel = "after-model" + HookNameBeforeToolSelection = "before-tool-selection" + HookNameBeforeTool = "before-tool" + HookNameAfterTool = "after-tool" + HookNamePreCompress = "pre-compress" + HookNameNotification = "notification" +) + +// GeminiSettingsFileName is the settings file used by Gemini CLI. +const GeminiSettingsFileName = "settings.json" + +// entireHookPrefixes are command prefixes that identify Entire hooks +var entireHookPrefixes = []string{ + "entire ", + "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go ", +} + +// GetHookNames returns the hook verbs Gemini CLI supports. +// These become subcommands: entire hooks gemini +func (g *GeminiCLIAgent) GetHookNames() []string { + return []string{ + HookNameSessionStart, + HookNameSessionEnd, + HookNameBeforeAgent, + HookNameAfterAgent, + HookNameBeforeModel, + HookNameAfterModel, + HookNameBeforeToolSelection, + HookNameBeforeTool, + HookNameAfterTool, + HookNamePreCompress, + HookNameNotification, + } +} + +// InstallHooks installs Gemini CLI hooks in .gemini/settings.json. +// If force is true, removes existing Entire hooks before installing. +// Returns the number of hooks installed. +func (g *GeminiCLIAgent) InstallHooks(localDev bool, force bool) (int, error) { + cwd, err := os.Getwd() + if err != nil { + return 0, fmt.Errorf("failed to get current directory: %w", err) + } + + settingsPath := filepath.Join(cwd, ".gemini", GeminiSettingsFileName) + + // Read existing settings if they exist + var settings GeminiSettings + var rawSettings map[string]json.RawMessage + + existingData, readErr := os.ReadFile(settingsPath) //nolint:gosec // path is constructed from cwd + fixed path + if readErr == nil { + if err := json.Unmarshal(existingData, &rawSettings); err != nil { + return 0, fmt.Errorf("failed to parse existing settings.json: %w", err) + } + if hooksRaw, ok := rawSettings["hooks"]; ok { + if err := json.Unmarshal(hooksRaw, &settings.Hooks); err != nil { + return 0, fmt.Errorf("failed to parse hooks in settings.json: %w", err) + } + } + if toolsRaw, ok := rawSettings["tools"]; ok { + if err := json.Unmarshal(toolsRaw, &settings.Tools); err != nil { + return 0, fmt.Errorf("failed to parse tools in settings.json: %w", err) + } + } + } else { + rawSettings = make(map[string]json.RawMessage) + } + + // Enable hooks in tools config + settings.Tools.EnableHooks = true + + // Define hook commands based on localDev mode + var cmdPrefix string + if localDev { + cmdPrefix = "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini " + } else { + cmdPrefix = "entire hooks gemini " + } + + // Check for idempotency BEFORE removing hooks + // If the exact same hook command already exists, return 0 (no changes needed) + if !force { + existingCmd := getFirstEntireHookCommand(settings.Hooks.SessionStart) + expectedCmd := cmdPrefix + "session-start" + if existingCmd == expectedCmd { + return 0, nil // Already installed with same mode + } + } + + // Remove existing Entire hooks first (for clean installs and mode switching) + settings.Hooks.SessionStart = removeEntireHooks(settings.Hooks.SessionStart) + settings.Hooks.SessionEnd = removeEntireHooks(settings.Hooks.SessionEnd) + settings.Hooks.BeforeAgent = removeEntireHooks(settings.Hooks.BeforeAgent) + settings.Hooks.AfterAgent = removeEntireHooks(settings.Hooks.AfterAgent) + settings.Hooks.BeforeModel = removeEntireHooks(settings.Hooks.BeforeModel) + settings.Hooks.AfterModel = removeEntireHooks(settings.Hooks.AfterModel) + settings.Hooks.BeforeToolSelection = removeEntireHooks(settings.Hooks.BeforeToolSelection) + settings.Hooks.BeforeTool = removeEntireHooks(settings.Hooks.BeforeTool) + settings.Hooks.AfterTool = removeEntireHooks(settings.Hooks.AfterTool) + settings.Hooks.PreCompress = removeEntireHooks(settings.Hooks.PreCompress) + settings.Hooks.Notification = removeEntireHooks(settings.Hooks.Notification) + + // Install all hooks + // Session lifecycle hooks + settings.Hooks.SessionStart = addGeminiHook(settings.Hooks.SessionStart, "", "entire-session-start", cmdPrefix+"session-start") + // SessionEnd fires on both "exit" and "logout" - install hooks for both matchers + settings.Hooks.SessionEnd = addGeminiHook(settings.Hooks.SessionEnd, "exit", "entire-session-end-exit", cmdPrefix+"session-end") + settings.Hooks.SessionEnd = addGeminiHook(settings.Hooks.SessionEnd, "logout", "entire-session-end-logout", cmdPrefix+"session-end") + + // Agent hooks (user prompt and response) + settings.Hooks.BeforeAgent = addGeminiHook(settings.Hooks.BeforeAgent, "", "entire-before-agent", cmdPrefix+"before-agent") + settings.Hooks.AfterAgent = addGeminiHook(settings.Hooks.AfterAgent, "", "entire-after-agent", cmdPrefix+"after-agent") + + // Model hooks (LLM request/response - fires on every LLM call) + settings.Hooks.BeforeModel = addGeminiHook(settings.Hooks.BeforeModel, "", "entire-before-model", cmdPrefix+"before-model") + settings.Hooks.AfterModel = addGeminiHook(settings.Hooks.AfterModel, "", "entire-after-model", cmdPrefix+"after-model") + + // Tool selection hook (before planner selects tools) + settings.Hooks.BeforeToolSelection = addGeminiHook(settings.Hooks.BeforeToolSelection, "", "entire-before-tool-selection", cmdPrefix+"before-tool-selection") + + // Tool hooks (before/after tool execution) + settings.Hooks.BeforeTool = addGeminiHook(settings.Hooks.BeforeTool, "*", "entire-before-tool", cmdPrefix+"before-tool") + settings.Hooks.AfterTool = addGeminiHook(settings.Hooks.AfterTool, "*", "entire-after-tool", cmdPrefix+"after-tool") + + // Compression hook (before chat history compression) + settings.Hooks.PreCompress = addGeminiHook(settings.Hooks.PreCompress, "", "entire-pre-compress", cmdPrefix+"pre-compress") + + // Notification hook (errors, warnings, info) + settings.Hooks.Notification = addGeminiHook(settings.Hooks.Notification, "", "entire-notification", cmdPrefix+"notification") + + // 12 hooks total: + // - session-start (1) + // - session-end exit + logout (2) + // - before-agent, after-agent (2) + // - before-model, after-model (2) + // - before-tool-selection (1) + // - before-tool, after-tool (2) + // - pre-compress (1) + // - notification (1) + count := 12 + + // Marshal tools and hooks back to raw settings + toolsJSON, err := json.Marshal(settings.Tools) + if err != nil { + return 0, fmt.Errorf("failed to marshal tools: %w", err) + } + rawSettings["tools"] = toolsJSON + + hooksJSON, err := json.Marshal(settings.Hooks) + if err != nil { + return 0, fmt.Errorf("failed to marshal hooks: %w", err) + } + rawSettings["hooks"] = hooksJSON + + // Write back to file + if err := os.MkdirAll(filepath.Dir(settingsPath), 0o750); err != nil { + return 0, fmt.Errorf("failed to create .gemini directory: %w", err) + } + + output, err := json.MarshalIndent(rawSettings, "", " ") + if err != nil { + return 0, fmt.Errorf("failed to marshal settings: %w", err) + } + + if err := os.WriteFile(settingsPath, output, 0o600); err != nil { + return 0, fmt.Errorf("failed to write settings.json: %w", err) + } + + return count, nil +} + +// UninstallHooks removes Entire hooks from Gemini CLI settings. +func (g *GeminiCLIAgent) UninstallHooks() error { + settingsPath := ".gemini/" + GeminiSettingsFileName + data, err := os.ReadFile(settingsPath) + if err != nil { + return nil //nolint:nilerr // No settings file means nothing to uninstall + } + + var rawSettings map[string]json.RawMessage + if err := json.Unmarshal(data, &rawSettings); err != nil { + return fmt.Errorf("failed to parse settings.json: %w", err) + } + + var settings GeminiSettings + if hooksRaw, ok := rawSettings["hooks"]; ok { + if err := json.Unmarshal(hooksRaw, &settings.Hooks); err != nil { + return fmt.Errorf("failed to parse hooks: %w", err) + } + } + + // Remove Entire hooks from all hook types + settings.Hooks.SessionStart = removeEntireHooks(settings.Hooks.SessionStart) + settings.Hooks.SessionEnd = removeEntireHooks(settings.Hooks.SessionEnd) + settings.Hooks.BeforeAgent = removeEntireHooks(settings.Hooks.BeforeAgent) + settings.Hooks.AfterAgent = removeEntireHooks(settings.Hooks.AfterAgent) + settings.Hooks.BeforeModel = removeEntireHooks(settings.Hooks.BeforeModel) + settings.Hooks.AfterModel = removeEntireHooks(settings.Hooks.AfterModel) + settings.Hooks.BeforeToolSelection = removeEntireHooks(settings.Hooks.BeforeToolSelection) + settings.Hooks.BeforeTool = removeEntireHooks(settings.Hooks.BeforeTool) + settings.Hooks.AfterTool = removeEntireHooks(settings.Hooks.AfterTool) + settings.Hooks.PreCompress = removeEntireHooks(settings.Hooks.PreCompress) + settings.Hooks.Notification = removeEntireHooks(settings.Hooks.Notification) + + // Marshal hooks back + hooksJSON, err := json.Marshal(settings.Hooks) + if err != nil { + return fmt.Errorf("failed to marshal hooks: %w", err) + } + rawSettings["hooks"] = hooksJSON + + // Write back + output, err := json.MarshalIndent(rawSettings, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal settings: %w", err) + } + + if err := os.WriteFile(settingsPath, output, 0o600); err != nil { + return fmt.Errorf("failed to write settings.json: %w", err) + } + return nil +} + +// AreHooksInstalled checks if Entire hooks are installed. +func (g *GeminiCLIAgent) AreHooksInstalled() bool { + settingsPath := ".gemini/" + GeminiSettingsFileName + data, err := os.ReadFile(settingsPath) + if err != nil { + return false + } + + var settings GeminiSettings + if err := json.Unmarshal(data, &settings); err != nil { + return false + } + + // Check for at least one of our hooks using isEntireHook (works for both localDev and production) + return hasEntireHook(settings.Hooks.SessionStart) || + hasEntireHook(settings.Hooks.SessionEnd) || + hasEntireHook(settings.Hooks.BeforeAgent) || + hasEntireHook(settings.Hooks.AfterAgent) || + hasEntireHook(settings.Hooks.BeforeModel) || + hasEntireHook(settings.Hooks.AfterModel) || + hasEntireHook(settings.Hooks.BeforeToolSelection) || + hasEntireHook(settings.Hooks.BeforeTool) || + hasEntireHook(settings.Hooks.AfterTool) || + hasEntireHook(settings.Hooks.PreCompress) || + hasEntireHook(settings.Hooks.Notification) +} + +// GetSupportedHooks returns the hook types Gemini CLI supports. +func (g *GeminiCLIAgent) GetSupportedHooks() []agent.HookType { + return []agent.HookType{ + agent.HookSessionStart, + agent.HookStop, // Maps to Gemini's SessionEnd + agent.HookUserPromptSubmit, // Maps to Gemini's BeforeAgent (user prompt) + agent.HookPreToolUse, // Maps to Gemini's BeforeTool + agent.HookPostToolUse, // Maps to Gemini's AfterTool + } +} + +// Helper functions for hook management + +// addGeminiHook adds a hook entry to matchers. +// Unlike Claude Code, Gemini hooks require a "name" field. +func addGeminiHook(matchers []GeminiHookMatcher, matcherName, hookName, command string) []GeminiHookMatcher { + entry := GeminiHookEntry{ + Name: hookName, + Type: "command", + Command: command, + } + + // Find or create matcher + for i, matcher := range matchers { + if matcher.Matcher == matcherName { + matchers[i].Hooks = append(matchers[i].Hooks, entry) + return matchers + } + } + + // Create new matcher + newMatcher := GeminiHookMatcher{ + Hooks: []GeminiHookEntry{entry}, + } + if matcherName != "" { + newMatcher.Matcher = matcherName + } + return append(matchers, newMatcher) +} + +// isEntireHook checks if a command is an Entire hook +func isEntireHook(command string) bool { + for _, prefix := range entireHookPrefixes { + if strings.HasPrefix(command, prefix) { + return true + } + } + return false +} + +// hasEntireHook checks if any hook in the matchers is an Entire hook +func hasEntireHook(matchers []GeminiHookMatcher) bool { + for _, matcher := range matchers { + for _, hook := range matcher.Hooks { + if isEntireHook(hook.Command) { + return true + } + } + } + return false +} + +// getFirstEntireHookCommand returns the command of the first Entire hook found, or empty string +func getFirstEntireHookCommand(matchers []GeminiHookMatcher) string { + for _, matcher := range matchers { + for _, hook := range matcher.Hooks { + if isEntireHook(hook.Command) { + return hook.Command + } + } + } + return "" +} + +// removeEntireHooks removes all Entire hooks from a list of matchers +func removeEntireHooks(matchers []GeminiHookMatcher) []GeminiHookMatcher { + result := make([]GeminiHookMatcher, 0, len(matchers)) + for _, matcher := range matchers { + filteredHooks := make([]GeminiHookEntry, 0, len(matcher.Hooks)) + for _, hook := range matcher.Hooks { + if !isEntireHook(hook.Command) { + filteredHooks = append(filteredHooks, hook) + } + } + // Only keep the matcher if it has hooks remaining + if len(filteredHooks) > 0 { + matcher.Hooks = filteredHooks + result = append(result, matcher) + } + } + return result +} diff --git a/cmd/entire/cli/agent/geminicli/hooks_test.go b/cmd/entire/cli/agent/geminicli/hooks_test.go new file mode 100644 index 000000000..8ee8d0eb4 --- /dev/null +++ b/cmd/entire/cli/agent/geminicli/hooks_test.go @@ -0,0 +1,420 @@ +package geminicli + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestInstallHooks_FreshInstall(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + agent := &GeminiCLIAgent{} + count, err := agent.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + // 12 hooks: SessionStart, SessionEnd (exit+logout), BeforeAgent, AfterAgent, + // BeforeModel, AfterModel, BeforeToolSelection, BeforeTool, AfterTool, PreCompress, Notification + if count != 12 { + t.Errorf("InstallHooks() count = %d, want 12", count) + } + + // Verify settings.json was created with hooks + settings := readGeminiSettings(t, tempDir) + + // Verify enableHooks is true + if !settings.Tools.EnableHooks { + t.Error("tools.enableHooks should be true") + } + + // Verify all hooks are present + if len(settings.Hooks.SessionStart) != 1 { + t.Errorf("SessionStart hooks = %d, want 1", len(settings.Hooks.SessionStart)) + } + // SessionEnd has 2 matchers: exit and logout + if len(settings.Hooks.SessionEnd) != 2 { + t.Errorf("SessionEnd hooks = %d, want 2 (exit + logout)", len(settings.Hooks.SessionEnd)) + } + if len(settings.Hooks.BeforeAgent) != 1 { + t.Errorf("BeforeAgent hooks = %d, want 1", len(settings.Hooks.BeforeAgent)) + } + if len(settings.Hooks.AfterAgent) != 1 { + t.Errorf("AfterAgent hooks = %d, want 1", len(settings.Hooks.AfterAgent)) + } + if len(settings.Hooks.BeforeTool) != 1 { + t.Errorf("BeforeTool hooks = %d, want 1", len(settings.Hooks.BeforeTool)) + } + if len(settings.Hooks.AfterTool) != 1 { + t.Errorf("AfterTool hooks = %d, want 1", len(settings.Hooks.AfterTool)) + } + if len(settings.Hooks.BeforeModel) != 1 { + t.Errorf("BeforeModel hooks = %d, want 1", len(settings.Hooks.BeforeModel)) + } + if len(settings.Hooks.AfterModel) != 1 { + t.Errorf("AfterModel hooks = %d, want 1", len(settings.Hooks.AfterModel)) + } + if len(settings.Hooks.BeforeToolSelection) != 1 { + t.Errorf("BeforeToolSelection hooks = %d, want 1", len(settings.Hooks.BeforeToolSelection)) + } + if len(settings.Hooks.PreCompress) != 1 { + t.Errorf("PreCompress hooks = %d, want 1", len(settings.Hooks.PreCompress)) + } + if len(settings.Hooks.Notification) != 1 { + t.Errorf("Notification hooks = %d, want 1", len(settings.Hooks.Notification)) + } + + // Verify hook commands + verifyHookCommand(t, settings.Hooks.SessionStart, "", "entire hooks gemini session-start") + verifyHookCommand(t, settings.Hooks.SessionEnd, "exit", "entire hooks gemini session-end") + verifyHookCommand(t, settings.Hooks.SessionEnd, "logout", "entire hooks gemini session-end") + verifyHookCommand(t, settings.Hooks.BeforeAgent, "", "entire hooks gemini before-agent") + verifyHookCommand(t, settings.Hooks.AfterAgent, "", "entire hooks gemini after-agent") + verifyHookCommand(t, settings.Hooks.BeforeModel, "", "entire hooks gemini before-model") + verifyHookCommand(t, settings.Hooks.AfterModel, "", "entire hooks gemini after-model") + verifyHookCommand(t, settings.Hooks.BeforeToolSelection, "", "entire hooks gemini before-tool-selection") + verifyHookCommand(t, settings.Hooks.BeforeTool, "*", "entire hooks gemini before-tool") + verifyHookCommand(t, settings.Hooks.AfterTool, "*", "entire hooks gemini after-tool") + verifyHookCommand(t, settings.Hooks.PreCompress, "", "entire hooks gemini pre-compress") + verifyHookCommand(t, settings.Hooks.Notification, "", "entire hooks gemini notification") +} + +func TestInstallHooks_LocalDev(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + agent := &GeminiCLIAgent{} + _, err := agent.InstallHooks(true, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + settings := readGeminiSettings(t, tempDir) + + // Verify local dev commands use go run + verifyHookCommand(t, settings.Hooks.SessionStart, "", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini session-start") + verifyHookCommand(t, settings.Hooks.SessionEnd, "exit", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini session-end") + verifyHookCommand(t, settings.Hooks.SessionEnd, "logout", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini session-end") + verifyHookCommand(t, settings.Hooks.BeforeAgent, "", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini before-agent") + verifyHookCommand(t, settings.Hooks.AfterAgent, "", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini after-agent") + verifyHookCommand(t, settings.Hooks.BeforeModel, "", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini before-model") + verifyHookCommand(t, settings.Hooks.AfterModel, "", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini after-model") + verifyHookCommand(t, settings.Hooks.BeforeToolSelection, "", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini before-tool-selection") + verifyHookCommand(t, settings.Hooks.PreCompress, "", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini pre-compress") + verifyHookCommand(t, settings.Hooks.Notification, "", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini notification") +} + +func TestInstallHooks_Idempotent(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + agent := &GeminiCLIAgent{} + + // First install + count1, err := agent.InstallHooks(false, false) + if err != nil { + t.Fatalf("first InstallHooks() error = %v", err) + } + if count1 != 12 { + t.Errorf("first InstallHooks() count = %d, want 12", count1) + } + + // Second install should add 0 hooks + count2, err := agent.InstallHooks(false, false) + if err != nil { + t.Fatalf("second InstallHooks() error = %v", err) + } + if count2 != 0 { + t.Errorf("second InstallHooks() count = %d, want 0 (idempotent)", count2) + } + + // Verify still only 1 hook per type (except SessionEnd which has 2 matchers) + settings := readGeminiSettings(t, tempDir) + if len(settings.Hooks.SessionStart) != 1 { + t.Errorf("SessionStart hooks = %d after double install, want 1", len(settings.Hooks.SessionStart)) + } + if len(settings.Hooks.SessionEnd) != 2 { + t.Errorf("SessionEnd hooks = %d after double install, want 2", len(settings.Hooks.SessionEnd)) + } +} + +func TestInstallHooks_Force(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + agent := &GeminiCLIAgent{} + + // First install + _, err := agent.InstallHooks(false, false) + if err != nil { + t.Fatalf("first InstallHooks() error = %v", err) + } + + // Force reinstall should replace hooks + count, err := agent.InstallHooks(false, true) + if err != nil { + t.Fatalf("force InstallHooks() error = %v", err) + } + if count != 12 { + t.Errorf("force InstallHooks() count = %d, want 12", count) + } +} + +func TestInstallHooks_PreservesUserHooks(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Create settings.json with existing user hooks + writeGeminiSettings(t, tempDir, `{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [{"name": "my-hook", "type": "command", "command": "echo hello"}] + } + ] + } +}`) + + agent := &GeminiCLIAgent{} + _, err := agent.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + settings := readGeminiSettings(t, tempDir) + + // Verify user hooks are preserved + if len(settings.Hooks.SessionStart) != 2 { + t.Errorf("SessionStart hooks = %d, want 2 (user + entire)", len(settings.Hooks.SessionStart)) + } + + // Verify user hook is still there + foundUserHook := false + for _, matcher := range settings.Hooks.SessionStart { + if matcher.Matcher == "startup" { + for _, hook := range matcher.Hooks { + if hook.Name == "my-hook" { + foundUserHook = true + } + } + } + } + if !foundUserHook { + t.Error("user hook 'my-hook' was not preserved") + } +} + +func TestInstallHooks_PreservesUnknownFields(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Create settings.json with unknown fields + writeGeminiSettings(t, tempDir, `{ + "someOtherField": "value", + "customConfig": {"nested": true} +}`) + + agent := &GeminiCLIAgent{} + _, err := agent.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + // Read raw settings to verify unknown fields are preserved + settingsPath := filepath.Join(tempDir, ".gemini", "settings.json") + data, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("failed to read settings.json: %v", err) + } + + var rawSettings map[string]json.RawMessage + if err := json.Unmarshal(data, &rawSettings); err != nil { + t.Fatalf("failed to parse settings.json: %v", err) + } + + if _, ok := rawSettings["someOtherField"]; !ok { + t.Error("someOtherField was not preserved") + } + if _, ok := rawSettings["customConfig"]; !ok { + t.Error("customConfig was not preserved") + } +} + +func TestUninstallHooks(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + agent := &GeminiCLIAgent{} + + // First install + _, err := agent.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + // Verify hooks are installed + if !agent.AreHooksInstalled() { + t.Error("hooks should be installed before uninstall") + } + + // Uninstall + err = agent.UninstallHooks() + if err != nil { + t.Fatalf("UninstallHooks() error = %v", err) + } + + // Verify hooks are removed + if agent.AreHooksInstalled() { + t.Error("hooks should not be installed after uninstall") + } +} + +func TestUninstallHooks_NoSettingsFile(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + agent := &GeminiCLIAgent{} + + // Should not error when no settings file exists + err := agent.UninstallHooks() + if err != nil { + t.Fatalf("UninstallHooks() should not error when no settings file: %v", err) + } +} + +func TestUninstallHooks_PreservesUserHooks(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Create settings with both user and entire hooks + writeGeminiSettings(t, tempDir, `{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [{"name": "my-hook", "type": "command", "command": "echo hello"}] + }, + { + "hooks": [{"name": "entire-session-start", "type": "command", "command": "entire hooks gemini session-start"}] + } + ] + } +}`) + + agent := &GeminiCLIAgent{} + err := agent.UninstallHooks() + if err != nil { + t.Fatalf("UninstallHooks() error = %v", err) + } + + settings := readGeminiSettings(t, tempDir) + + // Verify only user hooks remain + if len(settings.Hooks.SessionStart) != 1 { + t.Errorf("SessionStart hooks = %d after uninstall, want 1 (user only)", len(settings.Hooks.SessionStart)) + } + + // Verify it's the user hook + if settings.Hooks.SessionStart[0].Matcher != "startup" { + t.Error("user hook was removed during uninstall") + } +} + +func TestAreHooksInstalled(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + agent := &GeminiCLIAgent{} + + // Should be false when no settings file + if agent.AreHooksInstalled() { + t.Error("AreHooksInstalled() should be false when no settings file") + } + + // Install hooks + _, err := agent.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + // Should be true after installation + if !agent.AreHooksInstalled() { + t.Error("AreHooksInstalled() should be true after installation") + } +} + +func TestGetHookNames(t *testing.T) { + agent := &GeminiCLIAgent{} + names := agent.GetHookNames() + + expected := []string{ + HookNameSessionStart, + HookNameSessionEnd, + HookNameBeforeAgent, + HookNameAfterAgent, + HookNameBeforeModel, + HookNameAfterModel, + HookNameBeforeToolSelection, + HookNameBeforeTool, + HookNameAfterTool, + HookNamePreCompress, + HookNameNotification, + } + + if len(names) != len(expected) { + t.Errorf("GetHookNames() returned %d names, want %d", len(names), len(expected)) + } + + for i, name := range expected { + if names[i] != name { + t.Errorf("GetHookNames()[%d] = %q, want %q", i, names[i], name) + } + } +} + +// Helper functions + +func readGeminiSettings(t *testing.T, tempDir string) GeminiSettings { + t.Helper() + settingsPath := filepath.Join(tempDir, ".gemini", "settings.json") + data, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("failed to read settings.json: %v", err) + } + + var settings GeminiSettings + if err := json.Unmarshal(data, &settings); err != nil { + t.Fatalf("failed to parse settings.json: %v", err) + } + return settings +} + +func writeGeminiSettings(t *testing.T, tempDir, content string) { + t.Helper() + geminiDir := filepath.Join(tempDir, ".gemini") + if err := os.MkdirAll(geminiDir, 0o755); err != nil { + t.Fatalf("failed to create .gemini dir: %v", err) + } + settingsPath := filepath.Join(geminiDir, "settings.json") + if err := os.WriteFile(settingsPath, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write settings.json: %v", err) + } +} + +func verifyHookCommand(t *testing.T, matchers []GeminiHookMatcher, expectedMatcher, expectedCommand string) { + t.Helper() + for _, matcher := range matchers { + if matcher.Matcher == expectedMatcher { + for _, hook := range matcher.Hooks { + if hook.Command == expectedCommand { + return // Found + } + } + } + } + t.Errorf("hook with matcher=%q command=%q not found", expectedMatcher, expectedCommand) +} diff --git a/cmd/entire/cli/agent/geminicli/transcript.go b/cmd/entire/cli/agent/geminicli/transcript.go new file mode 100644 index 000000000..333219cb0 --- /dev/null +++ b/cmd/entire/cli/agent/geminicli/transcript.go @@ -0,0 +1,168 @@ +package geminicli + +import ( + "encoding/json" + "fmt" +) + +// Transcript parsing types - Gemini CLI uses JSON format for session storage +// Based on transcript_path format: ~/.gemini/tmp//chats/session--.json + +// Message type constants for Gemini transcripts +const ( + MessageTypeUser = "user" + MessageTypeGemini = "gemini" +) + +// GeminiTranscript represents the top-level structure of a Gemini session file +type GeminiTranscript struct { + Messages []GeminiMessage `json:"messages"` +} + +// GeminiMessage represents a single message in the transcript +type GeminiMessage struct { + Type string `json:"type"` // MessageTypeUser or MessageTypeGemini + Content string `json:"content,omitempty"` + ToolCalls []GeminiToolCall `json:"toolCalls,omitempty"` +} + +// GeminiToolCall represents a tool call in a gemini message +type GeminiToolCall struct { + ID string `json:"id"` + Name string `json:"name"` + Args map[string]interface{} `json:"args"` + Status string `json:"status,omitempty"` +} + +// ParseTranscript parses raw JSON content into a transcript structure +func ParseTranscript(data []byte) (*GeminiTranscript, error) { + var transcript GeminiTranscript + if err := json.Unmarshal(data, &transcript); err != nil { + return nil, fmt.Errorf("failed to parse transcript: %w", err) + } + return &transcript, nil +} + +// ExtractModifiedFiles extracts files modified by tool calls from transcript data +func ExtractModifiedFiles(data []byte) ([]string, error) { + transcript, err := ParseTranscript(data) + if err != nil { + return nil, err + } + + return ExtractModifiedFilesFromTranscript(transcript), nil +} + +// ExtractModifiedFilesFromTranscript extracts files from a parsed transcript +func ExtractModifiedFilesFromTranscript(transcript *GeminiTranscript) []string { + fileSet := make(map[string]bool) + var files []string + + for _, msg := range transcript.Messages { + // Only process gemini messages (assistant messages) + if msg.Type != MessageTypeGemini { + continue + } + + // Process tool calls in this message + for _, toolCall := range msg.ToolCalls { + // Check if it's a file modification tool + isModifyTool := false + for _, name := range FileModificationTools { + if toolCall.Name == name { + isModifyTool = true + break + } + } + + if !isModifyTool { + continue + } + + // Extract file path from args map + var file string + if fp, ok := toolCall.Args["file_path"].(string); ok && fp != "" { + file = fp + } else if p, ok := toolCall.Args["path"].(string); ok && p != "" { + file = p + } else if fn, ok := toolCall.Args["filename"].(string); ok && fn != "" { + file = fn + } + + if file != "" && !fileSet[file] { + fileSet[file] = true + files = append(files, file) + } + } + } + + return files +} + +// ExtractLastUserPrompt extracts the last user message from transcript data +func ExtractLastUserPrompt(data []byte) (string, error) { + transcript, err := ParseTranscript(data) + if err != nil { + return "", err + } + + return ExtractLastUserPromptFromTranscript(transcript), nil +} + +// ExtractLastUserPromptFromTranscript extracts the last user prompt from a parsed transcript +func ExtractLastUserPromptFromTranscript(transcript *GeminiTranscript) string { + for i := len(transcript.Messages) - 1; i >= 0; i-- { + msg := transcript.Messages[i] + if msg.Type != MessageTypeUser { + continue + } + + // Content is now a string field + if msg.Content != "" { + return msg.Content + } + } + return "" +} + +// ExtractAllUserPrompts extracts all user messages from transcript data +func ExtractAllUserPrompts(data []byte) ([]string, error) { + transcript, err := ParseTranscript(data) + if err != nil { + return nil, err + } + + return ExtractAllUserPromptsFromTranscript(transcript), nil +} + +// ExtractAllUserPromptsFromTranscript extracts all user prompts from a parsed transcript +func ExtractAllUserPromptsFromTranscript(transcript *GeminiTranscript) []string { + var prompts []string + for _, msg := range transcript.Messages { + if msg.Type == MessageTypeUser && msg.Content != "" { + prompts = append(prompts, msg.Content) + } + } + return prompts +} + +// ExtractLastAssistantMessage extracts the last gemini response from transcript data +func ExtractLastAssistantMessage(data []byte) (string, error) { + transcript, err := ParseTranscript(data) + if err != nil { + return "", err + } + + return ExtractLastAssistantMessageFromTranscript(transcript), nil +} + +// ExtractLastAssistantMessageFromTranscript extracts the last gemini response from a parsed transcript +func ExtractLastAssistantMessageFromTranscript(transcript *GeminiTranscript) string { + for i := len(transcript.Messages) - 1; i >= 0; i-- { + msg := transcript.Messages[i] + if msg.Type == MessageTypeGemini && msg.Content != "" { + return msg.Content + } + } + return "" +} diff --git a/cmd/entire/cli/agent/geminicli/transcript_test.go b/cmd/entire/cli/agent/geminicli/transcript_test.go new file mode 100644 index 000000000..0ce52dadc --- /dev/null +++ b/cmd/entire/cli/agent/geminicli/transcript_test.go @@ -0,0 +1,243 @@ +package geminicli + +import ( + "testing" +) + +func TestParseTranscript(t *testing.T) { + t.Parallel() + + // GeminiMessage uses "type" field with values "user" or "gemini" + data := []byte(`{ + "messages": [ + {"type": "user", "content": "hello"}, + {"type": "gemini", "content": "hi there"} + ] +}`) + + transcript, err := ParseTranscript(data) + if err != nil { + t.Fatalf("ParseTranscript() error = %v", err) + } + + if len(transcript.Messages) != 2 { + t.Errorf("ParseTranscript() got %d messages, want 2", len(transcript.Messages)) + } + + if transcript.Messages[0].Type != "user" { + t.Errorf("First message type = %q, want user", transcript.Messages[0].Type) + } + if transcript.Messages[1].Type != "gemini" { + t.Errorf("Second message type = %q, want gemini", transcript.Messages[1].Type) + } +} + +func TestParseTranscript_Empty(t *testing.T) { + t.Parallel() + + data := []byte(`{"messages": []}`) + transcript, err := ParseTranscript(data) + if err != nil { + t.Fatalf("ParseTranscript() error = %v", err) + } + + if len(transcript.Messages) != 0 { + t.Errorf("ParseTranscript() got %d messages, want 0", len(transcript.Messages)) + } +} + +func TestParseTranscript_Invalid(t *testing.T) { + t.Parallel() + + data := []byte(`not valid json`) + _, err := ParseTranscript(data) + if err == nil { + t.Error("ParseTranscript() should error on invalid JSON") + } +} + +func TestExtractModifiedFiles(t *testing.T) { + t.Parallel() + + // Gemini transcript with tool calls in ToolCalls array + data := []byte(`{ + "messages": [ + {"type": "user", "content": "create a file"}, + {"type": "gemini", "content": "", "toolCalls": [{"name": "write_file", "args": {"file_path": "foo.go"}}]}, + {"type": "gemini", "content": "", "toolCalls": [{"name": "edit_file", "args": {"file_path": "bar.go"}}]}, + {"type": "gemini", "content": "", "toolCalls": [{"name": "read_file", "args": {"file_path": "other.go"}}]}, + {"type": "gemini", "content": "", "toolCalls": [{"name": "write_file", "args": {"file_path": "foo.go"}}]} + ] +}`) + + files, err := ExtractModifiedFiles(data) + if err != nil { + t.Fatalf("ExtractModifiedFiles() error = %v", err) + } + + // Should have foo.go and bar.go (deduplicated, read_file not included) + if len(files) != 2 { + t.Errorf("ExtractModifiedFiles() got %d files, want 2", len(files)) + } + + hasFile := func(name string) bool { + for _, f := range files { + if f == name { + return true + } + } + return false + } + + if !hasFile("foo.go") { + t.Error("ExtractModifiedFiles() missing foo.go") + } + if !hasFile("bar.go") { + t.Error("ExtractModifiedFiles() missing bar.go") + } +} + +func TestExtractModifiedFiles_AlternativeFieldNames(t *testing.T) { + t.Parallel() + + // Test different field names for file path + data := []byte(`{ + "messages": [ + {"type": "gemini", "content": "", "toolCalls": [{"name": "write_file", "args": {"path": "via_path.go"}}]}, + {"type": "gemini", "content": "", "toolCalls": [{"name": "save_file", "args": {"filename": "via_filename.go"}}]} + ] +}`) + + files, err := ExtractModifiedFiles(data) + if err != nil { + t.Fatalf("ExtractModifiedFiles() error = %v", err) + } + + if len(files) != 2 { + t.Errorf("ExtractModifiedFiles() got %d files, want 2", len(files)) + } + + hasFile := func(name string) bool { + for _, f := range files { + if f == name { + return true + } + } + return false + } + + if !hasFile("via_path.go") { + t.Error("ExtractModifiedFiles() missing via_path.go") + } + if !hasFile("via_filename.go") { + t.Error("ExtractModifiedFiles() missing via_filename.go") + } +} + +func TestExtractModifiedFiles_NoToolUses(t *testing.T) { + t.Parallel() + + data := []byte(`{ + "messages": [ + {"type": "user", "content": "hello"}, + {"type": "gemini", "content": "just text response"} + ] +}`) + + files, err := ExtractModifiedFiles(data) + if err != nil { + t.Fatalf("ExtractModifiedFiles() error = %v", err) + } + + if len(files) != 0 { + t.Errorf("ExtractModifiedFiles() got %d files, want 0", len(files)) + } +} + +func TestExtractLastUserPrompt(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data string + want string + }{ + { + name: "string content", + data: `{"messages": [ + {"type": "user", "content": "first"}, + {"type": "gemini", "content": "response"}, + {"type": "user", "content": "second"} + ]}`, + want: "second", + }, + { + name: "only one user message", + data: `{"messages": [{"type": "user", "content": "only message"}]}`, + want: "only message", + }, + { + name: "no user messages", + data: `{"messages": [{"type": "gemini", "content": "assistant only"}]}`, + want: "", + }, + { + name: "empty messages", + data: `{"messages": []}`, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := ExtractLastUserPrompt([]byte(tt.data)) + if err != nil { + t.Fatalf("ExtractLastUserPrompt() error = %v", err) + } + if got != tt.want { + t.Errorf("ExtractLastUserPrompt() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestExtractModifiedFilesFromTranscript(t *testing.T) { + t.Parallel() + + transcript := &GeminiTranscript{ + Messages: []GeminiMessage{ + {Type: "user", Content: "hello"}, + {Type: "gemini", Content: "", ToolCalls: []GeminiToolCall{ + {Name: "write_file", Args: map[string]interface{}{"file_path": "test.go"}}, + }}, + }, + } + + files := ExtractModifiedFilesFromTranscript(transcript) + + if len(files) != 1 { + t.Errorf("got %d files, want 1", len(files)) + } + if len(files) > 0 && files[0] != "test.go" { + t.Errorf("got file %q, want test.go", files[0]) + } +} + +func TestExtractLastUserPromptFromTranscript(t *testing.T) { + t.Parallel() + + transcript := &GeminiTranscript{ + Messages: []GeminiMessage{ + {Type: "user", Content: "first prompt"}, + {Type: "gemini", Content: "response"}, + {Type: "user", Content: "last prompt"}, + }, + } + + got := ExtractLastUserPromptFromTranscript(transcript) + + if got != "last prompt" { + t.Errorf("got %q, want 'last prompt'", got) + } +} diff --git a/cmd/entire/cli/agent/geminicli/types.go b/cmd/entire/cli/agent/geminicli/types.go new file mode 100644 index 000000000..f62533590 --- /dev/null +++ b/cmd/entire/cli/agent/geminicli/types.go @@ -0,0 +1,91 @@ +package geminicli + +import "encoding/json" + +// GeminiSettings represents the .gemini/settings.json structure +type GeminiSettings struct { + Tools GeminiToolsConfig `json:"tools,omitempty"` + Hooks GeminiHooks `json:"hooks,omitempty"` +} + +// GeminiToolsConfig contains tool-related settings +type GeminiToolsConfig struct { + EnableHooks bool `json:"enableHooks,omitempty"` +} + +// GeminiHooks contains all hook configurations +type GeminiHooks struct { + SessionStart []GeminiHookMatcher `json:"SessionStart,omitempty"` + SessionEnd []GeminiHookMatcher `json:"SessionEnd,omitempty"` + BeforeAgent []GeminiHookMatcher `json:"BeforeAgent,omitempty"` + AfterAgent []GeminiHookMatcher `json:"AfterAgent,omitempty"` + BeforeModel []GeminiHookMatcher `json:"BeforeModel,omitempty"` + AfterModel []GeminiHookMatcher `json:"AfterModel,omitempty"` + BeforeToolSelection []GeminiHookMatcher `json:"BeforeToolSelection,omitempty"` + BeforeTool []GeminiHookMatcher `json:"BeforeTool,omitempty"` + AfterTool []GeminiHookMatcher `json:"AfterTool,omitempty"` + PreCompress []GeminiHookMatcher `json:"PreCompress,omitempty"` + Notification []GeminiHookMatcher `json:"Notification,omitempty"` +} + +// GeminiHookMatcher matches hooks to specific patterns +type GeminiHookMatcher struct { + Matcher string `json:"matcher,omitempty"` + Hooks []GeminiHookEntry `json:"hooks"` +} + +// GeminiHookEntry represents a single hook command. +// Unlike Claude Code, Gemini CLI requires a "name" field for each hook entry. +type GeminiHookEntry struct { + Name string `json:"name"` + Type string `json:"type"` + Command string `json:"command"` +} + +// sessionInfoRaw is the JSON structure from SessionStart/SessionEnd hooks +type sessionInfoRaw struct { + SessionID string `json:"session_id"` + TranscriptPath string `json:"transcript_path"` + Cwd string `json:"cwd"` + HookEventName string `json:"hook_event_name"` + Timestamp string `json:"timestamp"` + Source string `json:"source,omitempty"` // For SessionStart: startup, resume, clear + Reason string `json:"reason,omitempty"` // For SessionEnd: exit, logout +} + +// agentHookInputRaw is the JSON structure from BeforeAgent/AfterAgent hooks. +// BeforeAgent includes the user's prompt, similar to Claude's UserPromptSubmit. +type agentHookInputRaw struct { + SessionID string `json:"session_id"` + TranscriptPath string `json:"transcript_path"` + Cwd string `json:"cwd"` + HookEventName string `json:"hook_event_name"` + Timestamp string `json:"timestamp"` + Prompt string `json:"prompt,omitempty"` // User's prompt (BeforeAgent only) +} + +// toolHookInputRaw is the JSON structure from BeforeTool/AfterTool hooks +type toolHookInputRaw struct { + SessionID string `json:"session_id"` + TranscriptPath string `json:"transcript_path"` + Cwd string `json:"cwd"` + HookEventName string `json:"hook_event_name"` + Timestamp string `json:"timestamp"` + ToolName string `json:"tool_name"` + ToolInput json.RawMessage `json:"tool_input"` + ToolResponse json.RawMessage `json:"tool_response,omitempty"` // Only for AfterTool +} + +// Tool names used in Gemini CLI that modify files +const ( + ToolWriteFile = "write_file" + ToolEditFile = "edit_file" + ToolSaveFile = "save_file" +) + +// FileModificationTools lists tools that create or modify files in Gemini CLI +var FileModificationTools = []string{ + ToolWriteFile, + ToolEditFile, + ToolSaveFile, +} diff --git a/cmd/entire/cli/agent/registry.go b/cmd/entire/cli/agent/registry.go index 897700ce0..75922eaa8 100644 --- a/cmd/entire/cli/agent/registry.go +++ b/cmd/entire/cli/agent/registry.go @@ -78,6 +78,28 @@ const ( // DefaultAgentName is the default when none specified const DefaultAgentName = AgentNameClaudeCode +// AgentTypeToRegistryName maps human-readable agent type names (as stored in session state) +// to their registry names. Used to look up the correct agent when showing resume commands. +var AgentTypeToRegistryName = map[string]string{ + "Claude Code": AgentNameClaudeCode, + "Gemini CLI": AgentNameGemini, + "Cursor": AgentNameCursor, + "Windsurf": AgentNameWindsurf, + "Aider": AgentNameAider, +} + +// GetByAgentType retrieves an agent by its human-readable type name (e.g., "Claude Code", "Gemini CLI"). +// This is used to get the correct agent for formatting resume commands based on session state. +// +//nolint:ireturn // Factory pattern requires returning the interface +func GetByAgentType(agentType string) (Agent, error) { + registryName, ok := AgentTypeToRegistryName[agentType] + if !ok { + return nil, fmt.Errorf("unknown agent type: %s", agentType) + } + return Get(registryName) +} + // Default returns the default agent. // Returns nil if the default agent is not registered. // diff --git a/cmd/entire/cli/agent/types.go b/cmd/entire/cli/agent/types.go index 9c7f30626..3075bce31 100644 --- a/cmd/entire/cli/agent/types.go +++ b/cmd/entire/cli/agent/types.go @@ -21,6 +21,9 @@ type HookInput struct { SessionRef string Timestamp time.Time + // UserPrompt is the user's prompt text (from UserPromptSubmit hooks) + UserPrompt string + // Tool-specific fields (PreToolUse/PostToolUse) ToolName string ToolUseID string diff --git a/cmd/entire/cli/hook_registry.go b/cmd/entire/cli/hook_registry.go index 25c0a70e9..0c04c3bd1 100644 --- a/cmd/entire/cli/hook_registry.go +++ b/cmd/entire/cli/hook_registry.go @@ -8,6 +8,7 @@ import ( "entire.io/cli/cmd/entire/cli/agent" "entire.io/cli/cmd/entire/cli/agent/claudecode" + "entire.io/cli/cmd/entire/cli/agent/geminicli" "entire.io/cli/cmd/entire/cli/logging" "entire.io/cli/cmd/entire/cli/paths" @@ -91,6 +92,95 @@ func init() { } return handlePostTodo() }) + + // Register Gemini CLI handlers + RegisterHookHandler(agent.AgentNameGemini, geminicli.HookNameSessionStart, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleGeminiSessionStart() + }) + + RegisterHookHandler(agent.AgentNameGemini, geminicli.HookNameSessionEnd, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleGeminiSessionEnd() + }) + + RegisterHookHandler(agent.AgentNameGemini, geminicli.HookNameBeforeTool, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleGeminiBeforeTool() + }) + + RegisterHookHandler(agent.AgentNameGemini, geminicli.HookNameAfterTool, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleGeminiAfterTool() + }) + + RegisterHookHandler(agent.AgentNameGemini, geminicli.HookNameBeforeAgent, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleGeminiBeforeAgent() + }) + + RegisterHookHandler(agent.AgentNameGemini, geminicli.HookNameAfterAgent, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleGeminiAfterAgent() + }) + + RegisterHookHandler(agent.AgentNameGemini, geminicli.HookNameBeforeModel, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleGeminiBeforeModel() + }) + + RegisterHookHandler(agent.AgentNameGemini, geminicli.HookNameAfterModel, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleGeminiAfterModel() + }) + + RegisterHookHandler(agent.AgentNameGemini, geminicli.HookNameBeforeToolSelection, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleGeminiBeforeToolSelection() + }) + + RegisterHookHandler(agent.AgentNameGemini, geminicli.HookNamePreCompress, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleGeminiPreCompress() + }) + + RegisterHookHandler(agent.AgentNameGemini, geminicli.HookNameNotification, func() error { + enabled, err := IsEnabled() + if err == nil && !enabled { + return nil + } + return handleGeminiNotification() + }) } // agentHookLogCleanup stores the cleanup function for agent hook logging. @@ -125,11 +215,14 @@ func newAgentHooksCmd(agentName string, handler agent.HookHandler) *cobra.Comman // getHookType returns the hook type based on the hook name. // Returns "subagent" for task-related hooks (pre-task, post-task, post-todo), +// "tool" for tool-related hooks (before-tool, after-tool), // "agent" for all other agent hooks. func getHookType(hookName string) string { switch hookName { case claudecode.HookNamePreTask, claudecode.HookNamePostTask, claudecode.HookNamePostTodo: return "subagent" + case geminicli.HookNameBeforeTool, geminicli.HookNameAfterTool: + return "tool" default: return "agent" } diff --git a/cmd/entire/cli/hooks_claudecode_handlers.go b/cmd/entire/cli/hooks_claudecode_handlers.go index f021044f5..033a401b8 100644 --- a/cmd/entire/cli/hooks_claudecode_handlers.go +++ b/cmd/entire/cli/hooks_claudecode_handlers.go @@ -158,8 +158,20 @@ func checkConcurrentSessions(ag agent.Agent, entireSessionID string) (bool, erro fmt.Fprintf(os.Stderr, "Warning: failed to save session state: %v\n", saveErr) } - // Get resume command for the other session - resumeCmd := ag.FormatResumeCommand(ag.ExtractAgentSessionID(otherSession.SessionID)) + // Get resume command for the other session using the CONFLICTING session's agent type. + // If the conflicting session is from a different agent (e.g., Gemini when we're Claude), + // use that agent's resume command format. Otherwise, use our own format (backward compatible). + var resumeCmd string + if otherSession.AgentType != "" && otherSession.AgentType != agentType { + // Different agent type - look up the conflicting agent + if conflictingAgent, agentErr := agent.GetByAgentType(otherSession.AgentType); agentErr == nil { + resumeCmd = conflictingAgent.FormatResumeCommand(conflictingAgent.ExtractAgentSessionID(otherSession.SessionID)) + } + } + // Fall back to current agent if same type or couldn't get the conflicting agent + if resumeCmd == "" { + resumeCmd = ag.FormatResumeCommand(ag.ExtractAgentSessionID(otherSession.SessionID)) + } // Output blocking JSON response if err := outputHookResponse(false, "You have another active session with uncommitted changes. Please commit them first and then start a new Claude session. If you continue here, your prompt and resulting changes will not be captured.\n\nTo resume the active session, close Claude Code and run: "+resumeCmd); err != nil { @@ -201,8 +213,19 @@ func handleSessionInitErrors(ag agent.Agent, initErr error) error { // Check for session ID conflict error (shadow branch has different session) var sessionConflictErr *strategy.SessionIDConflictError if errors.As(initErr, &sessionConflictErr) { - // Get agent's resume command format - resumeCmd := ag.FormatResumeCommand(ag.ExtractAgentSessionID(sessionConflictErr.ExistingSession)) + // Try to get the conflicting session's agent type from its state file + // If it's a different agent type, use that agent's resume command format + var resumeCmd string + existingState, loadErr := strategy.LoadSessionState(sessionConflictErr.ExistingSession) + if loadErr == nil && existingState != nil && existingState.AgentType != "" { + if conflictingAgent, agentErr := agent.GetByAgentType(existingState.AgentType); agentErr == nil { + resumeCmd = conflictingAgent.FormatResumeCommand(conflictingAgent.ExtractAgentSessionID(sessionConflictErr.ExistingSession)) + } + } + // Fall back to current agent if we couldn't get the conflicting agent + if resumeCmd == "" { + resumeCmd = ag.FormatResumeCommand(ag.ExtractAgentSessionID(sessionConflictErr.ExistingSession)) + } fmt.Fprintf(os.Stderr, "\n"+ "Warning: Session ID conflict detected!\n\n"+ " Shadow branch: %s\n"+ diff --git a/cmd/entire/cli/hooks_cmd.go b/cmd/entire/cli/hooks_cmd.go index d6f843de3..0d255de1a 100644 --- a/cmd/entire/cli/hooks_cmd.go +++ b/cmd/entire/cli/hooks_cmd.go @@ -2,8 +2,9 @@ package cli import ( "entire.io/cli/cmd/entire/cli/agent" - // Import claudecode to ensure agent is registered before we iterate + // Import agents to ensure they are registered before we iterate _ "entire.io/cli/cmd/entire/cli/agent/claudecode" + _ "entire.io/cli/cmd/entire/cli/agent/geminicli" "github.com/spf13/cobra" ) diff --git a/cmd/entire/cli/hooks_geminicli_handlers.go b/cmd/entire/cli/hooks_geminicli_handlers.go new file mode 100644 index 000000000..7df6bee55 --- /dev/null +++ b/cmd/entire/cli/hooks_geminicli_handlers.go @@ -0,0 +1,844 @@ +// hooks_geminicli_handlers.go contains Gemini CLI specific hook handler implementations. +// These are called by the hook registry in hook_registry.go. +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "time" + + "entire.io/cli/cmd/entire/cli/agent" + "entire.io/cli/cmd/entire/cli/agent/geminicli" + "entire.io/cli/cmd/entire/cli/logging" + "entire.io/cli/cmd/entire/cli/paths" + "entire.io/cli/cmd/entire/cli/strategy" +) + +// geminiBlockingResponse represents a JSON response for Gemini CLI hooks. +// When decision is "block", Gemini CLI will block the current operation and show the reason to the user. +type geminiBlockingResponse struct { + Decision string `json:"decision"` + Reason string `json:"reason"` + SystemMessage string `json:"systemMessage,omitempty"` +} + +// outputGeminiBlockingResponse outputs a blocking JSON response to stdout for Gemini CLI hooks +// and exits with code 0. For BeforeAgent hooks, the JSON response with decision "block" tells +// Gemini CLI to block the operation - exit code 0 is required for the JSON to be parsed. +// This function does not return - it calls os.Exit(0) after outputting the response. +func outputGeminiBlockingResponse(reason string) { + resp := geminiBlockingResponse{ + Decision: "block", + Reason: reason, + SystemMessage: "⚠️ Session blocked: " + reason, + } + // Output to stdout (Gemini reads hook output from stdout with exit code 0) + if err := json.NewEncoder(os.Stdout).Encode(resp); err != nil { + fmt.Fprintf(os.Stderr, "Error encoding blocking response: %v\n", err) + } + os.Exit(0) +} + +// checkConcurrentSessionsGemini checks for concurrent session conflicts for Gemini CLI. +// If a conflict is found, it outputs a Gemini-format blocking response and exits (via os.Exit). +// Returns true if the hook should be skipped (warning already shown), false to proceed normally. +// Note: This function may call os.Exit(0) and not return if a blocking response is needed. +func checkConcurrentSessionsGemini(entireSessionID string) bool { + // Always use the Gemini agent for resume commands in Gemini hooks + // (don't use GetAgent() which may return Claude based on settings) + geminiAgent, err := agent.Get("gemini") + if err != nil { + // Fall back to default if Gemini agent not found (shouldn't happen) + geminiAgent = agent.Default() + } + strat := GetStrategy() + + concurrentChecker, ok := strat.(strategy.ConcurrentSessionChecker) + if !ok { + return false // Strategy doesn't support concurrent checks + } + + // Check if this session already acknowledged the warning + existingState, loadErr := strategy.LoadSessionState(entireSessionID) + warningAlreadyShown := loadErr == nil && existingState != nil && existingState.ConcurrentWarningShown + + // Check for other active sessions with checkpoints (on current HEAD) + otherSession, checkErr := concurrentChecker.HasOtherActiveSessionsWithCheckpoints(entireSessionID) + hasConflict := checkErr == nil && otherSession != nil + + if warningAlreadyShown { + if hasConflict { + // Warning was shown and conflict still exists - skip hooks + return true + } + // Warning was shown but conflict is resolved (e.g., user committed) + // Clear the flag and proceed normally + if existingState != nil { + existingState.ConcurrentWarningShown = false + if saveErr := strategy.SaveSessionState(existingState); saveErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to clear concurrent warning flag: %v\n", saveErr) + } + } + return false + } + + if hasConflict { + // First time seeing conflict - show warning + // Include BaseCommit and WorktreePath so session state is complete if conflict later resolves + repo, err := strategy.OpenRepository() + if err != nil { + // Output user-friendly error message via blocking response + outputGeminiBlockingResponse(fmt.Sprintf("Failed to open git repository: %v\n\nPlease ensure you're in a git repository and try again.", err)) + // outputGeminiBlockingResponse calls os.Exit(0), so this line is never reached + return true + } + head, err := repo.Head() + if err != nil { + // Output user-friendly error message via blocking response + outputGeminiBlockingResponse(fmt.Sprintf("Failed to get git HEAD: %v\n\nPlease ensure the repository has at least one commit.", err)) + // outputGeminiBlockingResponse calls os.Exit(0), so this line is never reached + return true + } + worktreePath, err := strategy.GetWorktreePath() + if err != nil { + // Non-fatal: proceed without worktree path + worktreePath = "" + } + + // Derive agent type from agent description (e.g., "Gemini CLI" from "Gemini CLI - ...") + agentType := geminiAgent.Description() + if idx := strings.Index(agentType, " - "); idx > 0 { + agentType = agentType[:idx] + } + newState := &strategy.SessionState{ + SessionID: entireSessionID, + BaseCommit: head.Hash().String(), + WorktreePath: worktreePath, + ConcurrentWarningShown: true, + StartedAt: time.Now(), + AgentType: agentType, + } + if saveErr := strategy.SaveSessionState(newState); saveErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to save session state: %v\n", saveErr) + } + + // Get resume command for the other session using the CONFLICTING session's agent type. + // If the conflicting session is from a different agent (e.g., Claude when we're Gemini), + // use that agent's resume command format. Otherwise, use our own format. + var resumeCmd string + if otherSession.AgentType != "" && otherSession.AgentType != agentType { + // Different agent type - look up the conflicting agent + if conflictingAgent, agentErr := agent.GetByAgentType(otherSession.AgentType); agentErr == nil { + resumeCmd = conflictingAgent.FormatResumeCommand(conflictingAgent.ExtractAgentSessionID(otherSession.SessionID)) + } + } + // Fall back to Gemini agent if same type or couldn't get the conflicting agent + if resumeCmd == "" { + resumeCmd = geminiAgent.FormatResumeCommand(geminiAgent.ExtractAgentSessionID(otherSession.SessionID)) + } + + // Output blocking JSON response and exit + // Message format matches Claude Code but with Gemini-specific instructions + outputGeminiBlockingResponse("You have another active session with uncommitted changes. Please commit them first and then start a new Gemini session. If you continue here, your prompt and resulting changes will not be captured.\n\nTo resume the active session, close Gemini CLI and run: " + resumeCmd) + // outputGeminiBlockingResponse calls os.Exit(0), so this line is never reached + return true + } + + return false +} + +// handleGeminiSessionStart handles the SessionStart hook for Gemini CLI. +// It reads session info from stdin and sets it as the current session. +func handleGeminiSessionStart() error { + // Get the agent for session ID transformation + ag, err := GetAgent() + if err != nil { + return fmt.Errorf("failed to get agent: %w", err) + } + + // Parse hook input using agent interface + input, err := ag.ParseHookInput(agent.HookSessionStart, os.Stdin) + if err != nil { + return fmt.Errorf("failed to parse hook input: %w", err) + } + + logCtx := logging.WithComponent(context.Background(), "hooks") + logging.Info(logCtx, "gemini-session-start", + slog.String("hook", "session-start"), + slog.String("hook_type", "agent"), + slog.String("model_session_id", input.SessionID), + slog.String("transcript_path", input.SessionRef), + ) + + if input.SessionID == "" { + return errors.New("no session_id in input") + } + + // Generate the full Entire session ID (with date prefix) from the agent's session ID + entireSessionID := paths.EntireSessionID(input.SessionID) + + // Write session ID to current_session file + if err := paths.WriteCurrentSession(entireSessionID); err != nil { + return fmt.Errorf("failed to set current session: %w", err) + } + + fmt.Printf("Current session set to: %s\n", entireSessionID) + return nil +} + +// handleGeminiSessionEnd handles the SessionEnd hook for Gemini CLI. +// This is equivalent to Claude Code's "stop" hook - it commits the session changes with metadata. +// Uses Gemini-specific transcript parsing since Gemini uses JSON format (not JSONL like Claude). +func handleGeminiSessionEnd() error { + return commitWithMetadataGemini() +} + +// geminiSessionContext holds parsed session data for Gemini commits. +type geminiSessionContext struct { + entireSessionID string + modelSessionID string + transcriptPath string + sessionDir string + sessionDirAbs string + transcriptData []byte + allPrompts []string + summary string + modifiedFiles []string + commitMessage string +} + +// parseGeminiSessionEnd parses the session-end hook input and validates transcript. +func parseGeminiSessionEnd() (*geminiSessionContext, error) { + ag, err := GetAgent() + if err != nil { + return nil, fmt.Errorf("failed to get agent: %w", err) + } + + input, err := ag.ParseHookInput(agent.HookStop, os.Stdin) + if err != nil { + return nil, fmt.Errorf("failed to parse hook input: %w", err) + } + + logCtx := logging.WithComponent(context.Background(), "hooks") + logging.Info(logCtx, "stop", + slog.String("hook", "stop"), + slog.String("hook_type", "agent"), + slog.String("model_session_id", input.SessionID), + slog.String("transcript_path", input.SessionRef), + ) + + modelSessionID := input.SessionID + if modelSessionID == "" { + modelSessionID = "unknown" + } + + entireSessionID := currentSessionIDWithFallback(modelSessionID) + + if shouldSkipHooksForWarnedSession(entireSessionID) { + return nil, nil // Signal to skip + } + + transcriptPath := input.SessionRef + if transcriptPath == "" || !fileExists(transcriptPath) { + return nil, fmt.Errorf("transcript file not found or empty: %s", transcriptPath) + } + + return &geminiSessionContext{ + entireSessionID: entireSessionID, + modelSessionID: modelSessionID, + transcriptPath: transcriptPath, + }, nil +} + +// setupGeminiSessionDir creates session directory and copies transcript. +func setupGeminiSessionDir(ctx *geminiSessionContext) error { + ctx.sessionDir = paths.SessionMetadataDir(ctx.modelSessionID) + sessionDirAbs, err := paths.AbsPath(ctx.sessionDir) + if err != nil { + sessionDirAbs = ctx.sessionDir + } + ctx.sessionDirAbs = sessionDirAbs + + if err := os.MkdirAll(sessionDirAbs, 0o750); err != nil { + return fmt.Errorf("failed to create session directory: %w", err) + } + + logFile := filepath.Join(sessionDirAbs, paths.TranscriptFileName) + if err := copyFile(ctx.transcriptPath, logFile); err != nil { + return fmt.Errorf("failed to copy transcript: %w", err) + } + fmt.Fprintf(os.Stderr, "Copied transcript to: %s\n", ctx.sessionDir+"/"+paths.TranscriptFileName) + + transcriptData, err := os.ReadFile(ctx.transcriptPath) + if err != nil { + return fmt.Errorf("failed to read transcript: %w", err) + } + ctx.transcriptData = transcriptData + + return nil +} + +// extractGeminiMetadata extracts prompts, summary, and modified files from transcript. +func extractGeminiMetadata(ctx *geminiSessionContext) error { + allPrompts, err := geminicli.ExtractAllUserPrompts(ctx.transcriptData) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to extract prompts: %v\n", err) + } + ctx.allPrompts = allPrompts + + promptFile := filepath.Join(ctx.sessionDirAbs, paths.PromptFileName) + promptContent := strings.Join(allPrompts, "\n\n---\n\n") + if err := os.WriteFile(promptFile, []byte(promptContent), 0o600); err != nil { + return fmt.Errorf("failed to write prompt file: %w", err) + } + fmt.Fprintf(os.Stderr, "Extracted %d prompt(s) to: %s\n", len(allPrompts), ctx.sessionDir+"/"+paths.PromptFileName) + + summary, err := geminicli.ExtractLastAssistantMessage(ctx.transcriptData) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to extract summary: %v\n", err) + } + ctx.summary = summary + + summaryFile := filepath.Join(ctx.sessionDirAbs, paths.SummaryFileName) + if err := os.WriteFile(summaryFile, []byte(summary), 0o600); err != nil { + return fmt.Errorf("failed to write summary file: %w", err) + } + fmt.Fprintf(os.Stderr, "Extracted summary to: %s\n", ctx.sessionDir+"/"+paths.SummaryFileName) + + modifiedFiles, err := geminicli.ExtractModifiedFiles(ctx.transcriptData) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to extract modified files: %v\n", err) + } + ctx.modifiedFiles = modifiedFiles + + lastPrompt := "" + if len(allPrompts) > 0 { + lastPrompt = allPrompts[len(allPrompts)-1] + } + ctx.commitMessage = generateCommitMessage(lastPrompt) + fmt.Fprintf(os.Stderr, "Using commit message: %s\n", ctx.commitMessage) + + return nil +} + +// commitGeminiSession commits the session changes using the strategy. +func commitGeminiSession(ctx *geminiSessionContext) error { + repoRoot, err := paths.RepoRoot() + if err != nil { + return fmt.Errorf("failed to get repo root: %w", err) + } + + preState, err := LoadPrePromptState(ctx.entireSessionID) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to load pre-prompt state: %v\n", err) + } + if preState != nil { + fmt.Fprintf(os.Stderr, "Loaded pre-prompt state: %d pre-existing untracked files\n", len(preState.UntrackedFiles)) + } + + newFiles, err := ComputeNewFiles(preState) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to compute new files: %v\n", err) + } + + deletedFiles, err := ComputeDeletedFiles() + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to compute deleted files: %v\n", err) + } + + relModifiedFiles := FilterAndNormalizePaths(ctx.modifiedFiles, repoRoot) + relNewFiles := FilterAndNormalizePaths(newFiles, repoRoot) + relDeletedFiles := FilterAndNormalizePaths(deletedFiles, repoRoot) + + totalChanges := len(relModifiedFiles) + len(relNewFiles) + len(relDeletedFiles) + if totalChanges == 0 { + fmt.Fprintf(os.Stderr, "No files were modified during this session\n") + fmt.Fprintf(os.Stderr, "Skipping commit\n") + if cleanupErr := CleanupPrePromptState(ctx.entireSessionID); cleanupErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to cleanup pre-prompt state: %v\n", cleanupErr) + } + return nil + } + + logFileChanges(relModifiedFiles, relNewFiles, relDeletedFiles) + + contextFile := filepath.Join(ctx.sessionDirAbs, paths.ContextFileName) + if err := createContextFileForGemini(contextFile, ctx.commitMessage, ctx.entireSessionID, ctx.allPrompts, ctx.summary); err != nil { + return fmt.Errorf("failed to create context file: %w", err) + } + fmt.Fprintf(os.Stderr, "Created context file: %s\n", ctx.sessionDir+"/"+paths.ContextFileName) + + author, err := GetGitAuthor() + if err != nil { + return fmt.Errorf("failed to get git author: %w", err) + } + + strat := GetStrategy() + if err := strat.EnsureSetup(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to ensure strategy setup: %v\n", err) + } + + saveCtx := strategy.SaveContext{ + SessionID: ctx.entireSessionID, + ModifiedFiles: relModifiedFiles, + NewFiles: relNewFiles, + DeletedFiles: relDeletedFiles, + MetadataDir: ctx.sessionDir, + MetadataDirAbs: ctx.sessionDirAbs, + CommitMessage: ctx.commitMessage, + TranscriptPath: ctx.transcriptPath, + AuthorName: author.Name, + AuthorEmail: author.Email, + } + + if err := strat.SaveChanges(saveCtx); err != nil { + return fmt.Errorf("failed to save session: %w", err) + } + + if cleanupErr := CleanupPrePromptState(ctx.entireSessionID); cleanupErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to cleanup pre-prompt state: %v\n", cleanupErr) + } + + fmt.Fprintf(os.Stderr, "Session saved successfully\n") + return nil +} + +// logFileChanges logs the modified, new, and deleted files to stderr. +func logFileChanges(modified, newFiles, deleted []string) { + fmt.Fprintf(os.Stderr, "Files modified during session (%d):\n", len(modified)) + for _, file := range modified { + fmt.Fprintf(os.Stderr, " - %s\n", file) + } + if len(newFiles) > 0 { + fmt.Fprintf(os.Stderr, "New files created (%d):\n", len(newFiles)) + for _, file := range newFiles { + fmt.Fprintf(os.Stderr, " - %s\n", file) + } + } + if len(deleted) > 0 { + fmt.Fprintf(os.Stderr, "Files deleted (%d):\n", len(deleted)) + for _, file := range deleted { + fmt.Fprintf(os.Stderr, " - %s\n", file) + } + } +} + +// commitWithMetadataGemini commits the Gemini session changes with metadata. +// This is a Gemini-specific version that parses JSON transcripts correctly. +func commitWithMetadataGemini() error { + if skip, branchName := ShouldSkipOnDefaultBranchForStrategy(); skip { + fmt.Fprintf(os.Stderr, "Entire: skipping on branch '%s' - create a feature branch to use Entire tracking\n", branchName) + return nil + } + + ctx, err := parseGeminiSessionEnd() + if err != nil { + return err + } + if ctx == nil { + return nil // Skip signaled + } + + if err := setupGeminiSessionDir(ctx); err != nil { + return err + } + + if err := extractGeminiMetadata(ctx); err != nil { + return err + } + + return commitGeminiSession(ctx) +} + +// createContextFileForGemini creates a context.md file for Gemini sessions. +func createContextFileForGemini(contextFile, commitMessage, sessionID string, prompts []string, summary string) error { + var sb strings.Builder + + sb.WriteString("# Session Context\n\n") + sb.WriteString(fmt.Sprintf("Session ID: %s\n", sessionID)) + sb.WriteString(fmt.Sprintf("Commit Message: %s\n\n", commitMessage)) + + if len(prompts) > 0 { + sb.WriteString("## Prompts\n\n") + for i, p := range prompts { + sb.WriteString(fmt.Sprintf("### Prompt %d\n\n%s\n\n", i+1, p)) + } + } + + if summary != "" { + sb.WriteString("## Summary\n\n") + sb.WriteString(summary) + sb.WriteString("\n") + } + + return os.WriteFile(contextFile, []byte(sb.String()), 0o600) +} + +// handleGeminiBeforeTool handles the BeforeTool hook for Gemini CLI. +// This is similar to Claude Code's PreToolUse hook but applies to all tools. +func handleGeminiBeforeTool() error { + // Get the agent for hook input parsing + ag, err := GetAgent() + if err != nil { + return fmt.Errorf("failed to get agent: %w", err) + } + + // Parse hook input + input, err := ag.ParseHookInput(agent.HookPreToolUse, os.Stdin) + if err != nil { + return fmt.Errorf("failed to parse hook input: %w", err) + } + + logCtx := logging.WithComponent(context.Background(), "hooks") + logging.Debug(logCtx, "gemini-before-tool", + slog.String("hook", "before-tool"), + slog.String("hook_type", "tool"), + slog.String("model_session_id", input.SessionID), + slog.String("tool_name", input.ToolName), + ) + + // For now, BeforeTool is mainly for logging and potential future use + // We don't need to do anything special before tool execution + return nil +} + +// handleGeminiAfterTool handles the AfterTool hook for Gemini CLI. +// This is similar to Claude Code's PostToolUse hook but applies to all tools. +func handleGeminiAfterTool() error { + // Get the agent for hook input parsing + ag, err := GetAgent() + if err != nil { + return fmt.Errorf("failed to get agent: %w", err) + } + + // Parse hook input + input, err := ag.ParseHookInput(agent.HookPostToolUse, os.Stdin) + if err != nil { + return fmt.Errorf("failed to parse hook input: %w", err) + } + + logCtx := logging.WithComponent(context.Background(), "hooks") + logging.Debug(logCtx, "gemini-after-tool", + slog.String("hook", "after-tool"), + slog.String("hook_type", "tool"), + slog.String("model_session_id", input.SessionID), + slog.String("tool_name", input.ToolName), + ) + + // For now, AfterTool is mainly for logging + // Future: Could be used for incremental checkpoints similar to Claude's PostTodo + return nil +} + +// handleGeminiBeforeAgent handles the BeforeAgent hook for Gemini CLI. +// This is equivalent to Claude Code's UserPromptSubmit - it fires when the user submits a prompt. +// We capture the initial state here so we can track what files were modified during the session. +// It also checks for concurrent sessions and blocks if another session has uncommitted changes. +func handleGeminiBeforeAgent() error { + // Always use the Gemini agent for Gemini hooks (don't use GetAgent() which may + // return Claude based on auto-detection in environments like VSCode) + ag, err := agent.Get("gemini") + if err != nil { + return fmt.Errorf("failed to get gemini agent: %w", err) + } + + // Parse hook input - BeforeAgent provides user prompt info similar to UserPromptSubmit + input, err := ag.ParseHookInput(agent.HookUserPromptSubmit, os.Stdin) + if err != nil { + return fmt.Errorf("failed to parse hook input: %w", err) + } + + logCtx := logging.WithComponent(context.Background(), "hooks") + + // Log with prompt if available (Gemini provides the user's prompt in BeforeAgent) + logArgs := []any{ + slog.String("hook", "before-agent"), + slog.String("hook_type", "agent"), + slog.String("model_session_id", input.SessionID), + slog.String("transcript_path", input.SessionRef), + } + if input.UserPrompt != "" { + // Truncate long prompts for logging + promptPreview := input.UserPrompt + if len(promptPreview) > 100 { + promptPreview = promptPreview[:100] + "..." + } + logArgs = append(logArgs, slog.String("prompt_preview", promptPreview)) + } + logging.Info(logCtx, "gemini-before-agent", logArgs...) + + if input.SessionID == "" { + return errors.New("no session_id in input") + } + + // Get the Entire session ID + entireSessionID := paths.EntireSessionID(input.SessionID) + + // Check for concurrent sessions before proceeding + // This will output a blocking response and exit if there's a conflict + if checkConcurrentSessionsGemini(entireSessionID) { + return nil + } + + // Capture pre-prompt state (same as Claude Code's captureInitialState) + if err := CapturePrePromptState(entireSessionID); err != nil { + return fmt.Errorf("failed to capture pre-prompt state: %w", err) + } + + // If strategy implements SessionInitializer, call it to initialize session state + strat := GetStrategy() + if initializer, ok := strat.(strategy.SessionInitializer); ok { + // Use agent description, but trim to just the name part (before " - ") + agentType := ag.Description() + if idx := strings.Index(agentType, " - "); idx > 0 { + agentType = agentType[:idx] + } + if initErr := initializer.InitializeSession(entireSessionID, agentType); initErr != nil { + if handleErr := handleGeminiSessionInitErrors(ag, initErr); handleErr != nil { + return handleErr + } + } + } + + return nil +} + +// handleGeminiSessionInitErrors handles session initialization errors for Gemini CLI. +// Provides user-friendly error messages for common error cases. +func handleGeminiSessionInitErrors(ag agent.Agent, initErr error) error { + // Check for shadow branch conflict error (worktree conflict) + var conflictErr *strategy.ShadowBranchConflictError + if errors.As(initErr, &conflictErr) { + fmt.Fprintf(os.Stderr, "\n"+ + "Warning: Shadow branch conflict detected!\n\n"+ + " Branch: %s\n"+ + " Existing session: %s\n"+ + " From worktree: %s\n"+ + " Started: %s\n\n"+ + " This may indicate another agent session is active from a different worktree,\n"+ + " or a previous session wasn't completed.\n\n"+ + " Options:\n"+ + " 1. Commit your changes (git commit) to create a new base commit\n"+ + " 2. Run 'entire rewind reset' to discard the shadow branch and start fresh\n"+ + " 3. Continue the previous session from the original worktree: %s\n\n", + conflictErr.Branch, + conflictErr.ExistingSession, + conflictErr.ExistingWorktree, + conflictErr.LastActivity.Format(time.RFC822), + conflictErr.ExistingWorktree, + ) + return fmt.Errorf("shadow branch conflict: %w", initErr) + } + + // Check for session ID conflict error (shadow branch has different session) + var sessionConflictErr *strategy.SessionIDConflictError + if errors.As(initErr, &sessionConflictErr) { + // Try to get the conflicting session's agent type from its state file + // If it's a different agent type, use that agent's resume command format + var resumeCmd string + existingState, loadErr := strategy.LoadSessionState(sessionConflictErr.ExistingSession) + if loadErr == nil && existingState != nil && existingState.AgentType != "" { + if conflictingAgent, agentErr := agent.GetByAgentType(existingState.AgentType); agentErr == nil { + resumeCmd = conflictingAgent.FormatResumeCommand(conflictingAgent.ExtractAgentSessionID(sessionConflictErr.ExistingSession)) + } + } + // Fall back to current agent if we couldn't get the conflicting agent + if resumeCmd == "" { + resumeCmd = ag.FormatResumeCommand(ag.ExtractAgentSessionID(sessionConflictErr.ExistingSession)) + } + fmt.Fprintf(os.Stderr, "\n"+ + "Warning: Session ID conflict detected!\n\n"+ + " Shadow branch: %s\n"+ + " Existing session: %s\n"+ + " New session: %s\n\n"+ + " The shadow branch already has checkpoints from a different session.\n"+ + " Starting a new session would orphan the existing work.\n\n"+ + " Options:\n"+ + " 1. Commit your changes (git commit) to create a new base commit\n"+ + " 2. Run 'entire rewind reset' to discard the shadow branch and start fresh\n"+ + " 3. Resume the existing session: %s\n\n", + sessionConflictErr.ShadowBranch, + sessionConflictErr.ExistingSession, + sessionConflictErr.NewSession, + resumeCmd, + ) + return fmt.Errorf("session ID conflict: %w", initErr) + } + + // Unknown error type + fmt.Fprintf(os.Stderr, "Warning: failed to initialize session state: %v\n", initErr) + return nil +} + +// handleGeminiAfterAgent handles the AfterAgent hook for Gemini CLI. +// This fires after the agent has finished processing and generated a response. +// This is a Gemini-specific hook - Claude Code doesn't have an equivalent. +func handleGeminiAfterAgent() error { + // Get the agent for hook input parsing + ag, err := GetAgent() + if err != nil { + return fmt.Errorf("failed to get agent: %w", err) + } + + // Parse hook input - AfterAgent is similar to session/tool hooks + input, err := ag.ParseHookInput(agent.HookPostToolUse, os.Stdin) + if err != nil { + return fmt.Errorf("failed to parse hook input: %w", err) + } + + logCtx := logging.WithComponent(context.Background(), "hooks") + logging.Debug(logCtx, "gemini-after-agent", + slog.String("hook", "after-agent"), + slog.String("hook_type", "agent"), + slog.String("model_session_id", input.SessionID), + ) + + // For now, AfterAgent is mainly for logging + // Future: Could be used for streaming/incremental state capture + return nil +} + +// handleGeminiBeforeModel handles the BeforeModel hook for Gemini CLI. +// This fires before every LLM call (potentially multiple times per agent loop). +// Useful for logging/monitoring LLM requests. +func handleGeminiBeforeModel() error { + // Get the agent for hook input parsing + ag, err := GetAgent() + if err != nil { + return fmt.Errorf("failed to get agent: %w", err) + } + + // Parse hook input - use HookPreToolUse as a generic hook type for now + input, err := ag.ParseHookInput(agent.HookPreToolUse, os.Stdin) + if err != nil { + return fmt.Errorf("failed to parse hook input: %w", err) + } + + logCtx := logging.WithComponent(context.Background(), "hooks") + logging.Debug(logCtx, "gemini-before-model", + slog.String("hook", "before-model"), + slog.String("hook_type", "model"), + slog.String("model_session_id", input.SessionID), + ) + + // For now, BeforeModel is mainly for logging + // Future: Could be used for request interception/modification + return nil +} + +// handleGeminiAfterModel handles the AfterModel hook for Gemini CLI. +// This fires after every LLM response (potentially multiple times per agent loop). +// Useful for logging/monitoring LLM responses. +func handleGeminiAfterModel() error { + // Get the agent for hook input parsing + ag, err := GetAgent() + if err != nil { + return fmt.Errorf("failed to get agent: %w", err) + } + + // Parse hook input + input, err := ag.ParseHookInput(agent.HookPostToolUse, os.Stdin) + if err != nil { + return fmt.Errorf("failed to parse hook input: %w", err) + } + + logCtx := logging.WithComponent(context.Background(), "hooks") + logging.Debug(logCtx, "gemini-after-model", + slog.String("hook", "after-model"), + slog.String("hook_type", "model"), + slog.String("model_session_id", input.SessionID), + ) + + // For now, AfterModel is mainly for logging + // Future: Could be used for response processing/analysis + return nil +} + +// handleGeminiBeforeToolSelection handles the BeforeToolSelection hook for Gemini CLI. +// This fires before the planner runs to select which tools to use. +func handleGeminiBeforeToolSelection() error { + // Get the agent for hook input parsing + ag, err := GetAgent() + if err != nil { + return fmt.Errorf("failed to get agent: %w", err) + } + + // Parse hook input + input, err := ag.ParseHookInput(agent.HookPreToolUse, os.Stdin) + if err != nil { + return fmt.Errorf("failed to parse hook input: %w", err) + } + + logCtx := logging.WithComponent(context.Background(), "hooks") + logging.Debug(logCtx, "gemini-before-tool-selection", + slog.String("hook", "before-tool-selection"), + slog.String("hook_type", "model"), + slog.String("model_session_id", input.SessionID), + ) + + // For now, BeforeToolSelection is mainly for logging + // Future: Could be used to modify tool availability + return nil +} + +// handleGeminiPreCompress handles the PreCompress hook for Gemini CLI. +// This fires before chat history compression - useful for backing up transcript. +func handleGeminiPreCompress() error { + // Get the agent for hook input parsing + ag, err := GetAgent() + if err != nil { + return fmt.Errorf("failed to get agent: %w", err) + } + + // Parse hook input + input, err := ag.ParseHookInput(agent.HookSessionStart, os.Stdin) + if err != nil { + return fmt.Errorf("failed to parse hook input: %w", err) + } + + logCtx := logging.WithComponent(context.Background(), "hooks") + logging.Info(logCtx, "gemini-pre-compress", + slog.String("hook", "pre-compress"), + slog.String("hook_type", "session"), + slog.String("model_session_id", input.SessionID), + slog.String("transcript_path", input.SessionRef), + ) + + // PreCompress is important for ensuring we capture the transcript before compression + // The transcript_path gives us access to the full conversation before it's compressed + // Future: Could automatically backup/checkpoint the transcript here + return nil +} + +// handleGeminiNotification handles the Notification hook for Gemini CLI. +// This fires on notification events (errors, warnings, info). +func handleGeminiNotification() error { + // Get the agent for hook input parsing + ag, err := GetAgent() + if err != nil { + return fmt.Errorf("failed to get agent: %w", err) + } + + // Parse hook input + input, err := ag.ParseHookInput(agent.HookSessionStart, os.Stdin) + if err != nil { + return fmt.Errorf("failed to parse hook input: %w", err) + } + + logCtx := logging.WithComponent(context.Background(), "hooks") + logging.Debug(logCtx, "gemini-notification", + slog.String("hook", "notification"), + slog.String("hook_type", "notification"), + slog.String("model_session_id", input.SessionID), + ) + + // For now, Notification is mainly for logging + // Future: Could be used for error tracking/alerting + return nil +} diff --git a/cmd/entire/cli/integration_test/agent_test.go b/cmd/entire/cli/integration_test/agent_test.go index 157cdf61a..a83c84cae 100644 --- a/cmd/entire/cli/integration_test/agent_test.go +++ b/cmd/entire/cli/integration_test/agent_test.go @@ -10,6 +10,7 @@ import ( "entire.io/cli/cmd/entire/cli/agent" "entire.io/cli/cmd/entire/cli/agent/claudecode" + "entire.io/cli/cmd/entire/cli/agent/geminicli" ) // TestAgentDetection verifies agent detection and default behavior. @@ -438,3 +439,448 @@ func TestClaudeCodeHelperMethods(t *testing.T) { } }) } + +// TestGeminiCLIAgentDetection verifies Gemini CLI agent detection. +func TestGeminiCLIAgentDetection(t *testing.T) { + t.Parallel() + + t.Run("gemini agent is registered", func(t *testing.T) { + t.Parallel() + + agents := agent.List() + found := false + for _, name := range agents { + if name == "gemini" { + found = true + break + } + } + if !found { + t.Errorf("agent.List() = %v, want to contain 'gemini'", agents) + } + }) + + t.Run("gemini detects presence when .gemini exists", func(t *testing.T) { + t.Parallel() + env := NewTestEnv(t) + env.InitRepo() + + // Create .gemini/settings.json + geminiDir := filepath.Join(env.RepoDir, ".gemini") + if err := os.MkdirAll(geminiDir, 0o755); err != nil { + t.Fatalf("failed to create .gemini dir: %v", err) + } + settingsPath := filepath.Join(geminiDir, geminicli.GeminiSettingsFileName) + if err := os.WriteFile(settingsPath, []byte(`{"hooks":{}}`), 0o644); err != nil { + t.Fatalf("failed to write settings.json: %v", err) + } + + // Change to repo dir for detection + oldWd, _ := os.Getwd() + if err := os.Chdir(env.RepoDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + defer func() { _ = os.Chdir(oldWd) }() + + ag, err := agent.Get("gemini") + if err != nil { + t.Fatalf("Get(gemini) error = %v", err) + } + + present, err := ag.DetectPresence() + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if !present { + t.Error("DetectPresence() = false, want true when .gemini exists") + } + }) +} + +// TestGeminiCLIHookInstallation verifies hook installation via Gemini CLI agent interface. +// Note: These tests cannot run in parallel because they use os.Chdir which affects the entire process. +func TestGeminiCLIHookInstallation(t *testing.T) { + // Not parallel - tests use os.Chdir which is process-global + + t.Run("installs all required hooks", func(t *testing.T) { + // Not parallel - uses os.Chdir + env := NewTestEnv(t) + env.InitRepo() + + // Change to repo dir + oldWd, _ := os.Getwd() + if err := os.Chdir(env.RepoDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + defer func() { _ = os.Chdir(oldWd) }() + + ag, err := agent.Get("gemini") + if err != nil { + t.Fatalf("Get(gemini) error = %v", err) + } + + hookAgent, ok := ag.(agent.HookSupport) + if !ok { + t.Fatal("gemini agent does not implement HookSupport") + } + + count, err := hookAgent.InstallHooks(false, false) + if err != nil { + t.Fatalf("InstallHooks() error = %v", err) + } + + // Should install 12 hooks: SessionStart, SessionEnd (exit+logout), BeforeAgent, AfterAgent, + // BeforeModel, AfterModel, BeforeToolSelection, BeforeTool, AfterTool, PreCompress, Notification + if count != 12 { + t.Errorf("InstallHooks() count = %d, want 12", count) + } + + // Verify hooks are installed + if !hookAgent.AreHooksInstalled() { + t.Error("AreHooksInstalled() = false after InstallHooks()") + } + + // Verify settings.json was created + settingsPath := filepath.Join(env.RepoDir, ".gemini", geminicli.GeminiSettingsFileName) + if _, err := os.Stat(settingsPath); os.IsNotExist(err) { + t.Error("settings.json was not created") + } + + // Verify hooks structure in settings.json + data, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("failed to read settings.json: %v", err) + } + content := string(data) + + // Verify all hook types are present + if !strings.Contains(content, "SessionStart") { + t.Error("settings.json should contain SessionStart hook") + } + if !strings.Contains(content, "SessionEnd") { + t.Error("settings.json should contain SessionEnd hook") + } + if !strings.Contains(content, "BeforeAgent") { + t.Error("settings.json should contain BeforeAgent hook") + } + if !strings.Contains(content, "AfterAgent") { + t.Error("settings.json should contain AfterAgent hook") + } + if !strings.Contains(content, "BeforeModel") { + t.Error("settings.json should contain BeforeModel hook") + } + if !strings.Contains(content, "AfterModel") { + t.Error("settings.json should contain AfterModel hook") + } + if !strings.Contains(content, "BeforeToolSelection") { + t.Error("settings.json should contain BeforeToolSelection hook") + } + if !strings.Contains(content, "BeforeTool") { + t.Error("settings.json should contain BeforeTool hook") + } + if !strings.Contains(content, "AfterTool") { + t.Error("settings.json should contain AfterTool hook") + } + if !strings.Contains(content, "PreCompress") { + t.Error("settings.json should contain PreCompress hook") + } + if !strings.Contains(content, "Notification") { + t.Error("settings.json should contain Notification hook") + } + + // Verify enableHooks is set + if !strings.Contains(content, "enableHooks") { + t.Error("settings.json should contain tools.enableHooks") + } + }) + + t.Run("idempotent - second install returns 0", func(t *testing.T) { + // Not parallel - uses os.Chdir + env := NewTestEnv(t) + env.InitRepo() + + oldWd, _ := os.Getwd() + if err := os.Chdir(env.RepoDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + defer func() { _ = os.Chdir(oldWd) }() + + ag, _ := agent.Get("gemini") + hookAgent := ag.(agent.HookSupport) + + // First install + _, err := hookAgent.InstallHooks(false, false) + if err != nil { + t.Fatalf("first InstallHooks() error = %v", err) + } + + // Second install should be idempotent + count, err := hookAgent.InstallHooks(false, false) + if err != nil { + t.Fatalf("second InstallHooks() error = %v", err) + } + if count != 0 { + t.Errorf("second InstallHooks() count = %d, want 0 (idempotent)", count) + } + }) + + t.Run("localDev mode uses go run", func(t *testing.T) { + // Not parallel - uses os.Chdir + env := NewTestEnv(t) + env.InitRepo() + + oldWd, _ := os.Getwd() + if err := os.Chdir(env.RepoDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + defer func() { _ = os.Chdir(oldWd) }() + + ag, _ := agent.Get("gemini") + hookAgent := ag.(agent.HookSupport) + + _, err := hookAgent.InstallHooks(true, false) // localDev = true + if err != nil { + t.Fatalf("InstallHooks(localDev=true) error = %v", err) + } + + // Read settings and verify commands use "go run" + settingsPath := filepath.Join(env.RepoDir, ".gemini", geminicli.GeminiSettingsFileName) + data, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("failed to read settings.json: %v", err) + } + + content := string(data) + if !strings.Contains(content, "go run") { + t.Error("localDev hooks should use 'go run', but settings.json doesn't contain it") + } + if !strings.Contains(content, "${GEMINI_PROJECT_DIR}") { + t.Error("localDev hooks should use '${GEMINI_PROJECT_DIR}', but settings.json doesn't contain it") + } + }) + + t.Run("production mode uses entire binary", func(t *testing.T) { + // Not parallel - uses os.Chdir + env := NewTestEnv(t) + env.InitRepo() + + oldWd, _ := os.Getwd() + if err := os.Chdir(env.RepoDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + defer func() { _ = os.Chdir(oldWd) }() + + ag, _ := agent.Get("gemini") + hookAgent := ag.(agent.HookSupport) + + _, err := hookAgent.InstallHooks(false, false) // localDev = false + if err != nil { + t.Fatalf("InstallHooks(localDev=false) error = %v", err) + } + + // Read settings and verify commands use "entire" binary + settingsPath := filepath.Join(env.RepoDir, ".gemini", geminicli.GeminiSettingsFileName) + data, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("failed to read settings.json: %v", err) + } + + content := string(data) + if !strings.Contains(content, "entire hooks gemini") { + t.Error("production hooks should use 'entire hooks gemini', but settings.json doesn't contain it") + } + }) + + t.Run("force flag reinstalls hooks", func(t *testing.T) { + // Not parallel - uses os.Chdir + env := NewTestEnv(t) + env.InitRepo() + + oldWd, _ := os.Getwd() + if err := os.Chdir(env.RepoDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + defer func() { _ = os.Chdir(oldWd) }() + + ag, _ := agent.Get("gemini") + hookAgent := ag.(agent.HookSupport) + + // First install + _, err := hookAgent.InstallHooks(false, false) + if err != nil { + t.Fatalf("first InstallHooks() error = %v", err) + } + + // Force reinstall should return count > 0 + count, err := hookAgent.InstallHooks(false, true) // force = true + if err != nil { + t.Fatalf("force InstallHooks() error = %v", err) + } + if count != 12 { + t.Errorf("force InstallHooks() count = %d, want 12", count) + } + }) +} + +// TestGeminiCLISessionOperations verifies ReadSession/WriteSession via Gemini agent interface. +func TestGeminiCLISessionOperations(t *testing.T) { + t.Parallel() + + t.Run("ReadSession parses transcript and computes ModifiedFiles", func(t *testing.T) { + t.Parallel() + env := NewTestEnv(t) + env.InitRepo() + + // Create a Gemini transcript file (JSON format) + // Gemini uses "type" field with values "user" or "gemini", and "toolCalls" array with "args" + transcriptPath := filepath.Join(env.RepoDir, "test-transcript.json") + transcriptContent := `{ + "messages": [ + {"type": "user", "content": "Fix the bug"}, + {"type": "gemini", "content": "", "toolCalls": [{"name": "write_file", "args": {"file_path": "main.go"}}]}, + {"type": "gemini", "content": "", "toolCalls": [{"name": "edit_file", "args": {"file_path": "util.go"}}]} + ] +}` + if err := os.WriteFile(transcriptPath, []byte(transcriptContent), 0o644); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + ag, _ := agent.Get("gemini") + session, err := ag.ReadSession(&agent.HookInput{ + SessionID: "test-session", + SessionRef: transcriptPath, + }) + if err != nil { + t.Fatalf("ReadSession() error = %v", err) + } + + // Verify session metadata + if session.SessionID != "test-session" { + t.Errorf("SessionID = %q, want %q", session.SessionID, "test-session") + } + if session.AgentName != "gemini" { + t.Errorf("AgentName = %q, want %q", session.AgentName, "gemini") + } + + // Verify NativeData is populated + if len(session.NativeData) == 0 { + t.Error("NativeData is empty, want transcript content") + } + + // Verify ModifiedFiles computed + if len(session.ModifiedFiles) != 2 { + t.Errorf("ModifiedFiles = %v, want 2 files (main.go, util.go)", session.ModifiedFiles) + } + }) + + t.Run("WriteSession writes NativeData to file", func(t *testing.T) { + t.Parallel() + env := NewTestEnv(t) + env.InitRepo() + + ag, _ := agent.Get("gemini") + + // First read a session + srcPath := filepath.Join(env.RepoDir, "src.json") + srcContent := `{"messages": [{"role": "user", "content": "hello"}]}` + if err := os.WriteFile(srcPath, []byte(srcContent), 0o644); err != nil { + t.Fatalf("failed to write source: %v", err) + } + + session, _ := ag.ReadSession(&agent.HookInput{ + SessionID: "test", + SessionRef: srcPath, + }) + + // Write to a new location + dstPath := filepath.Join(env.RepoDir, "dst.json") + session.SessionRef = dstPath + + if err := ag.WriteSession(session); err != nil { + t.Fatalf("WriteSession() error = %v", err) + } + + // Verify file was written + data, err := os.ReadFile(dstPath) + if err != nil { + t.Fatalf("failed to read destination: %v", err) + } + if string(data) != srcContent { + t.Errorf("written content = %q, want %q", string(data), srcContent) + } + }) + + t.Run("WriteSession rejects wrong agent", func(t *testing.T) { + t.Parallel() + + ag, _ := agent.Get("gemini") + + session := &agent.AgentSession{ + SessionID: "test", + AgentName: "other-agent", // Wrong agent + SessionRef: "/tmp/test.json", + NativeData: []byte("data"), + } + + err := ag.WriteSession(session) + if err == nil { + t.Error("WriteSession() should reject session from different agent") + } + }) +} + +// TestGeminiCLIHelperMethods verifies Gemini-specific helper methods. +func TestGeminiCLIHelperMethods(t *testing.T) { + t.Parallel() + + t.Run("TransformSessionID adds date prefix", func(t *testing.T) { + t.Parallel() + + ag, _ := agent.Get("gemini") + entireID := ag.TransformSessionID("abc123") + + // Should have format YYYY-MM-DD-abc123 + if len(entireID) < 15 { // "2025-01-01-abc123" is 17 chars + t.Errorf("TransformSessionID() = %q, too short", entireID) + } + if entireID[4] != '-' || entireID[7] != '-' || entireID[10] != '-' { + t.Errorf("TransformSessionID() = %q, want date prefix format", entireID) + } + if entireID[11:] != "abc123" { + t.Errorf("TransformSessionID() suffix = %q, want %q", entireID[11:], "abc123") + } + }) + + t.Run("ExtractAgentSessionID removes date prefix", func(t *testing.T) { + t.Parallel() + + ag, _ := agent.Get("gemini") + agentID := ag.ExtractAgentSessionID("2025-12-18-abc123") + + if agentID != "abc123" { + t.Errorf("ExtractAgentSessionID() = %q, want %q", agentID, "abc123") + } + }) + + t.Run("FormatResumeCommand returns gemini --resume", func(t *testing.T) { + t.Parallel() + + ag, _ := agent.Get("gemini") + cmd := ag.FormatResumeCommand("abc123") + + if cmd != "gemini --resume abc123" { + t.Errorf("FormatResumeCommand() = %q, want %q", cmd, "gemini --resume abc123") + } + }) + + t.Run("GetHookConfigPath returns .gemini/settings.json", func(t *testing.T) { + t.Parallel() + + ag, _ := agent.Get("gemini") + path := ag.GetHookConfigPath() + + if path != ".gemini/settings.json" { + t.Errorf("GetHookConfigPath() = %q, want %q", path, ".gemini/settings.json") + } + }) +} diff --git a/cmd/entire/cli/integration_test/gemini_concurrent_session_test.go b/cmd/entire/cli/integration_test/gemini_concurrent_session_test.go new file mode 100644 index 000000000..7f0197823 --- /dev/null +++ b/cmd/entire/cli/integration_test/gemini_concurrent_session_test.go @@ -0,0 +1,472 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "strings" + "testing" + + "entire.io/cli/cmd/entire/cli/agent" + "entire.io/cli/cmd/entire/cli/strategy" +) + +// TestGeminiConcurrentSessionWarning_BlocksFirstPrompt verifies that when a user starts +// a new Gemini session while another session has uncommitted changes (checkpoints), +// the first prompt is blocked with a Gemini-format JSON response (decision: block). +func TestGeminiConcurrentSessionWarning_BlocksFirstPrompt(t *testing.T) { + env := NewTestEnv(t) + defer env.Cleanup() + + env.InitRepo() + env.WriteFile("README.md", "# Test") + env.GitAdd("README.md") + env.GitCommit("Initial commit") + env.GitCheckoutNewBranch("feature/test") + env.InitEntireWithAgent(strategy.StrategyNameManualCommit, "gemini") + + // Start session A and create a checkpoint + sessionA := env.NewGeminiSession() + if err := env.SimulateGeminiBeforeAgent(sessionA.ID); err != nil { + t.Fatalf("SimulateGeminiBeforeAgent (sessionA) failed: %v", err) + } + + env.WriteFile("file.txt", "content from session A") + sessionA.CreateGeminiTranscript("Add file", []FileChange{{Path: "file.txt", Content: "content from session A"}}) + if err := env.SimulateGeminiSessionEnd(sessionA.ID, sessionA.TranscriptPath); err != nil { + t.Fatalf("SimulateGeminiSessionEnd (sessionA) failed: %v", err) + } + + // Verify session A has checkpoints + stateA, err := env.GetSessionState(sessionA.ID) + if err != nil { + t.Fatalf("GetSessionState (sessionA) failed: %v", err) + } + if stateA == nil { + t.Fatal("Session A state should exist after SessionEnd hook") + } + if stateA.CheckpointCount == 0 { + t.Fatal("Session A should have at least 1 checkpoint") + } + t.Logf("Session A has %d checkpoint(s)", stateA.CheckpointCount) + + // Start session B - first prompt should be blocked + sessionB := env.NewGeminiSession() + output := env.SimulateGeminiBeforeAgentWithOutput(sessionB.ID) + + // Gemini blocking exits with code 0 (JSON parsed) and decision "block" + if output.Err != nil { + t.Fatalf("Hook should exit with code 0 for blocking, got error: %v", output.Err) + } + + // Parse the JSON response (Gemini format) + var response struct { + Decision string `json:"decision"` + Reason string `json:"reason"` + } + if err := json.Unmarshal(output.Stdout, &response); err != nil { + t.Fatalf("Failed to parse JSON response: %v\nStdout: %s", err, output.Stdout) + } + + // Verify decision is block + if response.Decision != "block" { + t.Errorf("Expected decision:block in JSON response, got: %s", response.Decision) + } + + // Verify reason contains expected message + expectedMessage := "another active session with uncommitted changes" + if !strings.Contains(response.Reason, expectedMessage) { + t.Errorf("Reason should contain %q, got: %s", expectedMessage, response.Reason) + } + + // Verify the resume command mentions gemini --resume + expectedResumeCmd := "gemini --resume" + if !strings.Contains(response.Reason, expectedResumeCmd) { + t.Errorf("Reason should contain %q, got: %s", expectedResumeCmd, response.Reason) + } + + t.Logf("Received expected blocking response: %s", output.Stdout) +} + +// TestGeminiConcurrentSessionWarning_SetsWarningFlag verifies that after the first prompt +// is blocked, the session state has ConcurrentWarningShown set to true. +func TestGeminiConcurrentSessionWarning_SetsWarningFlag(t *testing.T) { + env := NewTestEnv(t) + defer env.Cleanup() + + env.InitRepo() + env.WriteFile("README.md", "# Test") + env.GitAdd("README.md") + env.GitCommit("Initial commit") + env.GitCheckoutNewBranch("feature/test") + env.InitEntireWithAgent(strategy.StrategyNameManualCommit, "gemini") + + // Start session A and create a checkpoint + sessionA := env.NewGeminiSession() + if err := env.SimulateGeminiBeforeAgent(sessionA.ID); err != nil { + t.Fatalf("SimulateGeminiBeforeAgent (sessionA) failed: %v", err) + } + + env.WriteFile("file.txt", "content") + sessionA.CreateGeminiTranscript("Add file", []FileChange{{Path: "file.txt", Content: "content"}}) + if err := env.SimulateGeminiSessionEnd(sessionA.ID, sessionA.TranscriptPath); err != nil { + t.Fatalf("SimulateGeminiSessionEnd (sessionA) failed: %v", err) + } + + // Start session B - first prompt is blocked + sessionB := env.NewGeminiSession() + _ = env.SimulateGeminiBeforeAgentWithOutput(sessionB.ID) + + // Verify session B state has ConcurrentWarningShown flag + stateB, err := env.GetSessionState(sessionB.ID) + if err != nil { + t.Fatalf("GetSessionState (sessionB) failed: %v", err) + } + if stateB == nil { + t.Fatal("Session B state should exist after blocked prompt") + } + if !stateB.ConcurrentWarningShown { + t.Error("Session B state should have ConcurrentWarningShown=true") + } + + t.Logf("Session B state: ConcurrentWarningShown=%v", stateB.ConcurrentWarningShown) +} + +// TestGeminiConcurrentSessionWarning_SubsequentPromptsSucceed verifies that after the +// warning is shown, subsequent prompts in the same session are skipped silently. +func TestGeminiConcurrentSessionWarning_SubsequentPromptsSucceed(t *testing.T) { + env := NewTestEnv(t) + defer env.Cleanup() + + env.InitRepo() + env.WriteFile("README.md", "# Test") + env.GitAdd("README.md") + env.GitCommit("Initial commit") + env.GitCheckoutNewBranch("feature/test") + env.InitEntireWithAgent(strategy.StrategyNameManualCommit, "gemini") + + // Start session A and create a checkpoint + sessionA := env.NewGeminiSession() + if err := env.SimulateGeminiBeforeAgent(sessionA.ID); err != nil { + t.Fatalf("SimulateGeminiBeforeAgent (sessionA) failed: %v", err) + } + + env.WriteFile("file.txt", "content") + sessionA.CreateGeminiTranscript("Add file", []FileChange{{Path: "file.txt", Content: "content"}}) + if err := env.SimulateGeminiSessionEnd(sessionA.ID, sessionA.TranscriptPath); err != nil { + t.Fatalf("SimulateGeminiSessionEnd (sessionA) failed: %v", err) + } + + // Start session B - first prompt is blocked (exits with code 0, decision: block) + sessionB := env.NewGeminiSession() + output1 := env.SimulateGeminiBeforeAgentWithOutput(sessionB.ID) + + // Verify first prompt was blocked (exit code 0 with decision: block) + if output1.Err != nil { + t.Fatalf("First prompt should exit with code 0, got error: %v", output1.Err) + } + + var response1 struct { + Decision string `json:"decision"` + } + if err := json.Unmarshal(output1.Stdout, &response1); err != nil { + t.Fatalf("Failed to parse first response: %v", err) + } + if response1.Decision != "block" { + t.Fatalf("First prompt should have decision:block, got: %s", response1.Decision) + } + t.Log("First prompt correctly blocked") + + // Second prompt in session B should be skipped entirely (no processing) + // Since ConcurrentWarningShown is true, the hook returns nil and produces no output + output2 := env.SimulateGeminiBeforeAgentWithOutput(sessionB.ID) + + // The hook should succeed (no error) because it skips silently + if output2.Err != nil { + t.Errorf("Second prompt should succeed (skip silently), got error: %v", output2.Err) + } + + // The hook should produce no output (it was skipped) + if len(output2.Stdout) > 0 { + t.Errorf("Second prompt should produce no output (hook skipped), got: %s", output2.Stdout) + } + + // The important assertion: warning flag should still be set + stateB, _ := env.GetSessionState(sessionB.ID) + if stateB == nil { + t.Fatal("Session B state should exist") + } + if !stateB.ConcurrentWarningShown { + t.Error("ConcurrentWarningShown should remain true after second prompt") + } + + t.Log("Second prompt correctly skipped (hooks disabled for warned session)") +} + +// TestGeminiConcurrentSessionWarning_NoWarningWithoutCheckpoints verifies that starting +// a new session does NOT trigger the warning if the existing session has no checkpoints. +func TestGeminiConcurrentSessionWarning_NoWarningWithoutCheckpoints(t *testing.T) { + env := NewTestEnv(t) + defer env.Cleanup() + + env.InitRepo() + env.WriteFile("README.md", "# Test") + env.GitAdd("README.md") + env.GitCommit("Initial commit") + env.GitCheckoutNewBranch("feature/test") + env.InitEntireWithAgent(strategy.StrategyNameManualCommit, "gemini") + + // Start session A but do NOT create any checkpoints + sessionA := env.NewGeminiSession() + if err := env.SimulateGeminiBeforeAgent(sessionA.ID); err != nil { + t.Fatalf("SimulateGeminiBeforeAgent (sessionA) failed: %v", err) + } + + // Verify session A has no checkpoints + stateA, err := env.GetSessionState(sessionA.ID) + if err != nil { + t.Fatalf("GetSessionState (sessionA) failed: %v", err) + } + if stateA == nil { + t.Fatal("Session A state should exist after BeforeAgent hook") + } + if stateA.CheckpointCount != 0 { + t.Fatalf("Session A should have 0 checkpoints, got %d", stateA.CheckpointCount) + } + + // Start session B - should NOT be blocked since session A has no checkpoints + sessionB := env.NewGeminiSession() + output := env.SimulateGeminiBeforeAgentWithOutput(sessionB.ID) + + // Check if we got a blocking response (we shouldn't) + // With exit code 0, check if there's a blocking decision in the JSON + if len(output.Stdout) > 0 { + var response struct { + Decision string `json:"decision"` + Reason string `json:"reason,omitempty"` + } + if json.Unmarshal(output.Stdout, &response) == nil { + if response.Decision == "block" && strings.Contains(response.Reason, "another active session") { + t.Error("Should NOT show concurrent session warning when existing session has no checkpoints") + } + } + } + + // Session B should proceed normally (or fail for other reasons, but not concurrent warning) + stateB, _ := env.GetSessionState(sessionB.ID) + if stateB != nil && stateB.ConcurrentWarningShown { + t.Error("Session B should not have ConcurrentWarningShown set when session A has no checkpoints") + } + + t.Log("No concurrent session warning shown when existing session has no checkpoints") +} + +// TestGeminiConcurrentSessionWarning_ResumeCommandFormat verifies that the blocking +// message includes the correct Gemini resume command format. +func TestGeminiConcurrentSessionWarning_ResumeCommandFormat(t *testing.T) { + env := NewTestEnv(t) + defer env.Cleanup() + + env.InitRepo() + env.WriteFile("README.md", "# Test") + env.GitAdd("README.md") + env.GitCommit("Initial commit") + env.GitCheckoutNewBranch("feature/test") + env.InitEntireWithAgent(strategy.StrategyNameManualCommit, "gemini") + + // Start session A and create a checkpoint + sessionA := env.NewGeminiSession() + if err := env.SimulateGeminiBeforeAgent(sessionA.ID); err != nil { + t.Fatalf("SimulateGeminiBeforeAgent (sessionA) failed: %v", err) + } + + env.WriteFile("file.txt", "content") + sessionA.CreateGeminiTranscript("Add file", []FileChange{{Path: "file.txt", Content: "content"}}) + if err := env.SimulateGeminiSessionEnd(sessionA.ID, sessionA.TranscriptPath); err != nil { + t.Fatalf("SimulateGeminiSessionEnd (sessionA) failed: %v", err) + } + + // Start session B - triggers blocking + sessionB := env.NewGeminiSession() + output := env.SimulateGeminiBeforeAgentWithOutput(sessionB.ID) + + // Parse the blocking response + var response struct { + Decision string `json:"decision"` + Reason string `json:"reason"` + } + if err := json.Unmarshal(output.Stdout, &response); err != nil { + t.Fatalf("Failed to parse JSON response: %v\nStdout: %s", err, output.Stdout) + } + + // Verify the resume command is for Gemini CLI, not Claude + if !strings.Contains(response.Reason, "gemini --resume") { + t.Errorf("Reason should contain 'gemini --resume', got: %s", response.Reason) + } + if strings.Contains(response.Reason, "claude -r") { + t.Errorf("Reason should NOT contain Claude's resume command, got: %s", response.Reason) + } + if !strings.Contains(response.Reason, "close Gemini CLI") { + t.Errorf("Reason should mention closing Gemini CLI, got: %s", response.Reason) + } + + t.Logf("Resume command correctly formatted for Gemini CLI: %s", response.Reason) +} + +// TestCrossAgentConcurrentSession_ClaudeSessionShowsClaudeResumeInGemini verifies that +// when a Claude Code session exists with checkpoints and Gemini tries to start, +// the blocking message shows "claude -r" (the Claude resume command), not "gemini --resume". +func TestCrossAgentConcurrentSession_ClaudeSessionShowsClaudeResumeInGemini(t *testing.T) { + env := NewTestEnv(t) + defer env.Cleanup() + + env.InitRepo() + env.WriteFile("README.md", "# Test") + env.GitAdd("README.md") + env.GitCommit("Initial commit") + env.GitCheckoutNewBranch("feature/test") + // Initialize with Claude Code agent first + env.InitEntireWithAgent(strategy.StrategyNameManualCommit, agent.AgentNameClaudeCode) + + // Start Claude session A and create a checkpoint + sessionA := env.NewSession() + if err := env.SimulateUserPromptSubmit(sessionA.ID); err != nil { + t.Fatalf("SimulateUserPromptSubmit (sessionA) failed: %v", err) + } + + env.WriteFile("file.txt", "content from Claude session") + sessionA.CreateTranscript("Add file", []FileChange{{Path: "file.txt", Content: "content from Claude session"}}) + if err := env.SimulateStop(sessionA.ID, sessionA.TranscriptPath); err != nil { + t.Fatalf("SimulateStop (sessionA) failed: %v", err) + } + + // Verify Claude session A has checkpoints and correct agent type + stateA, err := env.GetSessionState(sessionA.ID) + if err != nil { + t.Fatalf("GetSessionState (sessionA) failed: %v", err) + } + if stateA == nil { + t.Fatal("Session A state should exist after Stop hook") + } + if stateA.CheckpointCount == 0 { + t.Fatal("Session A should have at least 1 checkpoint") + } + if stateA.AgentType != "Claude Code" { + t.Errorf("Session A agent type should be 'Claude Code', got: %s", stateA.AgentType) + } + t.Logf("Claude session A has %d checkpoint(s), agent type: %s", stateA.CheckpointCount, stateA.AgentType) + + // Now try to start a Gemini session - should be blocked with Claude resume command + sessionB := env.NewGeminiSession() + output := env.SimulateGeminiBeforeAgentWithOutput(sessionB.ID) + + // Parse the JSON response (Gemini format) + var response struct { + Decision string `json:"decision"` + Reason string `json:"reason"` + } + if err := json.Unmarshal(output.Stdout, &response); err != nil { + t.Fatalf("Failed to parse JSON response: %v\nStdout: %s", err, output.Stdout) + } + + // Verify decision is block + if response.Decision != "block" { + t.Errorf("Expected decision:block in JSON response, got: %s", response.Decision) + } + + // CRITICAL: The resume command should be for Claude Code (the conflicting session's agent), + // NOT Gemini (the current agent). + if !strings.Contains(response.Reason, "claude -r") { + t.Errorf("Resume command should be 'claude -r' (Claude session is conflicting), got: %s", response.Reason) + } + if strings.Contains(response.Reason, "gemini --resume") { + t.Errorf("Resume command should NOT be 'gemini --resume' (that's the current agent, not conflicting session), got: %s", response.Reason) + } + + // Extract the session ID from the resume command + expectedSessionID := sessionA.ID[:len(sessionA.ID)-11] // Remove date prefix for raw session ID + if !strings.Contains(response.Reason, expectedSessionID) { + t.Errorf("Resume command should contain session ID %q, got: %s", expectedSessionID, response.Reason) + } + + t.Logf("Cross-agent blocking correctly shows Claude resume command: %s", response.Reason) +} + +// TestCrossAgentConcurrentSession_GeminiSessionShowsGeminiResumeInClaude verifies that +// when a Gemini CLI session exists with checkpoints and Claude tries to start, +// the blocking message shows "gemini --resume" (the Gemini resume command), not "claude -r". +func TestCrossAgentConcurrentSession_GeminiSessionShowsGeminiResumeInClaude(t *testing.T) { + env := NewTestEnv(t) + defer env.Cleanup() + + env.InitRepo() + env.WriteFile("README.md", "# Test") + env.GitAdd("README.md") + env.GitCommit("Initial commit") + env.GitCheckoutNewBranch("feature/test") + // Initialize with Gemini agent first + env.InitEntireWithAgent(strategy.StrategyNameManualCommit, agent.AgentNameGemini) + + // Start Gemini session A and create a checkpoint + sessionA := env.NewGeminiSession() + if err := env.SimulateGeminiBeforeAgent(sessionA.ID); err != nil { + t.Fatalf("SimulateGeminiBeforeAgent (sessionA) failed: %v", err) + } + + env.WriteFile("file.txt", "content from Gemini session") + sessionA.CreateGeminiTranscript("Add file", []FileChange{{Path: "file.txt", Content: "content from Gemini session"}}) + if err := env.SimulateGeminiSessionEnd(sessionA.ID, sessionA.TranscriptPath); err != nil { + t.Fatalf("SimulateGeminiSessionEnd (sessionA) failed: %v", err) + } + + // Verify Gemini session A has checkpoints and correct agent type + stateA, err := env.GetSessionState(sessionA.ID) + if err != nil { + t.Fatalf("GetSessionState (sessionA) failed: %v", err) + } + if stateA == nil { + t.Fatal("Session A state should exist after SessionEnd hook") + } + if stateA.CheckpointCount == 0 { + t.Fatal("Session A should have at least 1 checkpoint") + } + if stateA.AgentType != "Gemini CLI" { + t.Errorf("Session A agent type should be 'Gemini CLI', got: %s", stateA.AgentType) + } + t.Logf("Gemini session A has %d checkpoint(s), agent type: %s", stateA.CheckpointCount, stateA.AgentType) + + // Now try to start a Claude session - should be blocked with Gemini resume command + sessionB := env.NewSession() + output := env.SimulateUserPromptSubmitWithOutput(sessionB.ID) + + // Parse the JSON response (Claude format) + var response struct { + Continue bool `json:"continue"` + StopReason string `json:"stopReason"` + } + if err := json.Unmarshal(output.Stdout, &response); err != nil { + t.Fatalf("Failed to parse JSON response: %v\nStdout: %s", err, output.Stdout) + } + + // Verify continue is false (blocked) + if response.Continue { + t.Error("Expected continue:false in JSON response (blocked)") + } + + // CRITICAL: The resume command should be for Gemini CLI (the conflicting session's agent), + // NOT Claude (the current agent). + if !strings.Contains(response.StopReason, "gemini --resume") { + t.Errorf("Resume command should be 'gemini --resume' (Gemini session is conflicting), got: %s", response.StopReason) + } + if strings.Contains(response.StopReason, "claude -r") { + t.Errorf("Resume command should NOT be 'claude -r' (that's the current agent, not conflicting session), got: %s", response.StopReason) + } + + // Extract the session ID from the resume command + expectedSessionID := sessionA.ID[:len(sessionA.ID)-11] // Remove date prefix for raw session ID + if !strings.Contains(response.StopReason, expectedSessionID) { + t.Errorf("Resume command should contain session ID %q, got: %s", expectedSessionID, response.StopReason) + } + + t.Logf("Cross-agent blocking correctly shows Gemini resume command: %s", response.StopReason) +} diff --git a/cmd/entire/cli/integration_test/hooks.go b/cmd/entire/cli/integration_test/hooks.go index 5ae2a25f9..90cfc762f 100644 --- a/cmd/entire/cli/integration_test/hooks.go +++ b/cmd/entire/cli/integration_test/hooks.go @@ -366,3 +366,237 @@ func (env *TestEnv) GetSessionState(sessionID string) (*strategy.SessionState, e } return &state, nil } + +// GeminiHookRunner executes Gemini CLI hooks in the test environment. +type GeminiHookRunner struct { + RepoDir string + GeminiProjectDir string + T interface { + Helper() + Fatalf(format string, args ...interface{}) + Logf(format string, args ...interface{}) + } +} + +// NewGeminiHookRunner creates a new Gemini hook runner for the given repo directory. +func NewGeminiHookRunner(repoDir, geminiProjectDir string, t interface { + Helper() + Fatalf(format string, args ...interface{}) + Logf(format string, args ...interface{}) +}) *GeminiHookRunner { + return &GeminiHookRunner{ + RepoDir: repoDir, + GeminiProjectDir: geminiProjectDir, + T: t, + } +} + +// runGeminiHookWithInput runs a Gemini hook with the given input. +func (r *GeminiHookRunner) runGeminiHookWithInput(hookName string, input interface{}) error { + r.T.Helper() + + inputJSON, err := json.Marshal(input) + if err != nil { + return fmt.Errorf("failed to marshal hook input: %w", err) + } + + return r.runGeminiHookInRepoDir(hookName, inputJSON) +} + +func (r *GeminiHookRunner) runGeminiHookInRepoDir(hookName string, inputJSON []byte) error { + // Run using the shared test binary + // Command structure: entire hooks gemini + cmd := exec.Command(getTestBinary(), "hooks", "gemini", hookName) + cmd.Dir = r.RepoDir + cmd.Stdin = bytes.NewReader(inputJSON) + cmd.Env = append(os.Environ(), + "ENTIRE_TEST_GEMINI_PROJECT_DIR="+r.GeminiProjectDir, + ) + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("hook %s failed: %w\nInput: %s\nOutput: %s", + hookName, err, inputJSON, output) + } + + r.T.Logf("Gemini hook %s output: %s", hookName, output) + return nil +} + +// runGeminiHookWithOutput runs a Gemini hook and returns both stdout and stderr separately. +func (r *GeminiHookRunner) runGeminiHookWithOutput(hookName string, inputJSON []byte) HookOutput { + cmd := exec.Command(getTestBinary(), "hooks", "gemini", hookName) + cmd.Dir = r.RepoDir + cmd.Stdin = bytes.NewReader(inputJSON) + cmd.Env = append(os.Environ(), + "ENTIRE_TEST_GEMINI_PROJECT_DIR="+r.GeminiProjectDir, + ) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + return HookOutput{ + Stdout: stdout.Bytes(), + Stderr: stderr.Bytes(), + Err: err, + } +} + +// SimulateGeminiBeforeAgent simulates the BeforeAgent hook for Gemini CLI. +// This is equivalent to Claude Code's UserPromptSubmit. +func (r *GeminiHookRunner) SimulateGeminiBeforeAgent(sessionID string) error { + r.T.Helper() + + input := map[string]string{ + "session_id": sessionID, + "transcript_path": "", + "cwd": r.RepoDir, + "hook_event_name": "BeforeAgent", + "timestamp": "2025-01-01T00:00:00Z", + "prompt": "test prompt", + } + + return r.runGeminiHookWithInput("before-agent", input) +} + +// SimulateGeminiBeforeAgentWithOutput simulates the BeforeAgent hook and returns the output. +func (r *GeminiHookRunner) SimulateGeminiBeforeAgentWithOutput(sessionID string) HookOutput { + r.T.Helper() + + input := map[string]string{ + "session_id": sessionID, + "transcript_path": "", + "cwd": r.RepoDir, + "hook_event_name": "BeforeAgent", + "timestamp": "2025-01-01T00:00:00Z", + "prompt": "test prompt", + } + + inputJSON, err := json.Marshal(input) + if err != nil { + return HookOutput{Err: fmt.Errorf("failed to marshal hook input: %w", err)} + } + + return r.runGeminiHookWithOutput("before-agent", inputJSON) +} + +// SimulateGeminiSessionEnd simulates the SessionEnd hook for Gemini CLI. +// This is equivalent to Claude Code's Stop hook. +func (r *GeminiHookRunner) SimulateGeminiSessionEnd(sessionID, transcriptPath string) error { + r.T.Helper() + + input := map[string]string{ + "session_id": sessionID, + "transcript_path": transcriptPath, + "cwd": r.RepoDir, + "hook_event_name": "SessionEnd", + "timestamp": "2025-01-01T00:00:00Z", + "reason": "exit", + } + + return r.runGeminiHookWithInput("session-end", input) +} + +// GeminiSession represents a simulated Gemini CLI session. +type GeminiSession struct { + ID string // Raw model session ID (e.g., "gemini-session-1") + EntireID string // Entire session ID with date prefix (e.g., "2025-12-02-gemini-session-1") + TranscriptPath string + env *TestEnv +} + +// NewGeminiSession creates a new simulated Gemini session. +func (env *TestEnv) NewGeminiSession() *GeminiSession { + env.T.Helper() + + env.SessionCounter++ + sessionID := fmt.Sprintf("gemini-session-%d", env.SessionCounter) + entireID := paths.EntireSessionID(sessionID) + transcriptPath := filepath.Join(env.RepoDir, ".entire", "tmp", sessionID+".json") + + return &GeminiSession{ + ID: sessionID, + EntireID: entireID, + TranscriptPath: transcriptPath, + env: env, + } +} + +// CreateGeminiTranscript creates a Gemini JSON transcript file for the session. +func (s *GeminiSession) CreateGeminiTranscript(prompt string, changes []FileChange) string { + // Build Gemini-format transcript (JSON, not JSONL) + messages := []map[string]interface{}{ + { + "type": "user", + "content": prompt, + }, + { + "type": "assistant", + "content": "I'll help you with that.", + }, + } + + for _, change := range changes { + messages = append(messages, map[string]interface{}{ + "type": "tool_use", + "name": "write_file", + "input": map[string]string{ + "path": change.Path, + "content": change.Content, + }, + }) + messages = append(messages, map[string]interface{}{ + "type": "tool_result", + "output": "File written successfully", + }) + } + + messages = append(messages, map[string]interface{}{ + "type": "assistant", + "content": "Done!", + }) + + transcript := map[string]interface{}{ + "sessionId": s.ID, + "messages": messages, + } + + // Ensure directory exists + if err := os.MkdirAll(filepath.Dir(s.TranscriptPath), 0o755); err != nil { + s.env.T.Fatalf("failed to create transcript dir: %v", err) + } + + // Write transcript + data, err := json.MarshalIndent(transcript, "", " ") + if err != nil { + s.env.T.Fatalf("failed to marshal transcript: %v", err) + } + if err := os.WriteFile(s.TranscriptPath, data, 0o644); err != nil { + s.env.T.Fatalf("failed to write transcript: %v", err) + } + + return s.TranscriptPath +} + +// SimulateGeminiBeforeAgent is a convenience method on TestEnv. +func (env *TestEnv) SimulateGeminiBeforeAgent(sessionID string) error { + env.T.Helper() + runner := NewGeminiHookRunner(env.RepoDir, env.GeminiProjectDir, env.T) + return runner.SimulateGeminiBeforeAgent(sessionID) +} + +// SimulateGeminiBeforeAgentWithOutput is a convenience method on TestEnv. +func (env *TestEnv) SimulateGeminiBeforeAgentWithOutput(sessionID string) HookOutput { + env.T.Helper() + runner := NewGeminiHookRunner(env.RepoDir, env.GeminiProjectDir, env.T) + return runner.SimulateGeminiBeforeAgentWithOutput(sessionID) +} + +// SimulateGeminiSessionEnd is a convenience method on TestEnv. +func (env *TestEnv) SimulateGeminiSessionEnd(sessionID, transcriptPath string) error { + env.T.Helper() + runner := NewGeminiHookRunner(env.RepoDir, env.GeminiProjectDir, env.T) + return runner.SimulateGeminiSessionEnd(sessionID, transcriptPath) +} diff --git a/cmd/entire/cli/integration_test/testenv.go b/cmd/entire/cli/integration_test/testenv.go index fcde14c32..83e0e53ca 100644 --- a/cmd/entire/cli/integration_test/testenv.go +++ b/cmd/entire/cli/integration_test/testenv.go @@ -39,11 +39,12 @@ type TestEnv struct { T *testing.T RepoDir string ClaudeProjectDir string + GeminiProjectDir string SessionCounter int } // NewTestEnv creates a new isolated test environment. -// It creates temp directories for the git repo and Claude project files. +// It creates temp directories for the git repo and agent project files. // Note: Does NOT change working directory to allow parallel test execution. // Note: Does NOT use t.Setenv to allow parallel test execution - CLI commands // receive the env var via cmd.Env instead. @@ -60,15 +61,20 @@ func NewTestEnv(t *testing.T) *TestEnv { if resolved, err := filepath.EvalSymlinks(claudeProjectDir); err == nil { claudeProjectDir = resolved } + geminiProjectDir := t.TempDir() + if resolved, err := filepath.EvalSymlinks(geminiProjectDir); err == nil { + geminiProjectDir = resolved + } env := &TestEnv{ T: t, RepoDir: repoDir, ClaudeProjectDir: claudeProjectDir, + GeminiProjectDir: geminiProjectDir, } // Note: Don't use t.Setenv here - it's incompatible with t.Parallel() - // CLI commands receive ENTIRE_TEST_CLAUDE_PROJECT_DIR via cmd.Env instead + // CLI commands receive ENTIRE_TEST_CLAUDE_PROJECT_DIR or ENTIRE_TEST_GEMINI_PROJECT_DIR via cmd.Env instead return env } @@ -209,6 +215,13 @@ func (env *TestEnv) InitRepo() { // InitEntire initializes the .entire directory with the specified strategy. func (env *TestEnv) InitEntire(strategy string) { env.T.Helper() + env.InitEntireWithAgent(strategy, "") // empty string = default agent (claude-code) +} + +// InitEntireWithAgent initializes an Entire test environment with a specific agent. +// If agentName is empty, defaults to claude-code. +func (env *TestEnv) InitEntireWithAgent(strategy, agentName string) { + env.T.Helper() // Create .entire directory structure entireDir := filepath.Join(env.RepoDir, ".entire") @@ -227,6 +240,10 @@ func (env *TestEnv) InitEntire(strategy string) { "strategy": strategy, "local_dev": true, // Use go run for hooks in tests } + // Only add agent if specified (otherwise defaults to claude-code) + if agentName != "" { + settings["agent"] = agentName + } data, err := json.MarshalIndent(settings, "", " ") if err != nil { env.T.Fatalf("failed to marshal settings: %v", err) diff --git a/cmd/entire/cli/session/state.go b/cmd/entire/cli/session/state.go index e96f57fd7..50f4c8221 100644 --- a/cmd/entire/cli/session/state.go +++ b/cmd/entire/cli/session/state.go @@ -50,6 +50,9 @@ type State struct { // LastCheckpointID is the checkpoint ID from last condensation, reused for subsequent commits without new content LastCheckpointID string `json:"last_checkpoint_id,omitempty"` + + // AgentType identifies the agent that created this session (e.g., "Claude Code", "Gemini CLI") + AgentType string `json:"agent_type,omitempty"` } // StateStore provides low-level operations for managing session state files. diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index 33e15f263..8c6550ee2 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -179,6 +179,17 @@ func runEnableWithStrategy(w io.Writer, selectedStrategy string, localDev, _, us fmt.Fprintln(w, "✓ Claude Code hooks verified") } + // Setup Gemini CLI hooks + geminiHooksInstalled, err := setupGeminiCLIHook(localDev, forceHooks) + if err != nil { + return fmt.Errorf("failed to setup Gemini CLI hooks: %w", err) + } + if geminiHooksInstalled > 0 { + fmt.Fprintln(w, "✓ Gemini CLI hooks installed") + } else { + fmt.Fprintln(w, "✓ Gemini CLI hooks verified") + } + // Setup .entire directory dirCreated, err := setupEntireDirectory() if err != nil { @@ -287,6 +298,17 @@ func runEnableInteractive(w io.Writer, localDev, _, useLocalSettings, useProject fmt.Fprintln(w, "✓ Claude Code hooks verified") } + // Setup Gemini CLI hooks + geminiHooksInstalled, err := setupGeminiCLIHook(localDev, forceHooks) + if err != nil { + return fmt.Errorf("failed to setup Gemini CLI hooks: %w", err) + } + if geminiHooksInstalled > 0 { + fmt.Fprintln(w, "✓ Gemini CLI hooks installed") + } else { + fmt.Fprintln(w, "✓ Gemini CLI hooks verified") + } + // Setup .entire directory dirCreated, err := setupEntireDirectory() if err != nil { @@ -492,6 +514,28 @@ func setupClaudeCodeHook(localDev, forceHooks bool) (int, error) { return count, nil } +// setupGeminiCLIHook sets up Gemini CLI hooks. +// This is a convenience wrapper that uses the agent package. +// Returns the number of hooks installed (0 if already installed). +func setupGeminiCLIHook(localDev, forceHooks bool) (int, error) { + ag, err := agent.Get(agent.AgentNameGemini) + if err != nil { + return 0, fmt.Errorf("failed to get gemini agent: %w", err) + } + + hookAgent, ok := ag.(agent.HookSupport) + if !ok { + return 0, errors.New("gemini agent does not support hooks") + } + + count, err := hookAgent.InstallHooks(localDev, forceHooks) + if err != nil { + return 0, fmt.Errorf("failed to install gemini hooks: %w", err) + } + + return count, nil +} + // setupAgentHooksNonInteractive sets up hooks for a specific agent non-interactively. // If strategyName is provided, it sets the strategy; otherwise uses default. func setupAgentHooksNonInteractive(agentName, strategyName string, localDev, forceHooks bool) error { diff --git a/cmd/entire/cli/strategy/manual_commit.go b/cmd/entire/cli/strategy/manual_commit.go index 7005481a8..0cc3af0a9 100644 --- a/cmd/entire/cli/strategy/manual_commit.go +++ b/cmd/entire/cli/strategy/manual_commit.go @@ -71,6 +71,7 @@ func sessionStateToStrategy(state *session.State) *SessionState { FilesTouched: state.FilesTouched, ConcurrentWarningShown: state.ConcurrentWarningShown, LastCheckpointID: state.LastCheckpointID, + AgentType: state.AgentType, } } @@ -90,6 +91,7 @@ func sessionStateFromStrategy(state *SessionState) *session.State { FilesTouched: state.FilesTouched, ConcurrentWarningShown: state.ConcurrentWarningShown, LastCheckpointID: state.LastCheckpointID, + AgentType: state.AgentType, } } diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 301007f25..e91cf2d00 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -102,7 +102,8 @@ func (s *ManualCommitStrategy) CondenseSession(repo *git.Repository, checkpointI // Extract session data, starting from where we left off last condensation // Use tracked files from session state instead of collecting all files from tree - sessionData, err := s.extractSessionData(repo, ref.Hash(), state.SessionID, state.CondensedTranscriptLines, state.FilesTouched) + // Pass agent type to handle different transcript formats (JSONL for Claude, JSON for Gemini) + sessionData, err := s.extractSessionData(repo, ref.Hash(), state.SessionID, state.CondensedTranscriptLines, state.FilesTouched, state.AgentType) if err != nil { return nil, fmt.Errorf("failed to extract session data: %w", err) } @@ -145,7 +146,8 @@ func (s *ManualCommitStrategy) CondenseSession(repo *git.Repository, checkpointI // extractSessionData extracts session data from the shadow branch. // startLine specifies the first line to include (0 = all lines, for incremental condensation). // filesTouched is the list of files tracked during the session (from SessionState.FilesTouched). -func (s *ManualCommitStrategy) extractSessionData(repo *git.Repository, shadowRef plumbing.Hash, sessionID string, startLine int, filesTouched []string) (*ExtractedSessionData, error) { +// agentType identifies the agent (e.g., "Gemini CLI", "Claude Code") to determine transcript format. +func (s *ManualCommitStrategy) extractSessionData(repo *git.Repository, shadowRef plumbing.Hash, sessionID string, startLine int, filesTouched []string, agentType string) (*ExtractedSessionData, error) { commit, err := repo.CommitObject(shadowRef) if err != nil { return nil, fmt.Errorf("failed to get commit object: %w", err) @@ -173,27 +175,39 @@ func (s *ManualCommitStrategy) extractSessionData(repo *git.Repository, shadowRe } } - // Split into lines and filter + // Process transcript based on agent type if fullTranscript != "" { - allLines := strings.Split(fullTranscript, "\n") + // Check if this is a Gemini CLI transcript (JSON format, not JSONL) + isGeminiFormat := strings.Contains(agentType, "Gemini") || isGeminiJSONTranscript(fullTranscript) + + if isGeminiFormat { + // Gemini uses JSON format with a "messages" array + data.Transcript = []byte(fullTranscript) + data.FullTranscriptLines = 1 // JSON is a single "line" + data.Prompts = extractUserPromptsFromGeminiJSON(fullTranscript) + data.Context = generateContextFromPrompts(data.Prompts) + } else { + // Claude Code and others use JSONL format (one JSON object per line) + allLines := strings.Split(fullTranscript, "\n") - // Trim trailing empty lines (from final \n in JSONL) - for len(allLines) > 0 && strings.TrimSpace(allLines[len(allLines)-1]) == "" { - allLines = allLines[:len(allLines)-1] - } + // Trim trailing empty lines (from final \n in JSONL) + for len(allLines) > 0 && strings.TrimSpace(allLines[len(allLines)-1]) == "" { + allLines = allLines[:len(allLines)-1] + } - data.FullTranscriptLines = len(allLines) + data.FullTranscriptLines = len(allLines) - // Get only lines from startLine onwards for this condensation - if startLine < len(allLines) { - newLines := allLines[startLine:] - data.Transcript = []byte(strings.Join(newLines, "\n")) + // Get only lines from startLine onwards for this condensation + if startLine < len(allLines) { + newLines := allLines[startLine:] + data.Transcript = []byte(strings.Join(newLines, "\n")) - // Extract prompts from the new portion only - data.Prompts = extractUserPromptsFromLines(newLines) + // Extract prompts from the new portion only + data.Prompts = extractUserPromptsFromLines(newLines) - // Generate context from prompts - data.Context = generateContextFromPrompts(data.Prompts) + // Generate context from prompts + data.Context = generateContextFromPrompts(data.Prompts) + } } } @@ -203,6 +217,52 @@ func (s *ManualCommitStrategy) extractSessionData(repo *git.Repository, shadowRe return data, nil } +// isGeminiJSONTranscript detects if the transcript is in Gemini's JSON format. +// Gemini transcripts start with a JSON object containing a "messages" array. +func isGeminiJSONTranscript(content string) bool { + content = strings.TrimSpace(content) + // Quick check: Gemini JSON starts with { and contains "messages" + if !strings.HasPrefix(content, "{") { + return false + } + // Try to parse as Gemini format + var transcript struct { + Messages []json.RawMessage `json:"messages"` + } + if err := json.Unmarshal([]byte(content), &transcript); err != nil { + return false + } + return len(transcript.Messages) > 0 +} + +// extractUserPromptsFromGeminiJSON extracts user prompts from Gemini's JSON transcript format. +// Gemini transcripts are structured as: {"messages": [{"type": "user", "content": "..."}, ...]} +func extractUserPromptsFromGeminiJSON(content string) []string { + var transcript struct { + Messages []struct { + Type string `json:"type"` + Content string `json:"content"` + } `json:"messages"` + } + + if err := json.Unmarshal([]byte(content), &transcript); err != nil { + return nil + } + + var prompts []string + for _, msg := range transcript.Messages { + if msg.Type == "user" && msg.Content != "" { + // Strip IDE context tags for consistency with Claude Code handling + cleaned := textutil.StripIDEContextTags(msg.Content) + if cleaned != "" { + prompts = append(prompts, cleaned) + } + } + } + + return prompts +} + // extractUserPromptsFromLines extracts user prompts from JSONL transcript lines. // IDE-injected context tags (like ) are stripped from the results. func extractUserPromptsFromLines(lines []string) []string { diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 8223e851c..1a7e21118 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -779,7 +779,8 @@ func (s *ManualCommitStrategy) getLastPrompt(repo *git.Repository, state *Sessio } // Extract session data (using 0 as startLine to get all prompts) - sessionData, err := s.extractSessionData(repo, ref.Hash(), state.SessionID, 0, nil) + // Pass agent type to handle different transcript formats (JSONL for Claude, JSON for Gemini) + sessionData, err := s.extractSessionData(repo, ref.Hash(), state.SessionID, 0, nil, state.AgentType) if err != nil || len(sessionData.Prompts) == 0 { return "" } diff --git a/cmd/entire/cli/strategy/manual_commit_logs.go b/cmd/entire/cli/strategy/manual_commit_logs.go index 94f0449bd..390f5d047 100644 --- a/cmd/entire/cli/strategy/manual_commit_logs.go +++ b/cmd/entire/cli/strategy/manual_commit_logs.go @@ -346,6 +346,7 @@ func (s *ManualCommitStrategy) GetAdditionalSessions() ([]*Session, error) { } // getDescriptionFromShadowBranch reads the session description from the shadow branch. +// sessionID is expected to be an Entire session ID (already date-prefixed like "2026-01-12-abc123"). func (s *ManualCommitStrategy) getDescriptionFromShadowBranch(sessionID, baseCommit string) string { repo, err := OpenRepository() if err != nil { @@ -369,7 +370,9 @@ func (s *ManualCommitStrategy) getDescriptionFromShadowBranch(sessionID, baseCom return "" } - metadataDir := paths.SessionMetadataDir(sessionID) + // Use SessionMetadataDirFromEntireID since sessionID is already an Entire session ID + // (with date prefix like "2026-01-12-abc123") + metadataDir := paths.SessionMetadataDirFromEntireID(sessionID) return getSessionDescriptionFromTree(tree, metadataDir) } diff --git a/cmd/entire/cli/strategy/manual_commit_test.go b/cmd/entire/cli/strategy/manual_commit_test.go index 259d42c8e..9980c9613 100644 --- a/cmd/entire/cli/strategy/manual_commit_test.go +++ b/cmd/entire/cli/strategy/manual_commit_test.go @@ -1412,3 +1412,217 @@ func TestSaveChanges_EmptyBaseCommit_Recovery(t *testing.T) { t.Errorf("CheckpointCount = %d, want 1", loaded.CheckpointCount) } } + +// TestIsGeminiJSONTranscript tests detection of Gemini JSON transcript format. +func TestIsGeminiJSONTranscript(t *testing.T) { + tests := []struct { + name string + content string + expected bool + }{ + { + name: "valid Gemini JSON", + content: `{ + "messages": [ + {"type": "user", "content": "Hello"}, + {"type": "gemini", "content": "Hi there!"} + ] + }`, + expected: true, + }, + { + name: "empty messages array", + content: `{"messages": []}`, + expected: false, + }, + { + name: "JSONL format (Claude Code)", + content: `{"type":"human","message":{"content":"Hello"}} +{"type":"assistant","message":{"content":"Hi"}}`, + expected: false, + }, + { + name: "not JSON", + content: "plain text", + expected: false, + }, + { + name: "JSON without messages field", + content: `{"foo": "bar"}`, + expected: false, + }, + { + name: "empty string", + content: "", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isGeminiJSONTranscript(tt.content) + if result != tt.expected { + t.Errorf("isGeminiJSONTranscript() = %v, want %v", result, tt.expected) + } + }) + } +} + +// TestExtractUserPromptsFromGeminiJSON tests extraction of user prompts from Gemini JSON format. +func TestExtractUserPromptsFromGeminiJSON(t *testing.T) { + tests := []struct { + name string + content string + expected []string + }{ + { + name: "single user prompt", + content: `{ + "messages": [ + {"type": "user", "content": "Create a file called test.txt"} + ] + }`, + expected: []string{"Create a file called test.txt"}, + }, + { + name: "multiple user prompts", + content: `{ + "messages": [ + {"type": "user", "content": "First prompt"}, + {"type": "gemini", "content": "Response 1"}, + {"type": "user", "content": "Second prompt"}, + {"type": "gemini", "content": "Response 2"} + ] + }`, + expected: []string{"First prompt", "Second prompt"}, + }, + { + name: "no user messages", + content: `{ + "messages": [ + {"type": "gemini", "content": "Hello!"} + ] + }`, + expected: nil, + }, + { + name: "empty messages", + content: `{"messages": []}`, + expected: nil, + }, + { + name: "user message with empty content", + content: `{ + "messages": [ + {"type": "user", "content": ""}, + {"type": "user", "content": "Valid prompt"} + ] + }`, + expected: []string{"Valid prompt"}, + }, + { + name: "invalid JSON", + content: "not json", + expected: nil, + }, + { + name: "mixed message types", + content: `{ + "sessionId": "abc123", + "messages": [ + {"type": "user", "content": "Hello"}, + {"type": "gemini", "content": "Hi!", "toolCalls": []}, + {"type": "user", "content": "Goodbye"} + ] + }`, + expected: []string{"Hello", "Goodbye"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractUserPromptsFromGeminiJSON(tt.content) + if len(result) != len(tt.expected) { + t.Errorf("extractUserPromptsFromGeminiJSON() returned %d prompts, want %d", len(result), len(tt.expected)) + return + } + for i, prompt := range result { + if prompt != tt.expected[i] { + t.Errorf("prompt[%d] = %q, want %q", i, prompt, tt.expected[i]) + } + } + }) + } +} + +// TestExtractUserPromptsFromLines tests extraction of user prompts from JSONL format. +func TestExtractUserPromptsFromLines(t *testing.T) { + tests := []struct { + name string + lines []string + expected []string + }{ + { + name: "human type message", + lines: []string{ + `{"type":"human","message":{"content":"Hello world"}}`, + }, + expected: []string{"Hello world"}, + }, + { + name: "user type message", + lines: []string{ + `{"type":"user","message":{"content":"Test prompt"}}`, + }, + expected: []string{"Test prompt"}, + }, + { + name: "mixed human and assistant", + lines: []string{ + `{"type":"human","message":{"content":"First"}}`, + `{"type":"assistant","message":{"content":"Response"}}`, + `{"type":"human","message":{"content":"Second"}}`, + }, + expected: []string{"First", "Second"}, + }, + { + name: "array content", + lines: []string{ + `{"type":"human","message":{"content":[{"type":"text","text":"Part 1"},{"type":"text","text":"Part 2"}]}}`, + }, + expected: []string{"Part 1\n\nPart 2"}, + }, + { + name: "empty lines ignored", + lines: []string{ + `{"type":"human","message":{"content":"Valid"}}`, + "", + " ", + }, + expected: []string{"Valid"}, + }, + { + name: "invalid JSON ignored", + lines: []string{ + `{"type":"human","message":{"content":"Valid"}}`, + "not json", + }, + expected: []string{"Valid"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractUserPromptsFromLines(tt.lines) + if len(result) != len(tt.expected) { + t.Errorf("extractUserPromptsFromLines() returned %d prompts, want %d", len(result), len(tt.expected)) + return + } + for i, prompt := range result { + if prompt != tt.expected[i] { + t.Errorf("prompt[%d] = %q, want %q", i, prompt, tt.expected[i]) + } + } + }) + } +} From 95737cb28c4df85fa53a7d1e9be9bce75f20bfbe Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Tue, 13 Jan 2026 15:26:39 -0800 Subject: [PATCH 02/19] fix golangci-lint errors --- cmd/entire/cli/agent/geminicli/hooks.go | 2 +- cmd/entire/cli/agent/registry.go | 2 +- cmd/entire/cli/hooks_geminicli_handlers.go | 85 +++------------------- 3 files changed, 14 insertions(+), 75 deletions(-) diff --git a/cmd/entire/cli/agent/geminicli/hooks.go b/cmd/entire/cli/agent/geminicli/hooks.go index 3094b041f..f08cd0ea9 100644 --- a/cmd/entire/cli/agent/geminicli/hooks.go +++ b/cmd/entire/cli/agent/geminicli/hooks.go @@ -62,7 +62,7 @@ func (g *GeminiCLIAgent) GetHookNames() []string { // If force is true, removes existing Entire hooks before installing. // Returns the number of hooks installed. func (g *GeminiCLIAgent) InstallHooks(localDev bool, force bool) (int, error) { - cwd, err := os.Getwd() + cwd, err := os.Getwd() //nolint:forbidigo // matches Claude Code pattern; will be addressed in future refactor if err != nil { return 0, fmt.Errorf("failed to get current directory: %w", err) } diff --git a/cmd/entire/cli/agent/registry.go b/cmd/entire/cli/agent/registry.go index 75922eaa8..ea12867c1 100644 --- a/cmd/entire/cli/agent/registry.go +++ b/cmd/entire/cli/agent/registry.go @@ -91,7 +91,7 @@ var AgentTypeToRegistryName = map[string]string{ // GetByAgentType retrieves an agent by its human-readable type name (e.g., "Claude Code", "Gemini CLI"). // This is used to get the correct agent for formatting resume commands based on session state. // -//nolint:ireturn // Factory pattern requires returning the interface + func GetByAgentType(agentType string) (Agent, error) { registryName, ok := AgentTypeToRegistryName[agentType] if !ok { diff --git a/cmd/entire/cli/hooks_geminicli_handlers.go b/cmd/entire/cli/hooks_geminicli_handlers.go index 7df6bee55..0fd25969f 100644 --- a/cmd/entire/cli/hooks_geminicli_handlers.go +++ b/cmd/entire/cli/hooks_geminicli_handlers.go @@ -20,6 +20,9 @@ import ( "entire.io/cli/cmd/entire/cli/strategy" ) +// ErrSessionSkipped is returned when a session should be skipped (e.g., due to concurrent warning). +var ErrSessionSkipped = errors.New("session skipped") + // geminiBlockingResponse represents a JSON response for Gemini CLI hooks. // When decision is "block", Gemini CLI will block the current operation and show the reason to the user. type geminiBlockingResponse struct { @@ -241,7 +244,7 @@ func parseGeminiSessionEnd() (*geminiSessionContext, error) { entireSessionID := currentSessionIDWithFallback(modelSessionID) if shouldSkipHooksForWarnedSession(entireSessionID) { - return nil, nil // Signal to skip + return nil, ErrSessionSkipped } transcriptPath := input.SessionRef @@ -439,11 +442,11 @@ func commitWithMetadataGemini() error { ctx, err := parseGeminiSessionEnd() if err != nil { + if errors.Is(err, ErrSessionSkipped) { + return nil // Skip signaled + } return err } - if ctx == nil { - return nil // Skip signaled - } if err := setupGeminiSessionDir(ctx); err != nil { return err @@ -477,7 +480,10 @@ func createContextFileForGemini(contextFile, commitMessage, sessionID string, pr sb.WriteString("\n") } - return os.WriteFile(contextFile, []byte(sb.String()), 0o600) + if err := os.WriteFile(contextFile, []byte(sb.String()), 0o600); err != nil { + return fmt.Errorf("failed to write context file: %w", err) + } + return nil } // handleGeminiBeforeTool handles the BeforeTool hook for Gemini CLI. @@ -600,7 +606,7 @@ func handleGeminiBeforeAgent() error { agentType = agentType[:idx] } if initErr := initializer.InitializeSession(entireSessionID, agentType); initErr != nil { - if handleErr := handleGeminiSessionInitErrors(ag, initErr); handleErr != nil { + if handleErr := handleSessionInitErrors(ag, initErr); handleErr != nil { return handleErr } } @@ -609,73 +615,6 @@ func handleGeminiBeforeAgent() error { return nil } -// handleGeminiSessionInitErrors handles session initialization errors for Gemini CLI. -// Provides user-friendly error messages for common error cases. -func handleGeminiSessionInitErrors(ag agent.Agent, initErr error) error { - // Check for shadow branch conflict error (worktree conflict) - var conflictErr *strategy.ShadowBranchConflictError - if errors.As(initErr, &conflictErr) { - fmt.Fprintf(os.Stderr, "\n"+ - "Warning: Shadow branch conflict detected!\n\n"+ - " Branch: %s\n"+ - " Existing session: %s\n"+ - " From worktree: %s\n"+ - " Started: %s\n\n"+ - " This may indicate another agent session is active from a different worktree,\n"+ - " or a previous session wasn't completed.\n\n"+ - " Options:\n"+ - " 1. Commit your changes (git commit) to create a new base commit\n"+ - " 2. Run 'entire rewind reset' to discard the shadow branch and start fresh\n"+ - " 3. Continue the previous session from the original worktree: %s\n\n", - conflictErr.Branch, - conflictErr.ExistingSession, - conflictErr.ExistingWorktree, - conflictErr.LastActivity.Format(time.RFC822), - conflictErr.ExistingWorktree, - ) - return fmt.Errorf("shadow branch conflict: %w", initErr) - } - - // Check for session ID conflict error (shadow branch has different session) - var sessionConflictErr *strategy.SessionIDConflictError - if errors.As(initErr, &sessionConflictErr) { - // Try to get the conflicting session's agent type from its state file - // If it's a different agent type, use that agent's resume command format - var resumeCmd string - existingState, loadErr := strategy.LoadSessionState(sessionConflictErr.ExistingSession) - if loadErr == nil && existingState != nil && existingState.AgentType != "" { - if conflictingAgent, agentErr := agent.GetByAgentType(existingState.AgentType); agentErr == nil { - resumeCmd = conflictingAgent.FormatResumeCommand(conflictingAgent.ExtractAgentSessionID(sessionConflictErr.ExistingSession)) - } - } - // Fall back to current agent if we couldn't get the conflicting agent - if resumeCmd == "" { - resumeCmd = ag.FormatResumeCommand(ag.ExtractAgentSessionID(sessionConflictErr.ExistingSession)) - } - fmt.Fprintf(os.Stderr, "\n"+ - "Warning: Session ID conflict detected!\n\n"+ - " Shadow branch: %s\n"+ - " Existing session: %s\n"+ - " New session: %s\n\n"+ - " The shadow branch already has checkpoints from a different session.\n"+ - " Starting a new session would orphan the existing work.\n\n"+ - " Options:\n"+ - " 1. Commit your changes (git commit) to create a new base commit\n"+ - " 2. Run 'entire rewind reset' to discard the shadow branch and start fresh\n"+ - " 3. Resume the existing session: %s\n\n", - sessionConflictErr.ShadowBranch, - sessionConflictErr.ExistingSession, - sessionConflictErr.NewSession, - resumeCmd, - ) - return fmt.Errorf("session ID conflict: %w", initErr) - } - - // Unknown error type - fmt.Fprintf(os.Stderr, "Warning: failed to initialize session state: %v\n", initErr) - return nil -} - // handleGeminiAfterAgent handles the AfterAgent hook for Gemini CLI. // This fires after the agent has finished processing and generated a response. // This is a Gemini-specific hook - Claude Code doesn't have an equivalent. From ddfc2cacc39faeef610b161de5ba79c15cc30f1c Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Thu, 15 Jan 2026 12:51:59 -0800 Subject: [PATCH 03/19] Run an agent that creates a color file inside docs, choose your color Entire-Checkpoint: c945f262e88d --- docs/blue.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 docs/blue.md diff --git a/docs/blue.md b/docs/blue.md new file mode 100644 index 000000000..afc22a18b --- /dev/null +++ b/docs/blue.md @@ -0,0 +1,3 @@ +# Blue + +Blue is a primary color in the RGB color model, and a secondary color (along with green and magenta) in the CMYK color model. It is the color of the sky and sea, and is often associated with depth and stability. It symbolizes trust, loyalty, wisdom, confidence, intelligence, faith, truth, and heaven. \ No newline at end of file From 0fb2f480ec187cd13d3836321cf15f193ab9c522 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Thu, 15 Jan 2026 12:52:29 -0800 Subject: [PATCH 04/19] Run an agent that creates a color file inside docs, choose your color Entire-Checkpoint: 9c53780d06b7 --- docs/blue.md | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 docs/blue.md diff --git a/docs/blue.md b/docs/blue.md deleted file mode 100644 index afc22a18b..000000000 --- a/docs/blue.md +++ /dev/null @@ -1,3 +0,0 @@ -# Blue - -Blue is a primary color in the RGB color model, and a secondary color (along with green and magenta) in the CMYK color model. It is the color of the sky and sea, and is often associated with depth and stability. It symbolizes trust, loyalty, wisdom, confidence, intelligence, faith, truth, and heaven. \ No newline at end of file From 15d697e8e2947b89099e327759d45e76dc2ebf54 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Thu, 15 Jan 2026 12:52:56 -0800 Subject: [PATCH 05/19] Run an agent that creates a color file inside docs, choose your color Entire-Checkpoint: ab2b9748b105 --- docs/color.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 docs/color.md diff --git a/docs/color.md b/docs/color.md new file mode 100644 index 000000000..93bd6e3a9 --- /dev/null +++ b/docs/color.md @@ -0,0 +1,35 @@ +# Blue + +Blue is a primary color in the RGB color model. It is often associated with the sky, the sea, and feelings of calmness and serenity. + +In art and design, blue can represent: +- Trust +- Loyalty +- Wisdom +- Confidence +- Intelligence + +Different shades of blue include: +- Sky blue +- Navy blue +- Royal blue +- Cyan +- Teal +- Azure + +# Red + +Red is a primary color, evoking strong emotions such as passion, love, anger, and danger. It is often used to signify importance, warning, or excitement. + +In various cultures, red can symbolize: +- Energy +- War +- Courage +- Sacrifice + +Different shades of red include: +- Scarlet +- Crimson +- Ruby +- Maroon +- Burgundy From b5e48b768fe92961c22005e9ffe3225b672a7633 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Thu, 15 Jan 2026 16:55:27 -0800 Subject: [PATCH 06/19] Fix Gemini CLI hooks: AfterAgent creates checkpoints, SessionEnd cleanup/fallback only --- cmd/entire/cli/hooks_geminicli_handlers.go | 75 ++++++++++++++++++---- 1 file changed, 62 insertions(+), 13 deletions(-) diff --git a/cmd/entire/cli/hooks_geminicli_handlers.go b/cmd/entire/cli/hooks_geminicli_handlers.go index 0fd25969f..624580c26 100644 --- a/cmd/entire/cli/hooks_geminicli_handlers.go +++ b/cmd/entire/cli/hooks_geminicli_handlers.go @@ -196,9 +196,13 @@ func handleGeminiSessionStart() error { } // handleGeminiSessionEnd handles the SessionEnd hook for Gemini CLI. -// This is equivalent to Claude Code's "stop" hook - it commits the session changes with metadata. -// Uses Gemini-specific transcript parsing since Gemini uses JSON format (not JSONL like Claude). +// This fires when the user explicitly exits the session (via "exit" or "logout" commands). +// Note: The primary checkpoint creation happens in AfterAgent (equivalent to Claude's Stop). +// SessionEnd serves as a cleanup/fallback - it will commit any uncommitted changes that +// weren't captured by AfterAgent (e.g., if the user exits mid-response). func handleGeminiSessionEnd() error { + // Note: Don't parse stdin here - commitWithMetadataGemini() does its own parsing + // and stdin can only be read once. Logging happens inside parseGeminiSessionEnd(). return commitWithMetadataGemini() } @@ -243,7 +247,11 @@ func parseGeminiSessionEnd() (*geminiSessionContext, error) { entireSessionID := currentSessionIDWithFallback(modelSessionID) - if shouldSkipHooksForWarnedSession(entireSessionID) { + // Check if this session was already warned about concurrent sessions + // (checkConcurrentSessionsGemini handles this in BeforeAgent, but we also check here + // in case the session was warned and user continued anyway) + state, stateErr := strategy.LoadSessionState(entireSessionID) + if stateErr == nil && state != nil && state.ConcurrentWarningShown { return nil, ErrSessionSkipped } @@ -617,30 +625,71 @@ func handleGeminiBeforeAgent() error { // handleGeminiAfterAgent handles the AfterAgent hook for Gemini CLI. // This fires after the agent has finished processing and generated a response. -// This is a Gemini-specific hook - Claude Code doesn't have an equivalent. +// This is equivalent to Claude Code's "Stop" hook - it commits the session changes with metadata. +// AfterAgent fires after EVERY user prompt/response cycle, making it the correct place +// for checkpoint creation (not SessionEnd, which only fires on explicit exit). func handleGeminiAfterAgent() error { - // Get the agent for hook input parsing - ag, err := GetAgent() + // Skip on default branch for strategies that don't allow it + if skip, branchName := ShouldSkipOnDefaultBranchForStrategy(); skip { + fmt.Fprintf(os.Stderr, "Entire: skipping on branch '%s' - create a feature branch to use Entire tracking\n", branchName) + return nil + } + + // Always use Gemini agent for Gemini hooks + ag, err := agent.Get("gemini") if err != nil { - return fmt.Errorf("failed to get agent: %w", err) + return fmt.Errorf("failed to get gemini agent: %w", err) } - // Parse hook input - AfterAgent is similar to session/tool hooks - input, err := ag.ParseHookInput(agent.HookPostToolUse, os.Stdin) + // Parse hook input using HookStop - AfterAgent provides the same data as Stop + // (session_id, transcript_path) which is what we need for committing + input, err := ag.ParseHookInput(agent.HookStop, os.Stdin) if err != nil { return fmt.Errorf("failed to parse hook input: %w", err) } logCtx := logging.WithComponent(context.Background(), "hooks") - logging.Debug(logCtx, "gemini-after-agent", + logging.Info(logCtx, "gemini-after-agent", slog.String("hook", "after-agent"), slog.String("hook_type", "agent"), slog.String("model_session_id", input.SessionID), + slog.String("transcript_path", input.SessionRef), ) - // For now, AfterAgent is mainly for logging - // Future: Could be used for streaming/incremental state capture - return nil + modelSessionID := input.SessionID + if modelSessionID == "" { + modelSessionID = "unknown" + } + + entireSessionID := currentSessionIDWithFallback(modelSessionID) + + // Skip if this session was warned about concurrent sessions + state, stateErr := strategy.LoadSessionState(entireSessionID) + if stateErr == nil && state != nil && state.ConcurrentWarningShown { + return nil + } + + transcriptPath := input.SessionRef + if transcriptPath == "" || !fileExists(transcriptPath) { + return fmt.Errorf("transcript file not found or empty: %s", transcriptPath) + } + + // Create session context and commit + ctx := &geminiSessionContext{ + entireSessionID: entireSessionID, + modelSessionID: modelSessionID, + transcriptPath: transcriptPath, + } + + if err := setupGeminiSessionDir(ctx); err != nil { + return err + } + + if err := extractGeminiMetadata(ctx); err != nil { + return err + } + + return commitGeminiSession(ctx) } // handleGeminiBeforeModel handles the BeforeModel hook for Gemini CLI. From 92a83f0f55a9665dbec668bcbb9a291a8281728f Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Mon, 19 Jan 2026 12:15:13 -0800 Subject: [PATCH 07/19] Add 'This is a work in progress' notice to Gemini CLI hook in entire enable --- cmd/entire/cli/setup.go | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index c778c9a08..0bce47bfb 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -164,9 +164,9 @@ func runEnableWithStrategy(w io.Writer, selectedStrategy string, localDev, _, us return fmt.Errorf("failed to setup Gemini CLI hooks: %w", err) } if geminiHooksInstalled > 0 { - fmt.Fprintln(w, "✓ Gemini CLI hooks installed") + fmt.Fprintln(w, "✓ Gemini CLI hooks installed - This is a work in progress") } else { - fmt.Fprintln(w, "✓ Gemini CLI hooks verified") + fmt.Fprintln(w, "✓ Gemini CLI hooks verified - This is a work in progress") } // Setup .entire directory @@ -318,9 +318,9 @@ func runEnableInteractive(w io.Writer, localDev, _, useLocalSettings, useProject return fmt.Errorf("failed to setup Gemini CLI hooks: %w", err) } if geminiHooksInstalled > 0 { - fmt.Fprintln(w, "✓ Gemini CLI hooks installed") + fmt.Fprintln(w, "✓ Gemini CLI hooks installed - This is a work in progress") } else { - fmt.Fprintln(w, "✓ Gemini CLI hooks verified") + fmt.Fprintln(w, "✓ Gemini CLI hooks verified - This is a work in progress") } // Setup .entire directory @@ -635,9 +635,17 @@ func setupAgentHooksNonInteractive(agentName, strategyName string, localDev, for } if count == 0 { - fmt.Printf("Hooks for %s already installed\n", ag.Description()) + msg := fmt.Sprintf("Hooks for %s already installed", ag.Description()) + if agentName == agent.AgentNameGemini { + msg += " - This is a work in progress" + } + fmt.Println(msg) } else { - fmt.Printf("Installed %d hooks for %s\n", count, ag.Description()) + msg := fmt.Sprintf("Installed %d hooks for %s", count, ag.Description()) + if agentName == agent.AgentNameGemini { + msg += " - This is a work in progress" + } + fmt.Println(msg) } // Update settings to store the agent choice and strategy From 698d706369a9bcd2d297a3a04dcd26c010350ef7 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Tue, 20 Jan 2026 16:44:29 +0100 Subject: [PATCH 08/19] add replace as type for gemini Entire-Checkpoint: 864e0c91f5c0 --- .../cli/agent/geminicli/transcript_test.go | 30 ++++++++++++++++++- cmd/entire/cli/agent/geminicli/types.go | 5 ++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/agent/geminicli/transcript_test.go b/cmd/entire/cli/agent/geminicli/transcript_test.go index 0ce52dadc..76d00b3fb 100644 --- a/cmd/entire/cli/agent/geminicli/transcript_test.go +++ b/cmd/entire/cli/agent/geminicli/transcript_test.go @@ -100,7 +100,7 @@ func TestExtractModifiedFiles(t *testing.T) { func TestExtractModifiedFiles_AlternativeFieldNames(t *testing.T) { t.Parallel() - // Test different field names for file path + // Test different field names for file path (path, filename) data := []byte(`{ "messages": [ {"type": "gemini", "content": "", "toolCalls": [{"name": "write_file", "args": {"path": "via_path.go"}}]}, @@ -154,6 +154,34 @@ func TestExtractModifiedFiles_NoToolUses(t *testing.T) { } } +func TestExtractModifiedFiles_ReplaceTool(t *testing.T) { + t.Parallel() + + // Test the "replace" tool which is used by Gemini CLI for file edits + data := []byte(`{ + "messages": [ + {"type": "user", "content": "make the output uppercase"}, + {"type": "gemini", "content": "", "toolCalls": [{"name": "read_file", "args": {"file_path": "random_letter.rb"}}]}, + {"type": "gemini", "content": "", "toolCalls": [{"name": "replace", "args": {"file_path": "/path/to/random_letter.rb", "old_string": "sample", "new_string": "sample.upcase"}}]}, + {"type": "gemini", "content": "Done!"} + ] +}`) + + files, err := ExtractModifiedFiles(data) + if err != nil { + t.Fatalf("ExtractModifiedFiles() error = %v", err) + } + + // Should have random_letter.rb (read_file not included) + if len(files) != 1 { + t.Errorf("ExtractModifiedFiles() got %d files, want 1", len(files)) + } + + if len(files) > 0 && files[0] != "/path/to/random_letter.rb" { + t.Errorf("ExtractModifiedFiles() got file %q, want /path/to/random_letter.rb", files[0]) + } +} + func TestExtractLastUserPrompt(t *testing.T) { t.Parallel() diff --git a/cmd/entire/cli/agent/geminicli/types.go b/cmd/entire/cli/agent/geminicli/types.go index 2c82df21c..6dbdaedcf 100644 --- a/cmd/entire/cli/agent/geminicli/types.go +++ b/cmd/entire/cli/agent/geminicli/types.go @@ -79,10 +79,14 @@ type toolHookInputRaw struct { } // Tool names used in Gemini CLI that modify files +// Note: Gemini CLI uses different names in different contexts: +// - Internal/transcript names: write_file, replace +// - Display names: WriteFile, Edit const ( ToolWriteFile = "write_file" ToolEditFile = "edit_file" ToolSaveFile = "save_file" + ToolReplace = "replace" ) // FileModificationTools lists tools that create or modify files in Gemini CLI @@ -90,4 +94,5 @@ var FileModificationTools = []string{ ToolWriteFile, ToolEditFile, ToolSaveFile, + ToolReplace, } From c352da9cd2188fa7232dbd28892f5de18d9d36af Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Tue, 20 Jan 2026 17:10:56 +0100 Subject: [PATCH 09/19] deduplication when AfterAgent and SessionEnd fire together Entire-Checkpoint: de22551397e1 --- cmd/entire/cli/checkpoint/checkpoint.go | 17 ++++++-- cmd/entire/cli/checkpoint/temporary.go | 41 +++++++++++++++----- cmd/entire/cli/strategy/manual_commit_git.go | 15 ++++++- 3 files changed, 59 insertions(+), 14 deletions(-) diff --git a/cmd/entire/cli/checkpoint/checkpoint.go b/cmd/entire/cli/checkpoint/checkpoint.go index c0518e38b..284fe5fda 100644 --- a/cmd/entire/cli/checkpoint/checkpoint.go +++ b/cmd/entire/cli/checkpoint/checkpoint.go @@ -48,14 +48,15 @@ const ( // This is used by strategies to implement their storage approach. // // The interface matches the GitStore implementation signatures directly: -// - WriteTemporary takes WriteTemporaryOptions and returns the commit hash +// - WriteTemporary takes WriteTemporaryOptions and returns a result with commit hash and skip status // - ReadTemporary takes baseCommit (not sessionID) since shadow branches are keyed by commit // - List methods return implementation-specific info types for richer data type Store interface { // WriteTemporary writes a temporary checkpoint (full state) to a shadow branch. // Shadow branches are named entire/. - // Returns the commit hash of the created checkpoint. - WriteTemporary(ctx context.Context, opts WriteTemporaryOptions) (plumbing.Hash, error) + // Returns a result containing the commit hash and whether the checkpoint was skipped. + // Checkpoints are skipped (deduplicated) when the tree hash matches the previous checkpoint. + WriteTemporary(ctx context.Context, opts WriteTemporaryOptions) (WriteTemporaryResult, error) // ReadTemporary reads the latest checkpoint from a shadow branch. // baseCommit is the commit hash the session is based on. @@ -77,6 +78,16 @@ type Store interface { ListCommitted(ctx context.Context) ([]CommittedInfo, error) } +// WriteTemporaryResult contains the result of writing a temporary checkpoint. +type WriteTemporaryResult struct { + // CommitHash is the hash of the created or existing checkpoint commit + CommitHash plumbing.Hash + + // Skipped is true if the checkpoint was skipped due to no changes + // (tree hash matched the previous checkpoint) + Skipped bool +} + // WriteTemporaryOptions contains options for writing a temporary checkpoint. type WriteTemporaryOptions struct { // SessionID is the session identifier diff --git a/cmd/entire/cli/checkpoint/temporary.go b/cmd/entire/cli/checkpoint/temporary.go index 2f6320bfe..e3f477f82 100644 --- a/cmd/entire/cli/checkpoint/temporary.go +++ b/cmd/entire/cli/checkpoint/temporary.go @@ -34,18 +34,20 @@ const ( // WriteTemporary writes a temporary checkpoint to a shadow branch. // Shadow branches are named entire/. -// Returns the commit hash of the created checkpoint. -func (s *GitStore) WriteTemporary(ctx context.Context, opts WriteTemporaryOptions) (plumbing.Hash, error) { +// Returns the result containing commit hash and whether it was skipped. +// If the new tree hash matches the last checkpoint's tree hash, the checkpoint +// is skipped to avoid duplicate commits (deduplication). +func (s *GitStore) WriteTemporary(ctx context.Context, opts WriteTemporaryOptions) (WriteTemporaryResult, error) { _ = ctx // Reserved for future use (e.g., cancellation) // Validate base commit - required for shadow branch naming if opts.BaseCommit == "" { - return plumbing.ZeroHash, errors.New("BaseCommit is required for temporary checkpoint") + return WriteTemporaryResult{}, errors.New("BaseCommit is required for temporary checkpoint") } // Validate session ID to prevent path traversal if err := paths.ValidateSessionID(opts.SessionID); err != nil { - return plumbing.ZeroHash, fmt.Errorf("invalid temporary checkpoint options: %w", err) + return WriteTemporaryResult{}, fmt.Errorf("invalid temporary checkpoint options: %w", err) } // Get shadow branch name @@ -54,7 +56,15 @@ func (s *GitStore) WriteTemporary(ctx context.Context, opts WriteTemporaryOption // Get or create shadow branch parentHash, baseTreeHash, err := s.getOrCreateShadowBranch(shadowBranchName) if err != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to get shadow branch: %w", err) + return WriteTemporaryResult{}, fmt.Errorf("failed to get shadow branch: %w", err) + } + + // Get the last checkpoint's tree hash for deduplication + var lastTreeHash plumbing.Hash + if parentHash != plumbing.ZeroHash { + if lastCommit, err := s.repo.CommitObject(parentHash); err == nil { + lastTreeHash = lastCommit.TreeHash + } } // Collect all files to include @@ -64,7 +74,7 @@ func (s *GitStore) WriteTemporary(ctx context.Context, opts WriteTemporaryOption // This ensures untracked files present at session start are included allFiles, err = collectWorkingDirectoryFiles() if err != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to collect working directory files: %w", err) + return WriteTemporaryResult{}, fmt.Errorf("failed to collect working directory files: %w", err) } } else { // For subsequent checkpoints, only include modified/new files @@ -76,7 +86,15 @@ func (s *GitStore) WriteTemporary(ctx context.Context, opts WriteTemporaryOption // Build tree with changes treeHash, err := s.buildTreeWithChanges(baseTreeHash, allFiles, opts.DeletedFiles, opts.MetadataDir, opts.MetadataDirAbs) if err != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to build tree: %w", err) + return WriteTemporaryResult{}, fmt.Errorf("failed to build tree: %w", err) + } + + // Deduplication: skip if tree hash matches the last checkpoint + if lastTreeHash != plumbing.ZeroHash && treeHash == lastTreeHash { + return WriteTemporaryResult{ + CommitHash: parentHash, + Skipped: true, + }, nil } // Create checkpoint commit with trailers @@ -84,17 +102,20 @@ func (s *GitStore) WriteTemporary(ctx context.Context, opts WriteTemporaryOption commitHash, err := s.createCommit(treeHash, parentHash, commitMsg, opts.AuthorName, opts.AuthorEmail) if err != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to create commit: %w", err) + return WriteTemporaryResult{}, fmt.Errorf("failed to create commit: %w", err) } // Update branch reference refName := plumbing.NewBranchReferenceName(shadowBranchName) newRef := plumbing.NewHashReference(refName, commitHash) if err := s.repo.Storer.SetReference(newRef); err != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to update branch reference: %w", err) + return WriteTemporaryResult{}, fmt.Errorf("failed to update branch reference: %w", err) } - return commitHash, nil + return WriteTemporaryResult{ + CommitHash: commitHash, + Skipped: false, + }, nil } // ReadTemporary reads the latest checkpoint from a shadow branch. diff --git a/cmd/entire/cli/strategy/manual_commit_git.go b/cmd/entire/cli/strategy/manual_commit_git.go index d86662e35..b025c5790 100644 --- a/cmd/entire/cli/strategy/manual_commit_git.go +++ b/cmd/entire/cli/strategy/manual_commit_git.go @@ -57,7 +57,7 @@ func (s *ManualCommitStrategy) SaveChanges(ctx SaveContext) error { // Use WriteTemporary to create the checkpoint isFirstCheckpointOfSession := state.CheckpointCount == 0 - _, err = store.WriteTemporary(context.Background(), checkpoint.WriteTemporaryOptions{ + result, err := store.WriteTemporary(context.Background(), checkpoint.WriteTemporaryOptions{ SessionID: sessionID, BaseCommit: state.BaseCommit, ModifiedFiles: ctx.ModifiedFiles, @@ -74,6 +74,19 @@ func (s *ManualCommitStrategy) SaveChanges(ctx SaveContext) error { return fmt.Errorf("failed to write temporary checkpoint: %w", err) } + // If checkpoint was skipped due to deduplication (no changes), return early + if result.Skipped { + logCtx := logging.WithComponent(context.Background(), "checkpoint") + logging.Info(logCtx, "checkpoint skipped (no changes)", + slog.String("strategy", "manual-commit"), + slog.String("checkpoint_type", "session"), + slog.Int("checkpoint_count", state.CheckpointCount), + slog.String("shadow_branch", shadowBranchName), + ) + fmt.Fprintf(os.Stderr, "Skipped checkpoint (no changes since last checkpoint)\n") + return nil + } + // Update session state state.CheckpointCount++ From bbdbf683be8da9d2b6812aca179c5c6a7c2ed468 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Tue, 20 Jan 2026 17:47:34 +0100 Subject: [PATCH 10/19] auto dedup on auto commit, tests Entire-Checkpoint: d70360ee0899 --- cmd/entire/cli/checkpoint/checkpoint_test.go | 125 +++++++++++++++++++ cmd/entire/cli/strategy/auto_commit.go | 36 ++++-- cmd/entire/cli/strategy/auto_commit_test.go | 98 +++++++++++++++ 3 files changed, 250 insertions(+), 9 deletions(-) diff --git a/cmd/entire/cli/checkpoint/checkpoint_test.go b/cmd/entire/cli/checkpoint/checkpoint_test.go index cf75ba6a1..17f07ae72 100644 --- a/cmd/entire/cli/checkpoint/checkpoint_test.go +++ b/cmd/entire/cli/checkpoint/checkpoint_test.go @@ -182,3 +182,128 @@ func TestWriteCommitted_AgentField(t *testing.T) { paths.AgentTrailerKey, agentName, commit.Message) } } + +// TestWriteTemporary_Deduplication verifies that WriteTemporary skips creating +// a new commit when the tree hash matches the previous checkpoint. +func TestWriteTemporary_Deduplication(t *testing.T) { + tempDir := t.TempDir() + + // Initialize a git repository with an initial commit + repo, err := git.PlainInit(tempDir, false) + if err != nil { + t.Fatalf("failed to init git repo: %v", err) + } + + // Create worktree and make initial commit + worktree, err := repo.Worktree() + if err != nil { + t.Fatalf("failed to get worktree: %v", err) + } + + readmeFile := filepath.Join(tempDir, "README.md") + if err := os.WriteFile(readmeFile, []byte("# Test"), 0o644); err != nil { + t.Fatalf("failed to write README: %v", err) + } + if _, err := worktree.Add("README.md"); err != nil { + t.Fatalf("failed to add README: %v", err) + } + initialCommit, err := worktree.Commit("Initial commit", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com"}, + }) + if err != nil { + t.Fatalf("failed to commit: %v", err) + } + + // Change to temp dir so paths.RepoRoot() works correctly + t.Chdir(tempDir) + + // Create a test file that will be included in checkpoints + testFile := filepath.Join(tempDir, "test.go") + if err := os.WriteFile(testFile, []byte("package main\n"), 0o644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + // Create metadata directory + metadataDir := filepath.Join(tempDir, ".entire", "metadata", "test-session") + if err := os.MkdirAll(metadataDir, 0o755); err != nil { + t.Fatalf("failed to create metadata dir: %v", err) + } + if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{"test": true}`), 0o644); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + // Create checkpoint store + store := NewGitStore(repo) + + // First checkpoint should be created + baseCommit := initialCommit.String() + result1, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{ + SessionID: "test-session", + BaseCommit: baseCommit, + ModifiedFiles: []string{"test.go"}, + MetadataDir: ".entire/metadata/test-session", + MetadataDirAbs: metadataDir, + CommitMessage: "Checkpoint 1", + AuthorName: "Test", + AuthorEmail: "test@test.com", + IsFirstCheckpoint: true, + }) + if err != nil { + t.Fatalf("WriteTemporary() first call error = %v", err) + } + if result1.Skipped { + t.Error("first checkpoint should not be skipped") + } + if result1.CommitHash == plumbing.ZeroHash { + t.Error("first checkpoint should have a commit hash") + } + + // Second checkpoint with identical content should be skipped + result2, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{ + SessionID: "test-session", + BaseCommit: baseCommit, + ModifiedFiles: []string{"test.go"}, + MetadataDir: ".entire/metadata/test-session", + MetadataDirAbs: metadataDir, + CommitMessage: "Checkpoint 2", + AuthorName: "Test", + AuthorEmail: "test@test.com", + IsFirstCheckpoint: false, + }) + if err != nil { + t.Fatalf("WriteTemporary() second call error = %v", err) + } + if !result2.Skipped { + t.Error("second checkpoint with identical content should be skipped") + } + if result2.CommitHash != result1.CommitHash { + t.Errorf("skipped checkpoint should return previous commit hash, got %s, want %s", + result2.CommitHash, result1.CommitHash) + } + + // Modify the file and create another checkpoint - should NOT be skipped + if err := os.WriteFile(testFile, []byte("package main\n\nfunc main() {}\n"), 0o644); err != nil { + t.Fatalf("failed to modify test file: %v", err) + } + + result3, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{ + SessionID: "test-session", + BaseCommit: baseCommit, + ModifiedFiles: []string{"test.go"}, + MetadataDir: ".entire/metadata/test-session", + MetadataDirAbs: metadataDir, + CommitMessage: "Checkpoint 3", + AuthorName: "Test", + AuthorEmail: "test@test.com", + IsFirstCheckpoint: false, + }) + if err != nil { + t.Fatalf("WriteTemporary() third call error = %v", err) + } + if result3.Skipped { + t.Error("third checkpoint with modified content should NOT be skipped") + } + if result3.CommitHash == result1.CommitHash { + t.Error("third checkpoint should have a different commit hash than first") + } +} diff --git a/cmd/entire/cli/strategy/auto_commit.go b/cmd/entire/cli/strategy/auto_commit.go index b8aefc7ae..8f175eb13 100644 --- a/cmd/entire/cli/strategy/auto_commit.go +++ b/cmd/entire/cli/strategy/auto_commit.go @@ -144,11 +144,23 @@ func (s *AutoCommitStrategy) SaveChanges(ctx SaveContext) error { // We do code first to avoid orphaned metadata if this step fails. // If metadata commit fails after this, the code commit exists but GetRewindPoints // already handles missing metadata gracefully (skips commits without metadata). - _, err = s.commitCodeToActive(repo, ctx, checkpointID) + codeResult, err := s.commitCodeToActive(repo, ctx, checkpointID) if err != nil { return fmt.Errorf("failed to commit code to active branch: %w", err) } + // If no code commit was created (no changes), skip metadata creation + // This prevents orphaned metadata commits that don't correspond to any code commit + if !codeResult.Created { + logCtx := logging.WithComponent(context.Background(), "checkpoint") + logging.Info(logCtx, "checkpoint skipped (no changes)", + slog.String("strategy", "auto-commit"), + slog.String("checkpoint_type", "session"), + ) + fmt.Fprintf(os.Stderr, "Skipped checkpoint (no changes since last commit)\n") + return nil + } + // Step 2: Commit metadata to entire/sessions branch using sharded path // Path is // for direct lookup _, err = s.commitMetadataToMetadataBranch(repo, ctx, checkpointID) @@ -170,24 +182,30 @@ func (s *AutoCommitStrategy) SaveChanges(ctx SaveContext) error { return nil } +// commitCodeResult contains the result of committing code to the active branch. +type commitCodeResult struct { + CommitHash plumbing.Hash + Created bool // True if a new commit was created, false if skipped (no changes) +} + // commitCodeToActive commits code changes to the active branch. // Adds an Entire-Checkpoint trailer for metadata lookup that survives amend/rebase. -// Returns the commit hash so metadata can be stored on entire/sessions. -func (s *AutoCommitStrategy) commitCodeToActive(repo *git.Repository, ctx SaveContext, checkpointID string) (plumbing.Hash, error) { +// Returns the result containing commit hash and whether a commit was created. +func (s *AutoCommitStrategy) commitCodeToActive(repo *git.Repository, ctx SaveContext, checkpointID string) (commitCodeResult, error) { // Check if there are any code changes to commit if len(ctx.ModifiedFiles) == 0 && len(ctx.NewFiles) == 0 && len(ctx.DeletedFiles) == 0 { fmt.Fprintf(os.Stderr, "No code changes to commit to active branch\n") - // Return current HEAD hash so metadata can still be stored + // Return current HEAD hash but mark as not created head, err := repo.Head() if err != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to get HEAD: %w", err) + return commitCodeResult{}, fmt.Errorf("failed to get HEAD: %w", err) } - return head.Hash(), nil + return commitCodeResult{CommitHash: head.Hash(), Created: false}, nil } worktree, err := repo.Worktree() if err != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to get worktree: %w", err) + return commitCodeResult{}, fmt.Errorf("failed to get worktree: %w", err) } // Stage code changes @@ -203,11 +221,11 @@ func (s *AutoCommitStrategy) commitCodeToActive(repo *git.Repository, ctx SaveCo } commitHash, err := commitOrHead(repo, worktree, commitMsg, author) if err != nil { - return plumbing.ZeroHash, err + return commitCodeResult{}, err } fmt.Fprintf(os.Stderr, "Committed code changes to active branch (%s)\n", commitHash.String()[:7]) - return commitHash, nil + return commitCodeResult{CommitHash: commitHash, Created: true}, nil } // commitMetadataToMetadataBranch commits session metadata to the entire/sessions branch. diff --git a/cmd/entire/cli/strategy/auto_commit_test.go b/cmd/entire/cli/strategy/auto_commit_test.go index 47c9dd0ec..229616164 100644 --- a/cmd/entire/cli/strategy/auto_commit_test.go +++ b/cmd/entire/cli/strategy/auto_commit_test.go @@ -901,3 +901,101 @@ func TestAutoCommitStrategy_GetCheckpointLog_ReadsFullJsonl(t *testing.T) { t.Errorf("GetCheckpointLog() content = %q, want %q", string(content), expectedContent) } } + +// TestAutoCommitStrategy_SaveChanges_NoChangesSkipped verifies that SaveChanges +// skips creating metadata when there are no code changes to commit. +// This ensures 1:1 mapping between code commits and metadata commits. +func TestAutoCommitStrategy_SaveChanges_NoChangesSkipped(t *testing.T) { + // Setup temp git repo + dir := t.TempDir() + repo, err := git.PlainInit(dir, false) + if err != nil { + t.Fatalf("failed to init git repo: %v", err) + } + + // Create initial commit + worktree, err := repo.Worktree() + if err != nil { + t.Fatalf("failed to get worktree: %v", err) + } + readmeFile := filepath.Join(dir, "README.md") + if err := os.WriteFile(readmeFile, []byte("# Test"), 0o644); err != nil { + t.Fatalf("failed to write README: %v", err) + } + if _, err := worktree.Add("README.md"); err != nil { + t.Fatalf("failed to add README: %v", err) + } + initialCommit, err := worktree.Commit("Initial commit", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com"}, + }) + if err != nil { + t.Fatalf("failed to commit: %v", err) + } + + t.Chdir(dir) + + // Setup strategy + s := NewAutoCommitStrategy() + if err := s.EnsureSetup(); err != nil { + t.Fatalf("EnsureSetup() error = %v", err) + } + + // Get count of commits on entire/sessions before the call + sessionsRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) + if err != nil { + t.Fatalf("entire/sessions branch not found: %v", err) + } + sessionsCommitBefore := sessionsRef.Hash() + + // Create metadata directory (without any file changes to commit) + sessionID := "2025-12-22-no-changes-test" + metadataDir := filepath.Join(dir, paths.EntireMetadataDir, sessionID) + if err := os.MkdirAll(metadataDir, 0o750); err != nil { + t.Fatalf("failed to create metadata dir: %v", err) + } + logFile := filepath.Join(metadataDir, paths.TranscriptFileName) + if err := os.WriteFile(logFile, []byte("test session log"), 0o644); err != nil { + t.Fatalf("failed to write log file: %v", err) + } + + metadataDirAbs, err := paths.AbsPath(metadataDir) + if err != nil { + metadataDirAbs = metadataDir + } + + // Call SaveChanges with NO file changes (empty lists) + ctx := SaveContext{ + CommitMessage: "Should be skipped", + MetadataDir: metadataDir, + MetadataDirAbs: metadataDirAbs, + NewFiles: []string{}, // Empty - no changes + ModifiedFiles: []string{}, // Empty - no changes + DeletedFiles: []string{}, // Empty - no changes + AuthorName: "Test", + AuthorEmail: "test@test.com", + } + + // SaveChanges should succeed without error (skip is not an error) + if err := s.SaveChanges(ctx); err != nil { + t.Fatalf("SaveChanges() error = %v", err) + } + + // Verify HEAD is still the initial commit (no new code commit) + head, err := repo.Head() + if err != nil { + t.Fatalf("failed to get HEAD: %v", err) + } + if head.Hash() != initialCommit { + t.Errorf("HEAD should still be initial commit %s, got %s", initialCommit, head.Hash()) + } + + // Verify entire/sessions branch has no new commits (metadata not created) + sessionsRefAfter, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) + if err != nil { + t.Fatalf("entire/sessions branch not found after SaveChanges: %v", err) + } + if sessionsRefAfter.Hash() != sessionsCommitBefore { + t.Errorf("entire/sessions should not have new commits when no code changes, before=%s after=%s", + sessionsCommitBefore, sessionsRefAfter.Hash()) + } +} From b760a7e2e1e3fa518d9b237a4173138f14c2b8d3 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Tue, 20 Jan 2026 17:58:01 +0100 Subject: [PATCH 11/19] review feedback Entire-Checkpoint: 881d5df3659e --- cmd/entire/cli/strategy/auto_commit.go | 15 ++- cmd/entire/cli/strategy/auto_commit_test.go | 114 ++++++++++++++++++++ 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/strategy/auto_commit.go b/cmd/entire/cli/strategy/auto_commit.go index 8f175eb13..5367a7255 100644 --- a/cmd/entire/cli/strategy/auto_commit.go +++ b/cmd/entire/cli/strategy/auto_commit.go @@ -208,6 +208,13 @@ func (s *AutoCommitStrategy) commitCodeToActive(repo *git.Repository, ctx SaveCo return commitCodeResult{}, fmt.Errorf("failed to get worktree: %w", err) } + // Get HEAD hash before commit to detect if commitOrHead actually creates a new commit + // (commitOrHead returns HEAD hash without error when git.ErrEmptyCommit occurs) + headBefore, err := repo.Head() + if err != nil { + return commitCodeResult{}, fmt.Errorf("failed to get HEAD: %w", err) + } + // Stage code changes StageFiles(worktree, ctx.ModifiedFiles, ctx.NewFiles, ctx.DeletedFiles, StageForSession) @@ -224,8 +231,12 @@ func (s *AutoCommitStrategy) commitCodeToActive(repo *git.Repository, ctx SaveCo return commitCodeResult{}, err } - fmt.Fprintf(os.Stderr, "Committed code changes to active branch (%s)\n", commitHash.String()[:7]) - return commitCodeResult{CommitHash: commitHash, Created: true}, nil + // Check if a new commit was actually created by comparing with HEAD before + created := commitHash != headBefore.Hash() + if created { + fmt.Fprintf(os.Stderr, "Committed code changes to active branch (%s)\n", commitHash.String()[:7]) + } + return commitCodeResult{CommitHash: commitHash, Created: created}, nil } // commitMetadataToMetadataBranch commits session metadata to the entire/sessions branch. diff --git a/cmd/entire/cli/strategy/auto_commit_test.go b/cmd/entire/cli/strategy/auto_commit_test.go index 229616164..72deccccb 100644 --- a/cmd/entire/cli/strategy/auto_commit_test.go +++ b/cmd/entire/cli/strategy/auto_commit_test.go @@ -902,6 +902,120 @@ func TestAutoCommitStrategy_GetCheckpointLog_ReadsFullJsonl(t *testing.T) { } } +// TestAutoCommitStrategy_SaveChanges_FilesAlreadyCommitted verifies that SaveChanges +// skips creating metadata when files are listed but already committed by the user. +// This handles the case where git.ErrEmptyCommit occurs during commit. +func TestAutoCommitStrategy_SaveChanges_FilesAlreadyCommitted(t *testing.T) { + // Setup temp git repo + dir := t.TempDir() + repo, err := git.PlainInit(dir, false) + if err != nil { + t.Fatalf("failed to init git repo: %v", err) + } + + // Create initial commit + worktree, err := repo.Worktree() + if err != nil { + t.Fatalf("failed to get worktree: %v", err) + } + readmeFile := filepath.Join(dir, "README.md") + if err := os.WriteFile(readmeFile, []byte("# Test"), 0o644); err != nil { + t.Fatalf("failed to write README: %v", err) + } + if _, err := worktree.Add("README.md"); err != nil { + t.Fatalf("failed to add README: %v", err) + } + _, err = worktree.Commit("Initial commit", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com"}, + }) + if err != nil { + t.Fatalf("failed to commit: %v", err) + } + + t.Chdir(dir) + + // Setup strategy + s := NewAutoCommitStrategy() + if err := s.EnsureSetup(); err != nil { + t.Fatalf("EnsureSetup() error = %v", err) + } + + // Create a test file and commit it manually (simulating user committing before hook runs) + testFile := filepath.Join(dir, "test.go") + if err := os.WriteFile(testFile, []byte("package main"), 0o644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + if _, err := worktree.Add("test.go"); err != nil { + t.Fatalf("failed to add test file: %v", err) + } + userCommit, err := worktree.Commit("User committed the file first", &git.CommitOptions{ + Author: &object.Signature{Name: "User", Email: "user@test.com"}, + }) + if err != nil { + t.Fatalf("failed to commit test file: %v", err) + } + + // Get count of commits on entire/sessions before the call + sessionsRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) + if err != nil { + t.Fatalf("entire/sessions branch not found: %v", err) + } + sessionsCommitBefore := sessionsRef.Hash() + + // Create metadata directory + sessionID := "2025-12-22-already-committed-test" + metadataDir := filepath.Join(dir, paths.EntireMetadataDir, sessionID) + if err := os.MkdirAll(metadataDir, 0o750); err != nil { + t.Fatalf("failed to create metadata dir: %v", err) + } + logFile := filepath.Join(metadataDir, paths.TranscriptFileName) + if err := os.WriteFile(logFile, []byte("test session log"), 0o644); err != nil { + t.Fatalf("failed to write log file: %v", err) + } + + metadataDirAbs, err := paths.AbsPath(metadataDir) + if err != nil { + metadataDirAbs = metadataDir + } + + // Call SaveChanges with the file that was already committed + // This simulates the hook running after the user already committed the changes + ctx := SaveContext{ + CommitMessage: "Should be skipped - file already committed", + MetadataDir: metadataDir, + MetadataDirAbs: metadataDirAbs, + NewFiles: []string{"test.go"}, // File exists but already committed + ModifiedFiles: []string{}, + DeletedFiles: []string{}, + AuthorName: "Test", + AuthorEmail: "test@test.com", + } + + // SaveChanges should succeed without error (skip is not an error) + if err := s.SaveChanges(ctx); err != nil { + t.Fatalf("SaveChanges() error = %v", err) + } + + // Verify HEAD is still the user's commit (no new code commit created) + head, err := repo.Head() + if err != nil { + t.Fatalf("failed to get HEAD: %v", err) + } + if head.Hash() != userCommit { + t.Errorf("HEAD should still be user's commit %s, got %s", userCommit, head.Hash()) + } + + // Verify entire/sessions branch has no new commits (metadata not created) + sessionsRefAfter, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) + if err != nil { + t.Fatalf("entire/sessions branch not found after SaveChanges: %v", err) + } + if sessionsRefAfter.Hash() != sessionsCommitBefore { + t.Errorf("entire/sessions should not have new commits when files already committed, before=%s after=%s", + sessionsCommitBefore, sessionsRefAfter.Hash()) + } +} + // TestAutoCommitStrategy_SaveChanges_NoChangesSkipped verifies that SaveChanges // skips creating metadata when there are no code changes to commit. // This ensures 1:1 mapping between code commits and metadata commits. From 84cec467efb1020194e2e8913a5d4d349fbdd465 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Tue, 20 Jan 2026 19:56:49 -0800 Subject: [PATCH 12/19] Remove Gemini hooks setup from entire enable by default, except with --agent --- cmd/entire/cli/setup.go | 44 ----------------------------------------- 1 file changed, 44 deletions(-) diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index 0bce47bfb..f2a223a60 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -158,17 +158,6 @@ func runEnableWithStrategy(w io.Writer, selectedStrategy string, localDev, _, us fmt.Fprintln(w, "✓ Claude Code hooks verified") } - // Setup Gemini CLI hooks - geminiHooksInstalled, err := setupGeminiCLIHook(localDev, forceHooks) - if err != nil { - return fmt.Errorf("failed to setup Gemini CLI hooks: %w", err) - } - if geminiHooksInstalled > 0 { - fmt.Fprintln(w, "✓ Gemini CLI hooks installed - This is a work in progress") - } else { - fmt.Fprintln(w, "✓ Gemini CLI hooks verified - This is a work in progress") - } - // Setup .entire directory dirCreated, err := setupEntireDirectory() if err != nil { @@ -312,17 +301,6 @@ func runEnableInteractive(w io.Writer, localDev, _, useLocalSettings, useProject fmt.Fprintln(w, "✓ Claude Code hooks verified") } - // Setup Gemini CLI hooks - geminiHooksInstalled, err := setupGeminiCLIHook(localDev, forceHooks) - if err != nil { - return fmt.Errorf("failed to setup Gemini CLI hooks: %w", err) - } - if geminiHooksInstalled > 0 { - fmt.Fprintln(w, "✓ Gemini CLI hooks installed - This is a work in progress") - } else { - fmt.Fprintln(w, "✓ Gemini CLI hooks verified - This is a work in progress") - } - // Setup .entire directory dirCreated, err := setupEntireDirectory() if err != nil { @@ -592,28 +570,6 @@ func setupClaudeCodeHook(localDev, forceHooks bool) (int, error) { return count, nil } -// setupGeminiCLIHook sets up Gemini CLI hooks. -// This is a convenience wrapper that uses the agent package. -// Returns the number of hooks installed (0 if already installed). -func setupGeminiCLIHook(localDev, forceHooks bool) (int, error) { - ag, err := agent.Get(agent.AgentNameGemini) - if err != nil { - return 0, fmt.Errorf("failed to get gemini agent: %w", err) - } - - hookAgent, ok := ag.(agent.HookSupport) - if !ok { - return 0, errors.New("gemini agent does not support hooks") - } - - count, err := hookAgent.InstallHooks(localDev, forceHooks) - if err != nil { - return 0, fmt.Errorf("failed to install gemini hooks: %w", err) - } - - return count, nil -} - // setupAgentHooksNonInteractive sets up hooks for a specific agent non-interactively. // If strategyName is provided, it sets the strategy; otherwise uses default. func setupAgentHooksNonInteractive(agentName, strategyName string, localDev, forceHooks, skipPushSessions, noTelemetry, disableMultisessionWarning bool) error { From 7407d1a863fd840b291566bdd79c01bf2f76b725 Mon Sep 17 00:00:00 2001 From: peyton-alt Date: Tue, 20 Jan 2026 21:37:55 -0800 Subject: [PATCH 13/19] Add session token tracking for metadata.json (#82) --- cmd/entire/cli/agent/geminicli/transcript.go | 105 +++++++++++++++++++ cmd/entire/cli/agent/geminicli/types.go | 19 ++++ cmd/entire/cli/hooks_geminicli_handlers.go | 23 +++- cmd/entire/cli/state.go | 66 ++++++++++++ 4 files changed, 210 insertions(+), 3 deletions(-) diff --git a/cmd/entire/cli/agent/geminicli/transcript.go b/cmd/entire/cli/agent/geminicli/transcript.go index 333219cb0..e934b48fb 100644 --- a/cmd/entire/cli/agent/geminicli/transcript.go +++ b/cmd/entire/cli/agent/geminicli/transcript.go @@ -3,6 +3,7 @@ package geminicli import ( "encoding/json" "fmt" + "os" ) // Transcript parsing types - Gemini CLI uses JSON format for session storage @@ -166,3 +167,107 @@ func ExtractLastAssistantMessageFromTranscript(transcript *GeminiTranscript) str } return "" } + +// TranscriptPosition holds the position information for a Gemini transcript +type TranscriptPosition struct { + MessageCount int // Total number of messages +} + +// GetTranscriptPosition reads a Gemini transcript file and returns the message count. +// Returns empty position if file doesn't exist or is empty. +// For Gemini, position is based on message count (not lines like Claude Code's JSONL). +func GetTranscriptPosition(path string) (TranscriptPosition, error) { + if path == "" { + return TranscriptPosition{}, nil + } + + data, err := os.ReadFile(path) //nolint:gosec // Reading from controlled transcript path + if err != nil { + if os.IsNotExist(err) { + return TranscriptPosition{}, nil + } + return TranscriptPosition{}, fmt.Errorf("failed to read transcript: %w", err) + } + + if len(data) == 0 { + return TranscriptPosition{}, nil + } + + transcript, err := ParseTranscript(data) + if err != nil { + return TranscriptPosition{}, fmt.Errorf("failed to parse transcript: %w", err) + } + + return TranscriptPosition{ + MessageCount: len(transcript.Messages), + }, nil +} + +// TokenUsage represents aggregated token usage for a checkpoint +type TokenUsage struct { + // InputTokens is the number of input tokens (fresh, not from cache) + InputTokens int `json:"input_tokens"` + // OutputTokens is the number of output tokens generated + OutputTokens int `json:"output_tokens"` + // CacheReadTokens is the number of tokens read from cache + CacheReadTokens int `json:"cache_read_tokens"` + // APICallCount is the number of API calls made + APICallCount int `json:"api_call_count"` +} + +// CalculateTokenUsage calculates token usage from a Gemini transcript. +// This is specific to Gemini's API format where each message may have a tokens object +// with input, output, cached, thoughts, tool, and total counts. +// Only processes messages from startMessageIndex onwards (0-indexed). +func CalculateTokenUsage(data []byte, startMessageIndex int) *TokenUsage { + var transcript struct { + Messages []geminiMessageWithTokens `json:"messages"` + } + + if err := json.Unmarshal(data, &transcript); err != nil { + return &TokenUsage{} + } + + usage := &TokenUsage{} + + for i, msg := range transcript.Messages { + // Skip messages before startMessageIndex + if i < startMessageIndex { + continue + } + + // Only count tokens from gemini (assistant) messages + if msg.Type != MessageTypeGemini { + continue + } + + if msg.Tokens == nil { + continue + } + + usage.APICallCount++ + usage.InputTokens += msg.Tokens.Input + usage.OutputTokens += msg.Tokens.Output + usage.CacheReadTokens += msg.Tokens.Cached + } + + return usage +} + +// CalculateTokenUsageFromFile calculates token usage from a Gemini transcript file. +// If startMessageIndex > 0, only considers messages from that index onwards. +func CalculateTokenUsageFromFile(path string, startMessageIndex int) (*TokenUsage, error) { + if path == "" { + return &TokenUsage{}, nil + } + + data, err := os.ReadFile(path) //nolint:gosec // Reading from controlled transcript path + if err != nil { + if os.IsNotExist(err) { + return &TokenUsage{}, nil + } + return nil, fmt.Errorf("failed to read transcript: %w", err) + } + + return CalculateTokenUsage(data, startMessageIndex), nil +} diff --git a/cmd/entire/cli/agent/geminicli/types.go b/cmd/entire/cli/agent/geminicli/types.go index 6dbdaedcf..fbfcf1acb 100644 --- a/cmd/entire/cli/agent/geminicli/types.go +++ b/cmd/entire/cli/agent/geminicli/types.go @@ -96,3 +96,22 @@ var FileModificationTools = []string{ ToolSaveFile, ToolReplace, } + +// geminiMessageTokens represents token usage from a Gemini API response. +// This is specific to Gemini's API format where each message has a tokens object. +type geminiMessageTokens struct { + Input int `json:"input"` + Output int `json:"output"` + Cached int `json:"cached"` + Thoughts int `json:"thoughts"` + Tool int `json:"tool"` + Total int `json:"total"` +} + +// geminiMessageWithTokens represents a Gemini message with token usage data. +// Used for extracting token counts from Gemini transcripts. +type geminiMessageWithTokens struct { + ID string `json:"id"` + Type string `json:"type"` + Tokens *geminiMessageTokens `json:"tokens,omitempty"` +} diff --git a/cmd/entire/cli/hooks_geminicli_handlers.go b/cmd/entire/cli/hooks_geminicli_handlers.go index 624580c26..4e0313787 100644 --- a/cmd/entire/cli/hooks_geminicli_handlers.go +++ b/cmd/entire/cli/hooks_geminicli_handlers.go @@ -350,7 +350,22 @@ func commitGeminiSession(ctx *geminiSessionContext) error { fmt.Fprintf(os.Stderr, "Warning: failed to load pre-prompt state: %v\n", err) } if preState != nil { - fmt.Fprintf(os.Stderr, "Loaded pre-prompt state: %d pre-existing untracked files\n", len(preState.UntrackedFiles)) + fmt.Fprintf(os.Stderr, "Loaded pre-prompt state: %d pre-existing untracked files, start message index: %d\n", len(preState.UntrackedFiles), preState.StartMessageIndex) + } + + // Calculate token usage for this prompt/response cycle (Gemini-specific) + if ctx.transcriptPath != "" { + startIndex := 0 + if preState != nil { + startIndex = preState.StartMessageIndex + } + tokenUsage, tokenErr := geminicli.CalculateTokenUsageFromFile(ctx.transcriptPath, startIndex) + if tokenErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to calculate token usage: %v\n", tokenErr) + } else if tokenUsage != nil && tokenUsage.APICallCount > 0 { + fmt.Fprintf(os.Stderr, "Token usage for this checkpoint: input=%d, output=%d, cache_read=%d, api_calls=%d\n", + tokenUsage.InputTokens, tokenUsage.OutputTokens, tokenUsage.CacheReadTokens, tokenUsage.APICallCount) + } } newFiles, err := ComputeNewFiles(preState) @@ -600,8 +615,10 @@ func handleGeminiBeforeAgent() error { return nil } - // Capture pre-prompt state (same as Claude Code's captureInitialState) - if err := CapturePrePromptState(entireSessionID); err != nil { + // Capture pre-prompt state with transcript position (Gemini-specific) + // This captures both untracked files and the current transcript message count + // so we can calculate token usage for just this prompt/response cycle + if err := CaptureGeminiPrePromptState(entireSessionID, input.SessionRef); err != nil { return fmt.Errorf("failed to capture pre-prompt state: %w", err) } diff --git a/cmd/entire/cli/state.go b/cmd/entire/cli/state.go index 2e7e62e4e..1800e7653 100644 --- a/cmd/entire/cli/state.go +++ b/cmd/entire/cli/state.go @@ -20,6 +20,10 @@ type PrePromptState struct { SessionID string `json:"session_id"` Timestamp string `json:"timestamp"` UntrackedFiles []string `json:"untracked_files"` + // StartMessageIndex is the message count in the transcript when this state + // was captured. Used for calculating token usage since the prompt started. + // Only set for Gemini sessions. Zero means not set or session just started. + StartMessageIndex int `json:"start_message_index"` } // CapturePrePromptState captures current untracked files before a prompt @@ -68,6 +72,68 @@ func CapturePrePromptState(sessionID string) error { return nil } +// CaptureGeminiPrePromptState captures current untracked files and transcript position +// before a prompt for Gemini sessions. This is called by the BeforeAgent hook. +// The transcriptPath is the path to the Gemini session transcript (JSON format). +func CaptureGeminiPrePromptState(sessionID, transcriptPath string) error { + if sessionID == "" { + sessionID = unknownSessionID + } + + // Get absolute path for tmp directory + tmpDirAbs, err := paths.AbsPath(paths.EntireTmpDir) + if err != nil { + tmpDirAbs = paths.EntireTmpDir // Fallback to relative + } + + // Create tmp directory if it doesn't exist + if err := os.MkdirAll(tmpDirAbs, 0o750); err != nil { + return fmt.Errorf("failed to create tmp directory: %w", err) + } + + // Get list of untracked files (excluding .entire directory itself) + untrackedFiles, err := getUntrackedFilesForState() + if err != nil { + return fmt.Errorf("failed to get untracked files: %w", err) + } + + // Get transcript position (message count) for Gemini + var startMessageIndex int + if transcriptPath != "" { + // Import function is in geminicli package, but to avoid circular import, + // we'll just count messages directly here using the same logic + if data, readErr := os.ReadFile(transcriptPath); readErr == nil && len(data) > 0 { //nolint:gosec // Reading from controlled transcript path + var transcript struct { + Messages []any `json:"messages"` + } + if jsonErr := json.Unmarshal(data, &transcript); jsonErr == nil { + startMessageIndex = len(transcript.Messages) + } + } + } + + // Create state file + stateFile := prePromptStateFile(sessionID) + state := PrePromptState{ + SessionID: sessionID, + Timestamp: time.Now().UTC().Format(time.RFC3339), + UntrackedFiles: untrackedFiles, + StartMessageIndex: startMessageIndex, + } + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal state: %w", err) + } + + if err := os.WriteFile(stateFile, data, 0o600); err != nil { + return fmt.Errorf("failed to write state file: %w", err) + } + + fmt.Fprintf(os.Stderr, "Captured Gemini state before prompt: %d untracked files, transcript position: %d\n", len(untrackedFiles), startMessageIndex) + return nil +} + // LoadPrePromptState loads previously captured state. // Returns nil if no state file exists. func LoadPrePromptState(sessionID string) (*PrePromptState, error) { From 05b72ec871a47db298db726d6dcb04f7f7553361 Mon Sep 17 00:00:00 2001 From: peyton-alt Date: Tue, 20 Jan 2026 21:38:15 -0800 Subject: [PATCH 14/19] Make Gemini hooks always use go run (#80) --- cmd/entire/cli/agent/geminicli/hooks.go | 11 +++------ cmd/entire/cli/agent/geminicli/hooks_test.go | 26 ++++++++++---------- 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/cmd/entire/cli/agent/geminicli/hooks.go b/cmd/entire/cli/agent/geminicli/hooks.go index 7b91ac8fb..6766ca846 100644 --- a/cmd/entire/cli/agent/geminicli/hooks.go +++ b/cmd/entire/cli/agent/geminicli/hooks.go @@ -61,7 +61,7 @@ func (g *GeminiCLIAgent) GetHookNames() []string { // InstallHooks installs Gemini CLI hooks in .gemini/settings.json. // If force is true, removes existing Entire hooks before installing. // Returns the number of hooks installed. -func (g *GeminiCLIAgent) InstallHooks(localDev bool, force bool) (int, error) { +func (g *GeminiCLIAgent) InstallHooks(_ bool, force bool) (int, error) { cwd, err := os.Getwd() //nolint:forbidigo // matches Claude Code pattern; will be addressed in future refactor if err != nil { return 0, fmt.Errorf("failed to get current directory: %w", err) @@ -97,13 +97,8 @@ func (g *GeminiCLIAgent) InstallHooks(localDev bool, force bool) (int, error) { settings.Tools.EnableHooks = true settings.Hooks.Enabled = true - // Define hook commands based on localDev mode - var cmdPrefix string - if localDev { - cmdPrefix = "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini " - } else { - cmdPrefix = "entire hooks gemini " - } + // Always use go run for Gemini hooks (matches local development pattern) + cmdPrefix := "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini " // Check for idempotency BEFORE removing hooks // If the exact same hook command already exists, return 0 (no changes needed) diff --git a/cmd/entire/cli/agent/geminicli/hooks_test.go b/cmd/entire/cli/agent/geminicli/hooks_test.go index 8ee8d0eb4..d739a7372 100644 --- a/cmd/entire/cli/agent/geminicli/hooks_test.go +++ b/cmd/entire/cli/agent/geminicli/hooks_test.go @@ -67,19 +67,19 @@ func TestInstallHooks_FreshInstall(t *testing.T) { t.Errorf("Notification hooks = %d, want 1", len(settings.Hooks.Notification)) } - // Verify hook commands - verifyHookCommand(t, settings.Hooks.SessionStart, "", "entire hooks gemini session-start") - verifyHookCommand(t, settings.Hooks.SessionEnd, "exit", "entire hooks gemini session-end") - verifyHookCommand(t, settings.Hooks.SessionEnd, "logout", "entire hooks gemini session-end") - verifyHookCommand(t, settings.Hooks.BeforeAgent, "", "entire hooks gemini before-agent") - verifyHookCommand(t, settings.Hooks.AfterAgent, "", "entire hooks gemini after-agent") - verifyHookCommand(t, settings.Hooks.BeforeModel, "", "entire hooks gemini before-model") - verifyHookCommand(t, settings.Hooks.AfterModel, "", "entire hooks gemini after-model") - verifyHookCommand(t, settings.Hooks.BeforeToolSelection, "", "entire hooks gemini before-tool-selection") - verifyHookCommand(t, settings.Hooks.BeforeTool, "*", "entire hooks gemini before-tool") - verifyHookCommand(t, settings.Hooks.AfterTool, "*", "entire hooks gemini after-tool") - verifyHookCommand(t, settings.Hooks.PreCompress, "", "entire hooks gemini pre-compress") - verifyHookCommand(t, settings.Hooks.Notification, "", "entire hooks gemini notification") + // Verify hook commands (always use go run) + verifyHookCommand(t, settings.Hooks.SessionStart, "", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini session-start") + verifyHookCommand(t, settings.Hooks.SessionEnd, "exit", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini session-end") + verifyHookCommand(t, settings.Hooks.SessionEnd, "logout", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini session-end") + verifyHookCommand(t, settings.Hooks.BeforeAgent, "", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini before-agent") + verifyHookCommand(t, settings.Hooks.AfterAgent, "", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini after-agent") + verifyHookCommand(t, settings.Hooks.BeforeModel, "", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini before-model") + verifyHookCommand(t, settings.Hooks.AfterModel, "", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini after-model") + verifyHookCommand(t, settings.Hooks.BeforeToolSelection, "", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini before-tool-selection") + verifyHookCommand(t, settings.Hooks.BeforeTool, "*", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini before-tool") + verifyHookCommand(t, settings.Hooks.AfterTool, "*", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini after-tool") + verifyHookCommand(t, settings.Hooks.PreCompress, "", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini pre-compress") + verifyHookCommand(t, settings.Hooks.Notification, "", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini notification") } func TestInstallHooks_LocalDev(t *testing.T) { From a697c79f30addc66f10cdfc9fc89416456c403b6 Mon Sep 17 00:00:00 2001 From: peyton-alt Date: Tue, 20 Jan 2026 21:38:32 -0800 Subject: [PATCH 15/19] Revert Claude hooks to use go run for local development (#79) --- .claude/settings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index f9436ee35..b0a190172 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "entire hooks claude-code session-start" + "command": "go run ${CLAUDE_PROJECT_DIR}/cmd/entire/main.go hooks claude-code session-start" } ] } @@ -17,7 +17,7 @@ "hooks": [ { "type": "command", - "command": "entire hooks claude-code user-prompt-submit" + "command": "go run ${CLAUDE_PROJECT_DIR}/cmd/entire/main.go hooks claude-code user-prompt-submit" } ] } @@ -28,7 +28,7 @@ "hooks": [ { "type": "command", - "command": "entire hooks claude-code stop" + "command": "go run ${CLAUDE_PROJECT_DIR}/cmd/entire/main.go hooks claude-code stop" } ] } @@ -39,7 +39,7 @@ "hooks": [ { "type": "command", - "command": "entire hooks claude-code pre-task" + "command": "go run ${CLAUDE_PROJECT_DIR}/cmd/entire/main.go hooks claude-code pre-task" } ] } @@ -50,7 +50,7 @@ "hooks": [ { "type": "command", - "command": "entire hooks claude-code post-task" + "command": "go run ${CLAUDE_PROJECT_DIR}/cmd/entire/main.go hooks claude-code post-task" } ] }, @@ -59,7 +59,7 @@ "hooks": [ { "type": "command", - "command": "entire hooks claude-code post-todo" + "command": "go run ${CLAUDE_PROJECT_DIR}/cmd/entire/main.go hooks claude-code post-todo" } ] } From a0a76816353fe7332028afcc21fa503070e54bc2 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Wed, 21 Jan 2026 09:58:59 +0100 Subject: [PATCH 16/19] respect --local-dev for gemini properly Entire-Checkpoint: 9bd0b182d20c --- .gemini/settings.json | 25 ++++++++++--------- cmd/entire/cli/agent/geminicli/hooks.go | 11 ++++++--- cmd/entire/cli/agent/geminicli/hooks_test.go | 26 ++++++++++---------- 3 files changed, 34 insertions(+), 28 deletions(-) diff --git a/.gemini/settings.json b/.gemini/settings.json index 6528f1784..d8916fca5 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -1,12 +1,13 @@ { "hooks": { + "enabled": true, "SessionStart": [ { "hooks": [ { "name": "entire-session-start", "type": "command", - "command": "entire hooks gemini session-start" + "command": "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini session-start" } ] } @@ -18,7 +19,7 @@ { "name": "entire-session-end-exit", "type": "command", - "command": "entire hooks gemini session-end" + "command": "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini session-end" } ] }, @@ -28,7 +29,7 @@ { "name": "entire-session-end-logout", "type": "command", - "command": "entire hooks gemini session-end" + "command": "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini session-end" } ] } @@ -39,7 +40,7 @@ { "name": "entire-before-agent", "type": "command", - "command": "entire hooks gemini before-agent" + "command": "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini before-agent" } ] } @@ -50,7 +51,7 @@ { "name": "entire-after-agent", "type": "command", - "command": "entire hooks gemini after-agent" + "command": "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini after-agent" } ] } @@ -61,7 +62,7 @@ { "name": "entire-before-model", "type": "command", - "command": "entire hooks gemini before-model" + "command": "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini before-model" } ] } @@ -72,7 +73,7 @@ { "name": "entire-after-model", "type": "command", - "command": "entire hooks gemini after-model" + "command": "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini after-model" } ] } @@ -83,7 +84,7 @@ { "name": "entire-before-tool-selection", "type": "command", - "command": "entire hooks gemini before-tool-selection" + "command": "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini before-tool-selection" } ] } @@ -95,7 +96,7 @@ { "name": "entire-before-tool", "type": "command", - "command": "entire hooks gemini before-tool" + "command": "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini before-tool" } ] } @@ -107,7 +108,7 @@ { "name": "entire-after-tool", "type": "command", - "command": "entire hooks gemini after-tool" + "command": "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini after-tool" } ] } @@ -118,7 +119,7 @@ { "name": "entire-pre-compress", "type": "command", - "command": "entire hooks gemini pre-compress" + "command": "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini pre-compress" } ] } @@ -129,7 +130,7 @@ { "name": "entire-notification", "type": "command", - "command": "entire hooks gemini notification" + "command": "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini notification" } ] } diff --git a/cmd/entire/cli/agent/geminicli/hooks.go b/cmd/entire/cli/agent/geminicli/hooks.go index 6766ca846..7b91ac8fb 100644 --- a/cmd/entire/cli/agent/geminicli/hooks.go +++ b/cmd/entire/cli/agent/geminicli/hooks.go @@ -61,7 +61,7 @@ func (g *GeminiCLIAgent) GetHookNames() []string { // InstallHooks installs Gemini CLI hooks in .gemini/settings.json. // If force is true, removes existing Entire hooks before installing. // Returns the number of hooks installed. -func (g *GeminiCLIAgent) InstallHooks(_ bool, force bool) (int, error) { +func (g *GeminiCLIAgent) InstallHooks(localDev bool, force bool) (int, error) { cwd, err := os.Getwd() //nolint:forbidigo // matches Claude Code pattern; will be addressed in future refactor if err != nil { return 0, fmt.Errorf("failed to get current directory: %w", err) @@ -97,8 +97,13 @@ func (g *GeminiCLIAgent) InstallHooks(_ bool, force bool) (int, error) { settings.Tools.EnableHooks = true settings.Hooks.Enabled = true - // Always use go run for Gemini hooks (matches local development pattern) - cmdPrefix := "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini " + // Define hook commands based on localDev mode + var cmdPrefix string + if localDev { + cmdPrefix = "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini " + } else { + cmdPrefix = "entire hooks gemini " + } // Check for idempotency BEFORE removing hooks // If the exact same hook command already exists, return 0 (no changes needed) diff --git a/cmd/entire/cli/agent/geminicli/hooks_test.go b/cmd/entire/cli/agent/geminicli/hooks_test.go index d739a7372..7aa78429f 100644 --- a/cmd/entire/cli/agent/geminicli/hooks_test.go +++ b/cmd/entire/cli/agent/geminicli/hooks_test.go @@ -67,19 +67,19 @@ func TestInstallHooks_FreshInstall(t *testing.T) { t.Errorf("Notification hooks = %d, want 1", len(settings.Hooks.Notification)) } - // Verify hook commands (always use go run) - verifyHookCommand(t, settings.Hooks.SessionStart, "", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini session-start") - verifyHookCommand(t, settings.Hooks.SessionEnd, "exit", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini session-end") - verifyHookCommand(t, settings.Hooks.SessionEnd, "logout", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini session-end") - verifyHookCommand(t, settings.Hooks.BeforeAgent, "", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini before-agent") - verifyHookCommand(t, settings.Hooks.AfterAgent, "", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini after-agent") - verifyHookCommand(t, settings.Hooks.BeforeModel, "", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini before-model") - verifyHookCommand(t, settings.Hooks.AfterModel, "", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini after-model") - verifyHookCommand(t, settings.Hooks.BeforeToolSelection, "", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini before-tool-selection") - verifyHookCommand(t, settings.Hooks.BeforeTool, "*", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini before-tool") - verifyHookCommand(t, settings.Hooks.AfterTool, "*", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini after-tool") - verifyHookCommand(t, settings.Hooks.PreCompress, "", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini pre-compress") - verifyHookCommand(t, settings.Hooks.Notification, "", "go run ${GEMINI_PROJECT_DIR}/cmd/entire/main.go hooks gemini notification") + // Verify hook commands (localDev=false, so use entire binary) + verifyHookCommand(t, settings.Hooks.SessionStart, "", "entire hooks gemini session-start") + verifyHookCommand(t, settings.Hooks.SessionEnd, "exit", "entire hooks gemini session-end") + verifyHookCommand(t, settings.Hooks.SessionEnd, "logout", "entire hooks gemini session-end") + verifyHookCommand(t, settings.Hooks.BeforeAgent, "", "entire hooks gemini before-agent") + verifyHookCommand(t, settings.Hooks.AfterAgent, "", "entire hooks gemini after-agent") + verifyHookCommand(t, settings.Hooks.BeforeModel, "", "entire hooks gemini before-model") + verifyHookCommand(t, settings.Hooks.AfterModel, "", "entire hooks gemini after-model") + verifyHookCommand(t, settings.Hooks.BeforeToolSelection, "", "entire hooks gemini before-tool-selection") + verifyHookCommand(t, settings.Hooks.BeforeTool, "*", "entire hooks gemini before-tool") + verifyHookCommand(t, settings.Hooks.AfterTool, "*", "entire hooks gemini after-tool") + verifyHookCommand(t, settings.Hooks.PreCompress, "", "entire hooks gemini pre-compress") + verifyHookCommand(t, settings.Hooks.Notification, "", "entire hooks gemini notification") } func TestInstallHooks_LocalDev(t *testing.T) { From 8329087e90181c1da107bb825bffa56919defbd7 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Wed, 21 Jan 2026 10:32:16 +0100 Subject: [PATCH 17/19] bring in latest changes to multi session warnings from claude Entire-Checkpoint: b78c37a2e644 --- cmd/entire/cli/hooks_geminicli_handlers.go | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/hooks_geminicli_handlers.go b/cmd/entire/cli/hooks_geminicli_handlers.go index 4e0313787..2b8fce82a 100644 --- a/cmd/entire/cli/hooks_geminicli_handlers.go +++ b/cmd/entire/cli/hooks_geminicli_handlers.go @@ -53,6 +53,11 @@ func outputGeminiBlockingResponse(reason string) { // Returns true if the hook should be skipped (warning already shown), false to proceed normally. // Note: This function may call os.Exit(0) and not return if a blocking response is needed. func checkConcurrentSessionsGemini(entireSessionID string) bool { + // Check if warnings are disabled via settings + if IsMultiSessionWarningDisabled() { + return false + } + // Always use the Gemini agent for resume commands in Gemini hooks // (don't use GetAgent() which may return Claude based on settings) geminiAgent, err := agent.Get("gemini") @@ -146,9 +151,20 @@ func checkConcurrentSessionsGemini(entireSessionID string) bool { resumeCmd = geminiAgent.FormatResumeCommand(geminiAgent.ExtractAgentSessionID(otherSession.SessionID)) } + // Try to read the other session's initial prompt + otherPrompt := strategy.ReadSessionPromptFromShadow(repo, otherSession.BaseCommit, otherSession.SessionID) + + // Build message - matches Claude Code format but with Gemini-specific instructions + var message string + suppressHint := "\n\nTo suppress this warning in future sessions, run:\n entire enable --disable-multisession-warning" + if otherPrompt != "" { + message = fmt.Sprintf("Another session is active: \"%s\"\n\nYou can continue here, but checkpoints from both sessions will be interleaved.\n\nTo resume the other session instead, exit Gemini CLI and run: %s%s\n\nPress the up arrow key to get your prompt back.", otherPrompt, resumeCmd, suppressHint) + } else { + message = "Another session is active with uncommitted changes. You can continue here, but checkpoints from both sessions will be interleaved.\n\nTo resume the other session instead, exit Gemini CLI and run: " + resumeCmd + suppressHint + "\n\nPress the up arrow key to get your prompt back." + } + // Output blocking JSON response and exit - // Message format matches Claude Code but with Gemini-specific instructions - outputGeminiBlockingResponse("You have another active session with uncommitted changes. Please commit them first and then start a new Gemini session. If you continue here, your prompt and resulting changes will not be captured.\n\nTo resume the active session, close Gemini CLI and run: " + resumeCmd) + outputGeminiBlockingResponse(message) // outputGeminiBlockingResponse calls os.Exit(0), so this line is never reached return true } From 22192adda9d13fc603ca5ef567733746b9d380ad Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Wed, 21 Jan 2026 11:18:12 +0100 Subject: [PATCH 18/19] fix concurrent session handling Entire-Checkpoint: 89e8867f9092 --- cmd/entire/cli/hooks_claudecode_handlers.go | 10 +- cmd/entire/cli/hooks_geminicli_handlers.go | 65 ++--- .../gemini_concurrent_session_test.go | 261 +++++++++++++++++- cmd/entire/cli/integration_test/hooks.go | 25 +- cmd/entire/cli/integration_test/testenv.go | 6 + 5 files changed, 308 insertions(+), 59 deletions(-) diff --git a/cmd/entire/cli/hooks_claudecode_handlers.go b/cmd/entire/cli/hooks_claudecode_handlers.go index ed16963eb..755e17a00 100644 --- a/cmd/entire/cli/hooks_claudecode_handlers.go +++ b/cmd/entire/cli/hooks_claudecode_handlers.go @@ -242,10 +242,18 @@ func handleSessionInitErrors(ag agent.Agent, initErr error) error { if IsMultiSessionWarningDisabled() { return nil } + // Check if EITHER session has the concurrent warning shown + // If so, the user was already warned and chose to continue - allow concurrent sessions + existingState, loadErr := strategy.LoadSessionState(sessionConflictErr.ExistingSession) + newState, newLoadErr := strategy.LoadSessionState(sessionConflictErr.NewSession) + if (loadErr == nil && existingState != nil && existingState.ConcurrentWarningShown) || + (newLoadErr == nil && newState != nil && newState.ConcurrentWarningShown) { + // At least one session was warned - allow concurrent operation + return nil + } // Try to get the conflicting session's agent type from its state file // If it's a different agent type, use that agent's resume command format var resumeCmd string - existingState, loadErr := strategy.LoadSessionState(sessionConflictErr.ExistingSession) if loadErr == nil && existingState != nil && existingState.AgentType != "" { if conflictingAgent, agentErr := agent.GetByAgentType(existingState.AgentType); agentErr == nil { resumeCmd = conflictingAgent.FormatResumeCommand(conflictingAgent.ExtractAgentSessionID(sessionConflictErr.ExistingSession)) diff --git a/cmd/entire/cli/hooks_geminicli_handlers.go b/cmd/entire/cli/hooks_geminicli_handlers.go index 2b8fce82a..4324c3474 100644 --- a/cmd/entire/cli/hooks_geminicli_handlers.go +++ b/cmd/entire/cli/hooks_geminicli_handlers.go @@ -49,13 +49,13 @@ func outputGeminiBlockingResponse(reason string) { } // checkConcurrentSessionsGemini checks for concurrent session conflicts for Gemini CLI. -// If a conflict is found, it outputs a Gemini-format blocking response and exits (via os.Exit). -// Returns true if the hook should be skipped (warning already shown), false to proceed normally. -// Note: This function may call os.Exit(0) and not return if a blocking response is needed. -func checkConcurrentSessionsGemini(entireSessionID string) bool { +// If a conflict is found (first time), it outputs a Gemini-format blocking response and exits (via os.Exit). +// If the warning was already shown, subsequent calls proceed normally (both sessions create interleaved checkpoints). +// Note: This function may call os.Exit(0) and not return if a blocking response is needed on first conflict. +func checkConcurrentSessionsGemini(entireSessionID string) { // Check if warnings are disabled via settings if IsMultiSessionWarningDisabled() { - return false + return } // Always use the Gemini agent for resume commands in Gemini hooks @@ -69,7 +69,7 @@ func checkConcurrentSessionsGemini(entireSessionID string) bool { concurrentChecker, ok := strat.(strategy.ConcurrentSessionChecker) if !ok { - return false // Strategy doesn't support concurrent checks + return // Strategy doesn't support concurrent checks } // Check if this session already acknowledged the warning @@ -81,19 +81,18 @@ func checkConcurrentSessionsGemini(entireSessionID string) bool { hasConflict := checkErr == nil && otherSession != nil if warningAlreadyShown { - if hasConflict { - // Warning was shown and conflict still exists - skip hooks - return true - } - // Warning was shown but conflict is resolved (e.g., user committed) - // Clear the flag and proceed normally - if existingState != nil { - existingState.ConcurrentWarningShown = false - if saveErr := strategy.SaveSessionState(existingState); saveErr != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to clear concurrent warning flag: %v\n", saveErr) + // Warning was already shown to user - don't show it again, just proceed normally + // Both sessions will create interleaved checkpoints as promised in the warning message + if !hasConflict { + // Conflict resolved (e.g., user committed) - clear the flag + if existingState != nil { + existingState.ConcurrentWarningShown = false + if saveErr := strategy.SaveSessionState(existingState); saveErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to clear concurrent warning flag: %v\n", saveErr) + } } } - return false + return // Proceed normally } if hasConflict { @@ -103,15 +102,13 @@ func checkConcurrentSessionsGemini(entireSessionID string) bool { if err != nil { // Output user-friendly error message via blocking response outputGeminiBlockingResponse(fmt.Sprintf("Failed to open git repository: %v\n\nPlease ensure you're in a git repository and try again.", err)) - // outputGeminiBlockingResponse calls os.Exit(0), so this line is never reached - return true + // outputGeminiBlockingResponse calls os.Exit(0), never returns } head, err := repo.Head() if err != nil { // Output user-friendly error message via blocking response outputGeminiBlockingResponse(fmt.Sprintf("Failed to get git HEAD: %v\n\nPlease ensure the repository has at least one commit.", err)) - // outputGeminiBlockingResponse calls os.Exit(0), so this line is never reached - return true + // outputGeminiBlockingResponse calls os.Exit(0), never returns } worktreePath, err := strategy.GetWorktreePath() if err != nil { @@ -165,11 +162,8 @@ func checkConcurrentSessionsGemini(entireSessionID string) bool { // Output blocking JSON response and exit outputGeminiBlockingResponse(message) - // outputGeminiBlockingResponse calls os.Exit(0), so this line is never reached - return true + // outputGeminiBlockingResponse calls os.Exit(0), never returns } - - return false } // handleGeminiSessionStart handles the SessionStart hook for Gemini CLI. @@ -263,14 +257,6 @@ func parseGeminiSessionEnd() (*geminiSessionContext, error) { entireSessionID := currentSessionIDWithFallback(modelSessionID) - // Check if this session was already warned about concurrent sessions - // (checkConcurrentSessionsGemini handles this in BeforeAgent, but we also check here - // in case the session was warned and user continued anyway) - state, stateErr := strategy.LoadSessionState(entireSessionID) - if stateErr == nil && state != nil && state.ConcurrentWarningShown { - return nil, ErrSessionSkipped - } - transcriptPath := input.SessionRef if transcriptPath == "" || !fileExists(transcriptPath) { return nil, fmt.Errorf("transcript file not found or empty: %s", transcriptPath) @@ -626,10 +612,9 @@ func handleGeminiBeforeAgent() error { entireSessionID := paths.EntireSessionID(input.SessionID) // Check for concurrent sessions before proceeding - // This will output a blocking response and exit if there's a conflict - if checkConcurrentSessionsGemini(entireSessionID) { - return nil - } + // This will output a blocking response and exit if there's a conflict (first time only) + // On subsequent prompts, it proceeds normally (both sessions create interleaved checkpoints) + checkConcurrentSessionsGemini(entireSessionID) // Capture pre-prompt state with transcript position (Gemini-specific) // This captures both untracked files and the current transcript message count @@ -696,12 +681,6 @@ func handleGeminiAfterAgent() error { entireSessionID := currentSessionIDWithFallback(modelSessionID) - // Skip if this session was warned about concurrent sessions - state, stateErr := strategy.LoadSessionState(entireSessionID) - if stateErr == nil && state != nil && state.ConcurrentWarningShown { - return nil - } - transcriptPath := input.SessionRef if transcriptPath == "" || !fileExists(transcriptPath) { return fmt.Errorf("transcript file not found or empty: %s", transcriptPath) diff --git a/cmd/entire/cli/integration_test/gemini_concurrent_session_test.go b/cmd/entire/cli/integration_test/gemini_concurrent_session_test.go index 7f0197823..f6d14d26f 100644 --- a/cmd/entire/cli/integration_test/gemini_concurrent_session_test.go +++ b/cmd/entire/cli/integration_test/gemini_concurrent_session_test.go @@ -74,7 +74,7 @@ func TestGeminiConcurrentSessionWarning_BlocksFirstPrompt(t *testing.T) { } // Verify reason contains expected message - expectedMessage := "another active session with uncommitted changes" + expectedMessage := "Another session is active" if !strings.Contains(response.Reason, expectedMessage) { t.Errorf("Reason should contain %q, got: %s", expectedMessage, response.Reason) } @@ -133,7 +133,7 @@ func TestGeminiConcurrentSessionWarning_SetsWarningFlag(t *testing.T) { } // TestGeminiConcurrentSessionWarning_SubsequentPromptsSucceed verifies that after the -// warning is shown, subsequent prompts in the same session are skipped silently. +// warning is shown, subsequent prompts in the same session proceed normally. func TestGeminiConcurrentSessionWarning_SubsequentPromptsSucceed(t *testing.T) { env := NewTestEnv(t) defer env.Cleanup() @@ -153,8 +153,8 @@ func TestGeminiConcurrentSessionWarning_SubsequentPromptsSucceed(t *testing.T) { env.WriteFile("file.txt", "content") sessionA.CreateGeminiTranscript("Add file", []FileChange{{Path: "file.txt", Content: "content"}}) - if err := env.SimulateGeminiSessionEnd(sessionA.ID, sessionA.TranscriptPath); err != nil { - t.Fatalf("SimulateGeminiSessionEnd (sessionA) failed: %v", err) + if err := env.SimulateGeminiAfterAgent(sessionA.ID, sessionA.TranscriptPath); err != nil { + t.Fatalf("SimulateGeminiAfterAgent (sessionA) failed: %v", err) } // Start session B - first prompt is blocked (exits with code 0, decision: block) @@ -177,21 +177,27 @@ func TestGeminiConcurrentSessionWarning_SubsequentPromptsSucceed(t *testing.T) { } t.Log("First prompt correctly blocked") - // Second prompt in session B should be skipped entirely (no processing) - // Since ConcurrentWarningShown is true, the hook returns nil and produces no output + // Second prompt in session B should PROCEED normally (both sessions capture checkpoints) + // The warning was shown on first prompt, but subsequent prompts continue to capture state output2 := env.SimulateGeminiBeforeAgentWithOutput(sessionB.ID) - // The hook should succeed (no error) because it skips silently + // The hook should succeed if output2.Err != nil { - t.Errorf("Second prompt should succeed (skip silently), got error: %v", output2.Err) + t.Errorf("Second prompt should succeed, got error: %v", output2.Err) } - // The hook should produce no output (it was skipped) + // The hook should process normally (capture state) - no blocking response if len(output2.Stdout) > 0 { - t.Errorf("Second prompt should produce no output (hook skipped), got: %s", output2.Stdout) + // Check if it's a blocking JSON response (which it shouldn't be) + var blockResponse struct { + Decision string `json:"decision"` + } + if json.Unmarshal(output2.Stdout, &blockResponse) == nil && blockResponse.Decision == "block" { + t.Errorf("Second prompt should not be blocked after warning was shown, got: %s", output2.Stdout) + } } - // The important assertion: warning flag should still be set + // Warning flag should remain set (for tracking) stateB, _ := env.GetSessionState(sessionB.ID) if stateB == nil { t.Fatal("Session B state should exist") @@ -200,7 +206,7 @@ func TestGeminiConcurrentSessionWarning_SubsequentPromptsSucceed(t *testing.T) { t.Error("ConcurrentWarningShown should remain true after second prompt") } - t.Log("Second prompt correctly skipped (hooks disabled for warned session)") + t.Log("Second prompt correctly processed (both sessions capture checkpoints)") } // TestGeminiConcurrentSessionWarning_NoWarningWithoutCheckpoints verifies that starting @@ -306,8 +312,8 @@ func TestGeminiConcurrentSessionWarning_ResumeCommandFormat(t *testing.T) { if strings.Contains(response.Reason, "claude -r") { t.Errorf("Reason should NOT contain Claude's resume command, got: %s", response.Reason) } - if !strings.Contains(response.Reason, "close Gemini CLI") { - t.Errorf("Reason should mention closing Gemini CLI, got: %s", response.Reason) + if !strings.Contains(response.Reason, "exit Gemini CLI") { + t.Errorf("Reason should mention exiting Gemini CLI, got: %s", response.Reason) } t.Logf("Resume command correctly formatted for Gemini CLI: %s", response.Reason) @@ -470,3 +476,230 @@ func TestCrossAgentConcurrentSession_GeminiSessionShowsGeminiResumeInClaude(t *t t.Logf("Cross-agent blocking correctly shows Gemini resume command: %s", response.StopReason) } + +// TestGeminiConcurrentSessionWarning_DisabledViaSetting verifies that when +// disable_multisession_warning is set in strategy_options, no warning is shown. +func TestGeminiConcurrentSessionWarning_DisabledViaSetting(t *testing.T) { + env := NewTestEnv(t) + defer env.Cleanup() + + env.InitRepo() + env.WriteFile("README.md", "# Test") + env.GitAdd("README.md") + env.GitCommit("Initial commit") + env.GitCheckoutNewBranch("feature/test") + + // Initialize Entire with multi-session warning disabled + env.InitEntireWithAgentAndOptions(strategy.StrategyNameManualCommit, "gemini", map[string]any{ + "disable_multisession_warning": true, + }) + + // Start session A and create a checkpoint + sessionA := env.NewGeminiSession() + if err := env.SimulateGeminiBeforeAgent(sessionA.ID); err != nil { + t.Fatalf("SimulateGeminiBeforeAgent (sessionA) failed: %v", err) + } + + env.WriteFile("file.txt", "content from session A") + sessionA.CreateGeminiTranscript("Add file", []FileChange{{Path: "file.txt", Content: "content from session A"}}) + if err := env.SimulateGeminiAfterAgent(sessionA.ID, sessionA.TranscriptPath); err != nil { + t.Fatalf("SimulateGeminiAfterAgent (sessionA) failed: %v", err) + } + + // Verify session A has checkpoints + stateA, err := env.GetSessionState(sessionA.ID) + if err != nil { + t.Fatalf("GetSessionState (sessionA) failed: %v", err) + } + if stateA == nil || stateA.CheckpointCount == 0 { + t.Fatal("Session A should have at least 1 checkpoint") + } + t.Logf("Session A has %d checkpoint(s)", stateA.CheckpointCount) + + // Start session B - should NOT be blocked because warnings are disabled + sessionB := env.NewGeminiSession() + output := env.SimulateGeminiBeforeAgentWithOutput(sessionB.ID) + + // The hook should succeed without blocking + if output.Err != nil { + t.Fatalf("Hook should succeed without blocking, got error: %v\nStderr: %s", output.Err, output.Stderr) + } + + // Check if we got a blocking response (which we shouldn't) + if len(output.Stdout) > 0 { + var response struct { + Decision string `json:"decision"` + Reason string `json:"reason,omitempty"` + } + if json.Unmarshal(output.Stdout, &response) == nil && response.Decision == "block" { + t.Errorf("Should NOT show concurrent session warning when disabled, got: %s", output.Stdout) + } + } + + // Session B should not have ConcurrentWarningShown set + stateB, _ := env.GetSessionState(sessionB.ID) + if stateB != nil && stateB.ConcurrentWarningShown { + t.Error("Session B should not have ConcurrentWarningShown set when warnings are disabled") + } + + t.Log("No concurrent session warning shown when setting is disabled") +} + +// TestGeminiConcurrentSessionWarning_ContainsSuppressHint verifies that the warning message +// includes instructions on how to suppress future warnings. +func TestGeminiConcurrentSessionWarning_ContainsSuppressHint(t *testing.T) { + env := NewTestEnv(t) + defer env.Cleanup() + + env.InitRepo() + env.WriteFile("README.md", "# Test") + env.GitAdd("README.md") + env.GitCommit("Initial commit") + env.GitCheckoutNewBranch("feature/test") + env.InitEntireWithAgent(strategy.StrategyNameManualCommit, "gemini") + + // Start session A and create a checkpoint + sessionA := env.NewGeminiSession() + if err := env.SimulateGeminiBeforeAgent(sessionA.ID); err != nil { + t.Fatalf("SimulateGeminiBeforeAgent (sessionA) failed: %v", err) + } + + env.WriteFile("file.txt", "content from session A") + sessionA.CreateGeminiTranscript("Add file", []FileChange{{Path: "file.txt", Content: "content from session A"}}) + if err := env.SimulateGeminiAfterAgent(sessionA.ID, sessionA.TranscriptPath); err != nil { + t.Fatalf("SimulateGeminiAfterAgent (sessionA) failed: %v", err) + } + + // Start session B - first prompt should be blocked with warning + sessionB := env.NewGeminiSession() + output := env.SimulateGeminiBeforeAgentWithOutput(sessionB.ID) + + // Parse the JSON response + var response struct { + Decision string `json:"decision"` + Reason string `json:"reason"` + } + if err := json.Unmarshal(output.Stdout, &response); err != nil { + t.Fatalf("Failed to parse JSON response: %v\nStdout: %s", err, output.Stdout) + } + + // Verify the warning message contains the suppression hint + expectedHint := "entire enable --disable-multisession-warning" + if !strings.Contains(response.Reason, expectedHint) { + t.Errorf("Warning message should contain suppression hint %q, got: %s", expectedHint, response.Reason) + } + + t.Logf("Warning message correctly includes suppression hint") +} + +// TestGeminiConcurrentSessions_BothCondensedOnCommit verifies that when two sessions have +// interleaved checkpoints, committing preserves both sessions' logs on entire/sessions. +func TestGeminiConcurrentSessions_BothCondensedOnCommit(t *testing.T) { + env := NewTestEnv(t) + defer env.Cleanup() + + env.InitRepo() + env.WriteFile("README.md", "# Test") + env.GitAdd("README.md") + env.GitCommit("Initial commit") + env.GitCheckoutNewBranch("feature/test") + env.InitEntireWithAgent(strategy.StrategyNameManualCommit, "gemini") + + // Session A: create checkpoint + sessionA := env.NewGeminiSession() + if err := env.SimulateGeminiBeforeAgent(sessionA.ID); err != nil { + t.Fatalf("SimulateGeminiBeforeAgent (sessionA) failed: %v", err) + } + + env.WriteFile("fileA.txt", "content from session A") + sessionA.CreateGeminiTranscript("Add file A", []FileChange{{Path: "fileA.txt", Content: "content from session A"}}) + if err := env.SimulateGeminiAfterAgent(sessionA.ID, sessionA.TranscriptPath); err != nil { + t.Fatalf("SimulateGeminiAfterAgent (sessionA) failed: %v", err) + } + + // Session B: acknowledge warning and create checkpoint + sessionB := env.NewGeminiSession() + // First prompt is blocked with warning + _ = env.SimulateGeminiBeforeAgentWithOutput(sessionB.ID) + + // Second prompt proceeds (after warning was shown) + if err := env.SimulateGeminiBeforeAgent(sessionB.ID); err != nil { + t.Fatalf("SimulateGeminiBeforeAgent (sessionB second prompt) failed: %v", err) + } + + env.WriteFile("fileB.txt", "content from session B") + sessionB.CreateGeminiTranscript("Add file B", []FileChange{{Path: "fileB.txt", Content: "content from session B"}}) + if err := env.SimulateGeminiAfterAgent(sessionB.ID, sessionB.TranscriptPath); err != nil { + t.Fatalf("SimulateGeminiAfterAgent (sessionB) failed: %v", err) + } + + // Verify both sessions have checkpoints + stateA, _ := env.GetSessionState(sessionA.ID) + stateB, _ := env.GetSessionState(sessionB.ID) + if stateA == nil || stateA.CheckpointCount == 0 { + t.Fatal("Session A should have checkpoints") + } + if stateB == nil || stateB.CheckpointCount == 0 { + t.Fatal("Session B should have checkpoints") + } + t.Logf("Session A: %d checkpoints, Session B: %d checkpoints", stateA.CheckpointCount, stateB.CheckpointCount) + + // Commit with hooks - this should condense both sessions + env.GitCommitWithShadowHooks("Add files from both sessions", "fileA.txt", "fileB.txt") + + // Get the checkpoint ID from entire/sessions + checkpointID := env.GetLatestCheckpointID() + if checkpointID == "" { + t.Fatal("Failed to get checkpoint ID from entire/sessions branch") + } + t.Logf("Checkpoint ID: %s", checkpointID) + + // Build the sharded path + shardedPath := checkpointID[:2] + "/" + checkpointID[2:] + + // Verify metadata.json exists and has multi-session info + metadataContent, found := env.ReadFileFromBranch("entire/sessions", shardedPath+"/metadata.json") + if !found { + t.Fatal("metadata.json should exist on entire/sessions branch") + } + + var metadata struct { + SessionCount int `json:"session_count"` + SessionIDs []string `json:"session_ids"` + SessionID string `json:"session_id"` + } + if err := json.Unmarshal([]byte(metadataContent), &metadata); err != nil { + t.Fatalf("Failed to parse metadata.json: %v", err) + } + + t.Logf("Metadata: session_count=%d, session_ids=%v, session_id=%s", + metadata.SessionCount, metadata.SessionIDs, metadata.SessionID) + + // Verify multi-session fields + if metadata.SessionCount != 2 { + t.Errorf("Expected session_count=2, got %d", metadata.SessionCount) + } + if len(metadata.SessionIDs) != 2 { + t.Errorf("Expected 2 session_ids, got %d", len(metadata.SessionIDs)) + } + + // Verify archived session exists in subfolder "1/" + archivedMetadata, found := env.ReadFileFromBranch("entire/sessions", shardedPath+"/1/metadata.json") + if !found { + t.Error("Archived session metadata should exist at 1/metadata.json") + } else { + t.Logf("Archived session metadata found: %s", archivedMetadata[:min(100, len(archivedMetadata))]) + } + + // Verify transcript exists for current session (at root) + if !env.FileExistsInBranch("entire/sessions", shardedPath+"/full.jsonl") { + t.Error("Current session transcript should exist at root (full.jsonl)") + } + + // Verify transcript exists for archived session + if !env.FileExistsInBranch("entire/sessions", shardedPath+"/1/full.jsonl") { + t.Error("Archived session transcript should exist at 1/full.jsonl") + } + + t.Log("Both sessions successfully condensed with proper archiving") +} diff --git a/cmd/entire/cli/integration_test/hooks.go b/cmd/entire/cli/integration_test/hooks.go index 90cfc762f..525aa0749 100644 --- a/cmd/entire/cli/integration_test/hooks.go +++ b/cmd/entire/cli/integration_test/hooks.go @@ -482,8 +482,24 @@ func (r *GeminiHookRunner) SimulateGeminiBeforeAgentWithOutput(sessionID string) return r.runGeminiHookWithOutput("before-agent", inputJSON) } +// SimulateGeminiAfterAgent simulates the AfterAgent hook for Gemini CLI. +// This is the primary checkpoint creation hook, equivalent to Claude Code's Stop hook. +func (r *GeminiHookRunner) SimulateGeminiAfterAgent(sessionID, transcriptPath string) error { + r.T.Helper() + + input := map[string]string{ + "session_id": sessionID, + "transcript_path": transcriptPath, + "cwd": r.RepoDir, + "hook_event_name": "AfterAgent", + "timestamp": "2025-01-01T00:00:00Z", + } + + return r.runGeminiHookWithInput("after-agent", input) +} + // SimulateGeminiSessionEnd simulates the SessionEnd hook for Gemini CLI. -// This is equivalent to Claude Code's Stop hook. +// This is a cleanup/fallback hook that fires on explicit exit. func (r *GeminiHookRunner) SimulateGeminiSessionEnd(sessionID, transcriptPath string) error { r.T.Helper() @@ -594,6 +610,13 @@ func (env *TestEnv) SimulateGeminiBeforeAgentWithOutput(sessionID string) HookOu return runner.SimulateGeminiBeforeAgentWithOutput(sessionID) } +// SimulateGeminiAfterAgent is a convenience method on TestEnv. +func (env *TestEnv) SimulateGeminiAfterAgent(sessionID, transcriptPath string) error { + env.T.Helper() + runner := NewGeminiHookRunner(env.RepoDir, env.GeminiProjectDir, env.T) + return runner.SimulateGeminiAfterAgent(sessionID, transcriptPath) +} + // SimulateGeminiSessionEnd is a convenience method on TestEnv. func (env *TestEnv) SimulateGeminiSessionEnd(sessionID, transcriptPath string) error { env.T.Helper() diff --git a/cmd/entire/cli/integration_test/testenv.go b/cmd/entire/cli/integration_test/testenv.go index 1affc5000..055b40114 100644 --- a/cmd/entire/cli/integration_test/testenv.go +++ b/cmd/entire/cli/integration_test/testenv.go @@ -230,6 +230,12 @@ func (env *TestEnv) InitEntireWithAgent(strategyName, agentName string) { env.initEntireInternal(strategyName, agentName, nil) } +// InitEntireWithAgentAndOptions initializes Entire with the specified strategy, agent, and options. +func (env *TestEnv) InitEntireWithAgentAndOptions(strategyName, agentName string, strategyOptions map[string]any) { + env.T.Helper() + env.initEntireInternal(strategyName, agentName, strategyOptions) +} + // initEntireInternal is the common implementation for InitEntire variants. func (env *TestEnv) initEntireInternal(strategyName, agentName string, strategyOptions map[string]any) { env.T.Helper() From 416347f2942f317412ff62fd6561c602e0576847 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Wed, 21 Jan 2026 13:39:57 +0100 Subject: [PATCH 19/19] pretty sure this shouldn't be here Entire-Checkpoint: 7662d755ef9f --- .../skills/test-repo/.claude/settings.json | 68 ------------------- 1 file changed, 68 deletions(-) delete mode 100644 .claude/skills/test-repo/.claude/settings.json diff --git a/.claude/skills/test-repo/.claude/settings.json b/.claude/skills/test-repo/.claude/settings.json deleted file mode 100644 index ddfedef3a..000000000 --- a/.claude/skills/test-repo/.claude/settings.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "hooks": { - "SessionStart": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "entire session resume --from-hook" - } - ] - } - ], - "UserPromptSubmit": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "entire rewind claude-hook --user-prompt-submit" - } - ] - } - ], - "Stop": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "entire rewind claude-hook --stop" - } - ] - } - ], - "PreToolUse": [ - { - "matcher": "Task", - "hooks": [ - { - "type": "command", - "command": "entire rewind claude-hook --pre-task" - } - ] - } - ], - "PostToolUse": [ - { - "matcher": "Task", - "hooks": [ - { - "type": "command", - "command": "entire rewind claude-hook --post-task" - } - ] - }, - { - "matcher": "TodoWrite", - "hooks": [ - { - "type": "command", - "command": "entire rewind claude-hook --post-todo" - } - ] - } - ] - } -} \ No newline at end of file