Skip to content

Commit 6e50e5a

Browse files
committed
feat(hooks): implement conditional execution for make and journal hooks
1 parent 5dce2c9 commit 6e50e5a

5 files changed

Lines changed: 313 additions & 35 deletions

File tree

.gemini/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
last_make_run
2+
last_journal_update

.gemini/hooks/journal.py

Lines changed: 96 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,125 @@
11
#!/usr/bin/env python3
22
"""
3-
Hook to enforce project journaling for all significant changes.
3+
Hook to enforce project journaling conditionally for all significant changes.
44
55
This hook is triggered 'AfterAgent' and checks if the daily journal entry has been updated
6-
whenever there are uncommitted changes in the repository.
6+
whenever there are NEW uncommitted changes since the last recorded journal update.
77
"""
88
import sys
99
import os
10+
import subprocess
11+
import time
1012
from datetime import datetime
1113

1214
# Add the hooks directory to path for importing utils
1315
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
1416
import utils
1517

18+
# Path to the state file relative to this script
19+
STATE_FILE = os.path.join(
20+
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
21+
"last_journal_update"
22+
)
23+
24+
def get_last_update_timestamp():
25+
"""Reads the last recorded journal update timestamp from the state file."""
26+
if not os.path.exists(STATE_FILE):
27+
return 0
28+
try:
29+
with open(STATE_FILE, "r") as f:
30+
return float(f.read().strip())
31+
except (ValueError, OSError):
32+
return 0
33+
34+
def update_last_update_timestamp():
35+
"""Updates the state file with the current timestamp."""
36+
try:
37+
with open(STATE_FILE, "w") as f:
38+
f.write(str(time.time()))
39+
except OSError:
40+
pass
41+
42+
def get_changed_files():
43+
"""
44+
Returns a list of files that are either modified or untracked in git.
45+
"""
46+
try:
47+
result = subprocess.run(
48+
["git", "status", "--porcelain=v1", "-uall"],
49+
capture_output=True,
50+
text=True,
51+
check=True
52+
)
53+
lines = result.stdout.splitlines()
54+
return [line[3:].strip() for line in lines if line.strip()]
55+
except (subprocess.CalledProcessError, FileNotFoundError):
56+
return []
57+
1658
def main():
1759
"""
1860
Main entry point for the journal enforcement hook.
1961
20-
Checks git status for uncommitted changes and identifies if the current daily journal
21-
has been updated accordingly. Returns 'deny' if it's missing.
62+
Identifies new uncommitted changes and identifies if the daily journal
63+
needs to be updated. Returns 'deny' if it's missing for new work.
2264
"""
2365
try:
24-
modified_files = utils.get_modified_files()
66+
changed_files = get_changed_files()
2567

2668
today = datetime.now().strftime("%Y-%m-%d")
2769
journal_file = f"journal/{today}.md"
70+
71+
last_update = get_last_update_timestamp()
2872

29-
# Check if there are changes other than the daily journal
30-
significant_changes = [
31-
f for f in modified_files
32-
if f != journal_file
33-
]
34-
35-
# Check if the daily journal was updated
36-
journal_updated = journal_file in modified_files
37-
38-
if significant_changes and not journal_updated:
39-
utils.send_hook_decision(
40-
"deny",
41-
reason=(
42-
f"Please add a one-line entry to {journal_file} "
43-
"describing the changes you just made. Do not stop until this file is updated."
44-
)
45-
)
46-
else:
73+
# 1. Identify files modified after last_update (excluding the journal)
74+
new_significant_changes = []
75+
latest_change_time = 0
76+
77+
for f in changed_files:
78+
if f == journal_file:
79+
continue
80+
81+
f_mtime = 0
82+
if os.path.exists(f):
83+
f_mtime = os.path.getmtime(f)
84+
else:
85+
# File deleted. We treat deletions as new work "now".
86+
f_mtime = time.time()
87+
88+
if f_mtime > last_update:
89+
new_significant_changes.append(f)
90+
latest_change_time = max(latest_change_time, f_mtime)
91+
92+
if not new_significant_changes:
93+
# No new work since last journal update.
94+
# However, if the journal itself was updated in the turn,
95+
# we should update the timestamp so we don't re-process old changes.
96+
if journal_file in changed_files:
97+
update_last_update_timestamp()
4798
utils.send_hook_decision("allow")
99+
return
100+
101+
# 2. We have new significant changes. Check if the journal was updated AFTER them.
102+
if journal_file in changed_files:
103+
journal_mtime = os.path.getmtime(journal_file)
104+
if journal_mtime > latest_change_time:
105+
# Journal was updated after the latest significant change.
106+
update_last_update_timestamp()
107+
utils.send_hook_decision("allow")
108+
return
109+
110+
# 3. Deny because new work exists but journal hasn't caught up.
111+
utils.send_hook_decision(
112+
"deny",
113+
reason=(
114+
f"New changes detected: {', '.join(new_significant_changes[:3])}{'...' if len(new_significant_changes) > 3 else ''}.\n"
115+
f"Please add or update a one-line entry to {journal_file} "
116+
"describing the work you just did. Do not stop until this file is updated."
117+
)
118+
)
48119

