"""Task-scoped Git checkpoints owned by the coordinator, never by the model.""" from pathlib import Path import subprocess class TaskCommits: def __init__(self, root, enabled=True): if not isinstance(enabled, bool): raise ValueError('git_commits must be a JSON boolean') self.root = Path(root).resolve() self.enabled = enabled def git(self, *args): result = subprocess.run(['git', '-C', str(self.root), *args], capture_output=True, timeout=60) if result.returncode: # Do not leak hooks, environment, signing details or credentials into reports. raise ValueError('Git ' + args[0] + ' failed; inspect Git configuration/hooks locally; changes preserved') return result.stdout.decode('utf-8', errors='surrogateescape') def changes(self): # Disable rename folding so each source/deletion is staged explicitly. raw = self.git('status', '--porcelain=v1', '-z', '--untracked-files=all', '--no-renames') return [row[3:] for row in raw.split('\0') if row] def preflight(self): if not self.enabled: return None self.repo = Path(self.git('rev-parse', '--show-toplevel').strip()).resolve() head = self.git('rev-parse', '--verify', 'HEAD').strip() self.git('symbolic-ref', '--quiet', 'HEAD') self.git('var', 'GIT_AUTHOR_IDENT') self.git('var', 'GIT_COMMITTER_IDENT') if self.changes(): raise ValueError('Git working tree/index must be clean before each agent; commit or separately preserve existing changes first') return head def message(self, item, agent, row, result): task_id = item['id'] if row['status'] in {'review', 'done', 'todo'} and not result.get('resolution_task'): path = self.root / 'state/commit_messages' / (agent + '.txt') if path.is_symlink() or not path.resolve().is_relative_to(self.root) or not path.is_file(): raise ValueError('Missing task commit message: state/commit_messages/' + agent + '.txt') text = path.read_text(encoding='utf-8').strip() lines = text.splitlines() if (not lines or not 15 <= len(lines[0]) <= 100 or not lines[0][0].isascii() or not lines[0][0].isupper() or any(ord(c) < 32 for c in text if c != '\n') or len(text) > 12000 or len(lines) < 4 or lines[1] != '' or 'Changes:' not in text or 'Validation:' not in text or len('\n'.join(lines[2:])) < 40): raise ValueError('Commit message needs a concrete English subject (15..100 chars), blank line, Changes: and Validation: with actual results') else: # An interrupted model cannot be relied on to write a completion message. # Preserve its bounded partial work explicitly as incomplete before remediation. reason = row.get('reason') or str(result) text = ('Record blocked task and remediation handoff\n\n' 'Changes: Preserve incomplete work and blocker metadata for ' + task_id + '. This checkpoint does not mark implementation accepted.\n' 'Validation: Session ended with status ' + row['status'] + '.\n' 'Blocker: ' + reason) return task_id + ': ' + text + '\n\nTask: ' + task_id + '\nAgent: ' + agent + \ '\nRole: ' + item['role'] + '\nAction: ' + item['action'] + '\nStatus: ' + row['status'] + '\n' def commit(self, item, agent, row, result, expected_head, task_title): if not self.enabled: return None if self.git('rev-parse', 'HEAD').strip() != expected_head: raise ValueError('Agent changed Git HEAD; coordinator will not commit or continue automatically') paths = self.changes() for name in paths: if not (self.repo / name).is_relative_to(self.root): raise ValueError('Changes outside project root; refusing to include unrelated files in task commit') message = self.message(item, agent, row, result) + '\nTask title: ' + task_title + '\n' if not paths: raise ValueError('Task transition produced no Git changes; refusing an empty task commit') self.git('add', '--', *[':(top,literal)' + name for name in paths]) # Include only the explicitly enumerated project files; never git add . / -A. staged = self.git('diff', '--cached', '--no-relative', '--name-only', '-z').split('\0') if any(name and name not in paths for name in staged): raise ValueError('Unexpected staged files; changes preserved for inspection') message_path = self.root / 'state/runs' / agent / 'commit-message.txt' message_path.parent.mkdir(parents=True, exist_ok=True) message_path.write_text(message, encoding='utf-8') self.git('commit', '--file', str(message_path)) commit = self.git('rev-parse', 'HEAD').strip() if self.changes(): raise ValueError('Git hook or concurrent writer left changes after commit; inspect before next agent') return commit