ai-push-hooks 0.1.0

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.
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..executors.exec import (
6
+ collect_commit_messages_for_ranges,
7
+ current_branch,
8
+ env_bool,
9
+ is_feature_branch,
10
+ lookup_open_pr_url,
11
+ )
12
+ from ..types import CollectorResult, RuntimeContext
13
+
14
+
15
+ def collect_pr_context(context: RuntimeContext, state: Any) -> CollectorResult:
16
+ branch_name = current_branch(context.repo_root)
17
+ flag_env = ""
18
+ for step in state.module.steps:
19
+ if step.when_env:
20
+ flag_env = step.when_env
21
+ break
22
+ if flag_env and env_bool(flag_env) is not True:
23
+ return CollectorResult(
24
+ artifacts={"pr-context.txt": f"branch={branch_name}\nflag_env={flag_env}\n"},
25
+ skip_module=True,
26
+ skip_reason="PR create env flag is not enabled",
27
+ )
28
+ if not branch_name or branch_name in {"HEAD", "main"} or not is_feature_branch(branch_name):
29
+ return CollectorResult(
30
+ artifacts={"pr-context.txt": f"branch={branch_name}\n"},
31
+ skip_module=True,
32
+ skip_reason="branch does not require PR creation",
33
+ )
34
+ existing_pr_url = ""
35
+ try:
36
+ existing_pr_url = lookup_open_pr_url(context.repo_root, branch_name)
37
+ except Exception: # noqa: BLE001
38
+ existing_pr_url = ""
39
+ if existing_pr_url:
40
+ return CollectorResult(
41
+ artifacts={"pr-context.txt": f"branch={branch_name}\nexisting_pr_url={existing_pr_url}\n"},
42
+ skip_module=True,
43
+ skip_reason="open PR already exists",
44
+ metadata={"existing_pr_url": existing_pr_url},
45
+ )
46
+
47
+ ranges = context.cache.get("ranges", [])
48
+ changed_files = context.cache.get("changed_files", [])
49
+ diff_text = context.cache.get("diff_text", "")
50
+ commits = collect_commit_messages_for_ranges(context.repo_root, ranges) if ranges else []
51
+ commit_lines = []
52
+ for commit in commits:
53
+ commit_lines.append(f"--- {commit['hash']}")
54
+ commit_lines.append(f"subject: {commit['subject']}")
55
+ if commit["body"]:
56
+ commit_lines.append("body:")
57
+ commit_lines.append(commit["body"])
58
+ commit_lines.append("")
59
+ return CollectorResult(
60
+ artifacts={
61
+ "pr-context.txt": "\n".join(
62
+ [
63
+ f"branch={branch_name}",
64
+ f"base_branch=main",
65
+ f"remote_name={context.remote_name or 'origin'}",
66
+ ]
67
+ )
68
+ + "\n",
69
+ "changed-files.txt": "\n".join(changed_files) + ("\n" if changed_files else ""),
70
+ "push.diff": diff_text + ("\n" if diff_text and not diff_text.endswith("\n") else ""),
71
+ "commits.txt": "\n".join(commit_lines).strip() + ("\n" if commit_lines else ""),
72
+ }
73
+ )
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ DOCS_QUERY_PROMPT = """Given the attached diff and changed file list, output a JSON array of concise documentation search queries.
4
+
5
+ Requirements:
6
+ - Return JSON only.
7
+ - Prefer exact config keys, commands, flags, function names, and domain terms.
8
+ - Keep the list concise and unique.
9
+ """
10
+
11
+ DOCS_ANALYSIS_PROMPT = """Review the attached diff and documentation context. Return JSON issues only for factual documentation drift caused by the code changes.
12
+
13
+ Each item must include:
14
+ - file
15
+ - line
16
+ - description
17
+ - doc_excerpt
18
+ - suggested_fix
19
+
20
+ Return [] when the docs remain factually correct.
21
+ """
22
+
23
+ DOCS_APPLY_PROMPT = """Apply the minimum Markdown documentation changes required to fix the detected factual drift.
24
+
25
+ Rules:
26
+ 1. Modify only README.md and docs/**/*.md files allowed by this step.
27
+ 2. Keep edits minimal and factual.
28
+ 3. Update docs index files when a referenced Markdown file is added or renamed.
29
+ 4. If no edits are required, do not modify files.
30
+ """
31
+
32
+ BEADS_PLAN_PROMPT = """Check the attached branch context and output a JSON object describing Beads alignment work.
33
+
34
+ Return keys:
35
+ - commands: array of non-interactive br command strings to run
36
+ - unresolved: boolean
37
+ - report_markdown: markdown string or empty
38
+
39
+ Return JSON only.
40
+ """
41
+
42
+ PR_COMPOSE_PROMPT = """Draft a pull request payload from the attached branch context.
43
+
44
+ Return a JSON object with:
45
+ - title
46
+ - body
47
+ - base_branch
48
+ - head_branch
49
+ - draft
50
+
51
+ Return JSON only.
52
+ """
53
+
54
+ BUILTIN_PROMPTS = {
55
+ "docs-query-basic": DOCS_QUERY_PROMPT,
56
+ "docs-analysis-basic": DOCS_ANALYSIS_PROMPT,
57
+ "docs-apply-basic": DOCS_APPLY_PROMPT,
58
+ "beads-plan-basic": BEADS_PLAN_PROMPT,
59
+ "pr-compose-basic": PR_COMPOSE_PROMPT,
60
+ }
61
+
62
+ MINIMAL_DOCS_TEMPLATE = '''[general]
63
+ enabled = true
64
+ allow_push_on_error = false
65
+ require_clean_worktree = false
66
+ skip_on_sync_branch = true
67
+
68
+ [llm]
69
+ runner = "opencode"
70
+ model = "openai/gpt-5.3-codex-spark"
71
+ variant = ""
72
+ timeout_seconds = 800
73
+ max_parallel = 2
74
+ json_max_retries = 2
75
+ invalid_json_feedback_max_chars = 6000
76
+ json_retry_new_session = true
77
+ delete_session_after_run = true
78
+
79
+ [logging]
80
+ level = "status"
81
+ jsonl = true
82
+ dir = ".git/ai-push-hooks/logs"
83
+ capture_llm_transcript = true
84
+ transcript_dir = ".git/ai-push-hooks/transcripts"
85
+ summary_dir = ".git/ai-push-hooks/summaries"
86
+
87
+ [workflow]
88
+ modules = ["docs"]
89
+
90
+ [modules.docs]
91
+ enabled = true
92
+
93
+ [[modules.docs.steps]]
94
+ id = "collect"
95
+ type = "collect"
96
+ collector = "docs_context"
97
+
98
+ [[modules.docs.steps]]
99
+ id = "query"
100
+ type = "llm"
101
+ prompt = """
102
+ Given the attached diff and changed file list, output a JSON array of concise
103
+ documentation search queries. Return JSON only.
104
+ """
105
+ inputs = ["collect/push.diff", "collect/changed-files.txt"]
106
+ output = "queries.json"
107
+ schema = "string_array"
108
+
109
+ [[modules.docs.steps]]
110
+ id = "analyze"
111
+ type = "llm"
112
+ prompt = """
113
+ Review the diff and matched docs excerpts. Return JSON issues only for factual
114
+ documentation drift caused by the code changes.
115
+ """
116
+ inputs = ["collect/push.diff", "collect/docs-context.txt", "query/queries.json", "collect/recent-commits.txt"]
117
+ output = "issues.json"
118
+ schema = "docs_issue_array"
119
+
120
+ [[modules.docs.steps]]
121
+ id = "apply"
122
+ type = "apply"
123
+ prompt = """
124
+ Apply the minimum Markdown documentation changes required to fix the detected
125
+ factual drift. Modify only files allowed by the step.
126
+ """
127
+ inputs = ["collect/push.diff", "collect/docs-context.txt", "analyze/issues.json"]
128
+ allow_paths = ["README.md", "docs/**/*.md"]
129
+
130
+ [[modules.docs.steps]]
131
+ id = "assert"
132
+ type = "assert"
133
+ assertion = "docs_apply_requires_manual_commit"
134
+ inputs = ["apply/result.json"]
135
+ '''
@@ -0,0 +1,236 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import pathlib
5
+ import sys
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime, timezone
8
+ from typing import Any
9
+
10
+ READ_ONLY_STEP_TYPES = frozenset({"collect", "llm"})
11
+ PROMPTABLE_STEP_TYPES = frozenset({"llm", "apply"})
12
+ SUPPORTED_STEP_TYPES = frozenset({"collect", "llm", "apply", "exec", "assert"})
13
+ FEATURE_BRANCH_PREFIXES = ("feat/", "feature/")
14
+
15
+
16
+ class HookError(RuntimeError):
17
+ pass
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class GeneralConfig:
22
+ enabled: bool = True
23
+ allow_push_on_error: bool = False
24
+ require_clean_worktree: bool = False
25
+ skip_on_sync_branch: bool = True
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class LlmConfig:
30
+ runner: str = "opencode"
31
+ model: str = "openai/gpt-5.3-codex-spark"
32
+ variant: str = ""
33
+ timeout_seconds: int = 800
34
+ max_parallel: int = 2
35
+ json_max_retries: int = 2
36
+ invalid_json_feedback_max_chars: int = 6000
37
+ json_retry_new_session: bool = True
38
+ delete_session_after_run: bool = True
39
+ max_diff_bytes: int = 180000
40
+ session_title_prefix: str = "ai-push-hooks"
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class LoggingConfig:
45
+ level: str = "status"
46
+ jsonl: bool = True
47
+ dir: str = ".git/ai-push-hooks/logs"
48
+ capture_llm_transcript: bool = True
49
+ transcript_dir: str = ".git/ai-push-hooks/transcripts"
50
+ summary_dir: str = ".git/ai-push-hooks/summaries"
51
+ print_llm_output: bool = False
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class StepConfig:
56
+ id: str
57
+ type: str
58
+ inputs: tuple[str, ...] = ()
59
+ output: str | None = None
60
+ schema: str | None = None
61
+ prompt: str | None = None
62
+ prompt_file: str | None = None
63
+ fallback_prompt_id: str | None = None
64
+ collector: str | None = None
65
+ allow_paths: tuple[str, ...] = ()
66
+ executor: str | None = None
67
+ assertion: str | None = None
68
+ when_env: str | None = None
69
+
70
+ @property
71
+ def is_read_only(self) -> bool:
72
+ return self.type in READ_ONLY_STEP_TYPES
73
+
74
+ @property
75
+ def is_promptable(self) -> bool:
76
+ return self.type in PROMPTABLE_STEP_TYPES
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class ModuleConfig:
81
+ id: str
82
+ enabled: bool
83
+ steps: tuple[StepConfig, ...]
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class WorkflowConfig:
88
+ modules: tuple[str, ...]
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class HookConfig:
93
+ general: GeneralConfig
94
+ llm: LlmConfig
95
+ logging: LoggingConfig
96
+ workflow: WorkflowConfig
97
+ modules: dict[str, ModuleConfig]
98
+
99
+
100
+ @dataclass
101
+ class CollectorResult:
102
+ artifacts: dict[str, str | dict[str, Any] | list[Any]] = field(default_factory=dict)
103
+ skip_module: bool = False
104
+ skip_reason: str = ""
105
+ metadata: dict[str, Any] = field(default_factory=dict)
106
+
107
+
108
+ @dataclass
109
+ class StepResult:
110
+ status: str = "completed"
111
+ artifacts: dict[str, pathlib.Path] = field(default_factory=dict)
112
+ metadata: dict[str, Any] = field(default_factory=dict)
113
+ message: str = ""
114
+
115
+
116
+ @dataclass
117
+ class ModuleRuntimeState:
118
+ module: ModuleConfig
119
+ step_index: int = 0
120
+ status: str = "pending"
121
+ active_step_id: str | None = None
122
+ metadata: dict[str, Any] = field(default_factory=dict)
123
+ artifacts: dict[str, pathlib.Path] = field(default_factory=dict)
124
+ error: str | None = None
125
+
126
+ @property
127
+ def next_step(self) -> StepConfig | None:
128
+ if self.step_index >= len(self.module.steps):
129
+ return None
130
+ return self.module.steps[self.step_index]
131
+
132
+
133
+ @dataclass
134
+ class RuntimeContext:
135
+ repo_root: pathlib.Path
136
+ git_dir: pathlib.Path
137
+ config: HookConfig
138
+ logger: "HookLogger"
139
+ remote_name: str
140
+ remote_url: str
141
+ stdin_lines: list[str]
142
+ run_id: str
143
+ run_dir: pathlib.Path
144
+ opencode_executable: str | None = None
145
+ cache: dict[str, Any] = field(default_factory=dict)
146
+
147
+
148
+ @dataclass
149
+ class WorkflowRunResult:
150
+ run_dir: pathlib.Path
151
+ modules: dict[str, str]
152
+
153
+
154
+ @dataclass
155
+ class HookLogger:
156
+ jsonl_path: pathlib.Path | None
157
+ console_level: str = "status"
158
+ jsonl_write_failed: bool = False
159
+ llm_calls: list[dict[str, Any]] = field(default_factory=list)
160
+
161
+ _verbosity_order = {"status": 0, "info": 1, "debug": 2}
162
+
163
+ def _level_is_enabled(self, level: str) -> bool:
164
+ if level in {"warn", "error"}:
165
+ return True
166
+ configured = self._verbosity_order.get(self.console_level, 0)
167
+ required = self._verbosity_order.get(level, 0)
168
+ return configured >= required
169
+
170
+ def _emit(self, level: str, event: str, message: str, **fields: Any) -> None:
171
+ if not self._level_is_enabled(level):
172
+ return
173
+ stamp = datetime.now(timezone.utc).isoformat()
174
+ sys.stderr.write(f"[ai-push-hooks] {message}\n")
175
+ if self.jsonl_path is None or self.jsonl_write_failed:
176
+ return
177
+ record = {"ts": stamp, "level": level, "event": event, "message": message, **fields}
178
+ try:
179
+ with self.jsonl_path.open("a", encoding="utf-8") as handle:
180
+ handle.write(json.dumps(record, ensure_ascii=True) + "\n")
181
+ except Exception as exc: # noqa: BLE001
182
+ self.jsonl_write_failed = True
183
+ sys.stderr.write(f"[ai-push-hooks] JSONL logging disabled after write failure: {exc}\n")
184
+
185
+ def debug(self, event: str, message: str, **fields: Any) -> None:
186
+ self._emit("debug", event, message, **fields)
187
+
188
+ def info(self, event: str, message: str, **fields: Any) -> None:
189
+ self._emit("info", event, message, **fields)
190
+
191
+ def status(self, event: str, message: str, **fields: Any) -> None:
192
+ self._emit("status", event, message, **fields)
193
+
194
+ def warn(self, event: str, message: str, **fields: Any) -> None:
195
+ self._emit("warn", event, message, **fields)
196
+
197
+ def error(self, event: str, message: str, **fields: Any) -> None:
198
+ self._emit("error", event, message, **fields)
199
+
200
+ def llm_call(
201
+ self,
202
+ stage_name: str,
203
+ purpose: str,
204
+ model: str,
205
+ attempt: int | None = None,
206
+ total_attempts: int | None = None,
207
+ ) -> None:
208
+ call_number = len(self.llm_calls) + 1
209
+ record: dict[str, Any] = {
210
+ "call_number": call_number,
211
+ "stage_name": stage_name,
212
+ "purpose": purpose,
213
+ "model": model,
214
+ }
215
+ if attempt is not None:
216
+ record["attempt"] = attempt
217
+ if total_attempts is not None:
218
+ record["total_attempts"] = total_attempts
219
+ self.llm_calls.append(record)
220
+ self.status(
221
+ "llm.call",
222
+ f"LLM call #{call_number}: {stage_name} - {purpose}",
223
+ **record,
224
+ )
225
+
226
+ def llm_summary(self) -> None:
227
+ stage_counts: dict[str, int] = {}
228
+ for call in self.llm_calls:
229
+ stage_name = str(call.get("stage_name", "")).strip() or "<unknown>"
230
+ stage_counts[stage_name] = stage_counts.get(stage_name, 0) + 1
231
+ self.status(
232
+ "llm.calls_total",
233
+ f"Total LLM calls this run: {len(self.llm_calls)}",
234
+ total_calls=len(self.llm_calls),
235
+ stage_counts=stage_counts,
236
+ )