49-
except Exception:
120+
except Exception as e:
50121
# Failsafe: always allow if something goes wrong
51-
utils.send_hook_decision("allow")
122+
utils.send_hook_decision("allow", reason=f"Hook error: {str(e)}")
52123

53124
if __name__ == "__main__":
54125
main()

.gemini/hooks/make.py

Lines changed: 90 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,107 @@
11
#!/usr/bin/env python3
22
"""
3-
Hook to run 'make' validation before critical agent actions.
3+
Hook to run 'make' validation conditionally before critical agent actions.
44
55
This hook is triggered 'AfterAgent' and ensures that the codebase passes
6-
all linting and testing checks defined in the makefile.
6+
all linting and testing checks defined in the makefile. It skips execution
7+
if no changes have been detected since the last successful run.
78
"""
89
import sys
910
import os
1011
import subprocess
12+
import time
1113

1214
# Add the hooks directory to path for importing utils
1315
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
1416
import utils
1517

18+
# Path to the state file relative to this script
19+
# Stored in .gemini/last_make_run (one level up from .gemini/hooks/)
20+
STATE_FILE = os.path.join(
21+
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
22+
"last_make_run"
23+
)
24+
25+
def get_last_run_timestamp():
26+
"""Reads the last successful run timestamp from the state file."""
27+
if not os.path.exists(STATE_FILE):
28+
return 0
29+
try:
30+
with open(STATE_FILE, "r") as f:
31+
return float(f.read().strip())
32+
except (ValueError, OSError):
33+
return 0
34+
35+
def update_last_run_timestamp():
36+
"""Updates the state file with the current timestamp."""
37+
try:
38+
with open(STATE_FILE, "w") as f:
39+
f.write(str(time.time()))
40+
except OSError:
41+
pass
42+
43+
def get_changed_files():
44+
"""
45+
Returns a list of files that are either modified or untracked in git.
46+
"""
47+
try:
48+
# Get both modified and untracked files
49+
# --porcelain=v1 ensures a stable machine-readable output
50+
# -u (all) shows untracked files
51+
result = subprocess.run(
52+
["git", "status", "--porcelain=v1", "-uall"],
53+
capture_output=True,
54+
text=True,
55+
check=True
56+
)
57+
lines = result.stdout.splitlines()
58+
# Each line is: "XY PATH" where XY are status codes
59+
# We want the path part (from index 3 onwards)
60+
return [line[3:].strip() for line in lines if line.strip()]
61+
except (subprocess.CalledProcessError, FileNotFoundError):
62+
return []
63+
64+
def should_run_make():
65+
"""
66+
Determines if 'make' should be executed based on file modification times.
67+
"""
68+
last_run = get_last_run_timestamp()
69+
if last_run == 0:
70+
return True, "Initial run or missing timestamp."
71+
72+
changed_paths = get_changed_files()
73+
if not changed_paths:
74+
return False, "No uncommitted or untracked changes detected."
75+
76+
for path in changed_paths:
77+
# Check if file exists (might have been deleted)
78+
if os.path.exists(path):
79+
if os.path.getmtime(path) > last_run:
80+
return True, f"File modified: {path}"
81+
else:
82+
# File was deleted. We treat deletions as changes.
83+
# Since the file is gone, its parent directory's mtime should have updated.
84+
# Or we can just assume any deletion warrants a re-run.
85+
return True, f"File deleted: {path}"
86+
87+
return False, "No files modified since last successful validation."
88+
1689
def main():
1790
"""
1891
Main entry point for the make validation hook.
1992
20-
Executes 'uv run make' and returns a 'deny' decision if the process fails.
93+
Executes 'make' conditionally and returns a decision.
2194
"""
2295
try:
23-
# Run make command using uv run to ensure dependencies are available
96+
run_needed, reason = should_run_make()
97+
98+
if not run_needed:
99+
utils.send_hook_decision("allow", reason=f"Skipping 'make' ({reason})")
100+
return
101+
102+
# Run make command directly
24103
result = subprocess.run(
25-
["uv", "run", "make"],
104+
["make"],
26105
capture_output=True,
27106
text=True,
28107
check=False
@@ -31,21 +110,22 @@ def main():
31110
if result.returncode != 0:
32111
# make failed
33112
error_message = result.stdout + "\n" + result.stderr
34-
reason = (
113+
fail_reason = (
35114
f"Validation failed (make returned {result.returncode}).\n"
36115
"Please fix the broken tests or linting issues.\n"
37116
"Output of 'make':\n"
38117
"```\n" + error_message.strip() + "\n```\n"
39118
"Fix these issues and ensure 'make' passes before continuing."
40119
)
41-
utils.send_hook_decision("deny", reason=reason)
120+
utils.send_hook_decision("deny", reason=fail_reason)
42121
else:
43122
# make passed
123+
update_last_run_timestamp()
44124
utils.send_hook_decision("allow")
45125

46-
except Exception:
47-
# Failsafe: always allow if the hook itself fails
48-
utils.send_hook_decision("allow")
126+
except Exception as e:
127+
# Failsafe: always allow if the hook itself fails, but log the error in reason
128+
utils.send_hook_decision("allow", reason=f"Hook error: {str(e)}")
49129

50130
if __name__ == "__main__":
51131
main()
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Execution Plan: Conditional Journal Hook Enforcement
2+
3+
This plan outlines the modifications needed to implement conditional enforcement for the `.gemini/hooks/journal.py` hook. The goal is to only require a journal update if new changes have been detected in the workspace since the last recorded journal update.
4+
5+
## Objective
6+
Modify the `journal` hook logic to use file modification times (`mtime`) and `git` status to determine if a journal update is strictly necessary.
7+
8+
## Architectural Impact
9+
- **Efficiency:** Reduces friction by only requiring journal entries when new work has actually been performed.
10+
- **State Management:** Introduces a lightweight persistence mechanism in `.gemini/last_journal_update`.
11+
12+
## File Operations
13+
14+
### 1. Modify `.gemini/.gitignore`
15+
- **Action:** Add `last_journal_update` to the existing `.gemini/.gitignore`.
16+
- **Content:**
17+
```text
18+
last_make_run
19+
last_journal_update
20+
```
21+
22+
### 2. Modify `.gemini/hooks/journal.py`
23+
- **Action:** Update the existing hook script to include change detection and timestamp logic.
24+
- **Key Logic Changes:**
25+
- Define `STATE_FILE` path (e.g., `.gemini/last_journal_update`).
26+
- Use `git status --porcelain=v1` to identify all modified and untracked files.
27+
- Implement `get_last_journal_update()` and `update_last_journal_update()` for state persistence.
28+
- If the current daily journal is in the list of modified files, update the `STATE_FILE` with the current time and return `allow`.
29+
- If any other modified or untracked file has an `mtime > last_journal_update`, return `deny` with a request to update the journal.
30+
- Return `allow` if no newer changes are found.
31+
32+
### 3. Update `TASKS.md`
33+
- **Action:** Add a new entry to the `Archive` or `Active Tasks` section.
34+
- **Task Description:** `Implement conditional journal hook enforcement based on file modification times.`
35+
36+
## Step-by-Step Execution
37+
38+
### Step 1: Update State Ignoring
39+
1. Open `.gemini/.gitignore`.
40+
2. Append `last_journal_update` to it.
41+
42+
### Step 2: Implement the Enhanced Hook
43+
1. Open `.gemini/hooks/journal.py`.
44+
2. Replace the current logic with the new implementation:
45+
- Add `import time`.
46+
- Define `STATE_FILE`.
47+
- Implement `should_require_journal_update()` to perform the comparison.
48+
- Refactor `main()` to use the new logic and update the timestamp when the journal is modified.
49+
50+
### Step 3: Verify and Document
51+
1. Update `TASKS.md` to reflect the completed work.
52+
2. Ensure PEP 257 docstrings are maintained/added for clarity.
53+
54+
## Testing Strategy
55+
56+
### Automated/Manual Validation Scenarios
57+
1. **Fresh Workspace:** Remove `.gemini/last_journal_update`. Modify a file. Verify it requires a journal entry.
58+
2. **Journal Updated:** Update the journal. Verify the hook now returns `allow` and creates/updates the state file.
59+
3. **No New Changes:** Run the hook again immediately. Verify it returns `allow`.
60+
4. **New Modification:** Update another source file. Verify it requires a journal entry again (since its `mtime > last_journal_update`).
61+
5. **Deleted File:** Delete a file. Verify it requires a journal entry (detected via parent directory `mtime`).

0 commit comments

Comments
 (0)