ai-push-hooks 0.2.1 → 0.3.1

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 (37) hide show
  1. package/CHANGELOG.md +73 -1
  2. package/README.md +80 -525
  3. package/SECURITY.md +102 -14
  4. package/ai-push-hooks.toml +9 -2
  5. package/bin/ai-push-hooks.js +6 -6
  6. package/package.json +3 -2
  7. package/pyproject.toml +1 -1
  8. package/src/ai_push_hooks/artifacts.py +67 -13
  9. package/src/ai_push_hooks/config.py +575 -22
  10. package/src/ai_push_hooks/engine.py +116 -7
  11. package/src/ai_push_hooks/executors/apply.py +75 -36
  12. package/src/ai_push_hooks/executors/ask.py +224 -0
  13. package/src/ai_push_hooks/executors/exec.py +17 -801
  14. package/src/ai_push_hooks/executors/runner_workflow.py +478 -0
  15. package/src/ai_push_hooks/executors/runners/__init__.py +78 -0
  16. package/src/ai_push_hooks/executors/runners/claude.py +286 -0
  17. package/src/ai_push_hooks/executors/runners/codex.py +254 -0
  18. package/src/ai_push_hooks/executors/runners/command.py +178 -0
  19. package/src/ai_push_hooks/executors/runners/contracts.py +597 -0
  20. package/src/ai_push_hooks/executors/runners/opencode.py +528 -0
  21. package/src/ai_push_hooks/executors/runners/opencode_support.py +276 -0
  22. package/src/ai_push_hooks/executors/runners/process.py +464 -0
  23. package/src/ai_push_hooks/executors/runners/registry.py +117 -0
  24. package/src/ai_push_hooks/executors/step_commands.py +478 -0
  25. package/src/ai_push_hooks/git_utils.py +834 -0
  26. package/src/ai_push_hooks/hook.py +1 -1
  27. package/src/ai_push_hooks/modules/beads.py +1 -1
  28. package/src/ai_push_hooks/modules/docs.py +129 -89
  29. package/src/ai_push_hooks/modules/pr.py +1 -1
  30. package/src/ai_push_hooks/plugin_loader.py +422 -0
  31. package/src/ai_push_hooks/plugins.py +134 -0
  32. package/src/ai_push_hooks/prompts_builtin.py +9 -2
  33. package/src/ai_push_hooks/types.py +407 -75
  34. package/vendor/README.md +15 -0
  35. package/vendor/requirements.txt +1 -0
  36. package/vendor/tomli-2.4.0-py3-none-any.whl +0 -0
  37. package/src/ai_push_hooks/executors/llm.py +0 -624
package/README.md CHANGED
@@ -1,524 +1,73 @@
1
1
  # ai-push-hooks
2
2
 
3
- `ai-push-hooks` catches repository drift before it reaches a remote. It turns `git push` into a configurable workflow that can inspect the exact outgoing diff, ask an LLM for structured findings, apply narrowly allowlisted documentation fixes, run deterministic actions, and block the push until changes are reviewed and committed.
3
+ **Modular AI checks before `git push`.**
4
4
 
5
- Use it to keep docs aligned with code, check branch/task consistency, or prepare pull requests without replacing your project's ordinary lint, test, and build checks. Workflows are assembled from `collect`, `llm`, `apply`, `exec`, and `assert` steps and default to failing closed.
5
+ Use **OpenCode, Codex, or Claude Code** to review outgoing changes, check that code and docs agree, and apply scoped fixes. Combine AI reasoning with your own scripts, tests, and rules in a repo-local Git hook.
6
6
 
7
- ## Quick start: repo-local hook
7
+ - **Check alignment:** compare changes with documentation, requirements, or task state.
8
+ - **Ask or apply:** get findings without edits, or let AI update explicitly allowed files.
9
+ - **Verify before pushing:** run deterministic checks and block when your rules fail.
10
+ - **Build your workflow:** choose modules, prompts, and runners per step.
8
11
 
