ai-push-hooks 0.1.18 → 0.1.19

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
@@ -6,6 +6,23 @@ It runs module-based steps (collect, LLM, apply, exec, assert) before push so yo
6
6
 
7
7
  ## Install
8
8
 
9
+ ### Mise (recommended for repository hooks)
10
+
11
+ Pin the currently published release in the consuming repository:
12
+
13
+ ```bash
14
+ mise use npm:ai-push-hooks@0.1.19
15
+ ```
16
+
17
+ This adds the following project-level tool entry to `mise.toml` and installs it:
18
+
19
+ ```toml
20
+ [tools]
21
+ "npm:ai-push-hooks" = "0.1.19"
22
+ ```
23
+
24
+ After checking in `mise.toml`, other contributors can install the pinned tool with `mise install`.
25
+
9
26
  ### Python
10
27
 
11
28
  ```bash
@@ -30,36 +47,52 @@ Requirements:
30
47
 
31
48
  ## Quick start
32
49
 
33
- 1. Install by following the steps above.
50
+ 1. Add the pinned Mise tool by following [Mise installation](#mise-recommended-for-repository-hooks) above.
34
51
  2. Generate a starter config:
35
52
 
36
- Python tool install (`uv tool` / `pipx`):
53
+ ```bash
54
+ mise exec -- ai-push-hooks init --template minimal-docs
55
+ ```
56
+
57
+ 3. Add the single repository-owned runner at `scripts/hooks/pre-push-runner.sh`:
37
58
 
38
59
  ```bash
39
- ai-push-hooks init --template minimal-docs
60
+ #!/usr/bin/env bash
61
+ set -euo pipefail
62
+
63
+ remote_name="${1:-}"
64
+ remote_url="${2:-}"
65
+ push_stdin="$(mktemp)"
66
+ trap 'rm -f "$push_stdin"' EXIT
67
+ cat >"$push_stdin"
68
+
69
+ # Run deterministic quality checks first. Replace these with the repository's checks.
70
+ npm run lint
71
+ npm test
72
+
73
+ # Keep ai-push-hooks as the single final phase and replay Git's pre-push input.
74
+ mise exec -- ai-push-hooks hook "$remote_name" "$remote_url" <"$push_stdin"
40
75
  ```
41
76
 
42
- npm/pnpm local install:
77
+ Make the runner executable:
43
78
 
44
79
  ```bash
45
- npx ai-push-hooks init --template minimal-docs
46
- # or
47
- pnpm exec ai-push-hooks init --template minimal-docs
80
+ chmod +x scripts/hooks/pre-push-runner.sh
48
81
  ```
49
82
 
50
- 3. Configure modules and steps in [Configuration reference](#configuration-reference).
51
- 4. Wire it into your pre-push hook manager. Lefthook example:
83
+ 4. Configure Lefthook to invoke only that runner in `lefthook.yml`:
52
84
 
53
85
  ```yaml
54
86
  pre-push:
55
87
  commands:
56
- ai-push-hooks:
57
- run: ai-push-hooks hook {1} {2}
88
+ repository-pre-push:
89
+ run: bash scripts/hooks/pre-push-runner.sh {1} {2}
90
+ use_stdin: true
58
91
  ```
59
92
 
60
- In Lefthook, `{1}` is the remote name and `{2}` is the remote URL from Git's `pre-push` hook args.
61
-
62
- 5. Push as usual. The workflow runs automatically before push completes.
93
+ `use_stdin: true` forwards Git's ref-update stream to the runner. The runner captures it before quality checks consume or close standard input, then replays it to `ai-push-hooks hook`. Lefthook's `{1}` and `{2}` are the remote name and remote URL. Keep all repository checks in this runner and keep the one `ai-push-hooks hook` call last so failures propagate and block the push.
94
+ 5. Configure modules and steps in [Configuration reference](#configuration-reference).
95
+ 6. Push as usual. The workflow runs automatically before push completes.
63
96
 
64
97
  ## Commands
65
98
 
@@ -99,13 +132,14 @@ If installed as a local npm/pnpm dependency, run commands with `npx` or `pnpm ex
99
132
  | `allow_push_on_error` | bool | `false` | If `true`, push continues even when workflow fails. |
