ai-push-hooks 0.1.17 → 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
@@ -24,32 +41,63 @@ pnpm add -D ai-push-hooks
24
41
 
25
42
  Requirements:
26
43
 
27
- - [Python 3.10+](https://www.python.org/downloads/) (`python3` or `python`) is required, including npm installs.
28
- - [OpenCode CLI](https://github.com/sst/opencode) is required for `llm` and `apply` steps. The expected executable is `opencode`; `opencode-cli` is also accepted for compatibility.
44
+ - [Python 3.10+](https://www.python.org/downloads/) (`python3` or `python`) is required. The npm package is a wrapper around the Python CLI, so Python is still required when installed through npm/pnpm.
45
+ - [OpenCode](https://github.com/anomalyco/opencode) is required for `llm` and `apply` steps.
29
46
  - [GitHub CLI (`gh`)](https://cli.github.com/manual/installation) is required only if you use PR creation via `gh_pr_create`.
30
47
 
31
48
  ## Quick start
32
49
 
33
- 1. Install the CLI.
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
53
  ```bash
37
- ai-push-hooks init --template minimal-docs
54
+ mise exec -- ai-push-hooks init --template minimal-docs
38
55
  ```
39
56
 
40
- 3. Wire it into Lefthook:
57
+ 3. Add the single repository-owned runner at `scripts/hooks/pre-push-runner.sh`:
58
+
59
+ ```bash
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"
75
+ ```
76
+
77
+ Make the runner executable:
78
+
79
+ ```bash
80
+ chmod +x scripts/hooks/pre-push-runner.sh
81
+ ```
82
+
83
+ 4. Configure Lefthook to invoke only that runner in `lefthook.yml`:
41
84
 
42
85
  ```yaml
43
86
  pre-push:
44
87
  commands:
45
- ai-push-hooks:
46
- 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
47
91
  ```
48
92
 
49
- 4. 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.
50
96
 
51
97
  ## Commands
52
98
 
99
+ If installed as a local npm/pnpm dependency, run commands with `npx` or `pnpm exec`.
100
+
53
101
  | Command | What it does |
54
102
  | --- | --- |
55
103
  | `ai-push-hooks hook <remote-name> <remote-url>` | Runs the configured pre-push workflow. |
@@ -58,9 +106,7 @@ Requirements:
58
106
 
59
107
  ## Configuration overview
60
108
 
61
- - Config file lookup order: `ai-push-hooks.toml`, then `.ai-push-hooks.toml` (legacy).
62
- - If no config file is present, built-in defaults are used.
63
- - File values are deep-merged over defaults.
109
+ - Config file: `ai-push-hooks.toml` in repo root (required).
64
110
  - Prompt resolution precedence for `llm` and `apply` steps:
65
111
  1. `prompt`
66
112
  2. `prompt_file`
@@ -72,11 +118,11 @@ Requirements:
72
118
 
73
119
  | Key | Type | Required | Default |
74
120
  | --- | --- | --- | --- |
75
- | `general` | table | no | built-in values |
76
- | `llm` | table | no | built-in values |
77
- | `logging` | table | no | built-in values |
78
- | `workflow` | table | yes | `{ modules = ["docs"] }` |
79
- | `modules` | table | yes | `{ docs = ... }` |
121
+ | `general` | table | no | see section defaults |
122
+ | `llm` | table | no | see section defaults |
123
+ | `logging` | table | no | see section defaults |
124
+ | `workflow` | table | yes | n/a |
125
+ | `modules` | table | yes | n/a |
80
126
 
81
127
  ### `[general]`
82
128
 
@@ -86,13 +132,14 @@ Requirements:
86
132
  | `allow_push_on_error` | bool | `false` | If `true`, push continues even when workflow fails. |
87
133
  | `require_clean_worktree` | bool | `false` | If `true`, aborts when local changes exist. |
88
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. |
89
136
 
90
137
  ### `[llm]`
91
138
 
92
139
  | Key | Type | Default | Description |
93
140
  | --- | --- | --- | --- |
94
141
  | `runner` | string | `"opencode"` | LLM runner label (currently OpenCode flow). |
95
- | `model` | string | `"openai/gpt-5.3-codex"` | Model passed to OpenCode. |
142
+ | `model` | string | `"openai/gpt-5.5"` | Model passed to OpenCode. |
96
143
  | `variant` | string | `""` | Optional OpenCode variant. |
97
144
  | `timeout_seconds` | int | `800` | Timeout per LLM invocation and related OpenCode calls. |
98
145
  | `max_parallel` | int | `2` | Max concurrent read-only steps (`collect`, `llm`). |
@@ -148,6 +195,8 @@ Requirements:
148
195
 
149
196
  `llm` and `apply` are promptable step types: at least one of `prompt`, `prompt_file`, or `fallback_prompt_id` must be set.
150
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
+
151
200
  ### Supported handler and schema values
152
201
 
153
202
  #### Collectors
@@ -201,6 +250,7 @@ Boolean env parsing accepts: `1`, `true`, `yes`, `y`, `on` and `0`, `false`, `no
201
250
  | `AI_PUSH_HOOKS_ALLOW_PUSH_ON_ERROR` | Overrides `general.allow_push_on_error`. |
202
251
  | `AI_PUSH_HOOKS_REQUIRE_CLEAN` | Overrides `general.require_clean_worktree`. |
203
252
  | `AI_PUSH_HOOKS_ALLOW_DIRTY` | If true, forces `general.require_clean_worktree = false`. |
253
+ | `AI_PUSH_HOOKS_BASE_BRANCH` | Overrides `general.base_branch`. |
204
254
  | `AI_PUSH_HOOKS_LOG_LEVEL` | Overrides `logging.level`. |
205
255
  | `AI_PUSH_HOOKS_PRINT_LLM_OUTPUT` | Overrides `logging.print_llm_output`. |
206
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.17",
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.17"
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
 
@@ -28,10 +28,8 @@ def init_config(template: str, force: bool, cwd: pathlib.Path | None = None) ->
28
28
  raise HookError("Only `minimal-docs` is supported")
29
29
  target_dir = cwd or pathlib.Path.cwd()
30
30
  config_path = target_dir / "ai-push-hooks.toml"
31
- legacy_path = target_dir / ".ai-push-hooks.toml"
32
- existing_path = config_path if config_path.exists() else legacy_path if legacy_path.exists() else None
33
- if existing_path is not None and not force:
34
- raise HookError(f"Refusing to overwrite existing config without --force: {existing_path}")
31
+ if config_path.exists() and not force:
32
+ raise HookError(f"Refusing to overwrite existing config without --force: {config_path}")
35
33
  config_path.write_text(MINIMAL_DOCS_TEMPLATE, encoding="utf-8")
36
34
  sys.stdout.write(str(config_path) + "\n")
37
35
  return 0
@@ -1,189 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
- import copy
4
- import json
5
3
  import os
6
4
  import pathlib
7
- import re
8
5
  from typing import Any
9
6
 
7
+ from .executors.exec import env_bool
10
8
  from .prompts_builtin import BUILTIN_PROMPTS
11
9
  from .types import GeneralConfig, HookConfig, HookError, LlmConfig, LoggingConfig, ModuleConfig, StepConfig, SUPPORTED_STEP_TYPES, WorkflowConfig
12
- from .executors.exec import env_bool
13
10
 
14
11
  try:
15
12
  import tomllib
16
13
  except ModuleNotFoundError: # pragma: no cover
17
- tomllib = None # type: ignore[assignment]
18
-
19
- DEFAULT_CONFIG_RAW: dict[str, Any] = {
20
- "general": {
21
- "enabled": True,
22
- "allow_push_on_error": False,
23
- "require_clean_worktree": False,
24
- "skip_on_sync_branch": True,
25
- },
26
- "llm": {
27
- "runner": "opencode",
28
- "model": "openai/gpt-5.3-codex",
29
- "variant": "",
30
- "timeout_seconds": 800,
31
- "max_parallel": 2,
32
- "json_max_retries": 2,
33
- "invalid_json_feedback_max_chars": 6000,
34
- "json_retry_new_session": True,
35
- "delete_session_after_run": True,
36
- "max_diff_bytes": 180000,
37
- "session_title_prefix": "ai-push-hooks",
38
- },
39
- "logging": {
40
- "level": "status",
41
- "jsonl": True,
42
- "dir": ".git/ai-push-hooks/logs",
43
- "capture_llm_transcript": True,
44
- "transcript_dir": ".git/ai-push-hooks/transcripts",
45
- "summary_dir": ".git/ai-push-hooks/summaries",
46
- "print_llm_output": False,
47
- },
48
- "workflow": {"modules": ["docs"]},
49
- "modules": {
50
- "docs": {
51
- "enabled": True,
52
- "steps": [
53
- {"id": "collect", "type": "collect", "collector": "docs_context"},
54
- {
55
- "id": "query",
56
- "type": "llm",
57
- "inputs": ["collect/push.diff", "collect/changed-files.txt"],
58
- "output": "queries.json",
59
- "schema": "string_array",
60
- "fallback_prompt_id": "docs-query-basic",
61
- },
62
- {
63
- "id": "analyze",
64
- "type": "llm",
65
- "inputs": [
66
- "collect/push.diff",
67
- "collect/docs-context.txt",
68
- "query/queries.json",
69
- "collect/recent-commits.txt",
70
- ],
71
- "output": "issues.json",
72
- "schema": "docs_issue_array",
73
- "fallback_prompt_id": "docs-analysis-basic",
74
- },
75
- {
76
- "id": "apply",
77
- "type": "apply",
78
- "inputs": ["collect/push.diff", "collect/docs-context.txt", "analyze/issues.json"],
79
- "allow_paths": ["README.md", "docs/**/*.md"],
80
- "fallback_prompt_id": "docs-apply-basic",
81
- },
82
- {
83
- "id": "assert",
84
- "type": "assert",
85
- "inputs": ["apply/result.json"],
86
- "assertion": "docs_apply_requires_manual_commit",
87
- },
88
- ]
89
- }
90
- },
91
- }
14
+ import tomli as tomllib
92
15
 
93
16
  ALLOWED_TOP_LEVEL_KEYS = {"general", "llm", "logging", "workflow", "modules"}
94
17
 
95
18
 
96
- def _parse_multiline_string(lines: list[str], index: int, initial: str) -> tuple[str, int]:
97
- chunks: list[str] = []
98
- value = initial[3:]
99
- while True:
100
- end_index = value.find('"""')
101
- if end_index >= 0:
102
- chunks.append(value[:end_index])
103
- return "\n".join(chunks), index
104
- chunks.append(value)
105
- index += 1
106
- if index >= len(lines):
107
- raise HookError("Unterminated multiline string in TOML fallback parser")
108
- value = lines[index]
109
-
110
-
111
- def _assign_path(root: dict[str, Any], path: list[str], value: Any, array_mode: bool = False) -> dict[str, Any]:
112
- current: Any = root
113
- for part in path[:-1]:
114
- if isinstance(current, list):
115
- if not current:
116
- current.append({})
117
- current = current[-1]
118
- current = current.setdefault(part, {})
119
- key = path[-1]
120
- if array_mode:
121
- items = current.setdefault(key, [])
122
- if not isinstance(items, list):
123
- raise HookError(f"Invalid array-of-table path: {'.'.join(path)}")
124
- item: dict[str, Any] = {}
125
- items.append(item)
126
- return item
127
- current[key] = value
128
- return current
129
-
130
-
131
- def _parse_scalar(raw: str) -> Any:
132
- raw = raw.strip()
133
- if raw.startswith('"') and raw.endswith('"'):
134
- return raw[1:-1]
135
- if raw in {"true", "false"}:
136
- return raw == "true"
137
- if re.fullmatch(r"-?\d+", raw):
138
- return int(raw)
139
- if raw.startswith("[") and raw.endswith("]"):
140
- return json.loads(raw)
141
- return raw
142
-
143
-
144
- def parse_toml_fallback(raw: str) -> dict[str, Any]:
145
- parsed: dict[str, Any] = {}
146
- lines = raw.splitlines()
147
- current: Any = parsed
148
- index = 0
149
- while index < len(lines):
150
- line = lines[index].strip()
151
- index += 1
152
- if not line or line.startswith("#"):
153
- continue
154
- if line.startswith("[[") and line.endswith("]]"):
155
- path = [part.strip() for part in line[2:-2].split(".") if part.strip()]
156
- current = _assign_path(parsed, path, None, array_mode=True)
157
- continue
158
- if line.startswith("[") and line.endswith("]"):
159
- path = [part.strip() for part in line[1:-1].split(".") if part.strip()]
160
- current = parsed
161
- for part in path:
162
- current = current.setdefault(part, {})
163
- continue
164
- if "=" not in line:
165
- continue
166
- key, value = line.split("=", 1)
167
- key = key.strip()
168
- value = value.strip()
169
- if value.startswith('"""'):
170
- parsed_value, index = _parse_multiline_string(lines, index - 1, value)
171
- else:
172
- parsed_value = _parse_scalar(value)
173
- current[key] = parsed_value
174
- return parsed
175
-
176
-
177
- def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
178
- merged = copy.deepcopy(base)
179
- for key, value in override.items():
180
- if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
181
- merged[key] = deep_merge(merged[key], value)
182
- else:
183
- merged[key] = copy.deepcopy(value)
184
- return merged
185
-
186
-
187
19
  def _normalize_step(raw: dict[str, Any]) -> StepConfig:
188
20
  step_type = str(raw.get("type", "")).strip()
189
21
  if step_type not in SUPPORTED_STEP_TYPES:
@@ -272,6 +104,7 @@ def _apply_env_overrides(config: HookConfig) -> HookConfig:
272
104
  "allow_push_on_error": config.general.allow_push_on_error,
273
105
  "require_clean_worktree": config.general.require_clean_worktree,
274
106
  "skip_on_sync_branch": config.general.skip_on_sync_branch,
107
+ "base_branch": config.general.base_branch,
275
108
  },
276
109
  "llm": config.llm.__dict__.copy(),
277
110
  "logging": config.logging.__dict__.copy(),
@@ -296,6 +129,9 @@ def _apply_env_overrides(config: HookConfig) -> HookConfig:
296
129
  allow_dirty = env_bool("AI_PUSH_HOOKS_ALLOW_DIRTY")
297
130
  if allow_dirty is True:
298
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"
299
135
 
300
136
  logging_level = os.getenv("AI_PUSH_HOOKS_LOG_LEVEL")
301
137
  if logging_level:
@@ -315,19 +151,18 @@ def _apply_env_overrides(config: HookConfig) -> HookConfig:
315
151
  return _build_config(raw)
316
152
 
317
153
 
318
- def load_config(repo_root: pathlib.Path) -> tuple[HookConfig, pathlib.Path | None]:
319
- config_path: pathlib.Path | None = None
320
- raw = copy.deepcopy(DEFAULT_CONFIG_RAW)
321
- for candidate in [repo_root / "ai-push-hooks.toml", repo_root / ".ai-push-hooks.toml"]:
322
- if candidate.exists():
323
- config_path = candidate
324
- text = candidate.read_text(encoding="utf-8")
325
- loaded = tomllib.loads(text) if tomllib is not None else parse_toml_fallback(text)
326
- if not isinstance(loaded, dict):
327
- raise HookError(f"Invalid config format in {candidate}")
328
- raw = deep_merge(raw, loaded)
329
- break
330
- return _apply_env_overrides(_build_config(raw)), config_path
154
+ def load_config(repo_root: pathlib.Path) -> tuple[HookConfig, pathlib.Path]:
155
+ config_path = repo_root / "ai-push-hooks.toml"
156
+ if not config_path.exists():
157
+ raise HookError(
158
+ "Missing required config file `ai-push-hooks.toml` in repo root. "
159
+ "Run `ai-push-hooks init --template minimal-docs` first"
160
+ )
161
+ text = config_path.read_text(encoding="utf-8")
162
+ loaded = tomllib.loads(text)
163
+ if not isinstance(loaded, dict):
164
+ raise HookError(f"Invalid config format in {config_path}")
165
+ return _apply_env_overrides(_build_config(loaded)), config_path
331
166
 
332
167
 
333
168
  def resolve_prompt_text(repo_root: pathlib.Path, step: StepConfig) -> str:
@@ -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()
@@ -32,7 +32,7 @@ def resolve_opencode_executable() -> str:
32
32
  cli_path = shutil.which("opencode-cli")
33
33
  if cli_path:
34
34
  return cli_path
35
- raise HookError("opencode (or opencode-cli) is required but not installed")
35
+ raise HookError("opencode is required but not installed")
36
36
 
37
37
 
38
38
  def parse_opencode_json_run_output(raw: str) -> tuple[str | None, str]:
@@ -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