9
- ### Prerequisites
10
-
11
- - [Git](https://git-scm.com/downloads) and a POSIX shell for the generated hook.
12
- - [Python 3.10–3.13](https://www.python.org/downloads/). Python is required even when installing the npm wrapper. The wrapper probes Python 3.14, 3.13, 3.12, 3.11, 3.10, then `python`; the 3.14 probe is not a beta support claim. Python 3.10 additionally needs the `tomli` package available to that interpreter.
13
- - [OpenCode](https://opencode.ai/docs/#install) is optional for workflows that use only deterministic steps, but the `minimal-docs` starter uses `llm` and `apply`. Those steps also need a provider/model and authentication; check with `opencode auth list`.
14
- - [GitHub CLI (`gh`)](https://cli.github.com/manual/installation) is optional and needed only for `gh_pr_create`.
15
- - [Beads (`bd`)](https://github.com/steveyegge/beads) is optional and needed only for Beads alignment steps. The integration requires the native `bd` CLI; Beads-Rust (`br`) is not a supported substitute.
16
- - [Lefthook](https://lefthook.dev/installation/) and [Mise](https://mise.jdx.dev/getting-started.html) are optional hook-manager/tool-version alternatives described below.
17
-
18
- ### Install ai-push-hooks
19
-
20
- This is the shortest path. It installs the package, writes the exact starter
21
- configuration filename, and installs a repository-local `pre-push` delegate:
22
-
23
- ```bash
24
- python -m pip install ai-push-hooks==0.2.1
25
- ai-push-hooks init --template minimal-docs
26
- ai-push-hooks install
27
- ```
28
-
29
- The installed-artifact tests exercise the equivalent install and hook sequence
30
- with wheel and npm artifacts in disposable repositories, including a real
31
- local push that succeeds and a second push that is rejected without changing
32
- the bare remote. The first command above can instead be `uv tool install
33
- ai-push-hooks` or `pipx install ai-push-hooks` when using an isolated
34
- application environment.
35
-
36
- These instructions describe the `0.2.1` beta release. npm exposes it through
37
- the `beta` dist-tag; PyPI has no separate beta channel, so Python installation
38
- must select the exact `0.2.1` version. Published `0.1.19` artifacts retain
39
- historical provenance and must not be assumed to contain this release's
40
- `install` command.
41
-
42
- The starter config currently writes `openai/gpt-5.6-terra` as its model value;
43
- availability and authentication are provider-dependent. To use a free OpenCode
44
- Zen model, run `opencode models opencode`, choose a model currently marked
45
- free, and set `[llm].model` to that full identifier. Free-model availability
46
- changes over time, so do not treat the model used in the recorded preview below
47
- as a permanent recommendation or default.
48
-
49
- For npm or pnpm, install and invoke the wrapper locally:
12
+ ## Quick Start
50
13
 
51
14
  ```bash
52
15
  npm install --save-dev ai-push-hooks@beta
53
16
  npx --no-install ai-push-hooks init --template minimal-docs
54
17
  npx --no-install ai-push-hooks install
55
- # or: pnpm add -D ai-push-hooks && pnpm exec ai-push-hooks install
56
18
  ```
57
19
 
58
- Use `ai-push-hooks@0.2.1` instead of `@beta` when an exact npm version pin is
59
- required.
20
+ Choose your [runner and model](#choose-your-ai) in `ai-push-hooks.toml`, then push normally. The starter checks docs, applies fixes, and stops for review if anything changed.
60
21
 
61
- The npm package does not contain a Python runtime. Ensure the Python
62
- requirement above is on `PATH` for the hook process; on Python 3.10 install
63
- `tomli` in that same environment. `npx --no-install` avoids an accidental
64
- registry lookup or global-package fallback.
22
+ [Other installation options](docs/configuration.md#installation) | [Existing hook managers](docs/configuration.md#hook-managers)
65
23
 
66
- `install` resolves Git's effective hook path without changing Git
67
- configuration. It accepts `install [--force]`, creates missing repository-local
68
- hook directories, writes atomically, and makes the delegate executable. An
69
- existing regular hook is refused unless `--force` is explicit; symlinks,
70
- reparse points, FIFOs, directories, external/shared `core.hooksPath` values,
71
- and linked-worktree shared hooks are refused even with `--force`. `--force`
72
- replaces a regular existing hook; it does not merge or back it up. Inspect a
73
- hook before using it and keep a copy if it contains work you need.
24
+ ## Modular By Design
74
25
 
75
- The generated delegate preserves Git's hook arguments, standard input, and
76
- exit status. When installed through the local npm wrapper, it records the
77
- absolute Node and package-script paths, so Git does not need
78
- `node_modules/.bin` on `PATH`; keep that local package installation in place.
79
- Python console installs similarly record their executable when it can be
80
- resolved. The fallback delegate fails clearly with status 127 if
81
- `ai-push-hooks` is not on the hook process's `PATH`.
26
+ A workflow is a list of modules. Each module combines the steps it needs:
82
27
 
83
- ### Mise (pinned tool option)
28
+ | Step | Purpose |
29
+ | --- | --- |
30
+ | `collect` | Gather the outgoing diff and relevant context. |
31
+ | `ask` | Ask AI for plain text or schema-validated JSON. |
32
+ | `apply` | Let AI edit a temporary workspace; copy back only allowlisted changes. |
33
+ | `exec` | Run a command, Python callback, or built-in action. |
34
+ | `assert` | Enforce a rule and block the push if it fails. |
84
35
 
85
- Pin an approved published release in the consuming repository:
36
+ Use a review-only module, an apply-only module, or a full collect/ask/apply/verify flow. Add your existing lint and test commands alongside AI checks. No AI runner is needed for deterministic-only workflows.
86
37
 
87
- ```bash
88
- mise use npm:ai-push-hooks@0.2.1
89
- ```
38
+ ## Choose Your AI
90
39
 
91
- This adds the following project-level tool entry to `mise.toml` and installs it:
40
+ Set the default runner in `ai-push-hooks.toml`. Change it to `codex` or `claude` to switch tools:
92
41
 
93
42
  ```toml
94
- [tools]
95
- "npm:ai-push-hooks" = "0.2.1"
96
- ```
97
-
98
- After checking in `mise.toml`, other contributors can install the pinned tool with `mise install`.
99
-
100
- ### OpenCode isolation limits
101
-
102
- OpenCode runs in `--pure` mode with project configuration disabled, isolated home/config/cache/state directories, sharing disabled, and an ai-push-hooks-owned custom agent configuration. Read-only steps run in an empty scratch directory, receive only hook-owned artifacts through `--file`, and have every tool denied. Apply steps run against a private temporary workspace containing only unignored regular files matching `allow_paths`; their agent permits only reads and allowlisted edits in that workspace. Casefolded, Unicode-normalized `.git` and `AGENTS.md` paths are always protected.
103
-
104
- Built-in OpenCode plugins remain enabled, including built-in authentication plugins such as Codex OAuth. Normal `--pure` execution disables external plugins, while the hook's empty plugin configuration and project-config disablement prevent project and global plugins and configuration from being inherited. The existing XDG data directory is retained for OpenCode authentication/session state, and recognized provider environment variables, including `OPENAI_API_KEY`, are forwarded; OpenCode itself chooses the authentication path using its normal precedence. Custom providers defined only in global OpenCode configuration are therefore unsupported; use a built-in provider with OpenCode auth state or environment credentials.
105
-
106
- After OpenCode session finalization, apply verifies that the Git-visible checkout, index, current-worktree control state, and critical shared `HEAD`/config/packed-refs/refs/hooks state still match their baselines. Pre-existing symlinks in monitored Git metadata fail closed before OpenCode runs, and symlinks introduced during execution fail before propagation. Apply then preflights every destination against its exact baseline type, content digest, and mode before propagating anything, performs atomic file replacement, and verifies the resulting checkout and protected Git state again. Safe existing ordinary `rwx` modes are preserved, existing special bits are stripped, new or group/world-writable modes become owner-only, and staged files carrying setuid/setgid/sticky bits are rejected before any propagation. Hook-owned runtime files default to `0600` and runtime directories to `0700`.
107
-
108
- These controls are OpenCode permission and workspace isolation, not an operating-system sandbox. Compare-and-swap preflight minimizes lost updates but cannot make the interval between preflight and filesystem replacement atomic against an independent local process. Ignored worktree trees, Git object/LFS stores, shared reflogs, and metadata belonging only to other linked worktrees are intentionally excluded from bounded snapshots; direct changes there may not be detected. Critical shared refs/config/hooks remain monitored. Automatic rollback is avoided so pre-existing user changes are not overwritten.
109
-
110
- ### Beads maintenance boundary
111
-
112
- The `beads_alignment` executor is for ordinary native `bd update` and `bd close`
113
- operations only. It does not run migrations, synchronize embedded Dolt, or
114
- publish Dolt refs. Hook-launched Beads commands also discard
115
- `BD_ALLOW_REMOTE_MIGRATE`, `BD_IGNORE_SCHEMA_SKEW`, and `BD_SMART_GATE` from
116
- their inherited environment so an operator maintenance override cannot leak
117
- into a push hook.
118
-
119
- Treat a Beads schema migration as separate operator maintenance. Pin and
120
- verify the native `bd` version, stop Beads writers and hooks, take a cold full
121
- backup of `.beads`, and rehearse against a disposable copy before opening the
122
- live embedded-Dolt store. Verify the schema, semantic issue/dependency data,
123
- memories, and a clean Dolt working set before and after the live cutover.
124
- Remote publication, including `bd dolt push`, is a separate explicit action;
125
- a successful local migration does not authorize it. Do not replace this flow
126
- with Beads-Rust (`br`).
127
-
128
- ## Full-featured alternative: Lefthook
129
-
130
- Use Lefthook when the repository needs several hook commands, shared hook
131
- configuration, or repository-managed installation. Keep one final
132
- `ai-push-hooks hook` call and forward Git's pre-push input:
133
-
134
- Create `scripts/hooks/pre-push-runner.sh` in the consuming repository with:
135
-
136
- ```yaml
137
- pre-push:
138
- commands:
139
- repository-pre-push:
140
- run: bash scripts/hooks/pre-push-runner.sh {1} {2}
141
- use_stdin: true
142
- ```
143
-
144
- The runner captures stdin before deterministic checks consume it, then replays
145
- it to the tool:
146
-
147
- ```bash
148
- #!/usr/bin/env bash
149
- set -euo pipefail
150
- remote_name="${1:-}"
151
- remote_url="${2:-}"
152
- push_stdin="$(mktemp)"
153
- trap 'rm -f "$push_stdin"' EXIT
154
- cat >"$push_stdin"
155
- git diff --check
156
- mise exec -- ai-push-hooks hook "$remote_name" "$remote_url" <"$push_stdin"
157
- ```
158
-
159
- Install and verify Lefthook in the consuming repository:
160
-
161
- ```bash
162
- lefthook version
163
- chmod +x scripts/hooks/pre-push-runner.sh
164
- lefthook install
165
- test -x "$(git rev-parse --git-path hooks/pre-push)" && echo "pre-push hook installed"
166
- ```
167
-
168
- `use_stdin: true` forwards Git's ref-update stream; `{1}` and `{2}` are the
169
- remote name and URL. This is the full-featured alternative to the small
170
- repo-local `install` delegate. Do not install both managers for the same hook
171
- unless their chaining is deliberate. The repository's deterministic installed
172
- hook coverage validates the delegate contract; Lefthook remains the tested
173
- repository-owned integration pattern and should be exercised with the commands
174
- above in each consuming repository.
175
-
176
- Configure modules and steps in the [configuration reference](#configuration-reference), then push as usual. If `apply` edits an allowlisted file, the starter assertion blocks that push so you can inspect `git diff`, validate, commit the approved edit, and push again.
177
-
178
- ## Troubleshooting
179
-
180
- - **`opencode is required but not installed`:** install OpenCode and ensure `opencode` (or `opencode-cli`) is on `PATH` for the Git hook process.
181
- - **Provider/model authentication fails:** run `opencode auth list`, authenticate a built-in provider, and verify `[llm].model`. Built-in auth plugins remain available, while project/global custom-provider configuration is intentionally not loaded. Recognized provider environment variables, including `OPENAI_API_KEY`, are forwarded and OpenCode chooses authentication. See [OpenCode isolation limits](#opencode-isolation-limits).
182
- - **The hook does not run:** rerun `lefthook install`, check `git config --get core.hooksPath`, and verify the pre-push path with the command above.
183
- - **The push is blocked after docs changed:** this is the expected edit-review-commit flow. Review `git diff`, validate and commit the changes, then push again.
184
- - **Find logs or transcripts:** inspect `.git/ai-push-hooks/logs`, `.git/ai-push-hooks/summaries`, and (when enabled) `.git/ai-push-hooks/transcripts`.
185
- - **Temporarily skip intentionally:** set `AI_PUSH_HOOKS_SKIP=1` for one invocation. Treat bypasses as an explicit project-policy decision.
186
-
187
- ### Failure, fail-open, and skip semantics
188
-
189
- - Fail closed is the default: configuration, collection, model, apply, exec,
190
- and assertion errors return nonzero and block the push. A rejected push does
191
- not update the remote.
192
- - Set `[general].allow_push_on_error = true`, or use
193
- `AI_PUSH_HOOKS_ALLOW_PUSH_ON_ERROR=1`, only as an explicit fail-open policy.
194
- The error is still logged and a warning is emitted, but the push is allowed.
195
- - `AI_PUSH_HOOKS_SKIP=1` exits before repository/config access and allows that
196
- invocation. `general.enabled = false` is a configured disable after config
197
- loading/log initialization. Neither path validates or runs the workflow.
198
- - `skip_on_sync_branch = true` can skip configured sync-branch context, and
199
- step/module conditions can report a normal skip. A skip is not a successful
200
- model or apply result.
201
-
202
- Git supplies hook stdin as one line per ref update:
203
- `<local-ref> <local-object-id> <remote-ref> <remote-object-id>`. New remote
204
- refs use a zero object ID (40 zeroes for the tested SHA-1 repositories); deletion
205
- uses `(delete)` and a zero local ID. The hook rejects malformed input, unknown
206
- objects, and multiple non-deleted branch updates rather than silently choosing
207
- a branch.
208
-
209
- ### Safe removal of the generated hook
210
-
211
- `install` has no uninstall command and never edits Git configuration. To remove
212
- only its delegate, first inspect the effective target and content:
213
-
214
- ```bash
215
- hook="$(git rev-parse --git-path hooks)/pre-push"
216
- if [ -f "$hook" ]; then sed -n '1,20p' "$hook"; fi
217
- ```
218
-
219
- For an exact check before removal, use the installed Python package rather than
220
- matching only a filename:
221
-
222
- ```bash
223
- python - "$hook" <<'PY'
224
- import pathlib
225
- import sys
226
-
227
- from ai_push_hooks.install import pre_push_hook_script
228
-
229
- path = pathlib.Path(sys.argv[1])
230
- if not path.is_file() or path.is_symlink():
231
- raise SystemExit("not a regular hook; nothing removed")
232
- if path.read_text(encoding="utf-8") != pre_push_hook_script():
233
- raise SystemExit("hook is not the ai-push-hooks delegate; nothing removed")
234
- path.unlink()
235
- print(f"removed {path}")
236
- PY
237
- ```
238
-
239
- Remove it only after confirming it is the generated `ai-push-hooks` delegate,
240
- not a Lefthook or shared hook. If it contains other commands, preserve it and
241
- remove only the documented ai-push-hooks entry instead. Do not run `rm` on an
242
- uninspected `pre-push` path.
243
-
244
- ## Security and privacy
245
-
246
- Repository diffs, selected context, and prompts may be sent by OpenCode to the configured model provider. Review that provider's data-handling terms and do not include secrets in commits or prompts. Transcripts are stored locally by default under `.git/ai-push-hooks/transcripts`; sharing is disabled and OpenCode sessions are deleted after each run by default. If transcript export fails, the run warns and still follows the configured deletion policy; do not assume an export exists. See [SECURITY.md](SECURITY.md) for reporting, the threat model, data handling, and sandbox limitations.
247
-
248
- ### BR-06 provider evidence (limited preview)
249
-
250
- At the time of the recorded synthetic provider run, OpenCode **1.18.29** listed
251
- `opencode/muse-spark-1.3-contributor-free` as free. Synthetic `query` and
252
- `analyze` steps passed at **zero reported cost**. This is evidence for that
253
- specific OpenCode/model path and synthetic inputs only; it does not establish
254
- that every model, provider, authentication mode, or live `apply` operation is
255
- compatible. Discover the current catalog with `opencode models opencode` and
256
- verify pricing before each run; free models can be renamed, replaced, or
257
- removed. The tested identifier is historical evidence, not a new default or a
258
- promise of future availability. Review provider billing, retention, and
259
- transmission terms before using repository content.
260
-
261
- ## Tested matrix and beta boundary
262
-
263
- The current candidate was exercised on **macOS Darwin 24.6.0 arm64** with
264
- Python **3.12.13**, Node **24.19.0**, npm **10.9.2**, Git **2.55.0**, Ruff
265
- **0.13.3**, OpenCode **1.18.29**, `gh` **2.93.0**, and `bd` **1.2.2**. The
266
- wheel and packed npm installed-hook tests use disposable repositories, full
267
- 40-character Git object IDs, a local bare remote, and a minimal PATH. The
268
- Lefthook **2.1.9** was also run in a disposable repository to install a
269
- pre-push hook and verify argument/stdin forwarding. The real OpenCode contract
270
- also passed in a Linux arm64 Docker container launched from this macOS host.
271
- Python 3.10, 3.11, and 3.13 and Node 18 were not available in this validation
272
- environment and are not claimed as locally run; their jobs remain part of the
273
- GitHub Actions matrix.
274
-
275
- Prior recorded BR evidence also covers the real OpenCode 1.18.29 CLI with a
276
- loopback mock provider inside a Linux Docker runtime with networking disabled.
277
- That is mock-provider permission/workspace evidence, not live-provider or
278
- operating-system-sandbox evidence.
279
-
280
- Validation results for this snapshot are **273 passed, 1 skipped** for the full
281
- Python suite, **8 passed** for install-unit coverage, **2 passed** for installed
282
- wheel and npm hook coverage against the exact release artifacts, a passing
283
- `npm run test:npm-pack`, and a passing Lefthook 2.1.9 disposable
284
- argument/stdin-forwarding check. The Docker contract passed against the real
285
- OpenCode 1.18.29 CLI with runtime networking disabled and a loopback mock
286
- provider.
287
-
288
- Python 3.10–3.13 and Node 18+ remain the declared compatibility ranges, not a
289
- claim that every patch/platform combination has passed. Windows has no native
290
- beta evidence and is explicitly untested/not supported for this beta. The
291
- generated hook and documented runner require a POSIX shell; defensive path
292
- handling is not Windows validation.
293
-
294
- ## Synthetic demo and evidence
295
-
296
- For a no-secrets, no-external-model-call wiring/permission demo, run:
43
+ [llm]
44
+ runner = "opencode"
297
45
 
298
- ```bash
299
- bash scripts/opencode-contract-smoke.sh
300
- ```
301
-
302
- It builds a disposable image, starts only an in-process loopback mock provider,
303
- and drives real OpenCode 1.18.29 through a synthetic repository. Runtime
304
- networking is disabled, so no external model call is possible; the initial
305
- Docker build/setup may need network access to fetch its pinned inputs. It shows
306
- the allowlisted `README.md` edit, denied outside/protected edits, and unchanged
307
- protected Git metadata. This is **wiring and permission evidence only**, not a
308
- live-provider demo or OS-sandbox claim; it exits 2 when Docker is unavailable.
46
+ [runners.opencode]
47
+ type = "opencode"
48
+ model = "provider/model-id" # Replace with an available OpenCode model.
49
+ project_access = "artifacts"
309
50
 
310
- For installed hook wiring without any model/provider call:
51
+ [runners.codex]
52
+ type = "codex"
311
53
 
312
- ```bash
313
- python -m pytest -q tests/test_installed_hook_e2e.py
314
- npm run test:npm-pack
54
+ [runners.claude]
55
+ type = "claude"
315
56
  ```
316
57
 
317
- These disposable fixtures show a successful local push followed by a
318
- fail-closed rejection. The actual BR-06 provider preview is documented above;
319
- it used synthetic query/analyze inputs and must not be presented as fabricated
320
- provider output or as evidence for live `apply`.
321
-
322
- ## Portfolio case study: bounded documentation maintenance
323
-
324
- **Problem.** A pushed code change can make repository documentation stale
325
- before review notices it. The hook inspects the outgoing ref range rather than
326
- the checked-out branch alone.
58
+ Each `ask` or `apply` step can override the default with, for example, `runner = "claude"`. You can review with one tool and apply with another.
327
59
 
328
- **Architecture and tradeoffs.** Deterministic `collect` steps establish diff,
329
- changed-file, and repository context before `llm` query/analyze steps. An
330
- `apply` step receives a private workspace and a narrow docs allowlist; the
331
- assertion then blocks the push for human review and commit. `exec` and `assert`
332
- remain available for deterministic repository actions. This ordering limits
333
- model scope without pretending to provide an OS sandbox.
60
+ OpenCode defaults to collected artifacts only. Set `project_access = "project"` for repository reads; Codex and Claude default to project access. Each runner uses its own authentication and model identifiers.
334
61
 
335
- **Evidence.** The real OpenCode 1.18.29 mock-provider contract found a
336
- version-specific permission mapping (`write` requests `edit`) and covers
337
- allowlisted propagation plus protected Git metadata. The BR-06 live synthetic
338
- preview used `opencode/muse-spark-1.3-contributor-free` for query/analyze at
339
- zero reported cost; it is not evidence for all providers/models or live apply.
340
- Installed wheel/npm tests cover hook wiring, a successful local push, and a
341
- fail-closed rejection. The Docker contract was not rerun in this environment.
342
-
343
- **Limitations.** Provider availability, billing, retention, OS-level access,
344
- ignored trees, shared metadata, and independent filesystem races remain outside
345
- the product guarantee. Review, validate, and commit any proposed edit; this is
346
- not unattended autonomous maintenance.
347
-
348
- ## Commands
349
-
350
- If installed as a local npm/pnpm dependency, run commands with `npx --no-install` or `pnpm exec`.
351
-
352
- | Command | What it does |
353
- | --- | --- |
354
- | `ai-push-hooks hook <remote-name> <remote-url>` | Runs the configured pre-push workflow. |
355
- | `ai-push-hooks init --template minimal-docs` | Writes `ai-push-hooks.toml` starter config. |
356
- | `ai-push-hooks init --template minimal-docs --force` | Overwrites an existing config file. |
357
- | `ai-push-hooks install [--force]` | Installs a repo-local executable pre-push delegate; `--force` replaces a regular existing hook. |
358
-
359
- ## Configuration overview
360
-
361
- - Config file: `ai-push-hooks.toml` in repo root (required).
362
- - Prompt resolution precedence for `llm` and `apply` steps:
363
- 1. `prompt`
364
- 2. `prompt_file`
365
- 3. `fallback_prompt_id`
366
-
367
- ## Configuration reference
368
-
369
- ### Top-level keys
370
-
371
- | Key | Type | Required | Default |
372
- | --- | --- | --- | --- |
373
- | `general` | table | no | see section defaults |
374
- | `llm` | table | no | see section defaults |
375
- | `logging` | table | no | see section defaults |
376
- | `workflow` | table | yes | n/a |
377
- | `modules` | table | yes | n/a |
378
-
379
- ### `[general]`
380
-
381
- | Key | Type | Default | Description |
382
- | --- | --- | --- | --- |
383
- | `enabled` | bool | `true` | Enables or disables the hook globally. |
384
- | `allow_push_on_error` | bool | `false` | If `true`, push continues even when workflow fails. |
385
- | `require_clean_worktree` | bool | `false` | If `true`, aborts when local changes exist. |
386
- | `skip_on_sync_branch` | bool | `true` | If `true`, skips on sync branch/worktree context. |
387
- | `base_branch` | string | `"main"` | Base branch used for new-branch range fallback and default PR base/context. |
388
-
389
- ### `[llm]`
390
-
391
- | Key | Type | Default | Description |
392
- | --- | --- | --- | --- |
393
- | `runner` | string | `"opencode"` | LLM runner label (currently OpenCode flow). |
394
- | `model` | string | `"openai/gpt-5.6-terra"` | Model passed to OpenCode. |
395
- | `variant` | string | `""` | Optional OpenCode variant. |
396
- | `timeout_seconds` | int | `800` | Timeout per LLM invocation and related OpenCode calls. |
397
- | `max_parallel` | int | `2` | Max concurrent read-only steps (`collect`, `llm`). |
398
- | `json_max_retries` | int | `2` | Retry count for invalid JSON responses. |
399
- | `invalid_json_feedback_max_chars` | int | `6000` | Max invalid output included in retry feedback. |
400
- | `json_retry_new_session` | bool | `true` | Starts a new OpenCode session on JSON retry. |
401
- | `delete_session_after_run` | bool | `true` | Deletes OpenCode sessions after completion. |
402
- | `max_diff_bytes` | int | `180000` | Max bytes of git diff sent into workflow artifacts. |
403
- | `session_title_prefix` | string | `"ai-push-hooks"` | Prefix for OpenCode session titles. |
404
-
405
- ### `[logging]`
406
-
407
- | Key | Type | Default | Description |
408
- | --- | --- | --- | --- |
409
- | `level` | string | `"status"` | Console verbosity (`status`, `info`, `debug`). |
410
- | `jsonl` | bool | `true` | Enables JSONL event logging. |
411
- | `dir` | string | `".git/ai-push-hooks/logs"` | Directory for `hook.jsonl`. |
412
- | `capture_llm_transcript` | bool | `true` | Exports OpenCode session transcripts. |
413
- | `transcript_dir` | string | `".git/ai-push-hooks/transcripts"` | Transcript export directory. |
414
- | `summary_dir` | string | `".git/ai-push-hooks/summaries"` | Per-run summary JSON directory. |
415
- | `print_llm_output` | bool | `false` | Mirrors raw OpenCode JSON stream to stdout. |
416
-
417
- ### `[workflow]`
418
-
419
- | Key | Type | Required | Description |
420
- | --- | --- | --- | --- |
421
- | `modules` | array of strings | yes | Ordered module IDs to run. Must contain at least one module and each ID must exist under `[modules]`. |
422
-
423
- ### `[modules.<module_id>]`
424
-
425
- | Key | Type | Required | Description |
426
- | --- | --- | --- | --- |
427
- | `enabled` | bool | no | Enables or disables that module. Default `true`. |
428
- | `steps` | array of step tables | yes | Ordered workflow steps for the module. Must be non-empty. |
429
-
430
- ### `[[modules.<module_id>.steps]]`
431
-
432
- | Key | Type | Required | Applies to | Description |
433
- | --- | --- | --- | --- | --- |
434
- | `id` | string | yes | all step types | Unique step identifier inside the module. |
435
- | `type` | string | yes | all step types | One of: `collect`, `llm`, `apply`, `exec`, `assert`. |
436
- | `inputs` | array of strings | no | non-`collect` steps | Artifact references from earlier steps. |
437
- | `output` | string | yes | `llm` | Output artifact filename (often `.json`). |
438
- | `schema` | string | no | `llm` | Validates parsed model output shape. |
439
- | `prompt` | string | conditional | `llm`, `apply` | Highest-priority prompt source. |
440
- | `prompt_file` | string | conditional | `llm`, `apply` | Repo-relative prompt file path; absolute, traversing, and symlinked paths are rejected. |
441
- | `fallback_prompt_id` | string | conditional | `llm`, `apply` | Built-in prompt ID used when no higher source resolves. |
442
- | `collector` | string | yes | `collect` | Collector handler ID. |
443
- | `allow_paths` | array of strings | yes | `apply` | File glob allowlist for edits. |
444
- | `executor` | string | yes | `exec` | Exec handler ID. |
445
- | `assertion` | string | yes | `assert` | Assertion handler ID. |
446
- | `when_env` | string | no | any step | Runs step only when env var parses as true. |
447
-
448
- `llm` and `apply` are promptable step types: at least one of `prompt`, `prompt_file`, or `fallback_prompt_id` must be set.
449
-
450
- Artifact references in `inputs` are module-local. Use `<step>/<artifact>` to reference an artifact produced by an earlier step in the same module (for example, `collect/push.diff` or `analyze/issues.json`). Cross-module references such as `docs:collect/push.diff` are not currently supported.
451
-
452
- ### Supported handler and schema values
453
-
454
- #### Collectors
455
-
456
- | Value | Purpose |
457
- | --- | --- |
458
- | `docs_context` | Collects docs-related context and diff artifacts. |
459
- | `beads_status_context` | Collects branch/beads alignment context. |
460
- | `pr_context` | Collects PR composition context. |
62
+ Other tools, including Pi, can use a [custom command runner](docs/configuration.md#custom-runners).
461
63
 
462
- #### LLM schemas
463
-
464
- | Value | Expected payload |
465
- | --- | --- |
466
- | `string_array` | JSON array of strings. |
467
- | `docs_issue_array` | JSON array of issue objects with at least `file` and `description`. |
468
- | `beads_alignment_result` | JSON object, optionally with `commands` string array. |
469
- | `pr_create_payload` | JSON object for PR creation fields. |
470
-
471
- #### Exec handlers
472
-
473
- | Value | Purpose |
474
- | --- | --- |
475
- | `beads_alignment` | Runs non-interactive Beads commands and writes action report when needed. |
476
- | `gh_pr_create` | Creates (or reuses) a GitHub PR via `gh`. |
477
-
478
- #### Assertion handlers
479
-
480
- | Value | Purpose |
481
- | --- | --- |
482
- | `docs_apply_requires_manual_commit` | Fails when docs were auto-edited and still need user review/commit. |
483
- | `beads_alignment_clean` | Fails when Beads alignment reports unresolved work. |
484
-
485
- #### Built-in fallback prompt IDs
486
-
487
- | Value | Purpose |
488
- | --- | --- |
489
- | `docs-query-basic` | Generate doc search queries from diff. |
490
- | `docs-analysis-basic` | Identify factual documentation drift. |
491
- | `docs-apply-basic` | Apply minimal doc fixes within allowlist. |
492
- | `beads-plan-basic` | Build Beads alignment command/report payload. |
493
- | `pr-compose-basic` | Draft PR title/body/base/head payload. |
64
+ ## Example: Docs Alignment
494
65
 
495
- ## Environment variable overrides
496
-
497
- Boolean env parsing accepts: `1`, `true`, `yes`, `y`, `on` and `0`, `false`, `no`, `n`, `off`.
498
-
499
- | Env var | Effect |
500
- | --- | --- |
501
- | `AI_PUSH_HOOKS_SKIP` | If true, sets `general.enabled = false`. |
502
- | `AI_PUSH_HOOKS_ALLOW_PUSH_ON_ERROR` | Overrides `general.allow_push_on_error`. |
503
- | `AI_PUSH_HOOKS_REQUIRE_CLEAN` | Overrides `general.require_clean_worktree`. |
504
- | `AI_PUSH_HOOKS_ALLOW_DIRTY` | If true, forces `general.require_clean_worktree = false`. |
505
- | `AI_PUSH_HOOKS_BASE_BRANCH` | Overrides `general.base_branch`. |
506
- | `AI_PUSH_HOOKS_LOG_LEVEL` | Overrides `logging.level`. |
507
- | `AI_PUSH_HOOKS_PRINT_LLM_OUTPUT` | Overrides `logging.print_llm_output`. |
508
- | `AI_PUSH_HOOKS_MODEL` | Overrides `llm.model`. |
509
- | `AI_PUSH_HOOKS_VARIANT` | Overrides `llm.variant`. |
510
- | `AI_PUSH_HOOKS_TIMEOUT_SECONDS` | Overrides `llm.timeout_seconds` (integer). |
511
-
512
- `when_env` is step-level and can point to any env var. A common example is `AI_PUSH_HOOKS_CREATE_PR` to gate PR creation steps.
513
-
514
- ## Example: docs + PR with opt-in creation
66
+ This workflow asks AI to find documentation drift, applies narrow fixes, and requires human review when files change. Use it with the runner settings above:
515
67
 
516
68
  ```toml
517
69
  [workflow]
518
- modules = ["docs", "pr"]
519
-
520
- [modules.docs]
521
- enabled = true
70
+ modules = ["docs"]
522
71
 
523
72
  [[modules.docs.steps]]
524
73
  id = "collect"
@@ -526,54 +75,60 @@ type = "collect"
526
75
  collector = "docs_context"
527
76
 
528
77
  [[modules.docs.steps]]
529
- id = "query"
530
- type = "llm"
531
- fallback_prompt_id = "docs-query-basic"
532
- inputs = ["collect/push.diff", "collect/changed-files.txt"]
533
- output = "queries.json"
534
- schema = "string_array"
535
-
536
- [[modules.docs.steps]]
537
- id = "analyze"
538
- type = "llm"
539
- fallback_prompt_id = "docs-analysis-basic"
540
- inputs = ["collect/push.diff", "collect/docs-context.txt", "query/queries.json", "collect/recent-commits.txt"]
78
+ id = "review"
79
+ type = "ask"
80
+ prompt = "Check that the docs match the outgoing code changes. Return a JSON array of factual issues with file and description fields, or [] if aligned."
81
+ inputs = ["collect/push.diff", "collect/docs-context.txt"]
541
82
  output = "issues.json"
542
83
  schema = "docs_issue_array"
543
84
 
544
85
  [[modules.docs.steps]]
545
- id = "apply"
86
+ id = "fix"
546
87
  type = "apply"
547
- fallback_prompt_id = "docs-apply-basic"
548
- inputs = ["collect/push.diff", "collect/docs-context.txt", "analyze/issues.json"]
88
+ prompt = "Fix only the reported documentation drift. Keep edits minimal."
89
+ inputs = ["collect/push.diff", "review/issues.json"]
549
90
  allow_paths = ["README.md", "docs/**/*.md"]
550
91
 
551
92
  [[modules.docs.steps]]
552
- id = "assert"
93
+ id = "review-required"
553
94
  type = "assert"
554
95
  assertion = "docs_apply_requires_manual_commit"
555
- inputs = ["apply/result.json"]
96
+ inputs = ["fix/result.json"]
97
+ ```
556
98
 
557
- [modules.pr]
558
- enabled = true
99
+ **Want findings only?** Remove the `fix` and `review-required` steps. `ask` saves a response; findings do not block a push unless you add an assertion to evaluate them.
559
100
 
560
- [[modules.pr.steps]]
561
- id = "collect"
562
- type = "collect"
563
- collector = "pr_context"
564
-
565
- [[modules.pr.steps]]
566
- id = "compose"
567
- type = "llm"
568
- fallback_prompt_id = "pr-compose-basic"
569
- inputs = ["collect/pr-context.txt", "collect/changed-files.txt", "collect/push.diff", "collect/commits.txt"]
570
- output = "pr-draft.json"
571
- schema = "pr_create_payload"
572
-
573
- [[modules.pr.steps]]
574
- id = "create"
101
+ **Want test verification too?** Change the workflow list to `modules = ["docs", "verify"]` and append a module using your project's test command:
102
+
103
+ ```toml
104
+ [[modules.verify.steps]]
105
+ id = "tests"
575
106
  type = "exec"
576
- executor = "gh_pr_create"
577
- when_env = "AI_PUSH_HOOKS_CREATE_PR"
578
- inputs = ["compose/pr-draft.json"]
107
+ command = ["npm", "test"]
108
+ timeout_seconds = 300
579
109
  ```
110
+
111
+ A nonzero test exit blocks the push. For requirements or plan alignment, use project access and a prompt such as: "Compare the outgoing diff with docs/requirements.md. Identify unmet acceptance criteria and missing tests." AI review complements verification; it does not replace running the tests.
112
+
113
+ ## Extend It
114
+
115
+ - **Prompts:** write them inline, load a `prompt_file`, or use a built-in prompt.
116
+ - **Custom checks:** use argv commands or repo-local Python callbacks.
117
+ - **Task alignment:** use the Beads collector and actions with the optional `bd` CLI.
118
+ - **Pull requests:** compose a PR with AI and create it with the optional `gh` CLI.
119
+ - **Opt-in steps:** gate a step with `when_env`.
120
+
121
+ See the [configuration guide](docs/configuration.md) for settings, callbacks, and built-in handlers.
122
+
123
+ ## Control And Safety
124
+
125
+ - Errors block pushes by default. Review and commit any applied edits before retrying.
126
+ - `apply` checks file and Git state before and after copying allowlisted changes back. It does not auto-commit.
127
+ - Runners, scripts, and callbacks are local programs, not an OS sandbox. Use only trusted configuration.
128
+ - Repository content may be sent to your runner's model provider. Review its privacy and billing terms.
129
+
130
+ Logs and run summaries live under `.git/ai-push-hooks/`. OpenCode transcripts are captured there by default. See [Security](SECURITY.md) for access and data-handling details.
131
+
132
+ To intentionally skip one push: `AI_PUSH_HOOKS_SKIP=1 git push`.
133
+
134
+ [Configuration](docs/configuration.md) | [Contributing](CONTRIBUTING.md) | [Changelog](CHANGELOG.md) | [MIT License](LICENSE)