100
133
  | `require_clean_worktree` | bool | `false` | If `true`, aborts when local changes exist. |
101
134
  | `skip_on_sync_branch` | bool | `true` | If `true`, skips on sync branch/worktree context. |
135
+ | `base_branch` | string | `"main"` | Base branch used for new-branch range fallback and default PR base/context. |
102
136
 
103
137
  ### `[llm]`
104
138
 
105
139
  | Key | Type | Default | Description |
106
140
  | --- | --- | --- | --- |
107
141
  | `runner` | string | `"opencode"` | LLM runner label (currently OpenCode flow). |
108
- | `model` | string | `"openai/gpt-5.3-codex"` | Model passed to OpenCode. |
142
+ | `model` | string | `"openai/gpt-5.5"` | Model passed to OpenCode. |
109
143
  | `variant` | string | `""` | Optional OpenCode variant. |
110
144
  | `timeout_seconds` | int | `800` | Timeout per LLM invocation and related OpenCode calls. |
111
145
  | `max_parallel` | int | `2` | Max concurrent read-only steps (`collect`, `llm`). |
@@ -161,6 +195,8 @@ If installed as a local npm/pnpm dependency, run commands with `npx` or `pnpm ex
161
195
 
162
196
  `llm` and `apply` are promptable step types: at least one of `prompt`, `prompt_file`, or `fallback_prompt_id` must be set.
163
197
 
198
+ 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.
199
+
164
200
  ### Supported handler and schema values
165
201
 
166
202
  #### Collectors
@@ -214,6 +250,7 @@ Boolean env parsing accepts: `1`, `true`, `yes`, `y`, `on` and `0`, `false`, `no
214
250
  | `AI_PUSH_HOOKS_ALLOW_PUSH_ON_ERROR` | Overrides `general.allow_push_on_error`. |
215
251
  | `AI_PUSH_HOOKS_REQUIRE_CLEAN` | Overrides `general.require_clean_worktree`. |
216
252
  | `AI_PUSH_HOOKS_ALLOW_DIRTY` | If true, forces `general.require_clean_worktree = false`. |
253
+ | `AI_PUSH_HOOKS_BASE_BRANCH` | Overrides `general.base_branch`. |
217
254
  | `AI_PUSH_HOOKS_LOG_LEVEL` | Overrides `logging.level`. |
218
255
  | `AI_PUSH_HOOKS_PRINT_LLM_OUTPUT` | Overrides `logging.print_llm_output`. |
219
256
  | `AI_PUSH_HOOKS_MODEL` | Overrides `llm.model`. |
@@ -3,10 +3,11 @@ enabled = true
3
3
  allow_push_on_error = false
4
4
  require_clean_worktree = false
5
5
  skip_on_sync_branch = true
6
+ base_branch = "main"
6
7
 
7
8
  [llm]
8
9
  runner = "opencode"
9
- model = "openai/gpt-5.3-codex"
10
+ model = "openai/gpt-5.5"
10
11
  variant = ""
11
12
  timeout_seconds = 800
12
13
  max_parallel = 2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-push-hooks",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "description": "Modular AI push-hook workflow runner",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -11,7 +11,8 @@
11
11
  "ai-push-hooks": "bin/ai-push-hooks.js"
12
12
  },
13
13
  "scripts": {
14
- "test": "uv run --with pytest pytest tests -q"
14
+ "test": "uv run --with pytest pytest tests -q",
15
+ "test:npm-pack": "node tests/npm-pack-smoke.mjs"
15
16
  },
