gator-command 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. gator_command/__init__.py +2 -0
  2. gator_command/cli.py +137 -0
  3. gator_command/scripts/crawler.py +633 -0
  4. gator_command/scripts/dashboard/dashboard.css +982 -0
  5. gator_command/scripts/dashboard/dashboard.html +84 -0
  6. gator_command/scripts/dashboard/dashboard.js +419 -0
  7. gator_command/scripts/dashboard/views/audit.js +270 -0
  8. gator_command/scripts/dashboard/views/fleet.js +307 -0
  9. gator_command/scripts/dashboard/views/repo.js +599 -0
  10. gator_command/scripts/dashboard/views/settings.js +173 -0
  11. gator_command/scripts/dashboard/views/updates.js +308 -0
  12. gator_command/scripts/enforcer-prompt.md +22 -0
  13. gator_command/scripts/extract-claude-sessions.py +489 -0
  14. gator_command/scripts/extract-codex-sessions.py +477 -0
  15. gator_command/scripts/extract-gemini-sessions.py +410 -0
  16. gator_command/scripts/gator-audit.py +956 -0
  17. gator_command/scripts/gator-charter-draft.py +919 -0
  18. gator_command/scripts/gator-charter-lint.py +427 -0
  19. gator_command/scripts/gator-charter-verify.py +606 -0
  20. gator_command/scripts/gator-dashboard.py +1271 -0
  21. gator_command/scripts/gator-deploy.py +916 -0
  22. gator_command/scripts/gator-drift.py +569 -0
  23. gator_command/scripts/gator-enforce.py +82 -0
  24. gator_command/scripts/gator-fleet-intel.py +460 -0
  25. gator_command/scripts/gator-fleet-report.py +615 -0
  26. gator_command/scripts/gator-init-command-post.py +315 -0
  27. gator_command/scripts/gator-init.py +434 -0
  28. gator_command/scripts/gator-machine-id.py +153 -0
  29. gator_command/scripts/gator-policy-status.py +631 -0
  30. gator_command/scripts/gator-pulse.py +459 -0
  31. gator_command/scripts/gator-repo-status.py +649 -0
  32. gator_command/scripts/gator-session-common.py +372 -0
  33. gator_command/scripts/gator-session-sink.py +831 -0
  34. gator_command/scripts/gator-sessions.py +1244 -0
  35. gator_command/scripts/gator-update.py +615 -0
  36. gator_command/scripts/gator-version.py +38 -0
  37. gator_command/scripts/gator_core.py +489 -0
  38. gator_command/scripts/gator_remote.py +381 -0
  39. gator_command/scripts/gator_runtime.py +142 -0
  40. gator_command/scripts/gatorize-actions.sh +989 -0
  41. gator_command/scripts/gatorize-lib.sh +166 -0
  42. gator_command/scripts/gatorize-post.sh +394 -0
  43. gator_command/scripts/gatorize.py +1163 -0
  44. gator_command/scripts/gatorize.sh +185 -0
  45. gator_command/scripts/generate_markdown.py +212 -0
  46. gator_command/scripts/generate_wiki.py +424 -0
  47. gator_command/scripts/graph_health.py +780 -0
  48. gator_command/scripts/memex-lint.py +286 -0
  49. gator_command/scripts/memex-lint.sh +205 -0
  50. gator_command/scripts/memex.py +1472 -0
  51. gator_command/scripts/memex_formatters.py +191 -0
  52. gator_command/scripts/memex_state.py +236 -0
  53. gator_command/scripts/spawn.py +650 -0
  54. gator_command/templates/gator-starter/blueprints/README.md +32 -0
  55. gator_command/templates/gator-starter/charterignore +53 -0
  56. gator_command/templates/gator-starter/charters/README.md +178 -0
  57. gator_command/templates/gator-starter/charters/_template.md +31 -0
  58. gator_command/templates/gator-starter/commands/commit.md +33 -0
  59. gator_command/templates/gator-starter/commands/init.md +11 -0
  60. gator_command/templates/gator-starter/commands/update.md +5 -0
  61. gator_command/templates/gator-starter/constitution.md +165 -0
  62. gator_command/templates/gator-starter/field-guides/README.md +25 -0
  63. gator_command/templates/gator-starter/gator-start-up.md +119 -0
  64. gator_command/templates/gator-starter/procedures/charter-alignment.md +83 -0
  65. gator_command/templates/gator-starter/procedures/enforcer-review.md +317 -0
  66. gator_command/templates/gator-starter/procedures/field-guide-generation.md +176 -0
  67. gator_command/templates/gator-starter/procedures/knowledge-capture.md +57 -0
  68. gator_command/templates/gator-starter/procedures/significance-check.md +69 -0
  69. gator_command/templates/gator-starter/reference-notes/concierge-responses.md +535 -0
  70. gator_command/templates/gator-starter/reference-notes/dangerous-patterns.md +91 -0
  71. gator_command/templates/gator-starter/reference-notes/dashboard-operations.md +22 -0
  72. gator_command/templates/gator-starter/reference-notes/enforcer-configuration.md +232 -0
  73. gator_command/templates/gator-starter/reference-notes/example-project.md +289 -0
  74. gator_command/templates/gator-starter/reference-notes/failure-modes-and-self-correction.md +72 -0
  75. gator_command/templates/gator-starter/reference-notes/git-workflow.md +60 -0
  76. gator_command/templates/gator-starter/reference-notes/identity-and-ownership.md +37 -0
  77. gator_command/templates/gator-starter/reference-notes/refactor-approach.md +155 -0
  78. gator_command/templates/gator-starter/reference-notes/what-gator-requires-from-a-model.md +108 -0
  79. gator_command/templates/gator-starter/reference-notes/why-navigation-coding-feels-different.md +99 -0
  80. gator_command/templates/gator-starter/reference-notes/workflow-profiles.md +155 -0
  81. gator_command/templates/gator-starter/scripts/__pycache__/enforcer-review.cpython-313.pyc +0 -0
  82. gator_command/templates/gator-starter/scripts/__pycache__/gator-approve.cpython-313.pyc +0 -0
  83. gator_command/templates/gator-starter/scripts/__pycache__/gator-init.cpython-313.pyc +0 -0
  84. gator_command/templates/gator-starter/scripts/__pycache__/gator-pre-commit.cpython-313.pyc +0 -0
  85. gator_command/templates/gator-starter/scripts/__pycache__/gator-update.cpython-313.pyc +0 -0
  86. gator_command/templates/gator-starter/scripts/enforcer-prompt.md +55 -0
  87. gator_command/templates/gator-starter/scripts/enforcer-review.py +1551 -0
  88. gator_command/templates/gator-starter/scripts/gator-approve.py +139 -0
  89. gator_command/templates/gator-starter/scripts/gator-enforce.py +82 -0
  90. gator_command/templates/gator-starter/scripts/gator-init.py +434 -0
  91. gator_command/templates/gator-starter/scripts/gator-pre-commit.py +2670 -0
  92. gator_command/templates/gator-starter/scripts/gator-pulse.py +459 -0
  93. gator_command/templates/gator-starter/scripts/gator-update.py +615 -0
  94. gator_command/templates/gator-starter/scripts/gator-version.py +38 -0
  95. gator_command/templates/gator-starter/scripts/gator_core.py +487 -0
  96. gator_command/templates/gator-starter/scripts/hooks/__pycache__/commit-msgcpython-313.pyc +0 -0
  97. gator_command/templates/gator-starter/scripts/hooks/__pycache__/post-commitcpython-313.pyc +0 -0
  98. gator_command/templates/gator-starter/scripts/hooks/__pycache__/pre-commitcpython-313.pyc +0 -0
  99. gator_command/templates/gator-starter/scripts/hooks/commit-msg +5 -0
  100. gator_command/templates/gator-starter/scripts/hooks/post-commit +7 -0
  101. gator_command/templates/gator-starter/scripts/hooks/pre-commit +5 -0
  102. gator_command/templates/gator-starter/sessions/.gitignore +7 -0
  103. gator_command/templates/gator-starter/vault/.gitkeep +0 -0
  104. gator_command/templates/gator-starter/whiteboard.md +5 -0
  105. gator_command-1.0.0.dist-info/METADATA +122 -0
  106. gator_command-1.0.0.dist-info/RECORD +110 -0
  107. gator_command-1.0.0.dist-info/WHEEL +5 -0
  108. gator_command-1.0.0.dist-info/entry_points.txt +2 -0
  109. gator_command-1.0.0.dist-info/licenses/LICENSE +21 -0
  110. gator_command-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2670 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ gator-pre-commit.py — Deterministic governance gate for gator-managed repos.
