|
1 | 1 | #!/usr/bin/env python3 |
2 | 2 | """ |
3 | | -Hook to enforce project journaling for all significant changes. |
| 3 | +Hook to enforce project journaling conditionally for all significant changes. |
4 | 4 |
|
5 | 5 | 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. |
7 | 7 | """ |
8 | 8 | import sys |
9 | 9 | import os |
| 10 | +import subprocess |
| 11 | +import time |
10 | 12 | from datetime import datetime |
11 | 13 |
|
12 | 14 | # Add the hooks directory to path for importing utils |
13 | 15 | sys.path.append(os.path.dirname(os.path.abspath(__file__))) |
14 | 16 | import utils |
15 | 17 |
|
| 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 | + |
16 | 58 | def main(): |
17 | 59 | """ |
18 | 60 | Main entry point for the journal enforcement hook. |
19 | 61 | |
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. |
22 | 64 | """ |
23 | 65 | try: |
24 | | - modified_files = utils.get_modified_files() |
| 66 | + changed_files = get_changed_files() |
25 | 67 |
|
26 | 68 | today = datetime.now().strftime("%Y-%m-%d") |
27 | 69 | journal_file = f"journal/{today}.md" |
| 70 | + |
| 71 | + last_update = get_last_update_timestamp() |
28 | 72 |
|
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() |
47 | 98 | 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 | + ) |
48 | 119 |
|
49 | | - except Exception: |
| 120 | + except Exception as e: |
50 | 121 | # 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)}") |
52 | 123 |
|
53 | 124 | if __name__ == "__main__": |
54 | 125 | main() |
0 commit comments