ai-push-hooks 0.3.0 → 0.3.2
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 +78 -2
- package/README.md +80 -993
- package/SECURITY.md +21 -16
- package/ai-push-hooks.toml +1 -1
- package/bin/ai-push-hooks.js +6 -6
- package/docs/configuration.md +263 -0
- package/package.json +4 -3
- package/pyproject.toml +12 -3
- package/src/ai_push_hooks/artifacts.py +19 -19
- package/src/ai_push_hooks/cli.py +24 -8
- package/src/ai_push_hooks/config.py +239 -54
- package/src/ai_push_hooks/engine.py +94 -43
- package/src/ai_push_hooks/executors/apply.py +124 -46
- package/src/ai_push_hooks/executors/ask.py +30 -536
- package/src/ai_push_hooks/executors/exec.py +73 -818
- package/src/ai_push_hooks/executors/runner_workflow.py +42 -20
- 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 +109 -27
- package/src/ai_push_hooks/executors/runners/opencode_support.py +286 -0
- package/src/ai_push_hooks/executors/runners/process.py +97 -17
- package/src/ai_push_hooks/executors/runners/registry.py +31 -9
- package/src/ai_push_hooks/executors/step_commands.py +65 -20
- package/src/ai_push_hooks/git_utils.py +899 -0
- package/src/ai_push_hooks/hook.py +49 -13
- package/src/ai_push_hooks/install.py +40 -18
- package/src/ai_push_hooks/modules/beads.py +19 -8
- package/src/ai_push_hooks/modules/docs.py +146 -96
- package/src/ai_push_hooks/modules/pr.py +19 -8
- package/src/ai_push_hooks/paths.py +6 -2
- package/src/ai_push_hooks/plugin_loader.py +182 -105
- 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 +48 -27
- package/vendor/README.md +15 -0
- package/vendor/requirements.txt +1 -0
- package/vendor/tomli-2.4.0-py3-none-any.whl +0 -0
- package/run.sh +0 -29
package/SECURITY.md
CHANGED
|
@@ -52,8 +52,9 @@ or the environment. A single-file callback may import dependencies already
|
|
|
52
52
|
installed in the interpreter running the hook, but the host never runs `pip`;
|
|
53
53
|
sibling/package-relative imports and installed-module references are not a
|
|
54
54
|
supported loading mechanism. The callback runs in-process as trusted user code:
|
|
55
|
-
there is no SDK, sandbox, filesystem write prevention, or
|
|
56
|
-
timeout
|
|
55
|
+
there is no SDK, sandbox, filesystem write prevention, or in-process timeout.
|
|
56
|
+
The configured timeout applies to child runner processes, not callback code.
|
|
57
|
+
Its `PluginContext` has frozen mappings/snapshots and validated `Path`
|
|
57
58
|
values, but those paths do not make file contents read-only. Callback prints and
|
|
58
59
|
direct writes can disclose or modify host data and are outside host
|
|
59
60
|
sanitization. Use a separately managed low-privilege process/container/VM when
|
|
@@ -97,22 +98,26 @@ captured stdout/stderr are each bounded to 16 MiB; staging is bounded to 10,000
|
|
|
97
98
|
entries/256 MiB and Git metadata snapshots to 20,000 entries/64 MiB. These
|
|
98
99
|
limits are resource and scope controls, not isolation. Existing baseline checks
|
|
99
100
|
are not an atomic CAS against arbitrary external writers, and automatic rollback
|
|
100
|
-
is avoided to protect pre-existing user changes. See
|
|
101
|
-
|
|
101
|
+
is avoided to protect pre-existing user changes. See [runner profiles and
|
|
102
|
+
access modes](docs/configuration.md#runner-profiles).
|
|
103
|
+
|
|
104
|
+
Apply intentionally repeats integrity and state scans: it snapshots the checkout
|
|
105
|
+
and Git metadata, inventories staging before and after the runner, checks each
|
|
106
|
+
propagation operation against its baseline, and verifies the propagated result
|
|
107
|
+
and protected state afterward. These repeated checks are defense in depth, not
|
|
108
|
+
an atomic CAS or an automatic rollback.
|
|
102
109
|
|
|
103
110
|
Timeout cleanup has platform limits: POSIX uses a private process group on a
|
|
104
111
|
best-effort basis, while Windows can terminate only the direct child. Neither
|
|
105
112
|
is a sandbox; Windows has no native beta evidence.
|
|
106
113
|
|
|
107
|
-
##
|
|
108
|
-
|
|
109
|
-
The
|
|
110
|
-
|
|
111
|
-
in-process loopback mock provider and no external
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
available. Treat generated-hook path checks and the Lefthook runner as
|
|
118
|
-
integration safeguards, not isolation boundaries.
|
|
114
|
+
## Historical 0.3.0 security evidence
|
|
115
|
+
|
|
116
|
+
The [0.3.0 release record](CHANGELOG.md#030---2026-09-09) documented a pinned
|
|
117
|
+
Lefthook suite reporting **407 tests with no skips**, an OpenCode **1.18.29**
|
|
118
|
+
contract smoke test with an in-process loopback mock provider and no external
|
|
119
|
+
model call, and version/help-only checks for Codex **0.148.0** and Claude
|
|
120
|
+
**2.1.220**. This is historical evidence, not a current suite result or proof
|
|
121
|
+
for every provider, model, authentication mode, platform, or live `apply` path.
|
|
122
|
+
Treat generated-hook path checks and the Lefthook runner as integration
|
|
123
|
+
safeguards, not isolation boundaries.
|
package/ai-push-hooks.toml
CHANGED
|
@@ -12,7 +12,7 @@ base_branch = "main"
|
|
|
12
12
|
# Repository callbacks use python = "path/to/file.py:callable" and command
|
|
13
13
|
# steps use a direct argv array; both are trusted local code.
|
|
14
14
|
runner = "opencode"
|
|
15
|
-
model = "openai/gpt-5.6-
|
|
15
|
+
model = "openai/gpt-5.6-luna"
|
|
16
16
|
variant = ""
|
|
17
17
|
timeout_seconds = 800
|
|
18
18
|
max_parallel = 2
|
package/bin/ai-push-hooks.js
CHANGED
|
@@ -6,16 +6,16 @@ const path = require('node:path');
|
|
|
6
6
|
|
|
7
7
|
const packageRoot = path.resolve(__dirname, '..');
|
|
8
8
|
const srcDir = path.join(packageRoot, 'src');
|
|
9
|
+
const tomliWheel = path.join(packageRoot, 'vendor', 'tomli-2.4.0-py3-none-any.whl');
|
|
9
10
|
const args = ['-m', 'ai_push_hooks', ...process.argv.slice(2)];
|
|
10
|
-
const pythonCommands = ['python3.14', 'python3.13', 'python3.12', 'python3.11', 'python3', 'python'];
|
|
11
|
+
const pythonCommands = ['python3.14', 'python3.13', 'python3.12', 'python3.11', 'python3.10', 'python3', 'python'];
|
|
11
12
|
|
|
12
13
|
function buildEnv() {
|
|
13
14
|
const env = { ...process.env };
|
|
14
15
|
env.AI_PUSH_HOOKS_NODE_EXECUTABLE = process.execPath;
|
|
15
16
|
env.AI_PUSH_HOOKS_NODE_SCRIPT = fs.realpathSync(__filename);
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
: srcDir;
|
|
17
|
+
// Pure-Python wheels are importable archives; no pip or install scripts needed.
|
|
18
|
+
env.PYTHONPATH = [srcDir, tomliWheel, env.PYTHONPATH].filter(Boolean).join(path.delimiter);
|
|
19
19
|
return env;
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -33,7 +33,7 @@ function canRunPackage(command) {
|
|
|
33
33
|
'-c',
|
|
34
34
|
'import sys; assert sys.version_info >= (3, 10); __import__("tomllib" if sys.version_info >= (3, 11) else "tomli")',
|
|
35
35
|
],
|
|
36
|
-
{ stdio: 'ignore' },
|
|
36
|
+
{ stdio: 'ignore', env: buildEnv() },
|
|
37
37
|
);
|
|
38
38
|
return check.status === 0;
|
|
39
39
|
}
|
|
@@ -41,7 +41,7 @@ function canRunPackage(command) {
|
|
|
41
41
|
const pythonCommand = pythonCommands.find(canRunPackage);
|
|
42
42
|
if (!pythonCommand) {
|
|
43
43
|
console.error(
|
|
44
|
-
'[ai-push-hooks] Python 3.
|
|
44
|
+
'[ai-push-hooks] Python 3.10+ is required and must be available on PATH.',
|
|
45
45
|
);
|
|
46
46
|
process.exit(1);
|
|
47
47
|
}
|
|
@@ -0,0 +1,263 @@
|
|
|
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
|
+
| Profile field | Values / behavior |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
| `type` | Required: `opencode`, `codex`, `claude`, or `command`. |
|
|
70
|
+
| `model` | Runner-specific model identifier. |
|
|
71
|
+
| `project_access` | `artifacts` or `project`; OpenCode defaults to `artifacts`, others to `project`. |
|
|
72
|
+
| `variant` | Optional OpenCode variant. |
|
|
73
|
+
| `command` | Required argv array for command runners. |
|
|
74
|
+
| `prompt_transport` | Command runners: `stdin` (default) or `argv`. |
|
|
75
|
+
|
|
76
|
+
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.
|
|
77
|
+
|
|
78
|
+
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`.
|
|
79
|
+
|
|
80
|
+
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.
|
|
81
|
+
|
|
82
|
+
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).
|
|
83
|
+
|
|
84
|
+
### Apply and manual commits
|
|
85
|
+
|
|
86
|
+
`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.
|
|
87
|
+
|
|
88
|
+
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:
|
|
89
|
+
|
|
90
|
+
```toml
|
|
91
|
+
[[modules.docs.steps]]
|
|
92
|
+
id = "manual-commit"
|
|
93
|
+
type = "assert"
|
|
94
|
+
assertion = "docs_apply_requires_manual_commit"
|
|
95
|
+
inputs = ["apply/result.json"]
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
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.
|
|
99
|
+
|
|
100
|
+
## Custom Runners
|
|
101
|
+
|
|
102
|
+
Use a command profile for another CLI, including Pi, or your own wrapper. This example expects a local `scripts/review-agent` program that reads the prompt from stdin and writes its final response to stdout:
|
|
103
|
+
|
|
104
|
+
```toml
|
|
105
|
+
[runners.custom]
|
|
106
|
+
type = "command"
|
|
107
|
+
command = ["/absolute/path/to/scripts/review-agent"]
|
|
108
|
+
prompt_transport = "stdin"
|
|
109
|
+
project_access = "project"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
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.
|
|
113
|
+
|
|
114
|
+
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 environment and own their permissions and session lifecycle.
|
|
115
|
+
|
|
116
|
+
## Commands And Callbacks
|
|
117
|
+
|
|
118
|
+
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.
|
|
119
|
+
|
|
120
|
+
```toml
|
|
121
|
+
[[modules.verify.steps]]
|
|
122
|
+
id = "tests"
|
|
123
|
+
type = "exec"
|
|
124
|
+
command = ["{python}", "-m", "pytest", "-q"]
|
|
125
|
+
timeout_seconds = 300
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
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.
|
|
129
|
+
|
|
130
|
+
For custom context or policy, reference a top-level synchronous Python function:
|
|
131
|
+
|
|
132
|
+
```toml
|
|
133
|
+
[[modules.policy.steps]]
|
|
134
|
+
id = "change-size"
|
|
135
|
+
type = "assert"
|
|
136
|
+
python = "checks/hooks.py:check_size"
|
|
137
|
+
options = { max_files = 25 }
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
In `checks/hooks.py`:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from ai_push_hooks.plugins import PluginContext
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def check_size(context: PluginContext) -> dict:
|
|
147
|
+
ok = len(context.push.changed_files) <= context.options["max_files"]
|
|
148
|
+
return {"ok": ok, "message": "Change exceeds the configured file limit." if not ok else ""}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
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.
|
|
152
|
+
|
|
153
|
+
| Callback type | Return value |
|
|
154
|
+
| --- | --- |
|
|
155
|
+
| `collect` | `CollectorResult` from `ai_push_hooks.plugins`, with artifacts and metadata. |
|
|
156
|
+
| `exec` | JSON-serializable dictionary, saved as `result.json`. |
|
|
157
|
+
| `assert` | Dictionary with boolean `ok` and optional string `message`; false blocks. |
|
|
158
|
+
|
|
159
|
+
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.
|
|
160
|
+
|
|
161
|
+
## Built-In Handlers
|
|
162
|
+
|
|
163
|
+
| Kind | Name | Purpose |
|
|
164
|
+
| --- | --- | --- |
|
|
165
|
+
| Collector | `docs_context` | Push diff, changed files, docs excerpts, recent commits. |
|
|
166
|
+
| Collector | `beads_status_context` | Branch and Beads task context. |
|
|
167
|
+
| Collector | `pr_context` | Context for a pull request. |
|
|
168
|
+
| Action | `beads_alignment` | Apply supported `bd update` / `bd close` operations. |
|
|
169
|
+
| Action | `gh_pr_create` | Create or reuse a PR through `gh`. |
|
|
170
|
+
| Assertion | `docs_apply_requires_manual_commit` | Block after applied docs changes. |
|
|
171
|
+
| Assertion | `beads_alignment_clean` | Block on unresolved task alignment. |
|
|
172
|
+
|
|
173
|
+
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.
|
|
174
|
+
|
|
175
|
+
| JSON schema | Payload |
|
|
176
|
+
| --- | --- |
|
|
177
|
+
| `string_array` | Array of strings. |
|
|
178
|
+
| `docs_issue_array` | Array of objects with `file` and `description`. |
|
|
179
|
+
| `beads_alignment_result` | Object with optional `commands` string array. |
|
|
180
|
+
| `pr_create_payload` | Object containing PR creation fields. |
|
|
181
|
+
|
|
182
|
+
Built-in prompts: `docs-query-basic`, `docs-analysis-basic`, `docs-apply-basic`, `beads-plan-basic`, and `pr-compose-basic`.
|
|
183
|
+
|
|
184
|
+
## Settings
|
|
185
|
+
|
|
186
|
+
These optional settings supplement the workflow and runner definitions:
|
|
187
|
+
|
|
188
|
+
| Section | Key | Default |
|
|
189
|
+
| --- | --- | --- |
|
|
190
|
+
| `general` | `enabled` | `true` |
|
|
191
|
+
| `general` | `allow_push_on_error` | `false` |
|
|
192
|
+
| `general` | `require_clean_worktree` | `false` |
|
|
193
|
+
| `general` | `skip_on_sync_branch` | `true` |
|
|
194
|
+
| `general` | `base_branch` | `"main"` |
|
|
195
|
+
| `llm` | `runner` | `"opencode"` |
|
|
196
|
+
| `llm` | `model` | `"openai/gpt-5.6-luna"` (implicit OpenCode profile) |
|
|
197
|
+
| `llm` | `variant` | `""` |
|
|
198
|
+
| `llm` | `timeout_seconds` | `800` |
|
|
199
|
+
| `llm` | `max_parallel` | `2` |
|
|
200
|
+
| `llm` | `json_max_retries` | `2` |
|
|
201
|
+
| `llm` | `invalid_json_feedback_max_chars` | `6000` |
|
|
202
|
+
| `llm` | `json_retry_new_session` | `true` |
|
|
203
|
+
| `llm` | `delete_session_after_run` | `true` |
|
|
204
|
+
| `llm` | `max_diff_bytes` | `180000` |
|
|
205
|
+
| `llm` | `session_title_prefix` | `"ai-push-hooks"` |
|
|
206
|
+
| `logging` | `level` | `"status"` (`info` and `debug` also available) |
|
|
207
|
+
| `logging` | `jsonl` | `true` |
|
|
208
|
+
| `logging` | `capture_llm_transcript` | `true` (OpenCode only) |
|
|
209
|
+
| `logging` | `print_llm_output` | `false` |
|
|
210
|
+
| `logging` | `dir` | `".git/ai-push-hooks/logs"` |
|
|
211
|
+
| `logging` | `transcript_dir` | `".git/ai-push-hooks/transcripts"` |
|
|
212
|
+
| `logging` | `summary_dir` | `".git/ai-push-hooks/summaries"` |
|
|
213
|
+
|
|
214
|
+
Model availability depends on the provider. Select one available to your account rather than assuming the starter's model is accessible.
|
|
215
|
+
|
|
216
|
+
## Environment Overrides
|
|
217
|
+
|
|
218
|
+
| Variable | Effect |
|
|
219
|
+
| --- | --- |
|
|
220
|
+
| `AI_PUSH_HOOKS_SKIP=1` | Skip the hook before loading configuration. |
|
|
221
|
+
| `AI_PUSH_HOOKS_ALLOW_PUSH_ON_ERROR=1` | Log errors but allow the push. |
|
|
222
|
+
| `AI_PUSH_HOOKS_REQUIRE_CLEAN=1` | Require a clean worktree. |
|
|
223
|
+
| `AI_PUSH_HOOKS_ALLOW_DIRTY=1` | Allow a dirty worktree. |
|
|
224
|
+
| `AI_PUSH_HOOKS_BASE_BRANCH` | Override the base branch. |
|
|
225
|
+
| `AI_PUSH_HOOKS_MODEL` | Override the selected runner's model. |
|
|
226
|
+
| `AI_PUSH_HOOKS_VARIANT` | Override the OpenCode variant. |
|
|
227
|
+
| `AI_PUSH_HOOKS_TIMEOUT_SECONDS` | Override the runner timeout. |
|
|
228
|
+
| `AI_PUSH_HOOKS_LOG_LEVEL` | Set console verbosity. |
|
|
229
|
+
| `AI_PUSH_HOOKS_PRINT_LLM_OUTPUT=1` | Print normalized, redacted final responses; sensitive opt-in. |
|
|
230
|
+
|
|
231
|
+
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.
|
|
232
|
+
|
|
233
|
+
## Hook Managers
|
|
234
|
+
|
|
235
|
+
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.
|
|
236
|
+
|
|
237
|
+
For Lefthook, use this in `lefthook.yml` instead of the built-in installer:
|
|
238
|
+
|
|
239
|
+
```yaml
|
|
240
|
+
pre-push:
|
|
241
|
+
commands:
|
|
242
|
+
ai-checks:
|
|
243
|
+
run: npx --no-install ai-push-hooks hook {1} {2}
|
|
244
|
+
use_stdin: true
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
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.
|
|
248
|
+
|
|
249
|
+
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.
|
|
250
|
+
|
|
251
|
+
## Troubleshooting
|
|
252
|
+
|
|
253
|
+
| Symptom | Check |
|
|
254
|
+
| --- | --- |
|
|
255
|
+
| Hook does not run | Reinstall with your chosen hook manager and inspect `git config --get core.hooksPath`. |
|
|
256
|
+
| Missing runner or authentication failure | Ensure the CLI is on the hook's `PATH`, authenticated, and configured with an available model. |
|
|
257
|
+
| Unknown profile | Match `runner` to an existing `[runners.<name>]`. |
|
|
258
|
+
| Runner capability error | Update the CLI to a version with the required adapter flags. |
|
|
259
|
+
| Invalid JSON | Check the prompt and schema; invalid JSON is retried twice by default. |
|
|
260
|
+
| Push blocked after edits | Review `git diff`, run checks, commit approved changes, and retry the push. |
|
|
261
|
+
| Need diagnostics | Inspect `.git/ai-push-hooks/logs` and `.git/ai-push-hooks/summaries`. |
|
|
262
|
+
|
|
263
|
+
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.2",
|
|
4
4
|
"description": "Run structured AI-assisted checks and allowlisted maintenance before git push",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -30,16 +30,17 @@
|
|
|
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": [
|
|
37
37
|
"bin",
|
|
38
38
|
"src/**/*.py",
|
|
39
|
+
"vendor",
|
|
39
40
|
"README.md",
|
|
41
|
+
"docs/configuration.md",
|
|
40
42
|
"CHANGELOG.md",
|
|
41
43
|
"SECURITY.md",
|
|
42
|
-
"run.sh",
|
|
43
44
|
"pyproject.toml",
|
|
44
45
|
"LICENSE",
|
|
45
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.2"
|
|
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
|
|
|
@@ -194,16 +207,3 @@ class ArtifactStore:
|
|
|
194
207
|
)
|
|
195
208
|
raise HookError(f"Unknown artifact reference: {reference}")
|
|
196
209
|
return path
|
|
197
|
-
|
|
198
|
-
def register_external(
|
|
199
|
-
self,
|
|
200
|
-
state: ModuleRuntimeState,
|
|
201
|
-
module_id: str,
|
|
202
|
-
step_id: str,
|
|
203
|
-
artifact_name: str,
|
|
204
|
-
path: pathlib.Path,
|
|
205
|
-
) -> None:
|
|
206
|
-
validate_path_component(module_id, "Artifact module id")
|
|
207
|
-
validate_path_component(step_id, "Artifact step id")
|
|
208
|
-
validate_path_component(artifact_name, "Artifact name")
|
|
209
|
-
state.artifacts[f"{module_id}:{step_id}/{artifact_name}"] = path
|
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
|