ai-push-hooks 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,8 @@
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
+ `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 a selected local runner for structured findings, apply narrowly allowlisted documentation fixes, run deterministic actions, and block the push until changes are reviewed and committed.
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 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`, `ask`, `apply`, `exec`, and `assert` steps and default to failing closed.
6
6
 
7
7
  ## Quick start: repo-local hook
8
8
 
@@ -10,9 +10,9 @@ Use it to keep docs aligned with code, check branch/task consistency, or prepare
10
10
 
11
11
  - [Git](https://git-scm.com/downloads) and a POSIX shell for the generated hook.
12
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`.
13
+ - A runner CLI is optional for workflows that use only deterministic steps, but the `minimal-docs` starter uses OpenCode for `ask` and `apply`. The selected runner still needs its normal provider/model authentication: for OpenCode, `opencode auth list` is a useful check; Codex, Claude, and custom commands use their own user-managed setup.
14
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.
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
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
17
 
18
18
  ### Install ai-push-hooks
@@ -21,7 +21,7 @@ This is the shortest path. It installs the package, writes the exact starter
21
21
  configuration filename, and installs a repository-local `pre-push` delegate:
22
22
 
23
23
  ```bash
24
- python -m pip install ai-push-hooks==0.2.0
24
+ python -m pip install ai-push-hooks==0.3.0
25
25
  ai-push-hooks init --template minimal-docs
26
26
  ai-push-hooks install
27
27
  ```
@@ -33,18 +33,19 @@ the bare remote. The first command above can instead be `uv tool install
33
33
  ai-push-hooks` or `pipx install ai-push-hooks` when using an isolated
34
34
  application environment.
35
35
 
36
- These instructions describe the `0.2.0` beta release. npm exposes it through
36
+ These instructions describe the `0.3.0` beta release. npm exposes it through
37
37
  the `beta` dist-tag; PyPI has no separate beta channel, so Python installation
38
- must select the exact `0.2.0` version. Published `0.1.19` artifacts retain
38
+ must select the exact `0.3.0` version. Published `0.1.19` artifacts retain
39
39
  historical provenance and must not be assumed to contain this release's
40
40
  `install` command.
41
41
 
42
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.
43
+ availability, pricing, and authentication are provider-dependent. Model strings
44
+ are opaque runner-specific identifiers; ai-push-hooks does not maintain a model
45
+ allowlist. To use a free OpenCode Zen model, run `opencode models opencode`,
46
+ choose a model currently marked free, and set `[llm].model` to that full
47
+ identifier. Free-model availability changes over time, so do not treat the model
48
+ used in the recorded preview below as a permanent recommendation or default.
48
49
 
49
50
  For npm or pnpm, install and invoke the wrapper locally:
50
51
 
@@ -55,7 +56,7 @@ npx --no-install ai-push-hooks install
55
56
  # or: pnpm add -D ai-push-hooks && pnpm exec ai-push-hooks install
56
57
  ```
57
58
 
58
- Use `ai-push-hooks@0.2.0` instead of `@beta` when an exact npm version pin is
59
+ Use `ai-push-hooks@0.3.0` instead of `@beta` when an exact npm version pin is
59
60
  required.
60
61
 
61
62
  The npm package does not contain a Python runtime. Ensure the Python
@@ -63,6 +64,19 @@ requirement above is on `PATH` for the hook process; on Python 3.10 install
63
64
  `tomli` in that same environment. `npx --no-install` avoids an accidental
64
65
  registry lookup or global-package fallback.
65
66
 
67
+ ### Version boundary
68
+
69
+ **Published `0.3.0` beta.** This release uses `type = "ask"` (not `llm` or
70
+ `agent`), adds repository-local Python callbacks and direct `exec`/`assert`
71
+ commands, and supports named runner profiles. The previous `0.2.1` beta used
72
+ `type = "llm"`; there is no compatibility alias, so update configurations when
73
+ upgrading to this release.
74
+
75
+ **Deferred/proposed.** Automatic discovery, installed-module references,
76
+ package-relative hook loading, a plugin SDK, remote services, sandboxing, and a
77
+ universal external-agent observer remain out of scope. Architecture-doc
78
+ changes are deferred; this README documents usage and trust boundaries only.
79
+
66
80
  `install` resolves Git's effective hook path without changing Git
67
81
  configuration. It accepts `install [--force]`, creates missing repository-local
68
82
  hook directories, writes atomically, and makes the delegate executable. An
@@ -85,27 +99,439 @@ resolved. The fallback delegate fails clearly with status 127 if
85
99
  Pin an approved published release in the consuming repository:
86
100
 
87
101
  ```bash
88
- mise use npm:ai-push-hooks@0.2.0
102
+ mise use npm:ai-push-hooks@0.3.0
89
103
  ```
90
104
 
91
105
  This adds the following project-level tool entry to `mise.toml` and installs it:
92
106
 
93
107
  ```toml
94
108
  [tools]
95
- "npm:ai-push-hooks" = "0.2.0"
109
+ "npm:ai-push-hooks" = "0.3.0"
96
110
  ```
97
111
 
98
112
  After checking in `mise.toml`, other contributors can install the pinned tool with `mise install`.
99
113
 
100
- ### OpenCode isolation limits
114
+ ### Runner profiles and access modes
115
+
116
+ > **Published in the `0.3.0` beta:** selectable runner profiles are available
117
+ > in both the Python wheel and npm package. The default OpenCode profile retains
118
+ > its artifact-only compatibility boundary; project access remains explicit.
119
+
120
+ `ask` and `apply` steps select a strict named profile. `[llm].runner` is the
121
+ workflow default; a `runner` on an individual `ask` or `apply` step overrides it.
122
+ `runner` is rejected on `collect`, `exec`, and `assert`. Every referenced name
123
+ must exist under `[runners.<name>]`, except `opencode`, which has an implicit
124
+ compatibility profile. Unknown profile fields, missing names, invalid
125
+ placeholders, and type-inapplicable fields fail during config loading rather
126
+ than being ignored.
127
+
128
+ The four static profile types are `opencode`, `codex`, `claude`, and `command`.
129
+ `model` is an opaque identifier passed to the selected runner. A profile's
130
+ `project_access` is either `artifacts` or `project`; it defaults to `artifacts`
131
+ for OpenCode and `project` for the other three types. The existing flat
132
+ `[llm]` form remains the shipped default:
133
+
134
+ ```toml
135
+ [llm]
136
+ runner = "opencode"
137
+ model = "openai/gpt-5.6-terra"
138
+ variant = ""
139
+ ```
140
+
141
+ It is equivalent to an OpenCode profile with `project_access = "artifacts"`.
142
+ That means read-only analysis uses an empty scratch directory with only
143
+ validated hook artifacts attached, with OpenCode tools denied, and compatibility
144
+ apply uses a private staging projection limited to eligible files matching its
145
+ allowlist. It does **not** silently become project-aware. In explicit OpenCode
146
+ project mode, read/list/glob/grep can inspect the real checkout for analysis;
147
+ edits, shell, tasks, web, sharing, plugins, MCP, and project/global config remain
148
+ restricted.
149
+ Opt into OpenCode project reads explicitly:
150
+
151
+ ```toml
152
+ [llm]
153
+ runner = "opencode-project"
154
+
155
+ [runners.opencode-project]
156
+ type = "opencode"
157
+ model = "openai/gpt-5.6-terra"
158
+ project_access = "project"
159
+ ```
160
+
161
+ The other built-in examples are:
162
+
163
+ ```toml
164
+ [llm]
165
+ runner = "codex-review"
166
+
167
+ [runners.codex-review]
168
+ type = "codex"
169
+ model = "gpt-5.6-codex"
170
+ project_access = "project"
171
+
172
+ [runners.claude-review]
173
+ type = "claude"
174
+ model = "sonnet"
175
+ project_access = "project"
176
+
177
+ [[modules.docs.steps]]
178
+ id = "analyze"
179
+ type = "ask"
180
+ runner = "claude-review" # per-step override
181
+ fallback_prompt_id = "docs-analysis-basic"
182
+ inputs = ["collect/push.diff"]
183
+ output = "issues.json"
184
+ schema = "docs_issue_array"
185
+ ```
186
+
187
+ The generic command profile is the supported way to describe a Pi invocation;
188
+ Pi is not a fourth built-in adapter:
189
+
190
+ ```toml
191
+ [runners.pi-apply]
192
+ type = "command"
193
+ model = "provider/exact-model-id"
194
+ project_access = "project"
195
+ prompt_transport = "stdin"
196
+ command = [
197
+ "pi", "--print", "--no-session", "--no-extensions", "--no-skills",
198
+ "--no-prompt-templates", "--no-themes", "--no-context-files",
199
+ "--tools", "read,grep,find,ls,edit,write", "--model", "{model}"
200
+ ]
201
+
202
+ [[modules.docs.steps]]
203
+ id = "apply"
204
+ type = "apply"
205
+ runner = "pi-apply"
206
+ fallback_prompt_id = "docs-apply-basic"
207
+ allow_paths = ["README.md", "docs/**/*.md"]
208
+ ```
209
+
210
+ Command profiles are argv vectors, never shell command strings. The executable
211
+ is launched with no implicit shell, shell expansion, pipes, redirection,
212
+ globbing, or command substitution. `stdin` (the default) sends the complete
213
+ instruction/artifact packet and then EOF. `argv` requires exactly one whole
214
+ argument `{prompt}`. The other whole-argument placeholders are `{model}`,
215
+ `{cwd}`, and `{stage}`; substring forms such as `--prompt={prompt}` are
216
+ rejected. An argv prompt can be visible in process listings, so stdin is the
217
+ safer default. Custom commands inherit the hook environment and have no
218
+ ai-push-hooks lifecycle or permission enforcement; stdout is the final result,
219
+ and stderr is diagnostic only.
220
+
221
+ ### Project visibility and apply boundary
222
+
223
+ For `project_access = "project"`, an analysis runner receives the real
224
+ repository root as its cwd. For `apply`, it receives a point-in-time temporary
225
+ projection, never the real checkout. Project apply copies eligible tracked or
226
+ unignored ordinary files, including the current dirty baseline, then allows
227
+ propagation only for paths matching that step's `allow_paths`. Ignored files are
228
+ excluded even when tracked-but-ignored (the `--no-index` ignore check is
229
+ intentional). Git metadata, casefolded/Unicode-normalized `AGENTS.md` paths,
230
+ symlinks/reparse points, and special files are excluded or rejected.
231
+
232
+ Staging and propagation are bounded: at most 10,000 staged entries and 256 MiB
233
+ of staging content, with Git metadata snapshots bounded at 20,000 entries and
234
+ 64 MiB. Runner input artifacts are capped at an aggregate 16 MiB, and each
235
+ captured child stdout/stderr stream is capped at 16 MiB. Changes outside the
236
+ allowlist, unsafe modes (setuid/setgid/sticky), destination type/content/mode
237
+ conflicts, or protected Git-state changes fail closed. Existing baseline checks
238
+ and atomic file replacement reduce lost updates; they are not an atomic
239
+ compare-and-swap against an arbitrary external writer. No rollback is attempted
240
+ over pre-existing user changes.
241
+
242
+ ## Pluggable workflow steps
243
+
244
+ The source tree supports two deliberately small extension seams. A deterministic
245
+ step may use one repository-local Python callback, or `exec`/`assert` may use a
246
+ direct argv command. This is configuration-defined trusted code, not a plugin
247
+ framework or SDK.
248
+
249
+ ### Python callbacks
250
+
251
+ Reference one explicit file and one top-level synchronous callable:
252
+ `checks/hooks.py:collect_context`. The file is a contained, ordinary `.py`
253
+ file; symlink/reparse traversal, attribute chains, installed-module references,
254
+ and package-relative loading are not supported. The callback is imported only
255
+ after the enabled-module gate, `when_env` gate, and input resolution. A source
256
+ file is loaded once per run (including concurrent collectors), with no `sys.path`,
257
+ cwd, or environment mutation. It may import standard-library or already
258
+ installed dependencies from the interpreter running the hook; the host never
259
+ runs `pip`. There is no hot reload, isolation sandbox, or enforceable hard
260
+ timeout for in-process Python.
261
+
262
+ Every callback receives exactly one frozen `PluginContext`. Its `repo_root`,
263
+ `module_id`, and `step_id` identify the call; `inputs` is an insertion-ordered,
264
+ read-only mapping from logical artifact references to validated `Path` values;
265
+ `options` and `prior_module_metadata` are recursive read-only snapshots; and
266
+ `push` contains bounded push facts (`branch_name`, `checked_out_branch`,
267
+ `base_branch`, `ranges`, `changed_files`, `diff_text`, and `push_updates`). The
268
+ existing thread-safe `logger` is also available. These values are immutable API
269
+ containers, not read-only filesystem handles.
270
+
271
+ This is a complete callback example for all three deterministic callback kinds:
272
+
273
+ ```python
274
+ # checks/hooks.py
275
+ import json
276
+
277
+ from ai_push_hooks.plugins import CollectorResult, PluginContext
278
+
279
+
280
+ def collect_context(context: PluginContext) -> CollectorResult:
281
+ return CollectorResult(
282
+ artifacts={"files.json": list(context.push.changed_files)},
283
+ metadata={"collected_by": context.step_id},
284
+ )
285
+
286
+
287
+ def run_check(context: PluginContext) -> dict:
288
+ files = json.loads(context.inputs["context/files.json"].read_text(encoding="utf-8"))
289
+ return {"file_count": len(files), "severity": context.options["severity"]}
290
+
291
+
292
+ def assert_policy(context: PluginContext) -> dict:
293
+ result = json.loads(context.inputs["check/result.json"].read_text(encoding="utf-8"))
294
+ ok = result["file_count"] <= context.options["max_files"]
295
+ return {"ok": ok, "message": "too many changed files" if not ok else ""}
296
+ ```
297
+
298
+ The corresponding step declarations are:
299
+
300
+ ```toml
301
+ [[modules.quality.steps]]
302
+ id = "context"
303
+ type = "collect"
304
+ python = "checks/hooks.py:collect_context"
305
+ options = { include_generated = false, severity = "high" }
101
306
 
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.
307
+ [[modules.quality.steps]]
308
+ id = "check"
309
+ type = "exec"
310
+ python = "checks/hooks.py:run_check"
311
+ inputs = ["context/files.json"]
312
+ options = { severity = "high" }
103
313
 
104
- Global and repository OpenCode instructions, custom agents, MCP servers, formatters, LSP configuration, sharing, and plugins are not inherited. The existing XDG data directory is retained for OpenCode authentication/session state, and recognized provider environment variables are forwarded. Custom providers defined only in global OpenCode configuration are therefore unsupported; use a built-in provider with OpenCode auth state or environment credentials.
314
+ [[modules.quality.steps]]
315
+ id = "policy"
316
+ type = "assert"
317
+ python = "checks/hooks.py:assert_policy"
318
+ inputs = ["check/result.json"]
319
+ options = { max_files = 25 }
320
+ ```
105
321
 
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`.
322
+ `collect` callbacks return `CollectorResult` (artifacts, metadata, and optional
323
+ module skip state). `exec` callbacks return a JSON-serializable `dict`, saved
324
+ as `result.json` without automatically merging into module metadata. `assert`
325
+ callbacks return a JSON-serializable `dict` with an actual boolean `ok` and,
326
+ when present, a string `message`; the report is saved before `ok = false` blocks.
327
+ Artifact names and serialization are validated, each plugin artifact is bounded
328
+ to 16 MiB, and the aggregate collect payload is bounded to 64 MiB. Callback
329
+ exceptions, import failures, `SystemExit`, coroutine functions, and awaitable
330
+ returns fail closed with a concise named error; `KeyboardInterrupt` is not
331
+ swallowed. Callback `print()` calls and direct host filesystem writes are
332
+ outside host sanitization and remain the author's responsibility.
333
+
334
+ `collect` callbacks retain the read-only/concurrent scheduling class and may
335
+ overlap up to `max_parallel`; callback authors must make them concurrency-safe.
336
+ Python `exec` and `assert` steps are serialized with other mutating work.
337
+
338
+ ### Direct argv commands
339
+
340
+ For `exec` and `assert`, `command` is a non-empty argv array. It runs with
341
+ `shell = false`, the repository root as cwd, the inherited user environment,
342
+ and stdin closed with EOF by default. `stdin = "<logical-ref>"` instead streams
343
+ that exact declared input artifact. The default command timeout is **60 seconds**;
344
+ configured values must be positive. There is no command allowlist or `trusted`
345
+ flag, and an explicit `bash -c` is the user's choice to adopt shell semantics.
107
346
 
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.
347
+ ```toml
348
+ [[modules.quality.steps]]
349
+ id = "lint"
350
+ type = "exec"
351
+ command = ["{python}", "scripts/lint_changed.py", "{input:context/files.json}"]
352
+ inputs = ["context/files.json"]
353
+ stdin = "context/files.json"
354
+ timeout_seconds = 60
355
+
356
+ [[modules.quality.steps]]
357
+ id = "policy-command"
358
+ type = "assert"
359
+ command = ["bash", "-c", "test -s \"$1\"", "assert", "{input:lint/result.json}"]
360
+ inputs = ["lint/result.json"]
361
+ ```
362
+
363
+ Reserved substitution happens only when the entire argv element is one exact
364
+ token: `{repo}` is the canonical repository root, `{python}` is the interpreter
365
+ running the hook, and `{input:<logical-ref>}` is a validated declared input.
366
+ The token grammar is one `{...}` pair containing only letters, digits, `_`, `.`,
367
+ `/`, `:`, or `-`; unknown tokens in that grammar (such as `{repos}`) are
368
+ rejected, as are embedded recognized tokens such as `--path={repo}`. Other
369
+ braces are literal command text, so Bash brace expansion, `awk '{print $1}'`,
370
+ and `python -c 'print({"a": 1})'` pass unchanged. Substitution is not shell
371
+ parsing or host-side string interpolation.
372
+
373
+ Both streams are captured as private, unredacted step artifacts named
374
+ `stdout.txt` and `stderr.txt`, including empty streams, and are not printed to
375
+ the console by default. `result.json` records the return code, artifact
376
+ references, and truncation flags. Valid stream text is UTF-8 and preserves
377
+ Unicode exactly; invalid UTF-8, a missing executable, timeout, signal, or a
378
+ stream exceeding the **16 MiB per-stream** bound is a process error and fails
379
+ closed. Captured output is retained when a process started. Exec requires exit
380
+ zero (an empty stdout is still success). Assert records `ok = true` for exit
381
+ zero; a nonzero exit records `ok = false` plus a bounded redacted message,
382
+ saves all reports first, and then blocks. Commands run in the real checkout and
383
+ may modify it; this is intentionally different from `apply`'s protected
384
+ staging projection. Exec/assert commands are serialized.
385
+
386
+ Only the workflow-level `general.allow_push_on_error = true` (or its explicit
387
+ `AI_PUSH_HOOKS_ALLOW_PUSH_ON_ERROR=1` override) changes a failure into a
388
+ fail-open warning. There is no per-command fail-open switch or custom success
389
+ exit-code list.
390
+
391
+ ### Ask and apply are separate
392
+
393
+ `ask` reads/reasons and returns a response. Omitting `schema` deliberately gives
394
+ plain text and does not implicitly block a push or perform an action:
395
+
396
+ ```toml
397
+ [[modules.review.steps]]
398
+ id = "summary"
399
+ type = "ask"
400
+ runner = "reviewer"
401
+ prompt = "Summarize the outgoing change in plain text."
402
+ output = "summary.txt"
403
+ # No schema: this is a response, not a verdict.
404
+ ```
405
+
406
+ `apply` inspects a staging projection and may edit it. A preceding `ask` is not
407
+ required, and JSON is not required. This standalone, no-JSON apply flow declares
408
+ the workflow, enables its module, selects a runner, and supplies an explicit
409
+ allowlist:
410
+
411
+ ```toml
412
+ [llm]
413
+ runner = "docs-apply"
414
+
415
+ [runners.docs-apply]
416
+ type = "opencode"
417
+ model = "openai/gpt-5.6-terra"
418
+ project_access = "project"
419
+
420
+ [workflow]
421
+ modules = ["docs"]
422
+
423
+ [modules.docs]
424
+ enabled = true
425
+
426
+ [[modules.docs.steps]]
427
+ id = "apply-docs"
428
+ type = "apply"
429
+ runner = "docs-apply"
430
+ prompt = "Inspect the readable project projection and fix only factual drift in the allowed files."
431
+ allow_paths = ["README.md", "docs/**/*.md"]
432
+ ```
433
+
434
+ The apply projection and propagation checks remain distinct: readable project
435
+ files may exceed the propagation allowlist, but only allowlisted changes can
436
+ propagate and any other staging change fails. The existing filename-specific
437
+ legacy shortcut also remains: an apply input whose filename ends in
438
+ `issues.json` containing the empty JSON list skips apply. It is legacy behavior,
439
+ not a general condition language or a replacement for an explicit policy step.
440
+
441
+ ### Safeguards versus user policy
442
+
443
+ | Surface | Host-enforced safeguard | User/trusted-author policy |
444
+ | --- | --- | --- |
445
+ | Python reference/loading | Contained no-follow regular file; lazy reached-only import; per-run cache | Callback imports, direct writes, prints, and termination behavior |
446
+ | Python context/results | Frozen snapshots; exact result types; JSON/artifact bounds; fail-closed malformed results | Semantic correctness and concurrency safety |
447
+ | Command | Direct argv, cwd/stdin contract, timeout, bounded capture, UTF-8 validation, private artifacts | Inherited environment, explicit `bash -c`, executable behavior, checkout writes |
448
+ | Assert | Strict Python boolean or command exit status; report saved before failure | Business verdict logic and whether a verdict should block |
449
+ | Ask | Selected runner/profile, access mode, schema handling, and existing runner controls | Model quality; add `assert` if findings should block |
450
+ | Apply | Staging inventory, allowlist propagation, Git/baseline/integrity checks | Runner/tool policy and prompt intent; no OS sandbox or atomic CAS claim |
451
+
452
+ ### OpenCode isolation limits
453
+
454
+ OpenCode uses `--pure`, isolated home/config/cache/state directories, disabled
455
+ sharing, and an ai-push-hooks-owned agent configuration. External plugins,
456
+ project/global configuration, MCP servers, instructions, and custom providers
457
+ from inherited global configuration are not loaded. Built-in plugins remain
458
+ available, including built-in authentication such as Codex OAuth. The existing
459
+ OpenCode data directory and recognized provider environment variables (including
460
+ `OPENAI_API_KEY`) are retained/forwarded, and OpenCode chooses authentication
461
+ using its own normal precedence.
462
+
463
+ These are permission and temporary-workspace controls, not an operating-system
464
+ sandbox. Every runner is a local program with the invoking user's OS identity;
465
+ same-user code can access other host paths. There is no mandatory command
466
+ allowlist, shell parser, container, credential broker, or trust prompt. Use an
467
+ external sandbox, container, VM, or low-privilege account when that boundary is
468
+ required. The scheduler may overlap `collect`/`ask` work up to `max_parallel`,
469
+ so a trusted custom `ask` command must really be safe for concurrent access;
470
+ `apply` remains globally serialized but custom command behavior is not enforced.
471
+
472
+ ### Invocation, lifecycle, and output
473
+
474
+ All adapters use direct argv execution, explicit cwd, separate stdout/stderr
475
+ capture, and the configured per-invocation timeout. The built-in mappings are:
476
+
477
+ | Profile | Analysis | Apply |
478
+ | --- | --- | --- |
479
+ | OpenCode | `opencode run --agent ... --pure --format json --model ...` with native attachments | same isolated agent in the selected staging projection |
480
+ | Codex | `codex exec --json --color never --sandbox read-only --ephemeral --cd <project> [--model <model>] -` | same with `--sandbox workspace-write` and `--skip-git-repo-check` |
481
+ | Claude | `claude -p --output-format json --no-session-persistence [--model <model>]` plus tested read-only flags | same with tested edit/write flags |
482
+ | Command/Pi | configured argv, prompt on stdin by default | configured argv in staging; host-side propagation checks still apply |
483
+
484
+ Codex and Claude require their advertised capability flags; a missing required
485
+ flag fails instead of silently weakening the policy. A successful built-in call
486
+ must have a successful terminal result and final text. The workflow still owns
487
+ JSON extraction, schema validation, bounded feedback, and retries.
488
+
489
+ After every call, the completion event identifies the module, step, purpose,
490
+ profile, adapter type, and success/failure. It reports a returned session ID and
491
+ session state when available, plus a local transcript path when one was really
492
+ created; unavailable lifecycle data is not invented. OpenCode retains its
493
+ existing export-to-private-storage and cleanup behavior: transcript capture is
494
+ on by default and `delete_session_after_run` is on by default. To inspect an
495
+ exported JSON transcript, use a truthful local view such as:
496
+
497
+ ```bash
498
+ python -m json.tool "path/to/exported-transcript.json"
499
+ ```
500
+
501
+ An OpenCode session retained by setting `delete_session_after_run = false`
502
+ does not cause ai-push-hooks to print a resume command. In particular, ordinary
503
+ `opencode -s ...` is not a valid way to resume a session created from an
504
+ arbitrary repository/temp-project context; the mock contract test confirms this
505
+ case. Codex is ephemeral and Claude disables session persistence, so neither
506
+ claims a resumable/provider transcript. A command profile has no lifecycle
507
+ inference at all; wrappers own any files or resume behavior they implement.
508
+
509
+ Console status lines have an `[ai-push-hooks]` prefix. LLM lines include
510
+ `module.step` and purpose, and completion lines include profile/type and a
511
+ success or failure label. `NO_COLOR` disables colors; otherwise non-empty
512
+ `FORCE_COLOR` (except `0`) enables them, while `TERM=dumb` disables them when
513
+ not forced. `logging.jsonl = true` writes plain JSONL records to the private
514
+ runtime log; console records remain one line. `print_llm_output` is a sensitive,
515
+ explicit opt-in: it prints normalized final text after redaction, not raw event
516
+ streams or child diagnostics.
517
+
518
+ ### Beads maintenance boundary
519
+
520
+ The `beads_alignment` executor is for ordinary native `bd update` and `bd close`
521
+ operations only. It does not run migrations, synchronize embedded Dolt, or
522
+ publish Dolt refs. Hook-launched Beads commands also discard
523
+ `BD_ALLOW_REMOTE_MIGRATE`, `BD_IGNORE_SCHEMA_SKEW`, and `BD_SMART_GATE` from
524
+ their inherited environment so an operator maintenance override cannot leak
525
+ into a push hook.
526
+
527
+ Treat a Beads schema migration as separate operator maintenance. Pin and
528
+ verify the native `bd` version, stop Beads writers and hooks, take a cold full
529
+ backup of `.beads`, and rehearse against a disposable copy before opening the
530
+ live embedded-Dolt store. Verify the schema, semantic issue/dependency data,
531
+ memories, and a clean Dolt working set before and after the live cutover.
532
+ Remote publication, including `bd dolt push`, is a separate explicit action;
533
+ a successful local migration does not authorize it. Do not replace this flow
534
+ with Beads-Rust (`br`).
109
535
 
110
536
  ## Full-featured alternative: Lefthook
111
537
 
@@ -160,7 +586,11 @@ Configure modules and steps in the [configuration reference](#configuration-refe
160
586
  ## Troubleshooting
161
587
 
162
588
  - **`opencode is required but not installed`:** install OpenCode and ensure `opencode` (or `opencode-cli`) is on `PATH` for the Git hook process.
163
- - **Provider/model authentication fails:** run `opencode auth list`, authenticate a built-in provider, and verify `[llm].model`. Project/global custom-provider configuration is intentionally not loaded; see [OpenCode isolation limits](#opencode-isolation-limits).
589
+ - **`Runner profile ... does not exist` or a missing-profile config error:** add the exact named profile under `[runners.<name>]`, or change the step/global `runner` to an existing name. Profile names are strict; environment overrides do not create profiles.
590
+ - **Selected runner capability check fails:** install the documented CLI version/flags. Claude's required `--permission-mode`, `--tools`, and `--allowedTools` contract is checked before invocation; it does not silently downgrade. Codex and OpenCode similarly fail when their required adapter contract cannot run.
591
+ - **Provider/model authentication fails:** for OpenCode, run `opencode auth list`, authenticate a built-in provider, and verify the selected profile's model. Built-in auth plugins remain available, while project/global custom-provider configuration is intentionally not loaded. Codex, Claude, and custom commands own their normal login/provider setup; ai-push-hooks never invokes login. See [OpenCode isolation limits](#opencode-isolation-limits).
592
+ - **`no final response`, timeout, signal, or nonzero runner error:** the selected profile, adapter type, and stage are reported with bounded redacted diagnostics. Check that the CLI is usable from the configured cwd and that its final output contract is enabled; do not expect child stderr or raw event streams to be printed.
593
+ - **Invalid JSON after retries:** `json_max_retries` defaults to `2`. The schema retry feedback is bounded by `invalid_json_feedback_max_chars` and, by default, each retry starts a fresh invocation (`json_retry_new_session = true`). If session reuse is requested but unsupported or no session was captured, the run explicitly falls back to a fresh invocation.
164
594
  - **The hook does not run:** rerun `lefthook install`, check `git config --get core.hooksPath`, and verify the pre-push path with the command above.
165
595
  - **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.
166
596
  - **Find logs or transcripts:** inspect `.git/ai-push-hooks/logs`, `.git/ai-push-hooks/summaries`, and (when enabled) `.git/ai-push-hooks/transcripts`.
@@ -225,7 +655,23 @@ uninspected `pre-push` path.
225
655
 
226
656
  ## Security and privacy
227
657
 
228
- 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.
658
+ Repository diffs, selected context, artifacts, and prompts may be sent by the
659
+ selected runner to its configured model provider. `project_access = "project"`
660
+ can expose substantially more repository content than the artifact-only default;
661
+ review provider data-handling, retention, and billing terms before using a
662
+ sensitive repository. Authentication belongs to the user and the selected CLI:
663
+ OpenCode retains its existing auth state and recognized provider environment
664
+ variables, while Codex, Claude, and custom commands inherit the normal user
665
+ environment/home they require. ai-push-hooks does not log environment values or
666
+ broker credentials.
667
+
668
+ OpenCode transcripts are exported to private local storage by default under
669
+ `.git/ai-push-hooks/transcripts`, and its sessions are deleted after each run by
670
+ default. Export is best effort: if it fails, the run warns and does not claim a
671
+ transcript exists. Codex and Claude use ephemeral/no-persistence modes and
672
+ command profiles have no inferred transcript. A local transcript never proves
673
+ provider-side deletion. See [SECURITY.md](SECURITY.md) for reporting, the threat
674
+ model, data handling, and sandbox limitations.
229
675
 
230
676
  ### BR-06 provider evidence (limited preview)
231
677
 
@@ -242,36 +688,66 @@ transmission terms before using repository content.
242
688
 
243
689
  ## Tested matrix and beta boundary
244
690
 
245
- The current candidate was exercised on **macOS Darwin 24.6.0 arm64** with
246
- Python **3.12.13**, Node **24.19.0**, npm **10.9.2**, Git **2.55.0**, Ruff
247
- **0.13.3**, OpenCode **1.18.29**, `gh` **2.93.0**, and `bd` **1.2.2**. The
248
- wheel and packed npm installed-hook tests use disposable repositories, full
249
- 40-character Git object IDs, a local bare remote, and a minimal PATH. The
250
- Lefthook **2.1.9** was also run in a disposable repository to install a
251
- pre-push hook and verify argument/stdin forwarding. The real OpenCode contract
252
- also passed in a Linux arm64 Docker container launched from this macOS host.
253
- Python 3.10, 3.11, and 3.13 and Node 18 were not available in this validation
254
- environment and are not claimed as locally run; their jobs remain part of the
255
- GitHub Actions matrix.
256
-
257
- Prior recorded BR evidence also covers the real OpenCode 1.18.29 CLI with a
258
- loopback mock provider inside a Linux Docker runtime with networking disabled.
259
- That is mock-provider permission/workspace evidence, not live-provider or
260
- operating-system-sandbox evidence.
261
-
262
- Validation results for this snapshot are **273 passed, 1 skipped** for the full
263
- Python suite, **8 passed** for install-unit coverage, **2 passed** for installed
264
- wheel and npm hook coverage against the exact release artifacts, a passing
265
- `npm run test:npm-pack`, and a passing Lefthook 2.1.9 disposable
266
- argument/stdin-forwarding check. The Docker contract passed against the real
267
- OpenCode 1.18.29 CLI with runtime networking disabled and a loopback mock
268
- provider.
691
+ The recorded environment details are intentionally not used as compatibility
692
+ proof for every platform. Python 3.10–3.13 and Node 18+ remain declared
693
+ compatibility ranges, and the generated hook requires a POSIX shell.
694
+
695
+ The current direct smoke command uses a loopback mock provider. Do not read this
696
+ as live-provider evidence or as proof of network or operating-system isolation.
697
+
698
+ Final pinned-Lefthook verification passed with no skips:
699
+
700
+ ```bash
701
+ mise exec lefthook@2.1.9 -- python -m pytest tests -q
702
+ ```
703
+
704
+ ```text
705
+ 407 passed, 0 skipped
706
+ ```
707
+
708
+ Ruff 0.13.3 also passed after the three direct-script `E402` fixes:
709
+
710
+ ```bash
711
+ uv tool run --from ruff==0.13.3 ruff check --isolated .
712
+ ```
713
+
714
+ Wheel, sdist, Twine 6.1.0, and npm packaging checks passed; the npm 10.9.2
715
+ offline smoke used zero LLM calls. The real OpenCode 1.18.29 smoke test passed
716
+ with an in-process loopback mock provider and no external model call; its read
717
+ probe also checks that no Git-visible project files were mutated. It is
718
+ permission/workspace evidence, not live-provider or operating-system-sandbox
719
+ evidence. Installed no-model conformance passed for OpenCode 1.18.29, Codex
720
+ 0.148.0, and Claude 2.1.220 using only version/help commands. Python 3.10.18
721
+ and Python 3.12 each passed 407 tests with 0 skips; the Python 3.10.18 run used
722
+ pytest 8.3.5, build 1.2.2.post1, tomli 2.4.1, and Lefthook 2.1.9. Default tests
723
+ make no authenticated or billable model calls. The live probe rejects a
724
+ present `AI_PUSH_HOOKS_MODEL` before any setup or child call; unset it so the
725
+ explicit `--model "provider/model-id"` argument remains deliberate. See the
726
+ [verification report](docs/reports/runner-verification.md) for the full gated
727
+ Codex/Pi read and apply commands.
728
+
729
+ ### Runner references
730
+
731
+ The invocation contracts were checked against the installed CLIs and their
732
+ authoritative documentation: [Codex non-interactive mode](https://developers.openai.com/codex/noninteractive),
733
+ [Codex CLI reference](https://developers.openai.com/codex/cli/reference),
734
+ [Codex authentication](https://developers.openai.com/codex/auth),
735
+ [Codex approvals and security](https://developers.openai.com/codex/agent-approvals-security),
736
+ [Claude CLI reference](https://code.claude.com/docs/en/cli-reference),
737
+ [Claude headless mode](https://code.claude.com/docs/en/headless),
738
+ [Claude permissions](https://code.claude.com/docs/en/permissions),
739
+ [Claude sessions](https://code.claude.com/docs/en/sessions),
740
+ [Claude authentication](https://code.claude.com/docs/en/authentication), and
741
+ [Pi usage/security/providers](https://pi.dev/docs/latest/usage),
742
+ [Pi JSON mode](https://pi.dev/docs/latest/json). CLI flags and model catalogs
743
+ can change; capability checks and additive parsers are intentional.
269
744
 
270
745
  Python 3.10–3.13 and Node 18+ remain the declared compatibility ranges, not a
271
- claim that every patch/platform combination has passed. Windows has no native
272
- beta evidence and is explicitly untested/not supported for this beta. The
273
- generated hook and documented runner require a POSIX shell; defensive path
274
- handling is not Windows validation.
746
+ claim that every patch/platform combination has passed. The generated hook
747
+ requires a POSIX shell, and Windows has no native beta evidence. On POSIX,
748
+ timeout cleanup signals a private process group on a best-effort basis; on
749
+ Windows, timeout cleanup can terminate only the direct child. Neither behavior
750
+ is a sandbox.
275
751
 
276
752
  ## Synthetic demo and evidence
277
753
 
@@ -282,12 +758,12 @@ bash scripts/opencode-contract-smoke.sh
282
758
  ```
283
759
 
284
760
  It builds a disposable image, starts only an in-process loopback mock provider,
285
- and drives real OpenCode 1.18.29 through a synthetic repository. Runtime
286
- networking is disabled, so no external model call is possible; the initial
287
- Docker build/setup may need network access to fetch its pinned inputs. It shows
288
- the allowlisted `README.md` edit, denied outside/protected edits, and unchanged
289
- protected Git metadata. This is **wiring and permission evidence only**, not a
290
- live-provider demo or OS-sandbox claim; it exits 2 when Docker is unavailable.
761
+ and drives real OpenCode 1.18.29 through a synthetic repository. The Docker
762
+ runtime uses `--network none`; the image build/setup may use network access to
763
+ fetch its pinned inputs. It shows the allowlisted `README.md` edit, denied
764
+ outside/protected edits, and unchanged protected Git metadata. This is
765
+ **wiring and permission evidence only**, not a live-provider demo or OS-sandbox
766
+ claim; it exits 2 when Docker is unavailable.
291
767
 
292
768
  For installed hook wiring without any model/provider call:
293
769
 
@@ -308,19 +784,18 @@ before review notices it. The hook inspects the outgoing ref range rather than
308
784
  the checked-out branch alone.
309
785
 
310
786
  **Architecture and tradeoffs.** Deterministic `collect` steps establish diff,
311
- changed-file, and repository context before `llm` query/analyze steps. An
787
+ changed-file, and repository context before `ask` query/analyze steps. An
312
788
  `apply` step receives a private workspace and a narrow docs allowlist; the
313
789
  assertion then blocks the push for human review and commit. `exec` and `assert`
314
790
  remain available for deterministic repository actions. This ordering limits
315
791
  model scope without pretending to provide an OS sandbox.
316
792
 
317
- **Evidence.** The real OpenCode 1.18.29 mock-provider contract found a
793
+ **Evidence.** The real OpenCode 1.18.29 loopback mock-provider contract found a
318
794
  version-specific permission mapping (`write` requests `edit`) and covers
319
- allowlisted propagation plus protected Git metadata. The BR-06 live synthetic
320
- preview used `opencode/muse-spark-1.3-contributor-free` for query/analyze at
321
- zero reported cost; it is not evidence for all providers/models or live apply.
322
- Installed wheel/npm tests cover hook wiring, a successful local push, and a
323
- fail-closed rejection. The Docker contract was not rerun in this environment.
795
+ allowlisted propagation plus protected Git metadata without an external model
796
+ call. The final pinned-Lefthook Python suite recorded 407 passed with no skips.
797
+ Installed runner conformance is version/help-only; live Codex and Pi probes were
798
+ intentionally not run, and Claude live verification is pending.
324
799
 
325
800
  **Limitations.** Provider availability, billing, retention, OS-level access,
326
801
  ignored trees, shared metadata, and independent filesystem races remain outside
@@ -341,7 +816,7 @@ If installed as a local npm/pnpm dependency, run commands with `npx --no-install
341
816
  ## Configuration overview
342
817
 
343
818
  - Config file: `ai-push-hooks.toml` in repo root (required).
344
- - Prompt resolution precedence for `llm` and `apply` steps:
819
+ - Prompt resolution precedence for `ask` and `apply` steps:
345
820
  1. `prompt`
346
821
  2. `prompt_file`
347
822
  3. `fallback_prompt_id`
@@ -357,6 +832,7 @@ If installed as a local npm/pnpm dependency, run commands with `npx --no-install
357
832
  | `logging` | table | no | see section defaults |
358
833
  | `workflow` | table | yes | n/a |
359
834
  | `modules` | table | yes | n/a |
835
+ | `runners` | table | no | Named runner profiles; omitted for the implicit OpenCode compatibility default. |
360
836
 
361
837
  ### `[general]`
362
838
 
@@ -372,18 +848,29 @@ If installed as a local npm/pnpm dependency, run commands with `npx --no-install
372
848
 
373
849
  | Key | Type | Default | Description |
374
850
  | --- | --- | --- | --- |
375
- | `runner` | string | `"opencode"` | LLM runner label (currently OpenCode flow). |
376
- | `model` | string | `"openai/gpt-5.6-terra"` | Model passed to OpenCode. |
377
- | `variant` | string | `""` | Optional OpenCode variant. |
378
- | `timeout_seconds` | int | `800` | Timeout per LLM invocation and related OpenCode calls. |
379
- | `max_parallel` | int | `2` | Max concurrent read-only steps (`collect`, `llm`). |
851
+ | `runner` | string | `"opencode"` | Global named runner profile for `ask`/`apply`; the implicit OpenCode compatibility profile is the default. |
852
+ | `model` | string | `"openai/gpt-5.6-terra"` | Compatibility model for implicit OpenCode; explicit profiles use their own model unless overridden by `AI_PUSH_HOOKS_MODEL`. |
853
+ | `variant` | string | `""` | Optional OpenCode compatibility variant. |
854
+ | `timeout_seconds` | int | `800` | Timeout per selected runner invocation and related lifecycle calls. |
855
+ | `max_parallel` | int | `2` | Max concurrent read-only steps (`collect`, `ask`). |
380
856
  | `json_max_retries` | int | `2` | Retry count for invalid JSON responses. |
381
857
  | `invalid_json_feedback_max_chars` | int | `6000` | Max invalid output included in retry feedback. |
382
- | `json_retry_new_session` | bool | `true` | Starts a new OpenCode session on JSON retry. |
858
+ | `json_retry_new_session` | bool | `true` | Requests a fresh invocation on JSON retry; unsupported/sessionless runners always fall back fresh. |
383
859
  | `delete_session_after_run` | bool | `true` | Deletes OpenCode sessions after completion. |
384
860
  | `max_diff_bytes` | int | `180000` | Max bytes of git diff sent into workflow artifacts. |
385
861
  | `session_title_prefix` | string | `"ai-push-hooks"` | Prefix for OpenCode session titles. |
386
862
 
863
+ ### `[runners.<name>]`
864
+
865
+ | Key | Type | Required/default | Description |
866
+ | --- | --- | --- | --- |
867
+ | `type` | string | required | One of `opencode`, `codex`, `claude`, or `command`. |
868
+ | `model` | string | optional | Opaque runner-specific model identifier; no catalog validation is performed. |
869
+ | `project_access` | string | OpenCode: `artifacts`; others: `project` | `artifacts` or `project`; see [project visibility](#project-visibility-and-apply-boundary). |
870
+ | `variant` | string | optional, OpenCode only | OpenCode variant. |
871
+ | `command` | string array | required for `command` | Direct argv vector; no shell parsing. |
872
+ | `prompt_transport` | `stdin`/`argv` | `stdin` for `command` | `argv` requires one whole-argument `{prompt}` placeholder. |
873
+
387
874
  ### `[logging]`
388
875
 
389
876
  | Key | Type | Default | Description |
@@ -394,7 +881,7 @@ If installed as a local npm/pnpm dependency, run commands with `npx --no-install
394
881
  | `capture_llm_transcript` | bool | `true` | Exports OpenCode session transcripts. |
395
882
  | `transcript_dir` | string | `".git/ai-push-hooks/transcripts"` | Transcript export directory. |
396
883
  | `summary_dir` | string | `".git/ai-push-hooks/summaries"` | Per-run summary JSON directory. |
397
- | `print_llm_output` | bool | `false` | Mirrors raw OpenCode JSON stream to stdout. |
884
+ | `print_llm_output` | bool | `false` | Sensitive opt-in; prints normalized, redacted final text, not raw runner events. |
398
885
 
399
886
  ### `[workflow]`
400
887
 
@@ -414,20 +901,21 @@ If installed as a local npm/pnpm dependency, run commands with `npx --no-install
414
901
  | Key | Type | Required | Applies to | Description |
415
902
  | --- | --- | --- | --- | --- |
416
903
  | `id` | string | yes | all step types | Unique step identifier inside the module. |
417
- | `type` | string | yes | all step types | One of: `collect`, `llm`, `apply`, `exec`, `assert`. |
904
+ | `type` | string | yes | all step types | One of: `collect`, `ask`, `apply`, `exec`, `assert`. Legacy `llm` and `agent` values are rejected; use `ask`. |
418
905
  | `inputs` | array of strings | no | non-`collect` steps | Artifact references from earlier steps. |
419
- | `output` | string | yes | `llm` | Output artifact filename (often `.json`). |
420
- | `schema` | string | no | `llm` | Validates parsed model output shape. |
421
- | `prompt` | string | conditional | `llm`, `apply` | Highest-priority prompt source. |
422
- | `prompt_file` | string | conditional | `llm`, `apply` | Repo-relative prompt file path; absolute, traversing, and symlinked paths are rejected. |
423
- | `fallback_prompt_id` | string | conditional | `llm`, `apply` | Built-in prompt ID used when no higher source resolves. |
906
+ | `output` | string | yes | `ask` | Output artifact filename (often `.json`). |
907
+ | `schema` | string | no | `ask` | Validates parsed model output shape. |
908
+ | `prompt` | string | conditional | `ask`, `apply` | Highest-priority prompt source. |
909
+ | `prompt_file` | string | conditional | `ask`, `apply` | Repo-relative prompt file path; absolute, traversing, and symlinked paths are rejected. |
910
+ | `fallback_prompt_id` | string | conditional | `ask`, `apply` | Built-in prompt ID used when no higher source resolves. |
424
911
  | `collector` | string | yes | `collect` | Collector handler ID. |
425
912
  | `allow_paths` | array of strings | yes | `apply` | File glob allowlist for edits. |
913
+ | `runner` | string | no | `ask`, `apply` | Per-step named profile override; invalid on other step types. |
426
914
  | `executor` | string | yes | `exec` | Exec handler ID. |
427
915
  | `assertion` | string | yes | `assert` | Assertion handler ID. |
428
916
  | `when_env` | string | no | any step | Runs step only when env var parses as true. |
429
917
 
430
- `llm` and `apply` are promptable step types: at least one of `prompt`, `prompt_file`, or `fallback_prompt_id` must be set.
918
+ `ask` and `apply` are promptable step types: at least one of `prompt`, `prompt_file`, or `fallback_prompt_id` must be set.
431
919
 
432
920
  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.
433
921
 
@@ -441,7 +929,7 @@ Artifact references in `inputs` are module-local. Use `<step>/<artifact>` to ref
441
929
  | `beads_status_context` | Collects branch/beads alignment context. |
442
930
  | `pr_context` | Collects PR composition context. |
443
931
 
444
- #### LLM schemas
932
+ #### Ask schemas
445
933
 
446
934
  | Value | Expected payload |
447
935
  | --- | --- |
@@ -478,6 +966,15 @@ Artifact references in `inputs` are module-local. Use `<step>/<artifact>` to ref
478
966
 
479
967
  Boolean env parsing accepts: `1`, `true`, `yes`, `y`, `on` and `0`, `false`, `no`, `n`, `off`.
480
968
 
969
+ Runner selection is resolved in this order: the step's `runner`, then
970
+ `[llm].runner`, then the named profile (or implicit `opencode` compatibility
971
+ profile). For the selected profile, `AI_PUSH_HOOKS_MODEL` is the final model
972
+ override. Without it, an explicit profile model wins; only the implicit
973
+ OpenCode profile inherits flat `[llm].model`. `AI_PUSH_HOOKS_VARIANT` applies
974
+ only to OpenCode and is the final override for its variant. These environment
975
+ overrides do not turn a missing profile into a valid one or change
976
+ `project_access`.
977
+
481
978
  | Env var | Effect |
482
979
  | --- | --- |
483
980
  | `AI_PUSH_HOOKS_SKIP` | If true, sets `general.enabled = false`. |
@@ -486,9 +983,9 @@ Boolean env parsing accepts: `1`, `true`, `yes`, `y`, `on` and `0`, `false`, `no
486
983
  | `AI_PUSH_HOOKS_ALLOW_DIRTY` | If true, forces `general.require_clean_worktree = false`. |
487
984
  | `AI_PUSH_HOOKS_BASE_BRANCH` | Overrides `general.base_branch`. |
488
985
  | `AI_PUSH_HOOKS_LOG_LEVEL` | Overrides `logging.level`. |
489
- | `AI_PUSH_HOOKS_PRINT_LLM_OUTPUT` | Overrides `logging.print_llm_output`. |
490
- | `AI_PUSH_HOOKS_MODEL` | Overrides `llm.model`. |
491
- | `AI_PUSH_HOOKS_VARIANT` | Overrides `llm.variant`. |
986
+ | `AI_PUSH_HOOKS_PRINT_LLM_OUTPUT` | Overrides `logging.print_llm_output`; normalized final text only, redacted, and sensitive. |
987
+ | `AI_PUSH_HOOKS_MODEL` | Final model override for the selected profile; values remain opaque identifiers. |
988
+ | `AI_PUSH_HOOKS_VARIANT` | Final variant override for OpenCode only. |
492
989
  | `AI_PUSH_HOOKS_TIMEOUT_SECONDS` | Overrides `llm.timeout_seconds` (integer). |
493
990
 
494
991
  `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.
@@ -509,7 +1006,7 @@ collector = "docs_context"
509
1006
 
510
1007
  [[modules.docs.steps]]
511
1008
  id = "query"
512
- type = "llm"
1009
+ type = "ask"
513
1010
  fallback_prompt_id = "docs-query-basic"
514
1011
  inputs = ["collect/push.diff", "collect/changed-files.txt"]
515
1012
  output = "queries.json"
@@ -517,7 +1014,7 @@ schema = "string_array"
517
1014
 
518
1015
  [[modules.docs.steps]]
519
1016
  id = "analyze"
520
- type = "llm"
1017
+ type = "ask"
521
1018
  fallback_prompt_id = "docs-analysis-basic"
522
1019
  inputs = ["collect/push.diff", "collect/docs-context.txt", "query/queries.json", "collect/recent-commits.txt"]
523
1020
  output = "issues.json"
@@ -546,7 +1043,7 @@ collector = "pr_context"
546
1043
 
547
1044
  [[modules.pr.steps]]
548
1045
  id = "compose"
549
- type = "llm"
1046
+ type = "ask"
550
1047
  fallback_prompt_id = "pr-compose-basic"
551
1048
  inputs = ["collect/pr-context.txt", "collect/changed-files.txt", "collect/push.diff", "collect/commits.txt"]
552
1049
  output = "pr-draft.json"