16
17
  "files": [
17
18
  "bin",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ai-push-hooks"
7
- version = "0.1.18"
7
+ version = "0.1.19"
8
8
  description = "Modular AI push-hook workflow runner"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -21,6 +21,7 @@ classifiers = [
21
21
  "Programming Language :: Python :: 3.12",
22
22
  "Topic :: Software Development :: Version Control :: Git",
23
23
  ]
24
+ dependencies = ["tomli>=2.0.0; python_version < '3.11'"]
24
25
 
25
26
  [project.scripts]
26
27
  ai-push-hooks = "ai_push_hooks.cli:main"
@@ -63,8 +63,11 @@ class ArtifactStore:
63
63
 
64
64
  def resolve_input(self, state: ModuleRuntimeState, reference: str) -> pathlib.Path:
65
65
  if ":" in reference:
66
- module_and_step, artifact_name = reference.split("/", 1)
67
- module_id, step_id = module_and_step.split(":", 1)
66
+ try:
67
+ module_and_step, artifact_name = reference.split("/", 1)
68
+ module_id, step_id = module_and_step.split(":", 1)
69
+ except ValueError as exc:
70
+ raise HookError(f"Invalid artifact reference: {reference}") from exc
68
71
  key = f"{module_id}:{step_id}/{artifact_name}"
69
72
  else:
70
73
  key = reference
@@ -72,6 +75,11 @@ class ArtifactStore:
72
75
  if path is None:
73
76
  path = state.artifacts.get(reference)
74
77
  if path is None:
78
+ if ":" in reference:
79
+ raise HookError(
80
+ f"Unknown artifact reference: {reference}. Artifact references are module-local; "
81
+ "use '<step>/<artifact>' from an earlier step in the same module."
82
+ )
75
83
  raise HookError(f"Unknown artifact reference: {reference}")
76
84
  return path
77
85
 
@@ -1,104 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
- import json
4
3
  import os
5
4
  import pathlib
6
- import re
7
5
  from typing import Any
8
6
 
7
+ from .executors.exec import env_bool
9
8
  from .prompts_builtin import BUILTIN_PROMPTS
10
9
  from .types import GeneralConfig, HookConfig, HookError, LlmConfig, LoggingConfig, ModuleConfig, StepConfig, SUPPORTED_STEP_TYPES, WorkflowConfig
11
- from .executors.exec import env_bool
12
10
 
13
11
  try:
14
12
  import tomllib
15
13
  except ModuleNotFoundError: # pragma: no cover
16
- tomllib = None # type: ignore[assignment]
14
+ import tomli as tomllib
17
15
 
18
16
  ALLOWED_TOP_LEVEL_KEYS = {"general", "llm", "logging", "workflow", "modules"}
19
17
 
20
18
 
21
- def _parse_multiline_string(lines: list[str], index: int, initial: str) -> tuple[str, int]:
22
- chunks: list[str] = []
23
- value = initial[3:]
24
- while True:
25
- end_index = value.find('"""')
26
- if end_index >= 0:
27
- chunks.append(value[:end_index])
28
- return "\n".join(chunks), index
29
- chunks.append(value)
30
- index += 1
31
- if index >= len(lines):
32
- raise HookError("Unterminated multiline string in TOML fallback parser")
33
- value = lines[index]
34
-
35
-
36
- def _assign_path(root: dict[str, Any], path: list[str], value: Any, array_mode: bool = False) -> dict[str, Any]:
37
- current: Any = root
38
- for part in path[:-1]:
39
- if isinstance(current, list):
40
- if not current:
41
- current.append({})
42
- current = current[-1]
43
- current = current.setdefault(part, {})
44
- key = path[-1]
45
- if array_mode:
46
- items = current.setdefault(key, [])
47
- if not isinstance(items, list):
48
- raise HookError(f"Invalid array-of-table path: {'.'.join(path)}")
49
- item: dict[str, Any] = {}
50
- items.append(item)
51
- return item
52
- current[key] = value
53
- return current
54
-
55
-
56
- def _parse_scalar(raw: str) -> Any:
57
- raw = raw.strip()
58
- if raw.startswith('"') and raw.endswith('"'):
59
- return raw[1:-1]
60
- if raw in {"true", "false"}:
61
- return raw == "true"
62
- if re.fullmatch(r"-?\d+", raw):
63
- return int(raw)
64
- if raw.startswith("[") and raw.endswith("]"):
65
- return json.loads(raw)
66
- return raw
67
-
68
-
69
- def parse_toml_fallback(raw: str) -> dict[str, Any]:
70
- parsed: dict[str, Any] = {}
71
- lines = raw.splitlines()
72
- current: Any = parsed
73
- index = 0
74
- while index < len(lines):
75
- line = lines[index].strip()
76
- index += 1
77
- if not line or line.startswith("#"):
78
- continue
79
- if line.startswith("[[") and line.endswith("]]"):
80
- path = [part.strip() for part in line[2:-2].split(".") if part.strip()]
81
- current = _assign_path(parsed, path, None, array_mode=True)
82
- continue
83
- if line.startswith("[") and line.endswith("]"):
84
- path = [part.strip() for part in line[1:-1].split(".") if part.strip()]
85
- current = parsed
86
- for part in path:
87
- current = current.setdefault(part, {})
88
- continue
89
- if "=" not in line:
90
- continue
91
- key, value = line.split("=", 1)
92
- key = key.strip()
93
- value = value.strip()
94
- if value.startswith('"""'):
95
- parsed_value, index = _parse_multiline_string(lines, index - 1, value)
96
- else:
97
- parsed_value = _parse_scalar(value)
98
- current[key] = parsed_value
99
- return parsed
100
-
101
-
102
19
  def _normalize_step(raw: dict[str, Any]) -> StepConfig:
103
20
  step_type = str(raw.get("type", "")).strip()
104
21
  if step_type not in SUPPORTED_STEP_TYPES:
@@ -187,6 +104,7 @@ def _apply_env_overrides(config: HookConfig) -> HookConfig:
187
104
  "allow_push_on_error": config.general.allow_push_on_error,
188
105
  "require_clean_worktree": config.general.require_clean_worktree,
189
106
  "skip_on_sync_branch": config.general.skip_on_sync_branch,
107
+ "base_branch": config.general.base_branch,
190
108
  },
