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.
- gator_command/__init__.py +2 -0
- gator_command/cli.py +137 -0
- gator_command/scripts/crawler.py +633 -0
- gator_command/scripts/dashboard/dashboard.css +982 -0
- gator_command/scripts/dashboard/dashboard.html +84 -0
- gator_command/scripts/dashboard/dashboard.js +419 -0
- gator_command/scripts/dashboard/views/audit.js +270 -0
- gator_command/scripts/dashboard/views/fleet.js +307 -0
- gator_command/scripts/dashboard/views/repo.js +599 -0
- gator_command/scripts/dashboard/views/settings.js +173 -0
- gator_command/scripts/dashboard/views/updates.js +308 -0
- gator_command/scripts/enforcer-prompt.md +22 -0
- gator_command/scripts/extract-claude-sessions.py +489 -0
- gator_command/scripts/extract-codex-sessions.py +477 -0
- gator_command/scripts/extract-gemini-sessions.py +410 -0
- gator_command/scripts/gator-audit.py +956 -0
- gator_command/scripts/gator-charter-draft.py +919 -0
- gator_command/scripts/gator-charter-lint.py +427 -0
- gator_command/scripts/gator-charter-verify.py +606 -0
- gator_command/scripts/gator-dashboard.py +1271 -0
- gator_command/scripts/gator-deploy.py +916 -0
- gator_command/scripts/gator-drift.py +569 -0
- gator_command/scripts/gator-enforce.py +82 -0
- gator_command/scripts/gator-fleet-intel.py +460 -0
- gator_command/scripts/gator-fleet-report.py +615 -0
- gator_command/scripts/gator-init-command-post.py +315 -0
- gator_command/scripts/gator-init.py +434 -0
- gator_command/scripts/gator-machine-id.py +153 -0
- gator_command/scripts/gator-policy-status.py +631 -0
- gator_command/scripts/gator-pulse.py +459 -0
- gator_command/scripts/gator-repo-status.py +649 -0
- gator_command/scripts/gator-session-common.py +372 -0
- gator_command/scripts/gator-session-sink.py +831 -0
- gator_command/scripts/gator-sessions.py +1244 -0
- gator_command/scripts/gator-update.py +615 -0
- gator_command/scripts/gator-version.py +38 -0
- gator_command/scripts/gator_core.py +489 -0
- gator_command/scripts/gator_remote.py +381 -0
- gator_command/scripts/gator_runtime.py +142 -0
- gator_command/scripts/gatorize-actions.sh +989 -0
- gator_command/scripts/gatorize-lib.sh +166 -0
- gator_command/scripts/gatorize-post.sh +394 -0
- gator_command/scripts/gatorize.py +1163 -0
- gator_command/scripts/gatorize.sh +185 -0
- gator_command/scripts/generate_markdown.py +212 -0
- gator_command/scripts/generate_wiki.py +424 -0
- gator_command/scripts/graph_health.py +780 -0
- gator_command/scripts/memex-lint.py +286 -0
- gator_command/scripts/memex-lint.sh +205 -0
- gator_command/scripts/memex.py +1472 -0
- gator_command/scripts/memex_formatters.py +191 -0
- gator_command/scripts/memex_state.py +236 -0
- gator_command/scripts/spawn.py +650 -0
- gator_command/templates/gator-starter/blueprints/README.md +32 -0
- gator_command/templates/gator-starter/charterignore +53 -0
- gator_command/templates/gator-starter/charters/README.md +178 -0
- gator_command/templates/gator-starter/charters/_template.md +31 -0
- gator_command/templates/gator-starter/commands/commit.md +33 -0
- gator_command/templates/gator-starter/commands/init.md +11 -0
- gator_command/templates/gator-starter/commands/update.md +5 -0
- gator_command/templates/gator-starter/constitution.md +165 -0
- gator_command/templates/gator-starter/field-guides/README.md +25 -0
- gator_command/templates/gator-starter/gator-start-up.md +119 -0
- gator_command/templates/gator-starter/procedures/charter-alignment.md +83 -0
- gator_command/templates/gator-starter/procedures/enforcer-review.md +317 -0
- gator_command/templates/gator-starter/procedures/field-guide-generation.md +176 -0
- gator_command/templates/gator-starter/procedures/knowledge-capture.md +57 -0
- gator_command/templates/gator-starter/procedures/significance-check.md +69 -0
- gator_command/templates/gator-starter/reference-notes/concierge-responses.md +535 -0
- gator_command/templates/gator-starter/reference-notes/dangerous-patterns.md +91 -0
- gator_command/templates/gator-starter/reference-notes/dashboard-operations.md +22 -0
- gator_command/templates/gator-starter/reference-notes/enforcer-configuration.md +232 -0
- gator_command/templates/gator-starter/reference-notes/example-project.md +289 -0
- gator_command/templates/gator-starter/reference-notes/failure-modes-and-self-correction.md +72 -0
- gator_command/templates/gator-starter/reference-notes/git-workflow.md +60 -0
- gator_command/templates/gator-starter/reference-notes/identity-and-ownership.md +37 -0
- gator_command/templates/gator-starter/reference-notes/refactor-approach.md +155 -0
- gator_command/templates/gator-starter/reference-notes/what-gator-requires-from-a-model.md +108 -0
- gator_command/templates/gator-starter/reference-notes/why-navigation-coding-feels-different.md +99 -0
- gator_command/templates/gator-starter/reference-notes/workflow-profiles.md +155 -0
- gator_command/templates/gator-starter/scripts/__pycache__/enforcer-review.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/__pycache__/gator-approve.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/__pycache__/gator-init.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/__pycache__/gator-pre-commit.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/__pycache__/gator-update.cpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/enforcer-prompt.md +55 -0
- gator_command/templates/gator-starter/scripts/enforcer-review.py +1551 -0
- gator_command/templates/gator-starter/scripts/gator-approve.py +139 -0
- gator_command/templates/gator-starter/scripts/gator-enforce.py +82 -0
- gator_command/templates/gator-starter/scripts/gator-init.py +434 -0
- gator_command/templates/gator-starter/scripts/gator-pre-commit.py +2670 -0
- gator_command/templates/gator-starter/scripts/gator-pulse.py +459 -0
- gator_command/templates/gator-starter/scripts/gator-update.py +615 -0
- gator_command/templates/gator-starter/scripts/gator-version.py +38 -0
- gator_command/templates/gator-starter/scripts/gator_core.py +487 -0
- gator_command/templates/gator-starter/scripts/hooks/__pycache__/commit-msgcpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/hooks/__pycache__/post-commitcpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/hooks/__pycache__/pre-commitcpython-313.pyc +0 -0
- gator_command/templates/gator-starter/scripts/hooks/commit-msg +5 -0
- gator_command/templates/gator-starter/scripts/hooks/post-commit +7 -0
- gator_command/templates/gator-starter/scripts/hooks/pre-commit +5 -0
- gator_command/templates/gator-starter/sessions/.gitignore +7 -0
- gator_command/templates/gator-starter/vault/.gitkeep +0 -0
- gator_command/templates/gator-starter/whiteboard.md +5 -0
- gator_command-1.0.0.dist-info/METADATA +122 -0
- gator_command-1.0.0.dist-info/RECORD +110 -0
- gator_command-1.0.0.dist-info/WHEEL +5 -0
- gator_command-1.0.0.dist-info/entry_points.txt +2 -0
- gator_command-1.0.0.dist-info/licenses/LICENSE +21 -0
- 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()
|