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.
Files changed (40) hide show
  1. package/CHANGELOG.md +78 -2
  2. package/README.md +80 -993
  3. package/SECURITY.md +21 -16
  4. package/ai-push-hooks.toml +1 -1
  5. package/bin/ai-push-hooks.js +6 -6
  6. package/docs/configuration.md +263 -0
  7. package/package.json +4 -3
  8. package/pyproject.toml +12 -3
  9. package/src/ai_push_hooks/artifacts.py +19 -19
  10. package/src/ai_push_hooks/cli.py +24 -8
  11. package/src/ai_push_hooks/config.py +239 -54
  12. package/src/ai_push_hooks/engine.py +94 -43
  13. package/src/ai_push_hooks/executors/apply.py +124 -46
  14. package/src/ai_push_hooks/executors/ask.py +30 -536
  15. package/src/ai_push_hooks/executors/exec.py +73 -818
  16. package/src/ai_push_hooks/executors/runner_workflow.py +42 -20
  17. package/src/ai_push_hooks/executors/runners/claude.py +18 -6
  18. package/src/ai_push_hooks/executors/runners/codex.py +9 -3
  19. package/src/ai_push_hooks/executors/runners/command.py +25 -9
  20. package/src/ai_push_hooks/executors/runners/contracts.py +52 -25
  21. package/src/ai_push_hooks/executors/runners/opencode.py +109 -27
  22. package/src/ai_push_hooks/executors/runners/opencode_support.py +286 -0
  23. package/src/ai_push_hooks/executors/runners/process.py +97 -17
  24. package/src/ai_push_hooks/executors/runners/registry.py +31 -9
  25. package/src/ai_push_hooks/executors/step_commands.py +65 -20
  26. package/src/ai_push_hooks/git_utils.py +899 -0
  27. package/src/ai_push_hooks/hook.py +49 -13
  28. package/src/ai_push_hooks/install.py +40 -18
  29. package/src/ai_push_hooks/modules/beads.py +19 -8
  30. package/src/ai_push_hooks/modules/docs.py +146 -96
  31. package/src/ai_push_hooks/modules/pr.py +19 -8
  32. package/src/ai_push_hooks/paths.py +6 -2
  33. package/src/ai_push_hooks/plugin_loader.py +182 -105
  34. package/src/ai_push_hooks/plugins.py +3 -1
  35. package/src/ai_push_hooks/prompts_builtin.py +1 -1
  36. package/src/ai_push_hooks/types.py +48 -27
  37. package/vendor/README.md +15 -0
  38. package/vendor/requirements.txt +1 -0
  39. package/vendor/tomli-2.4.0-py3-none-any.whl +0 -0
  40. 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 enforceable hard
56
- timeout. Its `PluginContext` has frozen mappings/snapshots and validated `Path`
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 the README's [runner
101
- profiles and access modes](README.md#runner-profiles-and-access-modes).
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
- ## Tested security boundary
108
-
109
- The final pinned-Lefthook suite passed **407 tests with no skips**. The current
110
- evidence also includes the real OpenCode **1.18.29** contract smoke test with an
111
- in-process loopback mock provider and no external model call; its read probe
112
- checks that no Git-visible project files were mutated. It does not cover every
113
- provider, model, authentication mode, or live `apply` path.
114
- Installed Codex **0.148.0** and Claude **2.1.220** checks use only version/help
115
- output. Live Codex/Pi verification was intentionally not run pending separate
116
- approval; Claude live verification is pending because no subscription is
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.
@@ -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-terra"
15
+ model = "openai/gpt-5.6-luna"
16
16
  variant = ""
17
17
  timeout_seconds = 800
18
18
  max_parallel = 2
@@ -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
- env.PYTHONPATH = env.PYTHONPATH
17
- ? `${srcDir}${path.delimiter}${env.PYTHONPATH}`
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.11+ is required for npm installs. Python 3.10 can be used if tomli is installed.',
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.0",
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 pytest pytest tests -q",
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>=69", "wheel"]
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.0"
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(f"Artifact module path must not traverse a symlink: {module_id}")
49
- module_path = resolve_contained_path(self.run_dir, module_name, "Artifact module path")
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(f"Artifact step path must not traverse a symlink: {step_id}")
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(path, json.dumps(payload, ensure_ascii=True, indent=2) + "\n")
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(payload, ensure_ascii=True, indent=2, allow_nan=False) + "\n"
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("CollectorResult artifacts exceed the aggregate size limit")
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
@@ -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(f"Could not inspect config path {config_path}: {exc}") from exc
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(f"Refusing to replace symlink or reparse point: {config_path}")
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(f"Refusing to replace non-regular config path: {config_path}")
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(f"Could not write config file {config_path}: {exc}") from exc
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(f"Could not inspect config path {config_path}: {exc}") from exc
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(f"Refusing to overwrite symlink or reparse point: {config_path}")
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(f"Refusing to overwrite non-regular config path: {config_path}")
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(f"Could not create config file {config_path}: {exc}") from exc
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