191
109
  "llm": config.llm.__dict__.copy(),
192
110
  "logging": config.logging.__dict__.copy(),
@@ -211,6 +129,9 @@ def _apply_env_overrides(config: HookConfig) -> HookConfig:
211
129
  allow_dirty = env_bool("AI_PUSH_HOOKS_ALLOW_DIRTY")
212
130
  if allow_dirty is True:
213
131
  raw["general"]["require_clean_worktree"] = False
132
+ base_branch = os.getenv("AI_PUSH_HOOKS_BASE_BRANCH")
133
+ if base_branch:
134
+ raw["general"]["base_branch"] = base_branch.strip() or "main"
214
135
 
215
136
  logging_level = os.getenv("AI_PUSH_HOOKS_LOG_LEVEL")
216
137
  if logging_level:
@@ -238,7 +159,7 @@ def load_config(repo_root: pathlib.Path) -> tuple[HookConfig, pathlib.Path]:
238
159
  "Run `ai-push-hooks init --template minimal-docs` first"
239
160
  )
240
161
  text = config_path.read_text(encoding="utf-8")
241
- loaded = tomllib.loads(text) if tomllib is not None else parse_toml_fallback(text)
162
+ loaded = tomllib.loads(text)
242
163
  if not isinstance(loaded, dict):
243
164
  raise HookError(f"Invalid config format in {config_path}")
244
165
  return _apply_env_overrides(_build_config(loaded)), config_path
