ai-push-hooks 0.3.1 → 0.3.3
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.
- package/CHANGELOG.md +69 -1
- package/README.md +90 -82
- package/SECURITY.md +7 -2
- package/ai-push-hooks.toml +1 -1
- package/docs/configuration.md +339 -0
- package/package.json +3 -3
- package/pyproject.toml +12 -3
- package/src/ai_push_hooks/artifacts.py +19 -6
- package/src/ai_push_hooks/cli.py +24 -8
- package/src/ai_push_hooks/config.py +220 -49
- package/src/ai_push_hooks/engine.py +93 -43
- package/src/ai_push_hooks/executors/apply.py +121 -43
- package/src/ai_push_hooks/executors/ask.py +30 -13
- package/src/ai_push_hooks/executors/exec.py +61 -22
- package/src/ai_push_hooks/executors/runner_workflow.py +38 -16
- package/src/ai_push_hooks/executors/runners/claude.py +18 -6
- package/src/ai_push_hooks/executors/runners/codex.py +9 -3
- package/src/ai_push_hooks/executors/runners/command.py +25 -9
- package/src/ai_push_hooks/executors/runners/contracts.py +52 -25
- package/src/ai_push_hooks/executors/runners/opencode.py +74 -21
- package/src/ai_push_hooks/executors/runners/opencode_support.py +15 -5
- package/src/ai_push_hooks/executors/runners/process.py +26 -7
- package/src/ai_push_hooks/executors/runners/registry.py +14 -5
- package/src/ai_push_hooks/executors/step_commands.py +58 -18
- package/src/ai_push_hooks/git_utils.py +92 -27
- package/src/ai_push_hooks/hook.py +48 -12
- package/src/ai_push_hooks/install.py +40 -18
- package/src/ai_push_hooks/modules/beads.py +18 -7
- package/src/ai_push_hooks/modules/docs.py +17 -7
- package/src/ai_push_hooks/modules/pr.py +18 -7
- package/src/ai_push_hooks/paths.py +6 -2
- package/src/ai_push_hooks/plugin_loader.py +79 -26
- package/src/ai_push_hooks/plugins.py +3 -1
- package/src/ai_push_hooks/prompts_builtin.py +1 -1
- package/src/ai_push_hooks/types.py +47 -27
- package/run.sh +0 -29
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
# Configuration
|
|
2
|
+
|
|
3
|
+
Define workflows in `ai-push-hooks.toml` at the repository root. Start with the [README example](../README.md#example-check-your-rules) or the [starter configuration](../ai-push-hooks.toml).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
The [npm quick start](../README.md#quick-start) installs a repository-local wrapper. It needs Node 18+ and Python 3.10-3.13 on the hook's `PATH`; the npm package does not bundle Python.
|
|
8
|
+
|
|
9
|
+
For pnpm:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm add -D ai-push-hooks@beta
|
|
13
|
+
pnpm exec ai-push-hooks init --template minimal-docs
|
|
14
|
+
pnpm exec ai-push-hooks install
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
For a Python-only installation:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
python -m pip install ai-push-hooks
|
|
21
|
+
ai-push-hooks init --template minimal-docs
|
|
22
|
+
ai-push-hooks install
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`uv tool install ai-push-hooks` or `pipx install ai-push-hooks` can replace the pip command. npm uses the `beta` tag while the package is in beta.
|
|
26
|
+
|
|
27
|
+
AI steps need the selected CLI installed and authenticated with an available model. Deterministic-only workflows do not need one. `init` and `install` refuse to overwrite existing files; `--force` explicitly replaces them.
|
|
28
|
+
|
|
29
|
+
## Modules And Steps
|
|
30
|
+
|
|
31
|
+
`[workflow].modules` selects the modules to run. Define each module under `modules.<name>` with a non-empty list of steps. Modules are enabled by default; set `enabled = false` to disable one.
|
|
32
|
+
|
|
33
|
+
Step inputs refer to earlier artifacts in the same module: `collect/push.diff`, for example. Cross-module inputs are not supported. Independent `collect` and `ask` work may run concurrently; built-in `exec`, `assert`, and `apply` steps are serialized. Trusted custom command runners, including project-access `ask` commands, are not enforced read-only.
|
|
34
|
+
|
|
35
|
+
| Field | Used by | Meaning |
|
|
36
|
+
| --- | --- | --- |
|
|
37
|
+
| `id` | All | Unique step name within the module. |
|
|
38
|
+
| `type` | All | `collect`, `ask`, `apply`, `exec`, or `assert`. |
|
|
39
|
+
| `inputs` | Non-collector steps | Earlier module-local artifacts. |
|
|
40
|
+
| `when_env` | All | Run only when the named environment variable is true. |
|
|
41
|
+
| `collector` | `collect` | Built-in collector name. |
|
|
42
|
+
| `prompt` | `ask`, `apply` | Inline instructions. |
|
|
43
|
+
| `prompt_file` | `ask`, `apply` | Repository-relative prompt file. |
|
|
44
|
+
| `fallback_prompt_id` | `ask`, `apply` | Built-in prompt name. |
|
|
45
|
+
| `runner` | `ask`, `apply` | Override the default runner profile. |
|
|
46
|
+
| `output` | `ask` | Required response artifact filename. |
|
|
47
|
+
| `schema` | `ask` | Optional JSON schema name. Omit for plain text. |
|
|
48
|
+
| `allow_paths` | `apply` | Required list of permitted edit globs. |
|
|
49
|
+
| `executor` | `exec` | Built-in action name. |
|
|
50
|
+
| `assertion` | `assert` | Built-in assertion name. |
|
|
51
|
+
| `python` | `collect`, `exec`, `assert` | Repository-local callback reference. |
|
|
52
|
+
| `options` | Python steps | JSON-compatible callback options. |
|
|
53
|
+
| `command` | `exec`, `assert` | Direct argv command. |
|
|
54
|
+
| `stdin` | Command steps | One declared input to send on stdin. |
|
|
55
|
+
| `timeout_seconds` | Command steps | Positive timeout; default `60`. |
|
|
56
|
+
|
|
57
|
+
Choose one handler per deterministic step: a built-in, a callback, or a command. Prompt resolution uses `prompt`, then `prompt_file`, then `fallback_prompt_id`.
|
|
58
|
+
|
|
59
|
+
`ask` produces a response, not an automatic policy verdict. Use `assert` to evaluate findings when they should block. `apply` can run without a preceding `ask`; an input ending in `issues.json` containing `[]` skips the apply step.
|
|
60
|
+
|
|
61
|
+
Upgrading from 0.2.1? Rename `type = "llm"` steps to `type = "ask"`.
|
|
62
|
+
|
|
63
|
+
## Runner Profiles
|
|
64
|
+
|
|
65
|
+
`[llm].runner` selects the default; a step's `runner` overrides it. Referenced profiles must exist under `[runners.<name>]`, except for the implicit OpenCode default.
|
|
66
|
+
|
|
67
|
+
OpenCode, Codex, and Claude are first-class adapters. The generic `command` type is for other agentic CLIs and follows the custom runner contract below.
|
|
68
|
+
|
|
69
|
+
| Profile field | Values / behavior |
|
|
70
|
+
| --- | --- |
|
|
71
|
+
| `type` | Required: `opencode`, `codex`, `claude`, or `command`. |
|
|
72
|
+
| `model` | Runner-specific model identifier. |
|
|
73
|
+
| `project_access` | `artifacts` or `project`; OpenCode defaults to `artifacts`, others to `project`. |
|
|
74
|
+
| `variant` | Optional OpenCode variant. |
|
|
75
|
+
| `command` | Required argv array for command runners. |
|
|
76
|
+
| `prompt_transport` | Command runners: `stdin` (default) or `argv`. |
|
|
77
|
+
|
|
78
|
+
Without an explicit OpenCode profile, `[llm].model` and `[llm].variant` configure it. Explicit profiles use their own model settings. `AI_PUSH_HOOKS_MODEL` overrides the selected profile's model.
|
|
79
|
+
|
|
80
|
+
Artifact mode supplies collected inputs in a scratch directory. Project mode lets analysis inspect the checkout. Apply always uses a temporary staging copy, with propagation limited by `allow_paths`.
|
|
81
|
+
|
|
82
|
+
OpenCode runs with isolated configuration and permissions; project/global configuration, external plugins, and MCP servers are not loaded. Codex uses read-only analysis and workspace-write apply modes. Claude Code uses separate analysis and editing permissions. Authenticate the CLI before using it in a hook.
|
|
83
|
+
|
|
84
|
+
Apply requires a single pushed branch whose local commit is the checked-out `HEAD`. Staging excludes Git metadata, `AGENTS.md`, ignored files, symlinks, and special files. These controls are not an OS sandbox or an automatic rollback system. See [Security](../SECURITY.md).
|
|
85
|
+
|
|
86
|
+
### Live validation snapshot
|
|
87
|
+
|
|
88
|
+
Live read/review and apply checks on Linux:
|
|
89
|
+
|
|
90
|
+
| Date | Runner (CLI; model) | Review | Apply |
|
|
91
|
+
| --- | --- | --- | --- |
|
|
92
|
+
| 2026-09-12 | OpenCode (1.18.29; `openai/gpt-5.6-luna`) | Passed | Passed |
|
|
93
|
+
| 2026-09-12 | Claude Code (2.1.220; `sonnet`) | Passed | Passed |
|
|
94
|
+
| 2026-09-14 | Codex (0.152.0; `gpt-5.6-luna`) | Passed | Passed |
|
|
95
|
+
|
|
96
|
+
The Codex check read a random value from a disposable repository, then verified
|
|
97
|
+
that only the allowlisted README changed. These checks establish the tested
|
|
98
|
+
operations, not compatibility with every model or platform.
|
|
99
|
+
|
|
100
|
+
#### Local troubleshooting: Codex sandbox
|
|
101
|
+
|
|
102
|
+
For Codex 0.152, the standalone no-AI sandbox syntax is
|
|
103
|
+
`codex sandbox -- /usr/bin/true`. Resolve host restrictions rather than disabling
|
|
104
|
+
protections, and use a deterministic postcondition to verify the desired checkout
|
|
105
|
+
outcome after an apply.
|
|
106
|
+
|
|
107
|
+
### Apply and manual commits
|
|
108
|
+
|
|
109
|
+
`apply` is generic: it can edit any eligible checkout file matching `allow_paths`; it is not limited to Markdown. The runner edits a temporary staging copy, and only validated changes propagate back to the checkout. Those edits do not enter the commit already being pushed, and `apply` never creates a Git commit.
|
|
110
|
+
|
|
111
|
+
The existing `docs_apply_requires_manual_commit` assertion is a workflow gate, not human-review enforcement. It prevents the original push from passing after `apply` changes files; it cannot prove that anyone reviewed the edits or that they conform to policy. Add it after the `apply` step when that gate is desired:
|
|
112
|
+
|
|
113
|
+
```toml
|
|
114
|
+
[[modules.docs.steps]]
|
|
115
|
+
id = "manual-commit"
|
|
116
|
+
type = "assert"
|
|
117
|
+
assertion = "docs_apply_requires_manual_commit"
|
|
118
|
+
inputs = ["apply/result.json"]
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The assertion checks `apply/result.json`'s `changed_files` and intentionally blocks when edits were propagated. Review `git diff`, run the relevant checks, commit the approved changes, and retry the push. On the retry, the assertion passes when the apply step reports no changes.
|
|
122
|
+
|
|
123
|
+
### Deterministic postconditions after apply
|
|
124
|
+
|
|
125
|
+
An apply process succeeding, or reporting `changed_files = []`, is not proof that
|
|
126
|
+
the requested result is present. The latter can simply mean that the apply was a
|
|
127
|
+
legitimate no-op because the checkout was already correct. Add a deterministic
|
|
128
|
+
postcondition after `apply` and before the manual-commit gate when the desired
|
|
129
|
+
file content has a precise representation:
|
|
130
|
+
|
|
131
|
+
```toml
|
|
132
|
+
[general]
|
|
133
|
+
require_clean_worktree = true
|
|
134
|
+
|
|
135
|
+
[workflow]
|
|
136
|
+
modules = ["docs"]
|
|
137
|
+
|
|
138
|
+
[modules.docs]
|
|
139
|
+
enabled = true
|
|
140
|
+
|
|
141
|
+
[[modules.docs.steps]]
|
|
142
|
+
id = "apply"
|
|
143
|
+
type = "apply"
|
|
144
|
+
prompt = "In the existing README.md, replace 'Release note: DRAFT.' with 'Release note: READY.' and make no other changes."
|
|
145
|
+
allow_paths = ["README.md"]
|
|
146
|
+
|
|
147
|
+
[[modules.docs.steps]]
|
|
148
|
+
id = "postcondition"
|
|
149
|
+
type = "assert"
|
|
150
|
+
command = [
|
|
151
|
+
"{python}",
|
|
152
|
+
"-c",
|
|
153
|
+
"import pathlib, sys; sys.exit(0 if pathlib.Path('README.md').read_text(encoding='utf-8') == 'Release note: READY.\\n' else 1)",
|
|
154
|
+
]
|
|
155
|
+
inputs = ["apply/result.json"]
|
|
156
|
+
|
|
157
|
+
[[modules.docs.steps]]
|
|
158
|
+
id = "manual-commit"
|
|
159
|
+
type = "assert"
|
|
160
|
+
assertion = "docs_apply_requires_manual_commit"
|
|
161
|
+
inputs = ["apply/result.json"]
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
This is an intentional synthetic example, not a recommendation to overwrite a
|
|
165
|
+
real README: its fixture starts with exactly `Release note: DRAFT.\n`, and its
|
|
166
|
+
desired full content is exactly `Release note: READY.\n`.
|
|
167
|
+
The command is a direct argv vector rather than a Python `assert`; Python
|
|
168
|
+
optimization must not be able to remove the check. The postcondition reads the
|
|
169
|
+
checkout after apply, not the commit being pushed. Keep the clean-worktree
|
|
170
|
+
workflow setting for the hook's starting state and the manual-commit gate for
|
|
171
|
+
the intentionally dirty post-apply checkout: review the resulting diff, run
|
|
172
|
+
relevant checks, commit it, and retry the push. This gate and postcondition
|
|
173
|
+
still do not prove human review, semantic correctness, or that an agent
|
|
174
|
+
complied with every instruction.
|
|
175
|
+
|
|
176
|
+
## Custom Runners
|
|
177
|
+
|
|
178
|
+
Use a `command` profile to invoke any other agentic CLI, including Pi, directly or through a thin wrapper. A wrapper can normalize JSONL events into a final response. The runner must be noninteractive: read the prompt from stdin or argv, write only the final response to stdout, and use exit codes to report success or failure.
|
|
179
|
+
|
|
180
|
+
```toml
|
|
181
|
+
[runners.custom]
|
|
182
|
+
type = "command"
|
|
183
|
+
command = ["/absolute/path/to/scripts/review-agent"]
|
|
184
|
+
prompt_transport = "stdin"
|
|
185
|
+
project_access = "project"
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Select it with `runner = "custom"` on an `ask` or `apply` step. The runner receives the full instruction and artifact packet. For apply, its working directory is the staging copy.
|
|
189
|
+
|
|
190
|
+
Commands are argv arrays, not shell strings. Whole-argument placeholders are `{model}`, `{cwd}`, `{stage}`, and `{prompt}`. The `argv` transport requires exactly one `{prompt}` argument; stdin avoids exposing prompts in process listings. Custom programs inherit the user's environment, including authentication variables, and manage their own permissions and session setup/cleanup. For apply, the working directory is the staging copy.
|
|
191
|
+
|
|
192
|
+
## Commands And Callbacks
|
|
193
|
+
|
|
194
|
+
Command steps run in the real repository, with no implicit shell. Exit zero means success; a nonzero exit blocks the workflow. stdout, stderr, and a `result.json` report are saved as step artifacts.
|
|
195
|
+
|
|
196
|
+
```toml
|
|
197
|
+
[[modules.verify.steps]]
|
|
198
|
+
id = "tests"
|
|
199
|
+
type = "exec"
|
|
200
|
+
command = ["{python}", "-m", "pytest", "-q"]
|
|
201
|
+
timeout_seconds = 300
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Available whole-argument placeholders are `{repo}`, `{python}`, and `{input:<step/artifact>}`. Input placeholders must also appear in `inputs`. Stdin is closed unless `stdin` names a declared input. Command output is bounded to 16 MiB per stream.
|
|
205
|
+
|
|
206
|
+
For custom context or policy, reference a top-level synchronous Python function:
|
|
207
|
+
|
|
208
|
+
```toml
|
|
209
|
+
[[modules.policy.steps]]
|
|
210
|
+
id = "change-size"
|
|
211
|
+
type = "assert"
|
|
212
|
+
python = "checks/hooks.py:check_size"
|
|
213
|
+
options = { max_files = 25 }
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
In `checks/hooks.py`:
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
from ai_push_hooks.plugins import PluginContext
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def check_size(context: PluginContext) -> dict:
|
|
223
|
+
ok = len(context.push.changed_files) <= context.options["max_files"]
|
|
224
|
+
return {"ok": ok, "message": "Change exceeds the configured file limit." if not ok else ""}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Add `policy` to `[workflow].modules` to enable it. Callbacks receive repository and step identifiers, push facts, input paths, options, prior module metadata, and a logger.
|
|
228
|
+
|
|
229
|
+
| Callback type | Return value |
|
|
230
|
+
| --- | --- |
|
|
231
|
+
| `collect` | `CollectorResult` from `ai_push_hooks.plugins`, with artifacts and metadata. |
|
|
232
|
+
| `exec` | JSON-serializable dictionary, saved as `result.json`. |
|
|
233
|
+
| `assert` | Dictionary with boolean `ok` and optional string `message`; false blocks. |
|
|
234
|
+
|
|
235
|
+
Callbacks run as trusted in-process code with already-installed dependencies. There is no callback timeout or automatic dependency installation. Collectors may run concurrently, so custom collectors must be concurrency-safe.
|
|
236
|
+
|
|
237
|
+
## Built-In Handlers
|
|
238
|
+
|
|
239
|
+
| Kind | Name | Purpose |
|
|
240
|
+
| --- | --- | --- |
|
|
241
|
+
| Collector | `docs_context` | Push diff, changed files, docs excerpts, recent commits. |
|
|
242
|
+
| Collector | `beads_status_context` | Branch and Beads task context. |
|
|
243
|
+
| Collector | `pr_context` | Context for a pull request. |
|
|
244
|
+
| Action | `beads_alignment` | Apply supported `bd update` / `bd close` operations. |
|
|
245
|
+
| Action | `gh_pr_create` | Create or reuse a PR through `gh`. |
|
|
246
|
+
| Assertion | `docs_apply_requires_manual_commit` | Block after applied docs changes. |
|
|
247
|
+
| Assertion | `beads_alignment_clean` | Block on unresolved task alignment. |
|
|
248
|
+
|
|
249
|
+
Beads steps need the native `bd` CLI; PR creation needs `gh`. Gate optional actions with `when_env = "AI_PUSH_HOOKS_CREATE_PR"`, for example. Beads schema and database maintenance remain separate from hook execution.
|
|
250
|
+
|
|
251
|
+
| JSON schema | Payload |
|
|
252
|
+
| --- | --- |
|
|
253
|
+
| `string_array` | Array of strings. |
|
|
254
|
+
| `docs_issue_array` | Array of objects with `file` and `description`. |
|
|
255
|
+
| `beads_alignment_result` | Object with optional `commands` string array. |
|
|
256
|
+
| `pr_create_payload` | Object containing PR creation fields. |
|
|
257
|
+
|
|
258
|
+
Built-in prompts: `docs-query-basic`, `docs-analysis-basic`, `docs-apply-basic`, `beads-plan-basic`, and `pr-compose-basic`.
|
|
259
|
+
|
|
260
|
+
## Settings
|
|
261
|
+
|
|
262
|
+
These optional settings supplement the workflow and runner definitions:
|
|
263
|
+
|
|
264
|
+
| Section | Key | Default |
|
|
265
|
+
| --- | --- | --- |
|
|
266
|
+
| `general` | `enabled` | `true` |
|
|
267
|
+
| `general` | `allow_push_on_error` | `false` |
|
|
268
|
+
| `general` | `require_clean_worktree` | `false` |
|
|
269
|
+
| `general` | `skip_on_sync_branch` | `true` |
|
|
270
|
+
| `general` | `base_branch` | `"main"` |
|
|
271
|
+
| `llm` | `runner` | `"opencode"` |
|
|
272
|
+
| `llm` | `model` | `"openai/gpt-5.6-luna"` (implicit OpenCode profile) |
|
|
273
|
+
| `llm` | `variant` | `""` |
|
|
274
|
+
| `llm` | `timeout_seconds` | `800` |
|
|
275
|
+
| `llm` | `max_parallel` | `2` |
|
|
276
|
+
| `llm` | `json_max_retries` | `2` |
|
|
277
|
+
| `llm` | `invalid_json_feedback_max_chars` | `6000` |
|
|
278
|
+
| `llm` | `json_retry_new_session` | `true` |
|
|
279
|
+
| `llm` | `delete_session_after_run` | `true` |
|
|
280
|
+
| `llm` | `max_diff_bytes` | `180000` |
|
|
281
|
+
| `llm` | `session_title_prefix` | `"ai-push-hooks"` |
|
|
282
|
+
| `logging` | `level` | `"status"` (`info` and `debug` also available) |
|
|
283
|
+
| `logging` | `jsonl` | `true` |
|
|
284
|
+
| `logging` | `capture_llm_transcript` | `true` (OpenCode only) |
|
|
285
|
+
| `logging` | `print_llm_output` | `false` |
|
|
286
|
+
| `logging` | `dir` | `".git/ai-push-hooks/logs"` |
|
|
287
|
+
| `logging` | `transcript_dir` | `".git/ai-push-hooks/transcripts"` |
|
|
288
|
+
| `logging` | `summary_dir` | `".git/ai-push-hooks/summaries"` |
|
|
289
|
+
|
|
290
|
+
Model availability depends on the provider. Select one available to your account rather than assuming the starter's model is accessible.
|
|
291
|
+
|
|
292
|
+
## Environment Overrides
|
|
293
|
+
|
|
294
|
+
| Variable | Effect |
|
|
295
|
+
| --- | --- |
|
|
296
|
+
| `AI_PUSH_HOOKS_SKIP=1` | Skip the hook before loading configuration. |
|
|
297
|
+
| `AI_PUSH_HOOKS_ALLOW_PUSH_ON_ERROR=1` | Log errors but allow the push. |
|
|
298
|
+
| `AI_PUSH_HOOKS_REQUIRE_CLEAN=1` | Require a clean worktree. |
|
|
299
|
+
| `AI_PUSH_HOOKS_ALLOW_DIRTY=1` | Allow a dirty worktree. |
|
|
300
|
+
| `AI_PUSH_HOOKS_BASE_BRANCH` | Override the base branch. |
|
|
301
|
+
| `AI_PUSH_HOOKS_MODEL` | Override the selected runner's model. |
|
|
302
|
+
| `AI_PUSH_HOOKS_VARIANT` | Override the OpenCode variant. |
|
|
303
|
+
| `AI_PUSH_HOOKS_TIMEOUT_SECONDS` | Override the runner timeout. |
|
|
304
|
+
| `AI_PUSH_HOOKS_LOG_LEVEL` | Set console verbosity. |
|
|
305
|
+
| `AI_PUSH_HOOKS_PRINT_LLM_OUTPUT=1` | Print normalized, redacted final responses; sensitive opt-in. |
|
|
306
|
+
|
|
307
|
+
Boolean values accept `1/0`, `true/false`, `yes/no`, `y/n`, and `on/off`. Skips and fail-open settings are explicit policy choices, not successful checks.
|
|
308
|
+
|
|
309
|
+
## Hook Managers
|
|
310
|
+
|
|
311
|
+
The built-in `install` command creates a repository-local pre-push hook without changing Git configuration. Existing hooks require explicit `--force` replacement; shared/external paths and symlinks are refused. Keep the installed Python executable or local npm package available at its installed location.
|
|
312
|
+
|
|
313
|
+
For Lefthook, use this in `lefthook.yml` instead of the built-in installer:
|
|
314
|
+
|
|
315
|
+
```yaml
|
|
316
|
+
pre-push:
|
|
317
|
+
commands:
|
|
318
|
+
ai-checks:
|
|
319
|
+
run: npx --no-install ai-push-hooks hook {1} {2}
|
|
320
|
+
use_stdin: true
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Run `lefthook install`. For a Python installation, omit the `npx --no-install` prefix. Other hook managers must likewise forward Git's remote arguments, ref-update stdin, and exit status. If earlier commands consume stdin, capture it first and replay it to ai-push-hooks.
|
|
324
|
+
|
|
325
|
+
To remove the generated hook, inspect `git rev-parse --git-path hooks/pre-push` and remove only the ai-push-hooks delegate or its entry in your hook manager. Preserve unrelated hook commands.
|
|
326
|
+
|
|
327
|
+
## Troubleshooting
|
|
328
|
+
|
|
329
|
+
| Symptom | Check |
|
|
330
|
+
| --- | --- |
|
|
331
|
+
| Hook does not run | Reinstall with your chosen hook manager and inspect `git config --get core.hooksPath`. |
|
|
332
|
+
| Missing runner or authentication failure | Ensure the CLI is on the hook's `PATH`, authenticated, and configured with an available model. |
|
|
333
|
+
| Unknown profile | Match `runner` to an existing `[runners.<name>]`. |
|
|
334
|
+
| Runner capability error | Update the CLI to a version with the required adapter flags. |
|
|
335
|
+
| Invalid JSON | Check the prompt and schema; invalid JSON is retried twice by default. |
|
|
336
|
+
| Push blocked after edits | Review `git diff`, run checks, commit approved changes, and retry the push. |
|
|
337
|
+
| Need diagnostics | Inspect `.git/ai-push-hooks/logs` and `.git/ai-push-hooks/summaries`. |
|
|
338
|
+
|
|
339
|
+
For validation commands, see [Contributing](../CONTRIBUTING.md).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-push-hooks",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Run structured AI-assisted checks and allowlisted maintenance before git push",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"ai-push-hooks": "bin/ai-push-hooks.js"
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
|
-
"test": "uv run --no-project --with
|
|
33
|
+
"test": "uv run --no-project --with \".[dev]\" python -m pytest tests -q",
|
|
34
34
|
"test:npm-pack": "node tests/npm-pack-smoke.mjs"
|
|
35
35
|
},
|
|
36
36
|
"files": [
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
"src/**/*.py",
|
|
39
39
|
"vendor",
|
|
40
40
|
"README.md",
|
|
41
|
+
"docs/configuration.md",
|
|
41
42
|
"CHANGELOG.md",
|
|
42
43
|
"SECURITY.md",
|
|
43
|
-
"run.sh",
|
|
44
44
|
"pyproject.toml",
|
|
45
45
|
"LICENSE",
|
|
46
46
|
"ai-push-hooks.toml"
|
package/pyproject.toml
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["setuptools>=
|
|
2
|
+
requires = ["setuptools>=77", "wheel"]
|
|
3
3
|
build-backend = "setuptools.build_meta"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ai-push-hooks"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.3"
|
|
8
8
|
description = "Run structured AI-assisted checks and allowlisted maintenance before git push"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -34,7 +34,7 @@ Changelog = "https://github.com/shanebishop1/ai-push-hooks/blob/main/CHANGELOG.m
|
|
|
34
34
|
ai-push-hooks = "ai_push_hooks.cli:main"
|
|
35
35
|
|
|
36
36
|
[project.optional-dependencies]
|
|
37
|
-
dev = ["pytest>=8.0"]
|
|
37
|
+
dev = ["pytest>=8.0", "build", "pip", "ruff", "twine"]
|
|
38
38
|
|
|
39
39
|
[tool.setuptools]
|
|
40
40
|
include-package-data = true
|
|
@@ -44,3 +44,12 @@ where = ["src"]
|
|
|
44
44
|
|
|
45
45
|
[tool.pytest.ini_options]
|
|
46
46
|
testpaths = ["tests"]
|
|
47
|
+
|
|
48
|
+
[tool.ruff]
|
|
49
|
+
target-version = "py310"
|
|
50
|
+
line-length = 88
|
|
51
|
+
|
|
52
|
+
[tool.ruff.format]
|
|
53
|
+
quote-style = "double"
|
|
54
|
+
indent-style = "space"
|
|
55
|
+
line-ending = "lf"
|
|
@@ -45,11 +45,17 @@ class ArtifactStore:
|
|
|
45
45
|
step_name = validate_path_component(step_id, "Artifact step id")
|
|
46
46
|
lexical_module_path = self.run_dir / module_name
|
|
47
47
|
if path_has_symlink(self.run_dir, lexical_module_path):
|
|
48
|
-
raise HookError(
|
|
49
|
-
|
|
48
|
+
raise HookError(
|
|
49
|
+
f"Artifact module path must not traverse a symlink: {module_id}"
|
|
50
|
+
)
|
|
51
|
+
module_path = resolve_contained_path(
|
|
52
|
+
self.run_dir, module_name, "Artifact module path"
|
|
53
|
+
)
|
|
50
54
|
lexical_step_path = module_path / f"{step_index:02d}-{step_name}"
|
|
51
55
|
if path_has_symlink(self.run_dir, lexical_step_path):
|
|
52
|
-
raise HookError(
|
|
56
|
+
raise HookError(
|
|
57
|
+
f"Artifact step path must not traverse a symlink: {step_id}"
|
|
58
|
+
)
|
|
53
59
|
path = resolve_contained_path(
|
|
54
60
|
module_path,
|
|
55
61
|
f"{step_index:02d}-{step_name}",
|
|
@@ -125,7 +131,9 @@ class ArtifactStore:
|
|
|
125
131
|
payload: Any,
|
|
126
132
|
) -> pathlib.Path:
|
|
127
133
|
path = self._artifact_path(state.module.id, step_index, step_id, artifact_name)
|
|
128
|
-
write_text_no_follow(
|
|
134
|
+
write_text_no_follow(
|
|
135
|
+
path, json.dumps(payload, ensure_ascii=True, indent=2) + "\n"
|
|
136
|
+
)
|
|
129
137
|
return self.register(state, step_id, artifact_name, path)
|
|
130
138
|
|
|
131
139
|
def serialize_plugin_artifacts(
|
|
@@ -152,7 +160,10 @@ class ArtifactStore:
|
|
|
152
160
|
try:
|
|
153
161
|
if isinstance(payload, (dict, list)) or artifact_name.endswith(".json"):
|
|
154
162
|
content = (
|
|
155
|
-
json.dumps(
|
|
163
|
+
json.dumps(
|
|
164
|
+
payload, ensure_ascii=True, indent=2, allow_nan=False
|
|
165
|
+
)
|
|
166
|
+
+ "\n"
|
|
156
167
|
).encode("utf-8")
|
|
157
168
|
elif isinstance(payload, str):
|
|
158
169
|
content = payload.encode("utf-8")
|
|
@@ -169,7 +180,9 @@ class ArtifactStore:
|
|
|
169
180
|
)
|
|
170
181
|
total_bytes += len(content)
|
|
171
182
|
if total_bytes > max_total_bytes:
|
|
172
|
-
raise HookError(
|
|
183
|
+
raise HookError(
|
|
184
|
+
"CollectorResult artifacts exceed the aggregate size limit"
|
|
185
|
+
)
|
|
173
186
|
serialized[artifact_name] = content
|
|
174
187
|
return serialized
|
|
175
188
|
|
package/src/ai_push_hooks/cli.py
CHANGED
|
@@ -43,30 +43,44 @@ def init_config(template: str, force: bool, cwd: pathlib.Path | None = None) ->
|
|
|
43
43
|
except FileNotFoundError:
|
|
44
44
|
metadata = None
|
|
45
45
|
except OSError as exc:
|
|
46
|
-
raise HookError(
|
|
46
|
+
raise HookError(
|
|
47
|
+
f"Could not inspect config path {config_path}: {exc}"
|
|
48
|
+
) from exc
|
|
47
49
|
if metadata is not None:
|
|
48
50
|
if path_is_link_or_reparse(config_path):
|
|
49
|
-
raise HookError(
|
|
51
|
+
raise HookError(
|
|
52
|
+
f"Refusing to replace symlink or reparse point: {config_path}"
|
|
53
|
+
)
|
|
50
54
|
if not stat.S_ISREG(metadata.st_mode):
|
|
51
|
-
raise HookError(
|
|
55
|
+
raise HookError(
|
|
56
|
+
f"Refusing to replace non-regular config path: {config_path}"
|
|
57
|
+
)
|
|
52
58
|
try:
|
|
53
59
|
write_text_no_follow(config_path, MINIMAL_DOCS_TEMPLATE)
|
|
54
60
|
except HookError:
|
|
55
61
|
raise
|
|
56
62
|
except OSError as exc:
|
|
57
|
-
raise HookError(
|
|
63
|
+
raise HookError(
|
|
64
|
+
f"Could not write config file {config_path}: {exc}"
|
|
65
|
+
) from exc
|
|
58
66
|
else:
|
|
59
67
|
try:
|
|
60
68
|
metadata = config_path.lstat()
|
|
61
69
|
except FileNotFoundError:
|
|
62
70
|
metadata = None
|
|
63
71
|
except OSError as exc:
|
|
64
|
-
raise HookError(
|
|
72
|
+
raise HookError(
|
|
73
|
+
f"Could not inspect config path {config_path}: {exc}"
|
|
74
|
+
) from exc
|
|
65
75
|
if metadata is not None:
|
|
66
76
|
if path_is_link_or_reparse(config_path):
|
|
67
|
-
raise HookError(
|
|
77
|
+
raise HookError(
|
|
78
|
+
f"Refusing to overwrite symlink or reparse point: {config_path}"
|
|
79
|
+
)
|
|
68
80
|
if not stat.S_ISREG(metadata.st_mode):
|
|
69
|
-
raise HookError(
|
|
81
|
+
raise HookError(
|
|
82
|
+
f"Refusing to overwrite non-regular config path: {config_path}"
|
|
83
|
+
)
|
|
70
84
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0)
|
|
71
85
|
flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
72
86
|
try:
|
|
@@ -76,7 +90,9 @@ def init_config(template: str, force: bool, cwd: pathlib.Path | None = None) ->
|
|
|
76
90
|
f"Refusing to overwrite existing config without --force: {config_path}"
|
|
77
91
|
) from exc
|
|
78
92
|
except OSError as exc:
|
|
79
|
-
raise HookError(
|
|
93
|
+
raise HookError(
|
|
94
|
+
f"Could not create config file {config_path}: {exc}"
|
|
95
|
+
) from exc
|
|
80
96
|
try:
|
|
81
97
|
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
82
98
|
descriptor = -1
|