4
+
5
+ Called from three git hooks:
6
+ pre-commit: --phase validate
7
+ commit-msg: --phase trailers <msg-file>
8
+ post-commit: --phase cleanup
9
+
10
+ Phase 1 (validate): Checks structural rules, writes status.json and
11
+ whiteboard.md, stages both. Blocks the commit if hard rules fail.
12
+
13
+ Phase 2 (trailers): Reads commit_draft.md frontmatter and .gator/ state,
14
+ assembles Gator-* trailers, appends them to the commit message.
15
+
16
+ Phase 3 (cleanup): Resets .gator/commit_draft.md to the blank stub after a
17
+ successful commit so stale content does not leak into the next session.
18
+ Also appends the commit metadata to the agent's rolling active session file.
19
+
20
+ No LLM. No interpretation. Same result every time regardless of which
21
+ model produced the session work or how deep into context it was.
22
+
23
+ @reads: .gator/, commit_draft.md, git diff --cached
24
+ @writes: .gator/status.json, .gator/whiteboard.md, commit message (trailers),
25
+ .gator/sessions/_active/*.md, .gator/session-snippets/*.json, .gator/commit_draft.md
26
+ @does-not-own: the code changes themselves (the LLM/PI did that)
27
+ """
28
+
29
+ import argparse
30
+ import io
31
+ import json
32
+ import os
33
+ import re
34
+ import subprocess
35
+ import sys
36
+ from datetime import datetime, timezone
37
+ from pathlib import Path
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Constants
42
+ # ---------------------------------------------------------------------------
43
+
44
+ def _git_version():
45
+ """Version from git tags, resolved from this script's location."""
46
+ import subprocess
47
+ from pathlib import Path
48
+ # Walk up from script location to find .git
49
+ p = Path(__file__).resolve().parent
50
+ for _ in range(10):
51
+ if (p / ".git").is_dir():
52
+ break
53
+ p = p.parent
54
+ try:
55
+ r = subprocess.run(["git", "describe", "--tags", "--always"],
56
+ capture_output=True, text=True, cwd=p, timeout=5)
57
+ if r.returncode == 0 and r.stdout.strip():
58
+ return r.stdout.strip()
59
+ except Exception:
60
+ pass
61
+ return "dev"
62
+
63
+ VERSION = _git_version()
64
+
65
+ # Extensions that don't require a charter update when changed
66
+ EXEMPT_EXTENSIONS = {
67
+ ".md", ".txt", ".json", ".yaml", ".yml", ".toml", ".cfg", ".ini",
68
+ ".gitignore", ".gitkeep", ".env.example", ".lock",
69
+ }
70
+
71
+ # Paths that never require a charter update
72
+ EXEMPT_PATHS = {
73
+ "LICENSE", "README.md", "CLAUDE.md", "AGENTS.md",
74
+ ".claude/", ".github/", ".vscode/", ".gitignore",
75
+ }
76
+
77
+ CHARTER_SCAFFOLD_FILES = {"_template.md", "README.md", "INDEX.md", ".gitkeep"}
78
+ COMMIT_DRAFT_STUB = (
79
+ "---\n"
80
+ "message: \"\"\n"
81
+ "change-type:\n"
82
+ "significance:\n"
83
+ "decision-tags: []\n"
84
+ "agent:\n"
85
+ "architect:\n"
86
+ "---\n\n"
87
+ "# Session Change Log\n"
88
+ )
89
+ ACTIVE_SESSIONS_DIRNAME = "_active"
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Layer 1 lint rules — dangerous code patterns (from enforcer-review.py)
93
+ # Embedded here so the hook is self-contained with no import path fragility.
94
+ # ---------------------------------------------------------------------------
95
+
96
+ LINT_RULES = [
97
+ # Secrets & credentials
98
+ {
99
+ "id": "SEC-001", "name": "Hardcoded password",
100
+ "pattern": r"""(?i)(password|passwd|pwd)\s*=\s*['\"][^'\"]+['\"]""",
101
+ "severity": "HIGH",
102
+ "message": "Possible hardcoded password. Use environment variables or a secrets manager.",
103
+ "exclude_extensions": {".md", ".txt", ".rst"},
104
+ },
105
+ {
106
+ "id": "SEC-002", "name": "API key in source",
107
+ "pattern": r"""(?i)(api[_-]?key|secret[_-]?key|access[_-]?token)\s*=\s*['\"][A-Za-z0-9+/=_-]{16,}['\"]""",
108
+ "severity": "HIGH",
109
+ "message": "Possible API key or secret in source code.",
110
+ "exclude_extensions": {".md", ".txt", ".rst"},
111
+ },
112
+ {
113
+ "id": "SEC-003", "name": "Private key material",
114
+ "pattern": r"""-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----""",
115
+ "severity": "CRITICAL",
116
+ "message": "Private key material in source file.",
117
+ "exclude_extensions": set(),
118
+ # Downgrade to warning in documentation directories — these contain example text
119
+ "doc_paths": ["reference-notes/", "procedures/", "docs/"],
120
+ # Outside doc paths: require base64-looking key body within context_window lines
121
+ # A lone PEM header without key body is almost always documentation or a stub
122
+ "context_required": r"""[A-Za-z0-9+/]{40,}={0,2}\s*$""",
123
+ "context_window": 10,
124
+ },
125
+ # SQL dangers
126
+ {
127
+ "id": "SQL-001", "name": "DROP TABLE",
128
+ "pattern": r"""(?i)\bDROP\s+TABLE\b""",
129
+ "severity": "HIGH",
130
+ "message": "DROP TABLE detected. Verify this is intentional and scoped correctly.",
131
+ "exclude_extensions": {".md"},
132
+ },
133
+ {
134
+ "id": "SQL-002", "name": "DELETE without WHERE",
135
+ "pattern": r"""(?i)\bDELETE\s+FROM\s+\w+\s*(?:;|\n|$)""",
136
+ "severity": "HIGH",
137
+ "message": "DELETE FROM without WHERE clause — this deletes all rows.",
138
+ "exclude_extensions": {".md"},
139
+ },
140
+ {
141
+ "id": "SQL-003", "name": "TRUNCATE",
142
+ "pattern": r"""(?i)\bTRUNCATE\s+(?:TABLE\s+)?\w+""",
143
+ "severity": "HIGH",
144
+ "message": "TRUNCATE detected. Irreversible bulk deletion.",
145
+ "exclude_extensions": {".md"},
146
+ },
147
+ {
148
+ "id": "SQL-004", "name": "SQL string concatenation",
149
+ "pattern": r"""(?:execute|cursor\.execute|query)\s*\(.*(?:\+|%\s|\.format|f['\"])""",
150
+ "severity": "MEDIUM",
151
+ "message": "Possible SQL injection — string concatenation in query. Use parameterized queries.",
152
+ "exclude_extensions": {".md"},
153
+ },
154
+ # Code safety
155
+ {
156
+ "id": "CODE-001", "name": "eval() usage",
157
+ "pattern": r"""\beval\s*\(""",
158
+ "severity": "MEDIUM",
159
+ "message": "eval() executes arbitrary code. Verify input is trusted.",
160
+ "exclude_extensions": {".md", ".txt"},
161
+ },
162
+ {
163
+ "id": "CODE-002", "name": "shell=True in subprocess",
164
+ "pattern": r"""subprocess\.\w+\(.*shell\s*=\s*True""",
165
+ "severity": "MEDIUM",
166
+ "message": "subprocess with shell=True — command injection risk.",
167
+ "exclude_extensions": {".md"},
168
+ },
169
+ {
170
+ "id": "CODE-003", "name": "os.system usage",
171
+ "pattern": r"""\bos\.system\s*\(""",
172
+ "severity": "MEDIUM",
173
+ "message": "os.system() is a command injection risk. Use subprocess with shell=False.",
174
+ "exclude_extensions": {".md"},
175
+ },
176
+ ]
177
+
178
+ # Files that should never be committed
179
+ DANGEROUS_FILENAMES = {".env"}
180
+ DANGEROUS_PREFIXES = {".env."}
181
+ DANGEROUS_SAFE = {".env.example"}
182
+
183
+
184
+ def parse_diff_added_lines(repo_root):
185
+ """Parse git diff --cached to extract only added lines per file.
186
+
187
+ Returns dict: {filepath: [(lineno, line_text), ...]}
188
+ Only includes lines starting with '+' (additions), excluding
189
+ diff headers. Line numbers are mapped to the target file.
190
+ """
191
+ raw_diff = git("diff", "--cached", "-U0", "--no-color", cwd=repo_root)
192
+ if not raw_diff:
193
+ return {}
194
+
195
+ added = {}
196
+ current_file = None
197
+ target_lineno = 0
198
+
199
+ for line in raw_diff.splitlines():
200
+ # New file header: +++ b/path/to/file
201
+ if line.startswith("+++ b/"):
202
+ current_file = line[6:]
203
+ added.setdefault(current_file, [])
204
+ continue
205
+
206
+ # Hunk header: @@ -old,count +new,count @@
207
+ if line.startswith("@@") and current_file:
208
+ match = re.search(r'\+(\d+)', line)
209
+ if match:
210
+ target_lineno = int(match.group(1))
211
+ continue
212
+
213
+ # Added line
214
+ if line.startswith("+") and not line.startswith("+++") and current_file:
215
+ added[current_file].append((target_lineno, line[1:]))
216
+ target_lineno += 1
217
+ continue
218
+
219
+ # Context or removed line — just advance target lineno for context
220
+ if not line.startswith("-") and current_file:
221
+ target_lineno += 1
222
+
223
+ return added
224
+
225
+
226
+ def load_lint_allowlist(gator_dir):
227
+ """Load the PI-approved lint allowlist from .gator/lint-allow.json.
228
+
229
+ Each entry is: {"rule": "SQL-001", "file": "path", ...}
230
+ Returns a set of (rule, file) tuples for fast lookup.
231
+ """
232
+ allowlist_file = gator_dir / "lint-allow.json"
233
+ if not allowlist_file.exists():
234
+ return set()
235
+ try:
236
+ entries = json.loads(allowlist_file.read_text(encoding="utf-8"))
237
+ return {(e["rule"], e["file"]) for e in entries if "rule" in e and "file" in e}
238
+ except (json.JSONDecodeError, OSError, KeyError):
239
+ return set()
240
+
241
+
242
+ def _effective_severity(rule, filepath, surrounding_lines):
243
+ """Compute context-aware effective severity for a lint match.
244
+
245
+ context_required takes priority over doc_paths: if corroborating evidence
246
+ (e.g. a base64 key body) is present in surrounding lines, the match is
247
+ treated as real secret material at original severity regardless of file
248
+ location. doc_paths only downgrades when evidence is absent — distinguishing
249
+ a lone example header in a reference doc from an actual committed key.
250
+ """
251
+ severity = rule["severity"]
252
+
253
+ # Context-required check first — real evidence overrides location heuristics.
254
+ if severity in ("CRITICAL", "HIGH") and rule.get("context_required"):
255
+ context_found = any(
256
+ re.search(rule["context_required"], line) for line in surrounding_lines
257
+ )
258
+ if context_found:
259
+ # Corroborating evidence present — real key material, keep severity.
260
+ return severity
261
+ # No body found — likely documentation or stub. Fall through to
262
+ # location-based downgrade or unconditional downgrade below.
263
+ if rule.get("doc_paths"):
264
+ f_norm = filepath.replace("\\", "/")
265
+ if any(dp in f_norm for dp in rule["doc_paths"]):
266
+ return "MEDIUM"
267
+ return "MEDIUM"
268
+
269
+ # Rules without context_required: apply doc_paths location-based downgrade.
270
+ if rule.get("doc_paths"):
271
+ f_norm = filepath.replace("\\", "/")
272
+ if any(dp in f_norm for dp in rule["doc_paths"]):
273
+ return "MEDIUM"
274
+
275
+ return severity
276
+
277
+
278
+ def run_layer1_lint(staged_files, repo_root):
279
+ """Run mechanical lint rules against ADDED lines only (from git diff).
280
+
281
+ Only lines being introduced in this commit are checked. Existing
282
+ code is implicitly approved by virtue of being in the codebase.
283
+ This eliminates false alarm fatigue and means the PI only reviews
284
+ what they're actually introducing.
285
+
286
+ The allowlist (.gator/lint-allow.json) handles the rare case where
287
+ new dangerous code is intentional.
288
+ """
289
+ findings = []
290
+ gator_dir = repo_root / ".gator"
291
+ allowed = load_lint_allowlist(gator_dir)
292
+
293
+ # Self-exclude patterns (these files contain or quote the patterns they detect)
294
+ self_excludes = {"gator-pre-commit.py", "enforcer-review.py", "enforcer-prompt.md", "lint-allow.json", "commit_issues.md", "whiteboard.md"}
295
+
296
+ # Path-scoped excludes for Gator-provided documentation that quotes dangerous
297
+ # patterns as examples. Unlike self_excludes (basename-only), these check the
298
+ # normalized path so a user file with the same name is still scanned.
299
+ gator_doc_excludes = {"reference-notes/dangerous-patterns.md"}
300
+
301
+ # Check for dangerous filenames in staged files (these are always checked
302
+ # regardless of diff — staging a .env file is the danger)
303
+ for filepath in staged_files:
304
+ basename = os.path.basename(filepath)
305
+ if basename in self_excludes:
306
+ continue
307
+ if basename in DANGEROUS_FILENAMES or (
308
+ any(basename.startswith(p) for p in DANGEROUS_PREFIXES)
309
+ and basename not in DANGEROUS_SAFE
310
+ ):
311
+ if ("HYG-002", filepath) not in allowed:
312
+ findings.append({
313
+ "rule": "HYG-002",
314
+ "severity": "HIGH",
315
+ "file": filepath,
316
+ "line": 0,
317
+ "message": f"{basename} should not be committed. Add to .gitignore.",
318
+ })
319
+
320
+ # Parse diff for added lines only
321
+ added_lines = parse_diff_added_lines(repo_root)
322
+
323
+ for filepath, lines in added_lines.items():
324
+ basename = os.path.basename(filepath)
325
+ if basename in self_excludes:
326
+ continue
327
+ f_norm = filepath.replace("\\", "/")
328
+ if any(f_norm.endswith(dp) for dp in gator_doc_excludes):
329
+ continue
330
+
331
+ _, ext = os.path.splitext(filepath)
332
+
333
+ for rule in LINT_RULES:
334
+ if ext in rule.get("exclude_extensions", set()):
335
+ continue
336
+ if (rule["id"], filepath) in allowed:
337
+ continue
338
+ pattern = rule["pattern"]
339
+ for i, (lineno, line_text) in enumerate(lines):
340
+ if re.search(pattern, line_text):
341
+ window = rule.get("context_window", 10)
342
+ if rule.get("context_required"):
343
+ # Read full file content so a key body already in the
344
+ # file (not newly added in this diff) is still detected.
345
+ try:
346
+ file_lines = (repo_root / filepath).read_text(
347
+ encoding="utf-8", errors="replace"
348
+ ).splitlines()
349
+ line_idx = lineno - 1
350
+ ctx = file_lines[
351
+ max(0, line_idx - window):line_idx + window + 1
352
+ ]
353
+ except OSError:
354
+ ctx = [lt for _, lt in lines[max(0, i - window):i + window + 1]]
355
+ else:
356
+ ctx = [lt for _, lt in lines[max(0, i - window):i + window + 1]]
357
+ effective_sev = _effective_severity(rule, filepath, ctx)
358
+ findings.append({
359
+ "rule": rule["id"],
360
+ "severity": effective_sev,
361
+ "file": filepath,
362
+ "line": lineno,
363
+ "message": rule["message"],
364
+ "match": line_text.strip()[:120],
365
+ })
366
+
367
+ return findings
368
+
369
+
370
+ # High file count threshold for soft warning
371
+ HIGH_FILE_COUNT = 20
372
+
373
+
374
+ # ---------------------------------------------------------------------------
375
+ # Git helpers
376
+ # ---------------------------------------------------------------------------
377
+
378
+ def git(*args, cwd=None):
379
+ """Run a git command, return stdout. Returns empty string on failure."""
380
+ try:
381
+ result = subprocess.run(
382
+ ["git"] + list(args),
383
+ capture_output=True, text=True, cwd=cwd,
384
+ encoding="utf-8", errors="replace",
385
+ )
386
+ return (result.stdout or "").strip()
387
+ except OSError:
388
+ return ""
389
+
390
+
391
+ def get_staged_files(repo_root):
392
+ """Return list of staged file paths (relative to repo root)."""
393
+ output = git("diff", "--cached", "--name-only", cwd=repo_root)
394
+ if not output:
395
+ return []
396
+ return [line for line in output.splitlines() if line.strip()]
397
+
398
+
399
+ def get_current_branch(repo_root):
400
+ """Return current branch name."""
401
+ return git("branch", "--show-current", cwd=repo_root) or "unknown"
402
+
403
+
404
+ def stage_file(filepath, repo_root):
405
+ """Stage a single file."""
406
+ git("add", str(filepath), cwd=repo_root)
407
+
408
+
409
+ # ---------------------------------------------------------------------------
410
+ # .gator/ state readers
411
+ # ---------------------------------------------------------------------------
412
+
413
+ def find_gator_root(start_path=None):
414
+ """Walk up from start_path looking for .gator/ directory."""
415
+ path = Path(start_path) if start_path else Path.cwd()
416
+ path = path.resolve()
417
+ if (path / ".gator").is_dir():
418
+ return path
419
+ for parent in path.parents:
420
+ if (parent / ".gator").is_dir():
421
+ return parent
422
+ return None
423
+
424
+
425
+ def _resolve_charter_surface(repo_root):
426
+ """Resolve the charter surface for this repo.
427
+
428
+ Tries gator_core.resolve_charter_surface() first (canonical resolver),
429
+ falls back to inline heuristic for self-contained operation.
430
+
431
+ Returns (charter_dir: Path, cross_cutting_name: str|None).
432
+ """
433
+ try:
434
+ scripts_dir = repo_root / "src" / "gator_command" / "scripts"
435
+ if not scripts_dir.is_dir():
436
+ scripts_dir = repo_root / "gator-command" / "scripts"
437
+ if not scripts_dir.is_dir():
438
+ scripts_dir = repo_root / ".gator" / "scripts"
439
+ if scripts_dir.is_dir() and str(scripts_dir) not in sys.path:
440
+ sys.path.insert(0, str(scripts_dir))
441
+ from gator_core import resolve_charter_surface
442
+ surface = resolve_charter_surface(repo_root)
443
+ return Path(surface["charter_dir"]), surface.get("cross_cutting")
444
+ except (ImportError, Exception):
445
+ pass
446
+
447
+ # Fallback: inline resolution (self-contained for fleet repos)
448
+ command_post_charters = repo_root / "gator-command" / "charters"
449
+ command_post_scripts = repo_root / "gator-command" / "scripts"
450
+ if command_post_charters.is_dir() and command_post_scripts.is_dir():
451
+ charter_dir = command_post_charters
452
+ else:
453
+ charter_dir = repo_root / ".gator" / "charters"
454
+
455
+ cross_cutting = None
456
+ if charter_dir.is_dir():
457
+ for f in charter_dir.iterdir():
458
+ if "cross-cutting" in f.name and f.suffix == ".md":
459
+ cross_cutting = f.name
460
+ break
461
+ return charter_dir, cross_cutting
462
+
463
+
464
+ def _resolve_charter_dir(repo_root):
465
+ """Convenience: return just the charter directory Path."""
466
+ charter_dir, _ = _resolve_charter_surface(repo_root)
467
+ return charter_dir
468
+
469
+
470
+ def _iter_charter_files(repo_root):
471
+ """Yield real charter files from the governed repo's charter surface."""
472
+ charter_dir = _resolve_charter_dir(repo_root)
473
+ if not charter_dir.is_dir():
474
+ return
475
+ for charter_file in charter_dir.iterdir():
476
+ if charter_file.suffix != ".md":
477
+ continue
478
+ if charter_file.name in CHARTER_SCAFFOLD_FILES:
479
+ continue
480
+ yield charter_file
481
+
482
+
483
+ def count_charters(gator_dir):
484
+ """Count charter files and documented functions."""
485
+ repo_root = gator_dir.parent
486
+ charter_files = list(_iter_charter_files(repo_root))
487
+
488
+ function_count = 0
489
+ for cf in charter_files:
490
+ try:
491
+ text = cf.read_text(encoding="utf-8", errors="replace")
492
+ for line in text.splitlines():
493
+ if line.strip().startswith("### ") and "(" in line:
494
+ function_count += 1
495
+ except OSError:
496
+ continue
497
+
498
+ return len(charter_files), function_count
499
+
500
+
501
+ def count_threads(gator_dir):
502
+ """Count threads across active-threads/ and threads/."""
503
+ total = 0
504
+ for subdir_name in ("active-threads", "threads"):
505
+ subdir = gator_dir / subdir_name
506
+ if subdir.is_dir():
507
+ total += len([
508
+ f for f in subdir.iterdir()
509
+ if f.suffix == ".md" and f.name != ".gitkeep"
510
+ ])
511
+ return total
512
+
513
+
514
+ def read_generation(gator_dir):
515
+ """Read generation from .gator-version."""
516
+ version_file = gator_dir / ".gator-version"
517
+ if not version_file.exists():
518
+ return 0
519
+ text = version_file.read_text(encoding="utf-8", errors="replace")
520
+ for line in text.splitlines():
521
+ if line.startswith("generation:"):
522
+ try:
523
+ return int(line.split(":", 1)[1].strip())
524
+ except ValueError:
525
+ return 0
526
+ return 0
527
+
528
+
529
+ def read_policy_version(gator_dir):
530
+ """Read policy version date from command-post.md."""
531
+ cp_file = gator_dir / "command-post.md"
532
+ if not cp_file.exists():
533
+ return None
534
+ text = cp_file.read_text(encoding="utf-8", errors="replace")
535
+ for line in text.splitlines():
536
+ if line.startswith("version:"):
537
+ return line.split(":", 1)[1].strip()
538
+ return None
539
+
540
+
541
+ def count_issues(gator_dir):
542
+ """Count open/working issues."""
543
+ issues_file = gator_dir / "issues.md"
544
+ if not issues_file.exists():
545
+ return 0
546
+ text = issues_file.read_text(encoding="utf-8", errors="replace")
547
+ count = 0
548
+ for line in text.splitlines():
549
+ if "**Status**: Open" in line or "**Status**: Working" in line:
550
+ count += 1
551
+ return count
552
+
553
+
554
+ def read_tripwire_patterns(gator_dir):
555
+ """Extract @tripwire file patterns from charters."""
556
+ patterns = set()
557
+ repo_root = gator_dir.parent
558
+ for cf in _iter_charter_files(repo_root):
559
+ try:
560
+ text = cf.read_text(encoding="utf-8", errors="replace")
561
+ for line in text.splitlines():
562
+ if "@tripwire" in line.lower():
563
+ # Extract file paths mentioned on tripwire lines
564
+ # Look for backtick-quoted paths or bare paths
565
+ for match in re.finditer(r'`([^`]+\.\w+)`', line):
566
+ patterns.add(match.group(1))
567
+ except OSError:
568
+ continue
569
+ return patterns
570
+
571
+
572
+ # ---------------------------------------------------------------------------
573
+ # commit_draft.md parsing
574
+ # ---------------------------------------------------------------------------
575
+
576
+ def parse_commit_draft(gator_dir):
577
+ """Parse commit_draft.md into frontmatter dict and body string.
578
+
579
+ Returns (frontmatter, body, error).
580
+ - frontmatter: dict of YAML fields, or {} if no frontmatter
581
+ - body: string of everything after frontmatter
582
+ - error: string if frontmatter is present but malformed, else None
583
+ """
584
+ draft_file = gator_dir / "commit_draft.md"
585
+ if not draft_file.exists():
586
+ return {}, "", None
587
+
588
+ text = draft_file.read_text(encoding="utf-8", errors="replace")
589
+ if not text.strip():
590
+ return {}, "", None
591
+
592
+ # Check for YAML frontmatter (--- delimited)
593
+ if text.startswith("---"):
594
+ parts = text.split("---", 2)
595
+ if len(parts) >= 3:
596
+ yaml_text = parts[1].strip()
597
+ body = parts[2].strip()
598
+ frontmatter = _parse_simple_yaml(yaml_text)
599
+ if frontmatter is None:
600
+ return {}, body, "Malformed YAML frontmatter in commit_draft.md"
601
+ return frontmatter, body, None
602
+
603
+ # No frontmatter — entire file is body
604
+ # Strip the "# Session Change Log" header if present
605
+ lines = text.splitlines()
606
+ body_lines = [l for l in lines if not l.startswith("# ")]
607
+ return {}, "\n".join(body_lines).strip(), None
608
+
609
+
610
+ def _parse_simple_yaml(text):
611
+ """Minimal YAML parser for commit_draft frontmatter.
612
+
613
+ Handles flat key: value pairs and simple lists [a, b, c].
614
+ No external dependency needed. Returns None on parse failure.
615
+ """
616
+ result = {}
617
+ try:
618
+ for line in text.splitlines():
619
+ line = line.strip()
620
+ if not line or line.startswith("#"):
621
+ continue
622
+ if ":" not in line:
623
+ return None # malformed
624
+ key, _, value = line.partition(":")
625
+ key = key.strip()
626
+ value = value.strip()
627
+
628
+ # Handle YAML list syntax: [a, b, c]
629
+ if value.startswith("[") and value.endswith("]"):
630
+ inner = value[1:-1]
631
+ items = [item.strip().strip("'\"") for item in inner.split(",")]
632
+ result[key] = [i for i in items if i]
633
+ # Handle quoted strings
634
+ elif (value.startswith('"') and value.endswith('"')) or \
635
+ (value.startswith("'") and value.endswith("'")):
636
+ result[key] = value[1:-1]
637
+ else:
638
+ result[key] = value
639
+
640
+ return result
641
+ except Exception:
642
+ return None
643
+
644
+
645
+ # ---------------------------------------------------------------------------
646
+ # Fallback heuristics (body text inference)
647
+ # ---------------------------------------------------------------------------
648
+
649
+ def extract_tags_from_body(body):
650
+ """Extract [#tag] patterns from commit_draft body."""
651
+ if not body:
652
+ return []
653
+ tags = set()
654
+ for match in re.finditer(r'\[#([a-zA-Z0-9_-]+)\]', body):
655
+ tags.add(match.group(1))
656
+ return sorted(tags)
657
+
658
+
659
+ def infer_change_type(body, has_code_staged):
660
+ """Infer change type from body tags, gated on staged reality.
661
+
662
+ Per Codex review: body-text inference must never produce a stronger
663
+ signal than the staged files justify. If no code is staged, cap at
664
+ docs/policy regardless of tags.
665
+ """
666
+ if not body:
667
+ return None
668
+
669
+ text = body.lower()
670
+
671
+ if not has_code_staged:
672
+ # No code files staged — this is docs/policy work at most
673
+ if "[#policy]" in text or "[#governance]" in text:
674
+ return "policy"
675
+ return "docs"
676
+
677
+ # Code is staged — full inference
678
+ if "[#security]" in text:
679
+ return "security"
680
+ if "[#bugfix]" in text or "[#fix]" in text:
681
+ return "fix"
682
+ if "[#refactor]" in text:
683
+ return "refactor"
684
+ if "[#architecture]" in text or "[#decision]" in text or "[#feature]" in text:
685
+ return "feature"
686
+ if "[#policy]" in text or "[#governance]" in text:
687
+ return "policy"
688
+ if "[#docs]" in text or "[#charter]" in text:
689
+ return "docs"
690
+ return None
691
+
692
+
693
+ def infer_significance(body, charter_changed, has_code_staged):
694
+ """Infer significance from body signals, gated on staged reality.
695
+
696
+ Per Codex review: if no code files staged, cap at routine regardless
697
+ of tags in the body.
698
+ """
699
+ if not body or not has_code_staged:
700
+ return "routine"
701
+
702
+ text = body.lower()
703
+ if any(tag in text for tag in ["[#security]", "[#architecture]", "[#breaking]"]):
704
+ return "high"
705
+ if charter_changed or "[#decision]" in text or "[#feature]" in text:
706
+ return "notable"
707
+ return "routine"
708
+
709
+
710
+ def detect_agent_from_body(body):
711
+ """Extract agent attribution from body text."""
712
+ if not body:
713
+ return None
714
+ agents = set()
715
+ for line in body.splitlines():
716
+ stripped = line.rstrip()
717
+ # Match org policy format: -claude, -codex, -gemini at end of line
718
+ if stripped.endswith("-claude"):
719
+ agents.add("claude")
720
+ elif stripped.endswith("-codex"):
721
+ agents.add("codex")
722
+ elif stripped.endswith("-gemini"):
723
+ agents.add("gemini")
724
+ # Also match — claude, — codex style
725
+ for model in ("claude", "codex", "gemini"):
726
+ if f"— {model}" in stripped.lower():
727
+ agents.add(model)
728
+ if agents:
729
+ return ",".join(sorted(agents))
730
+ return None
731
+
732
+
733
+ def detect_architect_from_body(body):
734
+ """Extract Architect attribution from body text.
735
+
736
+ Per Codex review: must match the actual org policy format (— AG),
737
+ not the old -pi suffix pattern. If no real value found, return None
738
+ rather than 'architect'.
739
+ """
740
+ if not body:
741
+ return None
742
+ for line in body.splitlines():
743
+ # Match "— AG" or "— JD" (em dash + space + 2-4 uppercase letters)
744
+ match = re.search(r'—\s+([A-Z]{2,4})(?:\s|$)', line)
745
+ if match:
746
+ return match.group(1)
747
+ return None
748
+
749
+
750
+ # ---------------------------------------------------------------------------
751
+ # Validation rules
752
+ # ---------------------------------------------------------------------------
753
+
754
+ def classify_staged_files(staged_files, _charter_patterns_cache=[None]):
755
+ """Classify staged files into code and charter changes.
756
+
757
+ Returns (has_code, has_charter, code_files, charter_files).
758
+ Uses the resolved charter directory to identify charter files.
759
+ """
760
+ # Resolve charter path patterns once per process
761
+ if _charter_patterns_cache[0] is None:
762
+ try:
763
+ repo_root = Path.cwd()
764
+ for _ in range(10):
765
+ if (repo_root / ".gator").is_dir():
766
+ break
767
+ repo_root = repo_root.parent
768
+ surface = _resolve_charter_surface(repo_root)
769
+ charter_dir = surface[0]
770
+ # Build both absolute and relative patterns for matching
771
+ # Git staged paths are relative; resolved dir is absolute
772
+ abs_str = str(charter_dir).replace("\\", "/")
773
+ try:
774
+ rel_str = str(charter_dir.relative_to(repo_root)).replace("\\", "/")
775
+ except ValueError:
776
+ rel_str = abs_str
777
+ _charter_patterns_cache[0] = [
778
+ rel_str + "/", # relative: gator-command/charters/
779
+ abs_str + "/", # absolute: C:/.../gator-command/charters/
780
+ ".gator/charters/", # always recognize standard path
781
+ ]
782
+ except Exception:
783
+ _charter_patterns_cache[0] = [".gator/charters/"]
784
+
785
+ charter_patterns = _charter_patterns_cache[0]
786
+
787
+ code_files = []
788
+ charter_files = []
789
+
790
+ for f in staged_files:
791
+ # Normalize path separators
792
+ f_normalized = f.replace("\\", "/")
793
+
794
+ # Charter files — match against resolved charter directory patterns
795
+ is_charter = any(p in f_normalized for p in charter_patterns)
796
+ if is_charter:
797
+ basename = f_normalized.split("/")[-1]
798
+ if basename not in CHARTER_SCAFFOLD_FILES:
799
+ charter_files.append(f)
800
+ continue
801
+
802
+ # Other .gator/ internal files — exempt
803
+ if ".gator/" in f_normalized:
804
+ continue
805
+
806
+ # Check exempt paths
807
+ is_exempt = False
808
+ for exempt in EXEMPT_PATHS:
809
+ if f_normalized.startswith(exempt) or f_normalized == exempt:
810
+ is_exempt = True
811
+ break
812
+ if is_exempt:
813
+ continue
814
+
815
+ # Check exempt extensions
816
+ _, ext = os.path.splitext(f_normalized)
817
+ if ext.lower() in EXEMPT_EXTENSIONS:
818
+ continue
819
+
820
+ # Everything else is a code file
821
+ code_files.append(f)
822
+
823
+ return bool(code_files), bool(charter_files), code_files, charter_files
824
+
825
+
826
+ def _generate_block_id():
827
+ """Generate a short unique block ID for override tracking."""
828
+ import hashlib
829
+ import time
830
+ raw = f"{time.time()}-{os.getpid()}"
831
+ return hashlib.sha256(raw.encode()).hexdigest()[:8]
832
+
833
+
834
+ def _write_override_request(gator_dir, failure_type, files, override_type="charter-skip"):
835
+ """Write an override request for PI review."""
836
+ import time
837
+ request = {
838
+ "block_id": _generate_block_id(),
839
+ "failure_type": failure_type,
840
+ "override_type": override_type,
841
+ "files": files[:10],
842
+ "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S%z"),
843
+ "epoch": time.time(),
844
+ }
845
+ request_file = gator_dir / "override-request.json"
846
+ request_file.write_text(
847
+ json.dumps(request, indent=2) + "\n",
848
+ encoding="utf-8",
849
+ )
850
+ return request
851
+
852
+
853
+ OVERRIDE_DELAY_SECONDS = 10
854
+
855
+
856
+ def check_override(gator_dir):
857
+ """Check for a PI-approved two-phase override.
858
+
859
+ Two-phase override flow:
860
+ 1. Hook blocks → writes override-request.json (block ID, failure)
861
+ 2. PI reviews findings, runs gator-approve.py → writes override-approved.json
862
+ 3. Agent retries git commit → hook checks approval matches request
863
+
864
+ Approval is valid only if:
865
+ - Both request and approval files exist
866
+ - Block IDs match
867
+ - Approval timestamp is later than request
868
+ - Minimum delay has passed (agent cannot instantly self-approve)
869
+
870
+ Also supports legacy .override file for backward compatibility,
871
+ but prints a deprecation notice.
872
+
873
+ Returns the override value if approved, None otherwise.
874
+ """
875
+ import time
876
+
877
+ # --- New two-phase flow ---
878
+ request_file = gator_dir / "override-request.json"
879
+ approved_file = gator_dir / "override-approved.json"
880
+
881
+ two_phase_valid = False
882
+ if request_file.exists() and approved_file.exists():
883
+ try:
884
+ request = json.loads(request_file.read_text(encoding="utf-8"))
885
+ approval = json.loads(approved_file.read_text(encoding="utf-8"))
886
+
887
+ ids_match = request.get("block_id") == approval.get("block_id")
888
+
889
+ req_epoch = request.get("epoch", 0)
890
+ try:
891
+ appr_str = approval["approved_at"]
892
+ # Parse with timezone offset (e.g. 2026-06-04T10:58:37-0400)
893
+ # Python 3.7+ fromisoformat handles most ISO formats;
894
+ # strptime with %z handles the +HHMM offset reliably.
895
+ try:
896
+ appr_dt = datetime.fromisoformat(appr_str)
897
+ except ValueError:
898
+ appr_dt = datetime.strptime(appr_str[:24], "%Y-%m-%dT%H:%M:%S%z")
899
+ appr_epoch = appr_dt.timestamp()
900
+ except (KeyError, ValueError):
901
+ appr_epoch = 0
902
+
903
+ timing_ok = appr_epoch >= req_epoch
904
+ delay_ok = time.time() - req_epoch >= OVERRIDE_DELAY_SECONDS
905
+
906
+ two_phase_valid = ids_match and timing_ok and delay_ok
907
+ except (json.JSONDecodeError, OSError):
908
+ two_phase_valid = False
909
+
910
+ if two_phase_valid:
911
+ # Valid two-phase approval — consume both files (one-shot)
912
+ override_type = approval.get("override_type", "charter-skip")
913
+ approved_by = approval.get("approved_by", "unknown")
914
+ reason = approval.get("reason", "")
915
+
916
+ for f in (request_file, approved_file):
917
+ try:
918
+ f.unlink()
919
+ except OSError:
920
+ pass
921
+ git("rm", "--cached", "--quiet", "--force", "--",
922
+ str(f.relative_to(gator_dir.parent)),
923
+ cwd=gator_dir.parent)
924
+
925
+ # Stash approval metadata for trailer assembly
926
+ _override_meta = {
927
+ "type": override_type,
928
+ "approved_by": approved_by,
929
+ "reason": reason,
930
+ "block_id": request.get("block_id", ""),
931
+ }
932
+ meta_file = gator_dir / ".override-meta.json"
933
+ meta_file.write_text(json.dumps(_override_meta), encoding="utf-8")
934
+
935
+ return override_type
936
+
937
+ # --- Legacy .override file (fallback when two-phase fails or is absent) ---
938
+ override_file = gator_dir / ".override"
939
+ if override_file.exists():
940
+ value = override_file.read_text(encoding="utf-8").strip()
941
+ try:
942
+ override_file.unlink()
943
+ except OSError:
944
+ pass
945
+ git("rm", "--cached", "--quiet", "--force", "--",
946
+ str(override_file.relative_to(gator_dir.parent)),
947
+ cwd=gator_dir.parent)
948
+
949
+ if value:
950
+ # Write minimal meta for trailers
951
+ meta_file = gator_dir / ".override-meta.json"
952
+ meta_file.write_text(json.dumps({
953
+ "type": value,
954
+ "approved_by": "legacy-override",
955
+ "reason": "",
956
+ "block_id": "",
957
+ }), encoding="utf-8")
958
+ return value or None
959
+
960
+ return None
961
+
962
+
963
+ def validate_hard_rules(staged_files, frontmatter, body, parse_error, gator_dir, override=None):
964
+ """Check hard rules. Returns list of (rule_name, message) for failures."""
965
+ failures = []
966
+
967
+ # 1. Frontmatter parse failure
968
+ if parse_error:
969
+ failures.append(("frontmatter-parse", parse_error))
970
+
971
+ # 2. Empty commit_draft
972
+ if not frontmatter and not body:
973
+ failures.append((
974
+ "empty-commit-draft",
975
+ "commit_draft.md is empty or missing. The agent must document "
976
+ "what changed and why before committing."
977
+ ))
978
+
979
+ # 3. Charter-alongside-code
980
+ has_code, has_charter, code_files, charter_files_staged = classify_staged_files(staged_files)
981
+
982
+ if has_code and override != "charter-skip":
983
+ files_str = ", ".join(code_files[:5])
984
+ if len(code_files) > 5:
985
+ files_str += f" (and {len(code_files) - 5} more)"
986
+
987
+ if not has_charter:
988
+ # No charter at all — block. Use INDEX to tell the agent
989
+ # exactly which charters are needed.
990
+ required = _required_charters_for_files(code_files, gator_dir.parent)
991
+ if required:
992
+ charters_str = ", ".join(sorted(required))
993
+ failures.append((
994
+ "charter-alongside-code",
995
+ f"Code files changed ({files_str}) but no charters "
996
+ f"were updated. INDEX.md requires: {charters_str}. "
997
+ f"Update the affected charters and retry."
998
+ ))
999
+ else:
1000
+ failures.append((
1001
+ "charter-alongside-code",
1002
+ f"Code files changed ({files_str}) but no charters "
1003
+ f"were updated. "
1004
+ f"Update the affected charters and retry."
1005
+ ))
1006
+ else:
1007
+ # Charter(s) staged — check if all INDEX-required charters
1008
+ # are covered. This catches the case where the module charter
1009
+ # is staged but cross-cutting (or another required charter)
1010
+ # is not.
1011
+ required = _required_charters_for_files(code_files, gator_dir.parent)
1012
+ if required:
1013
+ staged_names = set()
1014
+ for f in charter_files_staged:
1015
+ fn = f.replace("\\", "/").rsplit("/", 1)[-1]
1016
+ staged_names.add(fn)
1017
+ missing = required - staged_names
1018
+ if missing:
1019
+ missing_str = ", ".join(sorted(missing))
1020
+ failures.append((
1021
+ "charter-index-gap",
1022
+ f"Code files changed ({files_str}). INDEX.md requires "
1023
+ f"charters: {', '.join(sorted(required))}. "
1024
+ f"Missing from staged files: {missing_str}. "
1025
+ f"Update the missing charters and retry."
1026
+ ))
1027
+
1028
+ # 4. Missing commit message
1029
+ has_message = bool(frontmatter.get("message"))
1030
+ # The stub heading "# Session Change Log" alone should not count
1031
+ # as real body content. Only strip that exact stub line — preserve
1032
+ # all other #-prefixed lines (e.g. "#123 fix", markdown headings).
1033
+ _STUB_HEADING = "# Session Change Log"
1034
+ body_lines = [l for l in (body or "").splitlines()
1035
+ if l.strip() and l.strip() != _STUB_HEADING]
1036
+ has_body_content = bool(body_lines)
1037
+ if not has_message and not has_body_content:
1038
+ failures.append((
1039
+ "missing-message",
1040
+ "No commit message found. Add a 'message' field to "
1041
+ "commit_draft.md frontmatter, or add content to the body."
1042
+ ))
1043
+
1044
+ return failures
1045
+
1046
+
1047
+ def _check_charter_function_refs(charter_files, repo_root):
1048
+ """Check that ### func() entries in staged charters reference real functions.
1049
+
1050
+ Parses charter for '### name(' entries and the 'Covers:' line, then greps
1051
+ the covered files for those function names. Returns list of names not found.
1052
+ Self-contained — no gator_core imports.
1053
+ """
1054
+ stale = []
1055
+ for cf in charter_files:
1056
+ cf_path = Path(cf) if Path(cf).is_absolute() else repo_root / cf
1057
+ if not cf_path.is_file():
1058
+ continue
1059
+ try:
1060
+ text = cf_path.read_text(encoding="utf-8", errors="replace")
1061
+ except (OSError, UnicodeDecodeError):
1062
+ continue
1063
+
1064
+ # Extract function names from ### entries
1065
+ func_names = []
1066
+ for line in text.splitlines():
1067
+ stripped = line.strip()
1068
+ if stripped.startswith("### ") and "(" in stripped:
1069
+ # "### foo_bar(args)" → "foo_bar"
1070
+ name = stripped[4:].split("(")[0].strip()
1071
+ if name:
1072
+ func_names.append(name)
1073
+
1074
+ if not func_names:
1075
+ continue
1076
+
1077
+ # Extract covered files from Covers: line
1078
+ covered_files = []
1079
+ for line in text.splitlines():
1080
+ if line.startswith("**Covers**:") or line.startswith("**Covers:**"):
1081
+ # Parse backtick-quoted paths
1082
+ import re as _re
1083
+ covered_files = _re.findall(r'`([^`]+)`', line)
1084
+ break
1085
+
1086
+ if not covered_files:
1087
+ continue
1088
+
1089
+ # Read covered file contents
1090
+ covered_content = ""
1091
+ for rel_path in covered_files:
1092
+ full = repo_root / rel_path
1093
+ if full.is_file():
1094
+ try:
1095
+ covered_content += full.read_text(encoding="utf-8", errors="replace")
1096
+ except (OSError, UnicodeDecodeError):
1097
+ pass
1098
+
1099
+ if not covered_content:
1100
+ continue
1101
+
1102
+ # Check each function name exists somewhere in covered files
1103
+ for name in func_names:
1104
+ if name not in covered_content:
1105
+ stale.append(name)
1106
+
1107
+ return stale
1108
+
1109
+
1110
+ def _detect_new_functions(staged_files, repo_root):
1111
+ """Detect new function definitions in staged code and new ### entries in charters.
1112
+
1113
+ Uses git diff --cached to find added lines. Returns (new_code_funcs, new_charter_entries).
1114
+ Self-contained — no gator_core imports.
1115
+ """
1116
+ new_code_funcs = []
1117
+ new_charter_entries = []
1118
+
1119
+ try:
1120
+ result = subprocess.run(
1121
+ ["git", "diff", "--cached", "-U0", "--no-color"],
1122
+ capture_output=True, text=True, cwd=str(repo_root),
1123
+ encoding="utf-8", errors="replace",
1124
+ )
1125
+ if result.returncode != 0:
1126
+ return [], []
1127
+ except (OSError, subprocess.TimeoutExpired):
1128
+ return [], []
1129
+
1130
+ for line in result.stdout.splitlines():
1131
+ if not line.startswith("+") or line.startswith("+++"):
1132
+ continue
1133
+ added = line[1:].strip()
1134
+
1135
+ # New function definitions in code
1136
+ if (added.startswith("def ") or
1137
+ added.startswith("function ") or
1138
+ added.startswith("func ")):
1139
+ # Extract name: "def foo(..." → "foo"
1140
+ for prefix in ("def ", "function ", "func "):
1141
+ if added.startswith(prefix):
1142
+ rest = added[len(prefix):]
1143
+ name = rest.split("(")[0].strip()
1144
+ if name and not name.startswith("_"):
1145
+ new_code_funcs.append(name)
1146
+ break
1147
+
1148
+ # New charter entries
1149
+ if added.startswith("### ") and "(" in added:
1150
+ name = added[4:].split("(")[0].strip()
1151
+ if name:
1152
+ new_charter_entries.append(name)
1153
+
1154
+ return new_code_funcs, new_charter_entries
1155
+
1156
+
1157
+ def _parse_charter_index(repo_root):
1158
+ """Parse INDEX.md to build a map of file patterns → required charter names.
1159
+
1160
+ Returns list of (pattern_str, set_of_charter_filenames) or empty list
1161
+ if INDEX is missing or unparseable.
1162
+ Self-contained — no gator_core imports.
1163
+ """
1164
+ charter_dir = _resolve_charter_dir(repo_root)
1165
+ index_path = charter_dir / "INDEX.md"
1166
+ if not index_path.is_file():
1167
+ return []
1168
+
1169
+ try:
1170
+ text = index_path.read_text(encoding="utf-8", errors="replace")
1171
+ except OSError:
1172
+ return []
1173
+
1174
+ mappings = []
1175
+ for line in text.splitlines():
1176
+ line = line.strip()
1177
+ if not line.startswith("|") or line.startswith("| If") or line.startswith("|---"):
1178
+ continue
1179
+ parts = [p.strip() for p in line.split("|") if p.strip()]
1180
+ if len(parts) < 2:
1181
+ continue
1182
+
1183
+ # Parse file patterns from first column
1184
+ # Format: `file.py`, `file2.py`, ... or prose like "Any pattern..."
1185
+ patterns = re.findall(r'`([^`]+)`', parts[0])
1186
+ if not patterns:
1187
+ continue
1188
+
1189
+ # Parse charter refs from second column
1190
+ # Format: [Name](filename.md) · [Name2](filename2.md)
1191
+ charter_refs = set(re.findall(r'\(([^)]+\.md)\)', parts[1]))
1192
+ if not charter_refs:
1193
+ continue
1194
+
1195
+ for pattern in patterns:
1196
+ mappings.append((pattern, charter_refs))
1197
+
1198
+ return mappings
1199
+
1200
+
1201
+ def _required_charters_for_files(code_files, repo_root):
1202
+ """Given a list of changed code files, return the set of charter filenames
1203
+ that INDEX.md says should be consulted.
1204
+
1205
+ Returns set of charter filenames (e.g. {"scripts-fleet-intelligence.md",
1206
+ "scripts-cross-cutting.md"}).
1207
+ """
1208
+ mappings = _parse_charter_index(repo_root)
1209
+ if not mappings:
1210
+ return set()
1211
+
1212
+ import fnmatch as _fnmatch
1213
+ required = set()
1214
+ for code_file in code_files:
1215
+ f_normalized = code_file.replace("\\", "/")
1216
+ f_basename = f_normalized.rsplit("/", 1)[-1]
1217
+ for pattern, charter_refs in mappings:
1218
+ if "*" in pattern or "?" in pattern:
1219
+ if _fnmatch.fnmatch(f_basename, pattern) or _fnmatch.fnmatch(f_normalized, pattern):
1220
+ required.update(charter_refs)
1221
+ elif f_basename == pattern or pattern in f_normalized:
1222
+ required.update(charter_refs)
1223
+
1224
+ return required
1225
+
1226
+
1227
+ def validate_soft_rules(staged_files, frontmatter, body, gator_dir):
1228
+ """Check soft rules. Returns list of (rule_name, message) for warnings."""
1229
+ warnings = []
1230
+
1231
+ has_code, has_charter, code_files, _ = classify_staged_files(staged_files)
1232
+
1233
+ # 1. No significance assessment
1234
+ if has_code and not frontmatter.get("significance"):
1235
+ inferred = infer_significance(body, has_charter, has_code)
1236
+ if inferred == "routine":
1237
+ warnings.append((
1238
+ "no-significance",
1239
+ "commit_draft.md has no 'significance' field and no "
1240
+ "inferrable signals. Consider adding a significance "
1241
+ "assessment before committing."
1242
+ ))
1243
+
1244
+ # 2. No decision tags
1245
+ if not frontmatter.get("decision-tags") and not extract_tags_from_body(body):
1246
+ warnings.append((
1247
+ "no-decision-tags",
1248
+ "No decision tags found in commit_draft.md frontmatter or body. "
1249
+ "Untagged commits are harder to query later."
1250
+ ))
1251
+
1252
+ # 3. Tripwire files touched
1253
+ tripwires = read_tripwire_patterns(gator_dir)
1254
+ if tripwires:
1255
+ touched = []
1256
+ for f in staged_files:
1257
+ f_normalized = f.replace("\\", "/")
1258
+ for pattern in tripwires:
1259
+ if pattern in f_normalized or f_normalized.endswith(pattern):
1260
+ touched.append(f)
1261
+ break
1262
+ if touched:
1263
+ files_str = ", ".join(touched[:3])
1264
+ warnings.append((
1265
+ "tripwire-touched",
1266
+ f"Tripwire-tagged file(s) touched: {files_str}. "
1267
+ f"Verify the PI is aware of these changes."
1268
+ ))
1269
+
1270
+ # 4. High file count
1271
+ if len(staged_files) > HIGH_FILE_COUNT:
1272
+ warnings.append((
1273
+ "high-file-count",
1274
+ f"{len(staged_files)} files staged (threshold: {HIGH_FILE_COUNT}). "
1275
+ f"Large commits may indicate skipped incremental steps."
1276
+ ))
1277
+
1278
+ # 5. Charter function-name smoke test: check that ### func() entries in
1279
+ # staged charters still reference functions that exist in the code.
1280
+ if has_charter:
1281
+ _, _, _, charter_files = classify_staged_files(staged_files)
1282
+ stale_refs = _check_charter_function_refs(
1283
+ charter_files, gator_dir.parent
1284
+ )
1285
+ if stale_refs:
1286
+ refs_str = ", ".join(stale_refs[:5])
1287
+ if len(stale_refs) > 5:
1288
+ refs_str += f" (+{len(stale_refs) - 5} more)"
1289
+ warnings.append((
1290
+ "stale-charter-refs",
1291
+ f"Charter references function(s) not found in covered files: "
1292
+ f"{refs_str}. Verify these weren't renamed or removed."
1293
+ ))
1294
+
1295
+ # 6. New code functions without charter entries: if code added new def/func
1296
+ # lines but charter didn't add corresponding ### entries, warn.
1297
+ # Compares specific names, not just counts — adding an unrelated charter
1298
+ # entry doesn't suppress the warning for undocumented functions.
1299
+ if has_code and has_charter:
1300
+ new_code_funcs, new_charter_entries = _detect_new_functions(
1301
+ staged_files, gator_dir.parent
1302
+ )
1303
+ charter_entry_names = set(new_charter_entries)
1304
+ undocumented = [f for f in new_code_funcs if f not in charter_entry_names]
1305
+ if undocumented:
1306
+ funcs_str = ", ".join(undocumented[:5])
1307
+ if len(undocumented) > 5:
1308
+ funcs_str += f" (+{len(undocumented) - 5} more)"
1309
+ warnings.append((
1310
+ "new-functions-undocumented",
1311
+ f"New function(s) in code ({funcs_str}) without matching ### "
1312
+ f"entries in charters. Consider documenting them."
1313
+ ))
1314
+
1315
+ # 7. Cross-module imports — now enforced as a hard block in
1316
+ # validate_hard_rules() when cross-cutting charter is not staged.
1317
+ # No soft warning needed; the hard check covers it.
1318
+
1319
+ return warnings
1320
+
1321
+
1322
+ # ---------------------------------------------------------------------------
1323
+ # Trailer assembly
1324
+ # ---------------------------------------------------------------------------
1325
+
1326
+ def assemble_trailers(frontmatter, body, gator_dir, staged_files, override=None):
1327
+ """Build Gator-* trailer lines from all available sources."""
1328
+ charter_count, func_count = count_charters(gator_dir)
1329
+ thread_count = count_threads(gator_dir)
1330
+ generation = read_generation(gator_dir)
1331
+ policy_version = read_policy_version(gator_dir)
1332
+ issue_count = count_issues(gator_dir)
1333
+
1334
+ _, has_charter, _, _ = classify_staged_files(staged_files)
1335
+ has_code, _, _, _ = classify_staged_files(staged_files)
1336
+
1337
+ trailers = []
1338
+
1339
+ # Core metrics (always from file state — deterministic)
1340
+ trailers.append(f"Gator-Charters: {charter_count}")
1341
+ trailers.append(f"Gator-Functions: {func_count}")
1342
+ trailers.append(f"Gator-Threads: {thread_count}")
1343
+ trailers.append(f"Gator-Generation: {generation}")
1344
+
1345
+ if policy_version:
1346
+ trailers.append(f"Gator-Policy-Version: {policy_version}")
1347
+ if issue_count > 0:
1348
+ trailers.append(f"Gator-Issues: {issue_count}")
1349
+
1350
+ # Charter changed (from git diff --cached — deterministic)
1351
+ if override == "charter-skip":
1352
+ trailers.append("Gator-Charter-Changed: override-skip")
1353
+ # Include PI attribution from approval metadata
1354
+ meta_file = gator_dir / ".override-meta.json"
1355
+ if meta_file.exists():
1356
+ try:
1357
+ meta = json.loads(meta_file.read_text(encoding="utf-8"))
1358
+ approved_by = meta.get("approved_by", "")
1359
+ block_id = meta.get("block_id", "")
1360
+ if approved_by and approved_by != "legacy-override":
1361
+ trailers.append(f"Gator-Override-Approved-By: {approved_by}")
1362
+ if block_id:
1363
+ trailers.append(f"Gator-Override-Block: {block_id}")
1364
+ # Consume the meta file
1365
+ meta_file.unlink(missing_ok=True)
1366
+ except (json.JSONDecodeError, OSError):
1367
+ pass
1368
+ else:
1369
+ trailers.append(f"Gator-Charter-Changed: {'yes' if has_charter else 'no'}")
1370
+
1371
+ # Change type (frontmatter preferred, fallback to inference)
1372
+ change_type = frontmatter.get("change-type") or infer_change_type(body, has_code)
1373
+ if change_type:
1374
+ trailers.append(f"Gator-Change-Type: {change_type}")
1375
+
1376
+ # Decision tags (frontmatter preferred, fallback to body extraction)
1377
+ tags = frontmatter.get("decision-tags")
1378
+ if isinstance(tags, list):
1379
+ tag_str = ",".join(tags)
1380
+ elif isinstance(tags, str):
1381
+ tag_str = tags
1382
+ else:
1383
+ extracted = extract_tags_from_body(body)
1384
+ tag_str = ",".join(extracted) if extracted else ""
1385
+ if tag_str:
1386
+ trailers.append(f"Gator-Decision-Tags: {tag_str}")
1387
+
1388
+ # Significance (frontmatter preferred, fallback to inference)
1389
+ significance = (
1390
+ frontmatter.get("significance")
1391
+ or infer_significance(body, has_charter, has_code)
1392
+ )
1393
+ trailers.append(f"Gator-Significance: {significance}")
1394
+
1395
+ # Agent attribution (frontmatter preferred, fallback to body)
1396
+ agent = frontmatter.get("agent") or detect_agent_from_body(body)
1397
+ if agent:
1398
+ trailers.append(f"Gator-Agent: {agent}")
1399
+
1400
+ # Architect attribution (frontmatter preferred, fallback to body)
1401
+ # Accepts both "architect:" (current) and "pi:" (legacy) from frontmatter
1402
+ architect = frontmatter.get("architect") or frontmatter.get("pi") or detect_architect_from_body(body)
1403
+ if architect:
1404
+ trailers.append(f"Gator-Architect: {architect}")
1405
+
1406
+ return trailers
1407
+
1408
+
1409
+ # ---------------------------------------------------------------------------
1410
+ # Status snapshot
1411
+ # ---------------------------------------------------------------------------
1412
+
1413
+ def build_status(gator_dir, staged_files, frontmatter, body, override=None):
1414
+ """Build the status.json content."""
1415
+ charter_count, func_count = count_charters(gator_dir)
1416
+ thread_count = count_threads(gator_dir)
1417
+ generation = read_generation(gator_dir)
1418
+ policy_version = read_policy_version(gator_dir)
1419
+ issue_count = count_issues(gator_dir)
1420
+
1421
+ has_code, has_charter, _, _ = classify_staged_files(staged_files)
1422
+
1423
+ change_type = frontmatter.get("change-type") or infer_change_type(body, has_code)
1424
+ significance = (
1425
+ frontmatter.get("significance")
1426
+ or infer_significance(body, has_charter, has_code)
1427
+ )
1428
+
1429
+ tags = frontmatter.get("decision-tags")
1430
+ if isinstance(tags, str):
1431
+ tags = [t.strip() for t in tags.split(",")]
1432
+ elif not isinstance(tags, list):
1433
+ tags = extract_tags_from_body(body)
1434
+
1435
+ agent = frontmatter.get("agent") or detect_agent_from_body(body)
1436
+ architect = frontmatter.get("architect") or frontmatter.get("pi") or detect_architect_from_body(body)
1437
+
1438
+ charter_changed = "override-skip" if override == "charter-skip" else has_charter
1439
+
1440
+ return {
1441
+ "repo": gator_dir.parent.name,
1442
+ "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
1443
+ "branch": get_current_branch(gator_dir.parent),
1444
+ "charters": charter_count,
1445
+ "functions": func_count,
1446
+ "threads": thread_count,
1447
+ "generation": generation,
1448
+ "policy_version": policy_version,
1449
+ "issues": issue_count,
1450
+ "charter_changed": charter_changed,
1451
+ "change_type": change_type,
1452
+ "significance": significance,
1453
+ "decision_tags": tags,
1454
+ "agent": agent,
1455
+ "architect": architect,
1456
+ "draft_body": body.strip(),
1457
+ "files_touched": [str(f) for f in staged_files],
1458
+ }
1459
+
1460
+
1461
+ def write_status_json(gator_dir, status):
1462
+ """Write .gator/status.json."""
1463
+ status_file = gator_dir / "status.json"
1464
+ status_file.write_text(
1465
+ json.dumps(status, indent=2) + "\n",
1466
+ encoding="utf-8",
1467
+ )
1468
+ return status_file
1469
+
1470
+
1471
+ def normalize_agent_name(agent_value):
1472
+ """Normalize agent attribution to a filesystem-safe rolling-session key."""
1473
+ if not agent_value:
1474
+ return "unknown"
1475
+ agent = str(agent_value).split(",")[0].strip().lower()
1476
+ agent = re.sub(r"[^a-z0-9_-]+", "-", agent).strip("-")
1477
+ return agent or "unknown"
1478
+
1479
+
1480
+ def _update_frontmatter(text, updates):
1481
+ """Update flat frontmatter keys in a markdown document."""
1482
+ if not text.startswith("---\n"):
1483
+ return text
1484
+ parts = text.split("---\n", 2)
1485
+ if len(parts) < 3:
1486
+ return text
1487
+
1488
+ header_lines = parts[1].splitlines()
1489
+ seen = set()
1490
+ new_lines = []
1491
+ for line in header_lines:
1492
+ if ":" not in line:
1493
+ new_lines.append(line)
1494
+ continue
1495
+ key, _, value = line.partition(":")
1496
+ key = key.strip()
1497
+ if key in updates:
1498
+ new_lines.append(f"{key}: {updates[key]}")
1499
+ seen.add(key)
1500
+ else:
1501
+ new_lines.append(line)
1502
+
1503
+ for key, value in updates.items():
1504
+ if key not in seen:
1505
+ new_lines.append(f"{key}: {value}")
1506
+
1507
+ return "---\n" + "\n".join(new_lines) + "\n---\n" + parts[2]
1508
+
1509
+
1510
+ def _extract_note_lines(body, limit=8):
1511
+ """Extract concise note lines from commit_draft body text."""
1512
+ if not body:
1513
+ return []
1514
+ notes = []
1515
+ for raw_line in body.splitlines():
1516
+ line = raw_line.strip()
1517
+ if not line or line.startswith("#"):
1518
+ continue
1519
+ if line.startswith("- "):
1520
+ line = line[2:].strip()
1521
+ notes.append(line)
1522
+ if len(notes) >= limit:
1523
+ break
1524
+ return notes
1525
+
1526
+
1527
+ SESSION_TIMEOUT_HOURS = 4
1528
+
1529
+
1530
+ def _find_active_session(active_dir, repo_name, agent_key):
1531
+ """Find an existing active session file that's still fresh (< SESSION_TIMEOUT_HOURS old).
1532
+
1533
+ Returns the path if a fresh session exists, None otherwise.
1534
+ Session files are named <repo>-<agent>-<start_timestamp>.md.
1535
+ """
1536
+ prefix = f"{repo_name}-{agent_key}-"
1537
+ candidates = sorted(
1538
+ (f for f in active_dir.iterdir() if f.name.startswith(prefix) and f.suffix == ".md"),
1539
+ key=lambda f: f.stat().st_mtime,
1540
+ reverse=True,
1541
+ ) if active_dir.is_dir() else []
1542
+
1543
+ if not candidates:
1544
+ return None
1545
+
1546
+ newest = candidates[0]
1547
+ age_hours = (datetime.now(timezone.utc).timestamp() - newest.stat().st_mtime) / 3600
1548
+ if age_hours < SESSION_TIMEOUT_HOURS:
1549
+ return newest
1550
+ return None
1551
+
1552
+
1553
+ def _read_machine_id():
1554
+ """Read the stable machine UUID from ~/.gator/machine-id.
1555
+
1556
+ The file has key: value lines (id, hostname, label, created).
1557
+ Returns just the id value, not the whole file.
1558
+ """
1559
+ machine_id_path = Path.home() / ".gator" / "machine-id"
1560
+ try:
1561
+ for line in machine_id_path.read_text(encoding="utf-8").splitlines():
1562
+ line = line.strip()
1563
+ if line.startswith("id:"):
1564
+ return line.partition(":")[2].strip()
1565
+ return ""
1566
+ except OSError:
1567
+ return ""
1568
+
1569
+
1570
+ def _read_machine_label():
1571
+ """Read the human-friendly machine label from ~/.gator/machine-id."""
1572
+ machine_id_path = Path.home() / ".gator" / "machine-id"
1573
+ try:
1574
+ for line in machine_id_path.read_text(encoding="utf-8").splitlines():
1575
+ line = line.strip()
1576
+ if line.startswith("label:"):
1577
+ return line.partition(":")[2].strip()
1578
+ return ""
1579
+ except OSError:
1580
+ return ""
1581
+
1582
+
1583
+ def _derive_intent(entry):
1584
+ """Derive a human-readable intent from the commit entry.
1585
+
1586
+ Derivation order:
1587
+ 1. Cleaned commit subject: strip mechanical prefixes (Fix:, Deploy:, etc.)
1588
+ 2. First substantive note if subject is empty or generic
1589
+ 3. Raw subject as fallback
1590
+ """
1591
+ subject = entry.get("subject", "")
1592
+ # Strip mechanical prefixes that duplicate change_type
1593
+ cleaned = re.sub(
1594
+ r'^(Fix|Deploy|Blueprint|Docs|Refactor|Test|Plan):\s*',
1595
+ '', subject, flags=re.IGNORECASE,
1596
+ ).strip()
1597
+ if cleaned:
1598
+ return cleaned
1599
+ # Fallback to first note
1600
+ notes = entry.get("notes", [])
1601
+ return notes[0] if notes else subject
1602
+
1603
+
1604
+ def parse_ledger(path):
1605
+ """Parse a session ledger file into structured data.
1606
+
1607
+ Returns {"frontmatter": dict, "entries": list[dict], "raw_body": str}
1608
+ where raw_body is the text after frontmatter (for reassembly).
1609
+ """
1610
+ result = {"frontmatter": {}, "entries": [], "raw_body": ""}
1611
+ if not path or not path.exists():
1612
+ return result
1613
+
1614
+ text = path.read_text(encoding="utf-8", errors="replace")
1615
+ # Normalize CRLF to LF — Windows write_text() produces CRLF,
1616
+ # but our delimiters and regexes assume LF.
1617
+ text = text.replace("\r\n", "\n")
1618
+
1619
+ # Parse frontmatter
1620
+ if text.startswith("---\n"):
1621
+ parts = text.split("---\n", 2)
1622
+ if len(parts) >= 3:
1623
+ for line in parts[1].splitlines():
1624
+ if ":" in line:
1625
+ key, _, value = line.partition(":")
1626
+ result["frontmatter"][key.strip()] = value.strip()
1627
+ result["raw_body"] = parts[2]
1628
+
1629
+ # Parse commit entry blocks from body
1630
+ body = result["raw_body"]
1631
+ heading_re = re.compile(r"^### (\S+) - (.+)$")
1632
+ kv_re = re.compile(r"^- ([a-z][a-z0-9-]*): (.+)$")
1633
+ section_re = re.compile(r"^([A-Z][a-zA-Z-]+):$")
1634
+ item_re = re.compile(r"^- (.+)$")
1635
+
1636
+ current_entry = None
1637
+ current_section = None
1638
+
1639
+ for line in body.splitlines():
1640
+ heading_match = heading_re.match(line)
1641
+ if heading_match:
1642
+ if current_entry:
1643
+ result["entries"].append(current_entry)
1644
+ current_entry = {
1645
+ "short_commit": heading_match.group(1),
1646
+ "subject": heading_match.group(2),
1647
+ "commit": "",
1648
+ "commit_index": 0,
1649
+ "timestamp": "",
1650
+ "previous_commit": None,
1651
+ "branch": "",
1652
+ "change_type": "",
1653
+ "significance": "",
1654
+ "decision_tags": [],
1655
+ "charter_changed": False,
1656
+ "files_touched": [],
1657
+ "files_touched_source": "",
1658
+ "snippet_id": "",
1659
+ "notes": [],
1660
+ }
1661
+ current_section = None
1662
+ continue
1663
+
1664
+ if current_entry is None:
1665
+ continue
1666
+
1667
+ section_match = section_re.match(line)
1668
+ if section_match:
1669
+ current_section = section_match.group(1)
1670
+ continue
1671
+
1672
+ if current_section:
1673
+ item_match = item_re.match(line)
1674
+ if item_match:
1675
+ item_value = item_match.group(1)
1676
+ if current_section == "Files-Touched":
1677
+ current_entry["files_touched"].append(item_value)
1678
+ elif current_section == "Notes":
1679
+ current_entry["notes"].append(item_value)
1680
+ continue
1681
+ # Non-item line ends the section block
1682
+ if line.strip():
1683
+ current_section = None
1684
+
1685
+ kv_match = kv_re.match(line)
1686
+ if kv_match:
1687
+ current_section = None # key-value ends any active section
1688
+ key, value = kv_match.group(1), kv_match.group(2)
1689
+ if key == "commit":
1690
+ current_entry["commit"] = value
1691
+ elif key == "commit-index":
1692
+ try:
1693
+ current_entry["commit_index"] = int(value)
1694
+ except ValueError:
1695
+ pass
1696
+ elif key == "timestamp":
1697
+ current_entry["timestamp"] = value
1698
+ elif key == "previous-commit-in-session":
1699
+ current_entry["previous_commit"] = value if value != "~" else None
1700
+ elif key == "branch":
1701
+ current_entry["branch"] = value
1702
+ elif key == "change-type":
1703
+ current_entry["change_type"] = value
1704
+ elif key == "significance":
1705
+ current_entry["significance"] = value
1706
+ elif key == "decision-tags":
1707
+ current_entry["decision_tags"] = [t.strip() for t in value.split(",") if t.strip()]
1708
+ elif key == "charter-changed":
1709
+ current_entry["charter_changed"] = value.lower() in ("true", "yes")
1710
+ elif key == "files-touched-source":
1711
+ current_entry["files_touched_source"] = value
1712
+ elif key == "snippet-id":
1713
+ current_entry["snippet_id"] = value
1714
+
1715
+ if current_entry:
1716
+ result["entries"].append(current_entry)
1717
+
1718
+ return result
1719
+
1720
+
1721
+ def build_commit_entry(gator_dir, status, session_meta):
1722
+ """Build a canonical entry_record dict from status.json + git state + session_meta.
1723
+
1724
+ session_meta is the parsed ledger frontmatter dict (or empty dict for new sessions).
1725
+ This function never reads the ledger file itself — the caller provides session_meta.
1726
+ This is the single source of truth for both ledger blocks and snippets.
1727
+ """
1728
+ repo_root = gator_dir.parent
1729
+ latest = git("log", "-1", "--pretty=format:%H%n%h%n%s", cwd=repo_root).splitlines()
1730
+ if len(latest) < 3:
1731
+ return None
1732
+ commit_hash, short_hash, subject = latest[0], latest[1], latest[2]
1733
+
1734
+ timestamp = status.get("timestamp") or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
1735
+ branch = status.get("branch") or get_current_branch(repo_root)
1736
+ architect = status.get("architect") or status.get("pi") or ""
1737
+
1738
+ # previous_commit from ledger frontmatter (not body grep)
1739
+ previous_commit = session_meta.get("last-commit-hash") or None
1740
+
1741
+ # commit_index from ledger frontmatter
1742
+ try:
1743
+ commit_count = int(session_meta.get("commits", 0))
1744
+ except (ValueError, TypeError):
1745
+ commit_count = 0
1746
+ commit_index = commit_count + 1
1747
+
1748
+ # files_touched: prefer status.json, fall back to git diff-tree
1749
+ files_touched = status.get("files_touched") or []
1750
+ files_touched_source = "status-json"
1751
+ if not files_touched:
1752
+ try:
1753
+ dt_output = git("diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD", cwd=repo_root)
1754
+ files_touched = [f for f in dt_output.splitlines() if f.strip()]
1755
+ files_touched_source = "diff-tree"
1756
+ except Exception:
1757
+ files_touched = []
1758
+ files_touched_source = "unavailable"
1759
+
1760
+ snippet_id = f"snippet-{commit_hash[:13]}"
1761
+ notes = _extract_note_lines(status.get("draft_body", ""))
1762
+
1763
+ return {
1764
+ "commit": commit_hash,
1765
+ "short_commit": short_hash,
1766
+ "subject": subject,
1767
+ "timestamp": timestamp,
1768
+ "branch": branch,
1769
+ "architect": architect,
1770
+ "change_type": status.get("change_type") or "",
1771
+ "significance": status.get("significance") or "",
1772
+ "decision_tags": status.get("decision_tags") or [],
1773
+ "charter_changed": status.get("charter_changed", False),
1774
+ "commit_index": commit_index,
1775
+ "previous_commit": previous_commit,
1776
+ "files_touched": files_touched,
1777
+ "files_touched_source": files_touched_source,
1778
+ "snippet_id": snippet_id,
1779
+ "notes": notes,
1780
+ }
1781
+
1782
+
1783
+ def render_ledger_block(entry):
1784
+ """Render a ledger commit entry block from the canonical entry_record dict.
1785
+
1786
+ Uses two element types:
1787
+ - Key-value lines: '- key: value' for scalar fields
1788
+ - Section blocks: 'Section-Name:' followed by '- item' lines for lists
1789
+ """
1790
+ prev = entry["previous_commit"] or "~"
1791
+ lines = [
1792
+ f"### {entry['short_commit']} - {entry['subject']}",
1793
+ f"- commit-index: {entry['commit_index']}",
1794
+ f"- timestamp: {entry['timestamp']}",
1795
+ f"- commit: {entry['commit']}",
1796
+ f"- previous-commit-in-session: {prev}",
1797
+ f"- branch: {entry['branch']}",
1798
+ f"- change-type: {entry['change_type']}",
1799
+ f"- significance: {entry['significance']}",
1800
+ f"- decision-tags: {','.join(entry['decision_tags'])}",
1801
+ f"- charter-changed: {entry['charter_changed']}",
1802
+ f"- files-touched-source: {entry['files_touched_source']}",
1803
+ f"- snippet-id: {entry['snippet_id']}",
1804
+ ]
1805
+
1806
+ if entry["files_touched"]:
1807
+ lines.append("Files-Touched:")
1808
+ for f in entry["files_touched"]:
1809
+ lines.append(f"- {f}")
1810
+
1811
+ if entry["notes"]:
1812
+ lines.append("Notes:")
1813
+ for note in entry["notes"]:
1814
+ lines.append(f"- {note}")
1815
+
1816
+ lines.append("")
1817
+ lines.append("")
1818
+ return "\n".join(lines)
1819
+
1820
+
1821
+ def _infer_vendor_from_agent(agent_key):
1822
+ """Best-effort vendor inference from the agent key. Returns (vendor, model) tuple."""
1823
+ key = (agent_key or "").lower()
1824
+ if "claude" in key:
1825
+ return "anthropic", agent_key
1826
+ if "codex" in key or "gpt" in key or "openai" in key or key == "o3" or key == "o4-mini":
1827
+ return "openai", agent_key
1828
+ if "gemini" in key:
1829
+ return "google", agent_key
1830
+ return "unknown", agent_key
1831
+
1832
+
1833
+ def render_snippet(entry, session_meta):
1834
+ """Render a durable session snippet from the canonical entry record.
1835
+
1836
+ Returns the full markdown string for the snippet file.
1837
+ """
1838
+ session_id = session_meta.get("session-id", "")
1839
+ started_at = session_meta.get("started-at", entry["timestamp"])
1840
+ agent_key = session_meta.get("agent", "")
1841
+ # Architect from the current commit's entry, not session start
1842
+ architect = entry.get("architect", session_meta.get("architect", ""))
1843
+ machine_id = session_meta.get("machine-id", "")
1844
+ vendor_inferred, model_inferred = _infer_vendor_from_agent(agent_key)
1845
+
1846
+ prev = entry["previous_commit"] or "~"
1847
+
1848
+ # Build YAML frontmatter
1849
+ fm_lines = [
1850
+ "---",
1851
+ "schema: gator-session-snippet-v1",
1852
+ "type: session-snippet",
1853
+ f"snippet-id: {entry['snippet_id']}",
1854
+ f"session-id: {session_id}",
1855
+ f"repo: {session_meta.get('repo', '')}",
1856
+ f"commit: {entry['commit']}",
1857
+ f"short-commit: {entry['short_commit']}",
1858
+ f"previous-commit-in-session: {prev}",
1859
+ f"commit-index: {entry['commit_index']}",
1860
+ f"agent: {agent_key}",
1861
+ f"architect: {architect}",
1862
+ f"branch: {entry['branch']}",
1863
+ f"started-at: {started_at}",
1864
+ f"ended-at: {entry['timestamp']}",
1865
+ f"vendor-inferred: {vendor_inferred}",
1866
+ f"model-inferred: {model_inferred}",
1867
+ ]
1868
+
1869
+ # decision-tags as YAML flow list
1870
+ tags = entry["decision_tags"]
1871
+ if tags:
1872
+ fm_lines.append(f"decision-tags: [{', '.join(tags)}]")
1873
+ else:
1874
+ fm_lines.append("decision-tags: []")
1875
+
1876
+ fm_lines.append(f"change-type: {entry['change_type']}")
1877
+ fm_lines.append(f"significance: {entry['significance']}")
1878
+ fm_lines.append(f"charter-changed: {'yes' if entry['charter_changed'] else 'no'}")
1879
+
1880
+ # files-touched as YAML block list
1881
+ if entry["files_touched"]:
1882
+ fm_lines.append("files-touched:")
1883
+ for f in entry["files_touched"]:
1884
+ fm_lines.append(f" - {f}")
1885
+ else:
1886
+ fm_lines.append("files-touched: []")
1887
+
1888
+ fm_lines.append(f"files-touched-source: {entry['files_touched_source']}")
1889
+
1890
+ if machine_id:
1891
+ fm_lines.append(f"machine-id: {machine_id}")
1892
+
1893
+ fm_lines.append("---")
1894
+
1895
+ # Build body
1896
+ body_lines = [""]
1897
+
1898
+ # ## Interval
1899
+ if entry["commit_index"] == 1:
1900
+ interval_text = "First commit in session."
1901
+ else:
1902
+ total = session_meta.get("commits", "?")
1903
+ # total is the count *before* this commit, so the session will have commit_index commits after
1904
+ interval_text = (
1905
+ f"Session commit {entry['commit_index']}. "
1906
+ f"Covers the working interval since `{entry['short_commit'] if not entry['previous_commit'] else entry['previous_commit'][:7]}`."
1907
+ )
1908
+ body_lines.extend(["## Interval", "", interval_text, ""])
1909
+
1910
+ # ## Notes
1911
+ if entry["notes"]:
1912
+ body_lines.append("## Notes")
1913
+ body_lines.append("")
1914
+ for note in entry["notes"]:
1915
+ body_lines.append(f"- {note}")
1916
+ body_lines.append("")
1917
+
1918
+ # ## Files Touched
1919
+ if entry["files_touched"]:
1920
+ body_lines.append("## Files Touched")
1921
+ body_lines.append("")
1922
+ for f in entry["files_touched"]:
1923
+ body_lines.append(f"- `{f}`")
1924
+ body_lines.append("")
1925
+
1926
+ # ## Commit
1927
+ body_lines.extend([
1928
+ "## Commit",
1929
+ "",
1930
+ f"- `{entry['short_commit']}` {entry['subject']}",
1931
+ "",
1932
+ ])
1933
+
1934
+ return "\n".join(fm_lines) + "\n" + "\n".join(body_lines)
1935
+
1936
+
1937
+ def render_snippet_json(entry, session_meta):
1938
+ """Render a session snippet as JSON (v2 canonical format).
1939
+
1940
+ Immutable once committed. Transcript anchors live in the enrichment
1941
+ overlay, not here. Field order is deliberate: architect-relevant
1942
+ fields first, structural fields next, transcript join keys last.
1943
+ """
1944
+ from collections import OrderedDict
1945
+
1946
+ agent_key = session_meta.get("agent", "")
1947
+ vendor_inferred, model_inferred = _infer_vendor_from_agent(agent_key)
1948
+
1949
+ snippet = OrderedDict([
1950
+ ("schema", "gator-session-snippet-v2"),
1951
+ ("type", "session_snippet"),
1952
+ # --- architect-relevant (top) ---
1953
+ ("architect", entry.get("architect", session_meta.get("architect", ""))),
1954
+ ("agent", agent_key),
1955
+ ("repo", session_meta.get("repo", "")),
1956
+ ("branch", entry["branch"]),
1957
+ ("commit", entry["commit"]),
1958
+ ("short_commit", entry["short_commit"]),
1959
+ ("started_at", session_meta.get("started-at", entry["timestamp"])),
1960
+ ("ended_at", entry["timestamp"]),
1961
+ ("intent", _derive_intent(entry)),
1962
+ ("notes", entry["notes"]),
1963
+ ("files_touched", entry["files_touched"]),
1964
+ # --- identity and lineage ---
1965
+ ("snippet_id", entry["snippet_id"]),
1966
+ ("session_id", session_meta.get("session-id", "")),
1967
+ ("previous_commit_in_session", entry["previous_commit"]),
1968
+ ("commit_index", entry["commit_index"]),
1969
+ ("decision_tags", entry["decision_tags"]),
1970
+ ("change_type", entry["change_type"]),
1971
+ ("significance", entry["significance"]),
1972
+ ("charter_changed", entry["charter_changed"]),
1973
+ ("machine_id", session_meta.get("machine-id", "")),
1974
+ ("machine_label", _read_machine_label()),
1975
+ ("vendor_inferred", vendor_inferred),
1976
+ ("model_inferred", model_inferred),
1977
+ ("files_touched_source", entry["files_touched_source"]),
1978
+ # --- transcript join keys (null at commit time, never mutated) ---
1979
+ ("transcript_session_id", None),
1980
+ ("transcript_ref", None),
1981
+ ])
1982
+ return json.dumps(snippet, indent=2, default=str)
1983
+
1984
+
1985
+ def _reassemble_ledger(session_meta, raw_body, new_block, entry):
1986
+ """Reassemble a complete ledger file from updated frontmatter + existing body + new block.
1987
+
1988
+ Returns the full file content as a string.
1989
+ """
1990
+ # Update frontmatter fields
1991
+ updated_meta = dict(session_meta)
1992
+ updated_meta["last-updated"] = entry["timestamp"]
1993
+ updated_meta["commits"] = str(entry["commit_index"])
1994
+ updated_meta["last-commit-hash"] = entry["commit"]
1995
+ updated_meta["architect"] = entry.get("architect", session_meta.get("architect", ""))
1996
+ updated_meta["branch"] = entry["branch"]
1997
+
1998
+ # Render frontmatter
1999
+ fm_lines = ["---"]
2000
+ for key, value in updated_meta.items():
2001
+ fm_lines.append(f"{key}: {value}")
2002
+ fm_lines.append("---")
2003
+
2004
+ return "\n".join(fm_lines) + "\n" + raw_body + new_block
2005
+
2006
+
2007
+ def record_commit_and_emit_snippet(gator_dir, status):
2008
+ """Record a commit to the session ledger and emit a durable snippet.
2009
+
2010
+ Returns (ledger_path, snippet_path) or (ledger_path, None) if snippet
2011
+ emission fails or commit was already recorded (idempotent).
2012
+ """
2013
+ repo_root = gator_dir.parent
2014
+ agent_key = normalize_agent_name(status.get("agent"))
2015
+ repo_name = repo_root.name
2016
+ active_dir = gator_dir / "sessions" / ACTIVE_SESSIONS_DIRNAME
2017
+ active_dir.mkdir(parents=True, exist_ok=True)
2018
+
2019
+ # Find or create ledger file
2020
+ ledger_path = _find_active_session(active_dir, repo_name, agent_key)
2021
+ is_new_session = ledger_path is None
2022
+
2023
+ if is_new_session:
2024
+ start_ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%f")
2025
+ ledger_path = active_dir / f"{repo_name}-{agent_key}-{start_ts}.md"
2026
+
2027
+ # Parse existing ledger (or empty for new session)
2028
+ parsed = parse_ledger(ledger_path if not is_new_session else None)
2029
+ session_meta = parsed["frontmatter"]
2030
+ raw_body = parsed["raw_body"]
2031
+
2032
+ # Idempotency: if HEAD matches last-commit-hash, this commit is already recorded
2033
+ try:
2034
+ head_hash = git("rev-parse", "HEAD", cwd=repo_root).strip()
2035
+ except Exception:
2036
+ return (ledger_path, None)
2037
+
2038
+ if session_meta.get("last-commit-hash") == head_hash:
2039
+ return (ledger_path, None) # already recorded
2040
+
2041
+ # Initialize session_meta for new sessions
2042
+ timestamp = status.get("timestamp") or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
2043
+ architect = status.get("architect") or status.get("pi") or ""
2044
+ branch = status.get("branch") or get_current_branch(repo_root)
2045
+
2046
+ if is_new_session:
2047
+ session_id = datetime.now(timezone.utc).strftime(f"%Y%m%d-{agent_key}-%H%M%S")
2048
+ machine_id = _read_machine_id()
2049
+ session_meta = {
2050
+ "schema": "gator-active-session-ledger-v1",
2051
+ "type": "active-session-ledger",
2052
+ "status": "active",
2053
+ "session-id": session_id,
2054
+ "repo": repo_name,
2055
+ "agent": agent_key,
2056
+ "architect": architect,
2057
+ "branch": branch,
2058
+ "started-at": timestamp,
2059
+ "last-updated": timestamp,
2060
+ "commits": "0",
2061
+ "last-commit-hash": "",
2062
+ "machine-id": machine_id,
2063
+ }
2064
+ raw_body = "\n# Active Session\n\n## Commit Log\n\n"
2065
+
2066
+ # Build the canonical entry record
2067
+ entry = build_commit_entry(gator_dir, status, session_meta)
2068
+ if entry is None:
2069
+ return (ledger_path, None)
2070
+
2071
+ # Render ledger block and reassemble file atomically
2072
+ block = render_ledger_block(entry)
2073
+ full_content = _reassemble_ledger(session_meta, raw_body, block, entry)
2074
+
2075
+ # Atomic write: temp file + rename
2076
+ tmp_path = ledger_path.with_suffix(".tmp")
2077
+ try:
2078
+ tmp_path.write_text(full_content, encoding="utf-8", newline="\n")
2079
+ os.replace(str(tmp_path), str(ledger_path))
2080
+ except OSError as e:
2081
+ print(f"Warning: ledger write failed: {e}", file=sys.stderr)
2082
+ try:
2083
+ tmp_path.unlink(missing_ok=True)
2084
+ except OSError:
2085
+ pass
2086
+ return (ledger_path, None)
2087
+
2088
+ # Emit snippet (secondary — failure does not corrupt ledger)
2089
+ snippet_path = None
2090
+ try:
2091
+ snippets_dir = gator_dir / "session-snippets"
2092
+ snippets_dir.mkdir(parents=True, exist_ok=True)
2093
+
2094
+ date_str = timestamp[:10] # YYYY-MM-DD from ISO timestamp
2095
+ snippet_filename = f"{date_str}-{repo_name}-{entry['commit'][:13]}.json"
2096
+ snippet_path = snippets_dir / snippet_filename
2097
+
2098
+ snippet_content = render_snippet_json(entry, session_meta)
2099
+
2100
+ if snippet_path.exists():
2101
+ existing = snippet_path.read_text(encoding="utf-8", errors="replace")
2102
+ if existing == snippet_content:
2103
+ pass # idempotent: same content, no-op
2104
+ else:
2105
+ print(
2106
+ f"Warning: Snippet {entry['snippet_id']} already exists with different content. Skipping.",
2107
+ file=sys.stderr,
2108
+ )
2109
+ snippet_path = None
2110
+ else:
2111
+ snippet_path.write_text(snippet_content, encoding="utf-8", newline="\n")
2112
+ except Exception as e:
2113
+ print(
2114
+ f"Warning: Snippet emission failed for {entry['short_commit']}: {e}. Ledger entry preserved.",
2115
+ file=sys.stderr,
2116
+ )
2117
+ snippet_path = None
2118
+
2119
+ return (ledger_path, snippet_path)
2120
+
2121
+
2122
+ # ---------------------------------------------------------------------------
2123
+ # Commit summary (structural audit trail)
2124
+ # ---------------------------------------------------------------------------
2125
+
2126
+ def write_commit_summary(gator_dir, frontmatter, body, trailers, commit_msg_line):
2127
+ """Write a commit-level summary to .gator/sessions/.
2128
+
2129
+ This is the structural audit trail — written automatically on every
2130
+ commit, no manual command needed. Each commit gets a small markdown
2131
+ file with the metadata from commit_draft.md and the trailer values.
2132
+
2133
+ The archaeology-based summaries (gator sessions commit-summaries)
2134
+ are the enrichment layer — they add session-level context from raw
2135
+ vendor logs. This function provides the baseline that's always present.
2136
+ """
2137
+ sessions_dir = gator_dir / "sessions"
2138
+ sessions_dir.mkdir(parents=True, exist_ok=True)
2139
+
2140
+ now = datetime.now(timezone.utc)
2141
+ timestamp = now.strftime("%Y-%m-%dT%H:%M:%SZ")
2142
+ date_str = now.strftime("%Y-%m-%d")
2143
+ time_slug = now.strftime("%H%M%S")
2144
+ repo_name = gator_dir.parent.name
2145
+
2146
+ filename = f"{date_str}-{repo_name}-commit-{time_slug}.md"
2147
+ summary_path = sessions_dir / filename
2148
+
2149
+ # Build trailer dict for frontmatter
2150
+ trailer_dict = {}
2151
+ for t in trailers:
2152
+ if ": " in t:
2153
+ key, _, value = t.partition(": ")
2154
+ trailer_dict[key] = value
2155
+
2156
+ # Extract decisions from body (same signals as extract_tags_from_body
2157
+ # but captures the full line for audit value)
2158
+ decisions = []
2159
+ if body:
2160
+ for line in body.splitlines():
2161
+ stripped = line.strip()
2162
+ if not stripped or stripped.startswith("#"):
2163
+ continue
2164
+ # Lines with decision tags or Architect attribution
2165
+ if "[#" in stripped or stripped.endswith(("— AG",)):
2166
+ decisions.append(stripped)
2167
+ # Lines starting with decision-like bullets
2168
+ elif stripped.startswith("- ") and len(stripped) > 20:
2169
+ decisions.append(stripped[2:])
2170
+
2171
+ # Vendor attribution — the agent trailer carries the vendor identity
2172
+ agent = trailer_dict.get('Gator-Agent', frontmatter.get('agent', ''))
2173
+ vendor = agent.split(",")[0] if agent else "" # "claude,codex" → "claude"
2174
+
2175
+ lines = [
2176
+ "---",
2177
+ "schema: gator-commit-summary-v1",
2178
+ f"type: commit",
2179
+ f"date: {date_str}",
2180
+ f"timestamp: {timestamp}",
2181
+ f"repo: {repo_name}",
2182
+ f"vendor: {vendor}",
2183
+ f"message: {frontmatter.get('message', commit_msg_line)!s}",
2184
+ f"change-type: {trailer_dict.get('Gator-Change-Type', '')}",
2185
+ f"significance: {trailer_dict.get('Gator-Significance', '')}",
2186
+ f"decision-tags: {trailer_dict.get('Gator-Decision-Tags', '')}",
2187
+ f"agent: {trailer_dict.get('Gator-Agent', '')}",
2188
+ f"architect: {trailer_dict.get('Gator-Architect', trailer_dict.get('Gator-PI', ''))}",
2189
+ f"charter-changed: {trailer_dict.get('Gator-Charter-Changed', '')}",
2190
+ "---",
2191
+ "",
2192
+ ]
2193
+
2194
+ if decisions:
2195
+ lines.append("## Decisions")
2196
+ lines.append("")
2197
+ for d in decisions[:10]:
2198
+ lines.append(f"- {d}")
2199
+ lines.append("")
2200
+
2201
+ if body and body.strip():
2202
+ lines.append("## Session Notes")
2203
+ lines.append("")
2204
+ # Include body but cap at 30 lines to keep summaries small
2205
+ body_lines = body.strip().splitlines()[:30]
2206
+ lines.extend(body_lines)
2207
+ if len(body.strip().splitlines()) > 30:
2208
+ lines.append("")
2209
+ lines.append(f"*({len(body.strip().splitlines()) - 30} more lines in commit_draft.md)*")
2210
+ lines.append("")
2211
+
2212
+ try:
2213
+ summary_path.write_text("\n".join(lines), encoding="utf-8")
2214
+ # Stage it so it's included in this commit
2215
+ git("add", str(summary_path), cwd=gator_dir.parent)
2216
+ except OSError:
2217
+ pass # Non-fatal — don't block the commit for a summary write failure
2218
+
2219
+
2220
+ # ---------------------------------------------------------------------------
2221
+ # Whiteboard output
2222
+ # ---------------------------------------------------------------------------
2223
+
2224
+ def write_whiteboard(gator_dir, failures, warnings, override,
2225
+ enforcement_level="strict"):
2226
+ """Write findings to .gator/whiteboard.md."""
2227
+ whiteboard = gator_dir / "whiteboard.md"
2228
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
2229
+
2230
+ lines = [
2231
+ "# Enforcer Whiteboard",
2232
+ "",
2233
+ f"Last updated: {timestamp} (pre-commit hook)",
2234
+ "",
2235
+ ]
2236
+
2237
+ if enforcement_level == "off":
2238
+ lines.append("**Enforcement level: off** — governance checks disabled.")
2239
+ lines.append("")
2240
+ elif enforcement_level == "warn":
2241
+ lines.append("**Enforcement level: warn** — findings are advisory, "
2242
+ "commit was not blocked.")
2243
+ lines.append("")
2244
+
2245
+ if failures:
2246
+ lines.append("## Blocked")
2247
+ lines.append("")
2248
+ for rule, msg in failures:
2249
+ lines.append(f"- **{rule}**: {msg}")
2250
+ lines.append("")
2251
+
2252
+ if warnings:
2253
+ lines.append("## Warnings")
2254
+ lines.append("")
2255
+ for rule, msg in warnings:
2256
+ lines.append(f"- **{rule}**: {msg}")
2257
+ lines.append("")
2258
+
2259
+ if override:
2260
+ lines.append("## Overrides")
2261
+ lines.append("")
2262
+ override_detail = f"{override} (committed at {timestamp})"
2263
+ meta_file = gator_dir / ".override-meta.json"
2264
+ if meta_file.exists():
2265
+ try:
2266
+ meta = json.loads(meta_file.read_text(encoding="utf-8"))
2267
+ approved_by = meta.get("approved_by", "")
2268
+ reason = meta.get("reason", "")
2269
+ block_id = meta.get("block_id", "")
2270
+ if approved_by and approved_by != "legacy-override":
2271
+ override_detail += f" — approved by {approved_by}"
2272
+ if reason:
2273
+ override_detail += f" — reason: {reason}"
2274
+ if block_id:
2275
+ override_detail += f" — block: {block_id}"
2276
+ except (json.JSONDecodeError, OSError):
2277
+ pass
2278
+ lines.append(f"- **Gator-Override**: {override_detail}")
2279
+ lines.append("")
2280
+
2281
+ if not failures and not warnings and not override:
2282
+ lines.append("No findings.")
2283
+ lines.append("")
2284
+
2285
+ whiteboard.write_text("\n".join(lines), encoding="utf-8")
2286
+ return whiteboard
2287
+
2288
+
2289
+ # ---------------------------------------------------------------------------
2290
+ # Commit issues output (lint findings for PI review)
2291
+ # ---------------------------------------------------------------------------
2292
+
2293
+ def write_commit_issues(gator_dir, findings):
2294
+ """Write lint findings to .gator/commit_issues.md for PI review.
2295
+
2296
+ This is the PI-facing surface for reviewing dangerous code findings.
2297
+ The PI reads this, decides which are intentional, and the agent writes
2298
+ approvals to lint-allow.json. Separate from whiteboard.md (which is
2299
+ for enforcer/governance findings).
2300
+ """
2301
+ ci_file = gator_dir / "commit_issues.md"
2302
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
2303
+
2304
+ lines = [
2305
+ "# Commit Issues",
2306
+ "",
2307
+ f"Lint findings from pre-commit hook — {timestamp}",
2308
+ "",
2309
+ "Review each finding. If intentional, tell the agent to approve it.",
2310
+ "The agent will add the approval to `.gator/lint-allow.json` and retry the commit.",
2311
+ "",
2312
+ ]
2313
+
2314
+ for f in findings:
2315
+ sev = f["severity"]
2316
+ lines.append(f"- **{sev}** `{f['rule']}` in `{f['file']}:{f['line']}`")
2317
+ lines.append(f" {f['message']}")
2318
+ if f.get("match"):
2319
+ lines.append(f" `> {f['match']}`")
2320
+ lines.append("")
2321
+
2322
+ ci_file.write_text("\n".join(lines), encoding="utf-8")
2323
+ return ci_file
2324
+
2325
+
2326
+ def clear_commit_issues(gator_dir):
2327
+ """Clear commit_issues.md after a clean pass."""
2328
+ ci_file = gator_dir / "commit_issues.md"
2329
+ if ci_file.exists():
2330
+ ci_file.write_text("# Commit Issues\n\nNo findings.\n", encoding="utf-8")
2331
+
2332
+
2333
+ def clear_lint_allowlist(gator_dir, repo_root):
2334
+ """Clear lint-allow.json after a successful commit.
2335
+
2336
+ The allowlist is a one-shot approval mechanism. Once the commit lands,
2337
+ the dangerous code is in the codebase and future diffs won't show it
2338
+ as new — so the allowlist entry is no longer needed. Clear it to
2339
+ prevent stale approvals from accumulating.
2340
+ """
2341
+ allowlist_file = gator_dir / "lint-allow.json"
2342
+ if not allowlist_file.exists():
2343
+ return
2344
+ try:
2345
+ entries = json.loads(allowlist_file.read_text(encoding="utf-8"))
2346
+ if entries: # Only clear if non-empty (was actually used)
2347
+ allowlist_file.write_text("[]\n", encoding="utf-8")
2348
+ stage_file(allowlist_file, repo_root)
2349
+ except (json.JSONDecodeError, OSError):
2350
+ pass
2351
+
2352
+
2353
+ # ---------------------------------------------------------------------------
2354
+ # Phase 1: validate (called from pre-commit hook)
2355
+ # ---------------------------------------------------------------------------
2356
+
2357
+ def _read_enforcement_level(gator_dir):
2358
+ """Read enforcement level from .gator/config.json. Default: strict."""
2359
+ config_path = gator_dir / "config.json"
2360
+ if config_path.exists():
2361
+ try:
2362
+ config = json.loads(config_path.read_text(encoding="utf-8"))
2363
+ level = config.get("enforcement_level", "strict")
2364
+ if level in ("strict", "warn", "off"):
2365
+ return level
2366
+ except (json.JSONDecodeError, OSError):
2367
+ pass
2368
+ return "strict"
2369
+
2370
+
2371
+ def phase_validate():
2372
+ """Pre-commit validation. Exit 0 to allow, exit 1 to block."""
2373
+ repo_root = find_gator_root()
2374
+ if not repo_root:
2375
+ # Not a gator repo — allow the commit silently
2376
+ sys.exit(0)
2377
+
2378
+ gator_dir = repo_root / ".gator"
2379
+ staged_files = get_staged_files(repo_root)
2380
+
2381
+ # Read enforcement level
2382
+ enforcement = _read_enforcement_level(gator_dir)
2383
+
2384
+ # If enforcement is off, skip governance checks but clear stale artifacts
2385
+ # so the repo doesn't misrepresent its posture. Trailers and cleanup
2386
+ # still run in their own phases.
2387
+ if enforcement == "off":
2388
+ clear_commit_issues(gator_dir)
2389
+ ci_file = gator_dir / "commit_issues.md"
2390
+ if ci_file.exists():
2391
+ stage_file(ci_file, repo_root)
2392
+ wb = write_whiteboard(gator_dir, [], [], None, enforcement_level="off")
2393
+ stage_file(wb, repo_root)
2394
+ status = build_status(gator_dir, staged_files, {}, "", None)
2395
+ sf = write_status_json(gator_dir, status)
2396
+ stage_file(sf, repo_root)
2397
+ print()
2398
+ print(" gator pre-commit: enforcement OFF — governance checks skipped")
2399
+ print()
2400
+ sys.exit(0)
2401
+
2402
+ # Parse commit_draft
2403
+ frontmatter, body, parse_error = parse_commit_draft(gator_dir)
2404
+
2405
+ # Check override — read once, pass everywhere, file deleted atomically
2406
+ override = check_override(gator_dir)
2407
+
2408
+ # Validate governance rules
2409
+ failures = validate_hard_rules(staged_files, frontmatter, body, parse_error, gator_dir, override)
2410
+ warnings = validate_soft_rules(staged_files, frontmatter, body, gator_dir)
2411
+
2412
+ # Run Layer 1 mechanical lint on staged files (dangerous code patterns)
2413
+ lint_findings = run_layer1_lint(staged_files, repo_root)
2414
+ lint_failures = []
2415
+ lint_warnings = []
2416
+ for finding in lint_findings:
2417
+ if finding["severity"] in ("CRITICAL", "HIGH"):
2418
+ lint_failures.append(finding)
2419
+ failures.append((
2420
+ finding["rule"],
2421
+ f"{finding['file']}:{finding['line']} — {finding['message']}"
2422
+ + (f"\n > {finding['match']}" if finding.get("match") else ""),
2423
+ ))
2424
+ else:
2425
+ lint_warnings.append(finding)
2426
+ warnings.append((
2427
+ finding["rule"],
2428
+ f"{finding['file']}:{finding['line']} — {finding['message']}",
2429
+ ))
2430
+
2431
+ # Write lint findings to commit_issues.md (PI reviews these to approve)
2432
+ if lint_failures or lint_warnings:
2433
+ ci_file = write_commit_issues(gator_dir, lint_failures + lint_warnings)
2434
+ stage_file(ci_file, repo_root)
2435
+ else:
2436
+ # Clear any stale commit_issues from a previous blocked attempt
2437
+ clear_commit_issues(gator_dir)
2438
+
2439
+ # Warn mode: move failures to warnings (still report, but don't block)
2440
+ if enforcement == "warn" and failures:
2441
+ warnings.extend(failures)
2442
+ failures = []
2443
+
2444
+ # Write status.json (even on failure — captures the state at attempt time)
2445
+ status = build_status(gator_dir, staged_files, frontmatter, body, override)
2446
+ status_file = write_status_json(gator_dir, status)
2447
+ stage_file(status_file, repo_root)
2448
+
2449
+ # Write whiteboard (always — clears stale findings on clean pass)
2450
+ wb_file = write_whiteboard(gator_dir, failures, warnings, override,
2451
+ enforcement_level=enforcement)
2452
+ stage_file(wb_file, repo_root)
2453
+
2454
+ # Output
2455
+ if failures:
2456
+ print()
2457
+ print(" gator pre-commit: BLOCKED")
2458
+ print()
2459
+ for rule, msg in failures:
2460
+ print(f" ✗ {rule}: {msg}")
2461
+ if warnings:
2462
+ print()
2463
+ for rule, msg in warnings:
2464
+ print(f" ⚠ {rule}: {msg}")
2465
+ print()
2466
+ if lint_failures:
2467
+ print(" Lint findings written to .gator/commit_issues.md")
2468
+ print(" PI: review findings, approve with lint-allow.json, retry commit")
2469
+ else:
2470
+ print(" Findings written to .gator/whiteboard.md")
2471
+
2472
+ # Write override request for PI approval flow
2473
+ charter_failures = [
2474
+ r for r, _ in failures
2475
+ if r in ("charter-alongside-code", "cross-cutting-missing", "charter-index-gap")
2476
+ ]
2477
+ if charter_failures:
2478
+ _, has_charter, code_files, _ = classify_staged_files(staged_files)
2479
+ request = _write_override_request(
2480
+ gator_dir, charter_failures[0], code_files
2481
+ )
2482
+ block_id = request["block_id"]
2483
+ print()
2484
+ print(" ┌─────────────────────────────────────────────────────────┐")
2485
+ print(" │ STOP. Do not override this yourself. │")
2486
+ print(" │ │")
2487
+ print(" │ Present these findings to the PI. The PI decides: │")
2488
+ print(" │ 1. Update the affected charters and retry the commit │")
2489
+ print(" │ 2. Approve override: │")
2490
+ print(f" │ python .gator/scripts/gator-approve.py │")
2491
+ print(" │ │")
2492
+ print(" │ You may NOT create override files yourself. │")
2493
+ print(" │ You may NOT run gator-approve.py yourself. │")
2494
+ print(" │ Unauthorized self-approval is a governance violation. │")
2495
+ print(" └─────────────────────────────────────────────────────────┘")
2496
+ print()
2497
+ print(f" Block ID: {block_id}")
2498
+
2499
+ print()
2500
+ sys.exit(1)
2501
+
2502
+ # Commit is passing — consume and clear lint-allow.json (one-shot approvals)
2503
+ clear_lint_allowlist(gator_dir, repo_root)
2504
+
2505
+ if warnings:
2506
+ print()
2507
+ if enforcement == "warn":
2508
+ print(" gator pre-commit: PASS (enforcement: warn — findings are advisory)")
2509
+ else:
2510
+ print(" gator pre-commit: PASS (with warnings)")
2511
+ print()
2512
+ for rule, msg in warnings:
2513
+ print(f" ⚠ {rule}: {msg}")
2514
+ print()
2515
+
2516
+ if override:
2517
+ print()
2518
+ print(f" gator pre-commit: OVERRIDE ({override})")
2519
+ print(f" Override recorded in trailers and whiteboard.md")
2520
+ print()
2521
+
2522
+ sys.exit(0)
2523
+
2524
+
2525
+ # ---------------------------------------------------------------------------
2526
+ # Phase 2: trailers (called from commit-msg hook)
2527
+ # ---------------------------------------------------------------------------
2528
+
2529
+ def phase_trailers(msg_file_path):
2530
+ """Append Gator-* trailers to the commit message file."""
2531
+ repo_root = find_gator_root()
2532
+ if not repo_root:
2533
+ # Not a gator repo — leave message alone
2534
+ sys.exit(0)
2535
+
2536
+ gator_dir = repo_root / ".gator"
2537
+ staged_files = get_staged_files(repo_root)
2538
+ frontmatter, body, _ = parse_commit_draft(gator_dir)
2539
+
2540
+ # Read override from status.json (written by validate phase, which
2541
+ # already consumed and deleted the .override file)
2542
+ override = None
2543
+ status_file = gator_dir / "status.json"
2544
+ if status_file.exists():
2545
+ try:
2546
+ status_data = json.loads(status_file.read_text(encoding="utf-8"))
2547
+ charter_changed = status_data.get("charter_changed")
2548
+ if charter_changed == "override-skip":
2549
+ override = "charter-skip"
2550
+ except (json.JSONDecodeError, OSError):
2551
+ pass
2552
+
2553
+ # Build trailers
2554
+ trailers = assemble_trailers(frontmatter, body, gator_dir, staged_files, override)
2555
+
2556
+ # Read current message (the -m message the agent provided, if any)
2557
+ msg_path = Path(msg_file_path)
2558
+ current_msg = msg_path.read_text(encoding="utf-8", errors="replace")
2559
+
2560
+ # --- Assemble message from commit_draft.md when it has real content ---
2561
+ draft_message = (frontmatter.get("message") or "").strip()
2562
+ # Strip only the exact stub heading — preserve all other #-prefixed
2563
+ # lines (e.g. "#123 fix", "## Refactored auth", "#security follow-up").
2564
+ _STUB_HEADING = "# Session Change Log"
2565
+ draft_body_lines = []
2566
+ for l in (body or "").splitlines():
2567
+ if l.strip() == _STUB_HEADING:
2568
+ continue
2569
+ draft_body_lines.append(l)
2570
+ # Strip leading/trailing blank lines
2571
+ while draft_body_lines and not draft_body_lines[0].strip():
2572
+ draft_body_lines.pop(0)
2573
+ while draft_body_lines and not draft_body_lines[-1].strip():
2574
+ draft_body_lines.pop()
2575
+ draft_has_content = bool(draft_message or draft_body_lines)
2576
+
2577
+ if draft_has_content:
2578
+ # commit_draft.md is the source of truth for the commit message
2579
+ assembled_lines = []
2580
+ if draft_message:
2581
+ assembled_lines.append(draft_message)
2582
+ elif draft_body_lines:
2583
+ # Use first non-heading body line as summary if no message field
2584
+ assembled_lines.append(draft_body_lines[0])
2585
+ draft_body_lines = draft_body_lines[1:]
2586
+ assembled_lines.append("") # blank line after summary
2587
+
2588
+ if draft_body_lines:
2589
+ assembled_lines.extend(draft_body_lines)
2590
+ assembled_lines.append("") # blank line after body
2591
+
2592
+ # Append trailers
2593
+ final_lines = assembled_lines + trailers + [""]
2594
+ else:
2595
+ # Fallback: use whatever -m the agent provided (backward compatible)
2596
+ # Strip any existing Gator-* trailers (in case of retry)
2597
+ clean_lines = []
2598
+ for line in current_msg.splitlines():
2599
+ if not line.startswith("Gator-"):
2600
+ clean_lines.append(line)
2601
+ # Remove trailing blank lines
2602
+ while clean_lines and not clean_lines[-1].strip():
2603
+ clean_lines.pop()
2604
+
2605
+ # Assemble final message
2606
+ final_lines = clean_lines + [""] + trailers + [""]
2607
+
2608
+ msg_path.write_text("\n".join(final_lines), encoding="utf-8")
2609
+
2610
+ sys.exit(0)
2611
+
2612
+
2613
+ def phase_cleanup():
2614
+ """Reset commit_draft.md after a successful commit."""
2615
+ repo_root = find_gator_root()
2616
+ if not repo_root:
2617
+ sys.exit(0)
2618
+
2619
+ gator_dir = repo_root / ".gator"
2620
+ status_file = gator_dir / "status.json"
2621
+ if status_file.exists():
2622
+ try:
2623
+ status = json.loads(status_file.read_text(encoding="utf-8"))
2624
+ record_commit_and_emit_snippet(gator_dir, status)
2625
+ except (json.JSONDecodeError, OSError):
2626
+ pass
2627
+ draft_file = gator_dir / "commit_draft.md"
2628
+ draft_file.write_text(COMMIT_DRAFT_STUB, encoding="utf-8")
2629
+ sys.exit(0)
2630
+
2631
+
2632
+ # ---------------------------------------------------------------------------
2633
+ # Entry point
2634
+ # ---------------------------------------------------------------------------
2635
+
2636
+ def main():
2637
+ if sys.stdout.encoding != "utf-8":
2638
+ sys.stdout = io.TextIOWrapper(
2639
+ sys.stdout.buffer, encoding="utf-8", errors="replace"
2640
+ )
2641
+
2642
+ parser = argparse.ArgumentParser(
2643
+ description="Gator pre-commit hook — deterministic governance gate."
2644
+ )
2645
+ parser.add_argument(
2646
+ "--phase",
2647
+ choices=["validate", "trailers", "cleanup"],
2648
+ required=True,
2649
+ help="Which hook phase to run",
2650
+ )
2651
+ parser.add_argument(
2652
+ "msg_file",
2653
+ nargs="?",
2654
+ help="Path to commit message file (required for trailers phase)",
2655
+ )
2656
+ args = parser.parse_args()
2657
+
2658
+ if args.phase == "validate":
2659
+ phase_validate()
2660
+ elif args.phase == "trailers":
2661
+ if not args.msg_file:
2662
+ print("Error: trailers phase requires a message file path", file=sys.stderr)
2663
+ sys.exit(1)
2664
+ phase_trailers(args.msg_file)
2665
+ elif args.phase == "cleanup":
2666
+ phase_cleanup()
2667
+
2668
+
2669
+ if __name__ == "__main__":
2670
+ main()