@@ -8,6 +8,17 @@ from .exec import list_repo_changes, path_matches
8
8
  from .llm import call_opencode, finalize_opencode_session
9
9
 
10
10
 
11
+ def _snapshot_file_contents(repo_root: pathlib.Path, paths: set[str]) -> dict[str, bytes | None]:
12
+ snapshot: dict[str, bytes | None] = {}
13
+ for path in paths:
14
+ full_path = repo_root / path
15
+ if full_path.is_file():
16
+ snapshot[path] = full_path.read_bytes()
17
+ else:
18
+ snapshot[path] = None
19
+ return snapshot
20
+
21
+
11
22
  def run_apply_step(
12
23
  context: RuntimeContext,
13
24
  state: ModuleRuntimeState,
@@ -23,6 +34,7 @@ def run_apply_step(
23
34
  return {"changed": False, "changed_files": [], "skipped": True}
24
35
 
25
36
  baseline = list_repo_changes(context.repo_root)
37
+ baseline_contents = _snapshot_file_contents(context.repo_root, baseline)
26
38
  files = list(input_paths)
27
39
  agents = context.repo_root / "AGENTS.md"
28
40
  if agents.exists():
@@ -41,7 +53,14 @@ def run_apply_step(
41
53
  raise HookError(f"Apply step failed: {details}")
42
54
 
43
55
  after = list_repo_changes(context.repo_root)
44
- changed_files = sorted(after - baseline)
56
+ newly_dirty = after - baseline
57
+ after_baseline_contents = _snapshot_file_contents(context.repo_root, baseline)
58
+ changed_while_dirty = {
59
+ path
60
+ for path, before_content in baseline_contents.items()
61
+ if before_content != after_baseline_contents[path]
62
+ }
63
+ changed_files = sorted(newly_dirty | changed_while_dirty)
45
64
  unexpected = [
46
65
  path for path in changed_files if not any(path_matches(path, pattern) for pattern in step.allow_paths)
47
66
  ]
@@ -129,7 +129,7 @@ def path_matches(path: str, pattern: str) -> bool:
129
129
 
130
130
  def list_repo_changes(repo_root: pathlib.Path) -> set[str]:
131
131
  changes: set[str] = set()
132
- output = git(repo_root, ["status", "--short"], check=False)
132
+ output = run_command(["git", "status", "--short"], cwd=repo_root).stdout
133
133
  for line in output.splitlines():
134
134
  payload = line[3:].strip()
135
135
  if payload:
@@ -141,7 +141,9 @@ def collect_ranges_from_stdin(
141
141
  repo_root: pathlib.Path,
142
142
  remote_name: str,
143
143
  stdin_lines: list[str],
144
+ base_branch: str = "main",
144
145
  ) -> list[str]:
146
+ base_branch = base_branch.strip() or "main"
145
147
  ranges: set[str] = set()
146
148
  for line in stdin_lines:
147
149
  parts = line.strip().split()
@@ -160,7 +162,7 @@ def collect_ranges_from_stdin(
160
162
  ranges.add(f"{remote_sha}..{local_sha}")
161
163
  else:
162
164
  merge_base = git(
163
- repo_root, ["merge-base", local_sha, f"{remote_name}/main"], check=False
165
+ repo_root, ["merge-base", local_sha, f"{remote_name}/{base_branch}"], check=False
164
166
  )
165
167
  if merge_base:
166
168
  ranges.add(f"{merge_base}..{local_sha}")
@@ -438,7 +440,8 @@ def gh_pr_create_executor(
438
440
  if existing_pr:
439
441
  return {"skipped": False, "pr_url": existing_pr, "already_exists": True}
440
442
 
441
- base_branch = str(payload.get("base_branch", "main")).strip() or "main"
443
+ default_base_branch = context.config.general.base_branch.strip() or "main"
444
+ base_branch = str(payload.get("base_branch", default_base_branch)).strip() or default_base_branch
442
445
  head_branch = str(payload.get("head_branch", branch_name)).strip() or branch_name
443
446
  title = sanitize_pr_title(str(payload.get("title", "")).strip(), branch_name)
444
447
  body = str(payload.get("body", "")).strip()
@@ -73,7 +73,9 @@ def run_hook(
73
73
  return 0
74
74
 
75
75
  actual_stdin = list(stdin_lines) if stdin_lines is not None else [line.rstrip("\n") for line in sys.stdin]
76
- ranges = collect_ranges_from_stdin(repo_root, remote_name or "origin", actual_stdin)
76
+ ranges = collect_ranges_from_stdin(
77
+ repo_root, remote_name or "origin", actual_stdin, config.general.base_branch
78
+ )
77
79
  changed_files = collect_changed_files(repo_root, ranges) if ranges else []
78
80
  diff_text = collect_diff(repo_root, ranges, config.llm.max_diff_bytes) if ranges else ""
79
81
  run_id = generate_run_id()
@@ -104,6 +106,7 @@ def run_hook(
104
106
  "changed_files": changed_files,
105
107
  "diff_text": diff_text,
106
108
  "branch_name": current_branch(repo_root),
109
+ "base_branch": config.general.base_branch,
107
110
  "sync_branch": "beads-sync",
108
111
  },
109
112
  )
@@ -14,6 +14,7 @@ from ..types import CollectorResult, RuntimeContext
14
14
 
15
15
  def collect_pr_context(context: RuntimeContext, state: Any) -> CollectorResult:
16
16
  branch_name = current_branch(context.repo_root)
17
+ base_branch = context.config.general.base_branch.strip() or "main"
17
18
  flag_env = ""
18
19
  for step in state.module.steps:
19
20
  if step.when_env:
@@ -25,7 +26,7 @@ def collect_pr_context(context: RuntimeContext, state: Any) -> CollectorResult:
25
26
  skip_module=True,
26
27
  skip_reason="PR create env flag is not enabled",
27
28
  )
28
- if not branch_name or branch_name in {"HEAD", "main"} or not is_feature_branch(branch_name):
29
+ if not branch_name or branch_name in {"HEAD", base_branch} or not is_feature_branch(branch_name):
29
30
  return CollectorResult(
30
31
  artifacts={"pr-context.txt": f"branch={branch_name}\n"},
31
32
  skip_module=True,
@@ -61,7 +62,7 @@ def collect_pr_context(context: RuntimeContext, state: Any) -> CollectorResult:
61
62
  "pr-context.txt": "\n".join(
62
63
  [
63
64
  f"branch={branch_name}",
64
- f"base_branch=main",
65
+ f"base_branch={base_branch}",
65
66
  f"remote_name={context.remote_name or 'origin'}",
66
67
  ]
67
68
  )
@@ -64,10 +64,11 @@ enabled = true
64
64
  allow_push_on_error = false
65
65
  require_clean_worktree = false
66
66
  skip_on_sync_branch = true
67
+ base_branch = "main"
67
68
 
68
69
  [llm]
69
70
  runner = "opencode"
70
- model = "openai/gpt-5.3-codex"
71
+ model = "openai/gpt-5.5"
71
72
  variant = ""
72
73
  timeout_seconds = 800
73
74
  max_parallel = 2
@@ -23,12 +23,13 @@ class GeneralConfig:
23
23
  allow_push_on_error: bool = False
24
24
  require_clean_worktree: bool = False
25
25
  skip_on_sync_branch: bool = True
26
+ base_branch: str = "main"
26
27
 
27
28
 
28
29
  @dataclass(frozen=True)
29
30
  class LlmConfig:
30
31
  runner: str = "opencode"
31
- model: str = "openai/gpt-5.3-codex"
32
+ model: str = "openai/gpt-5.5"
32
33
  variant: str = ""
33
34
  timeout_seconds: int = 800
34
35
  max_parallel: int = 2