claude-code-generator 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. claude_code_generator-0.1.0.dist-info/METADATA +176 -0
  2. claude_code_generator-0.1.0.dist-info/RECORD +49 -0
  3. claude_code_generator-0.1.0.dist-info/WHEEL +5 -0
  4. claude_code_generator-0.1.0.dist-info/entry_points.txt +2 -0
  5. claude_code_generator-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. claude_code_generator-0.1.0.dist-info/top_level.txt +1 -0
  7. code_generator/__init__.py +3 -0
  8. code_generator/agents.py +177 -0
  9. code_generator/cli.py +49 -0
  10. code_generator/commands/__init__.py +1 -0
  11. code_generator/commands/generate.py +252 -0
  12. code_generator/commands/init.py +72 -0
  13. code_generator/commands/review.py +117 -0
  14. code_generator/commands/status.py +83 -0
  15. code_generator/env.py +55 -0
  16. code_generator/gh.py +331 -0
  17. code_generator/logging_setup.py +73 -0
  18. code_generator/orchestrator/__init__.py +4 -0
  19. code_generator/orchestrator/cycle_loop.py +371 -0
  20. code_generator/orchestrator/phase0_complexity.py +159 -0
  21. code_generator/orchestrator/phase1_plan.py +170 -0
  22. code_generator/orchestrator/phase2_review.py +126 -0
  23. code_generator/orchestrator/phase3_4_implement.py +164 -0
  24. code_generator/orchestrator/phase5_closure.py +154 -0
  25. code_generator/orchestrator/phase6_test.py +98 -0
  26. code_generator/orchestrator/phase7_commit.py +167 -0
  27. code_generator/prompts/__init__.py +86 -0
  28. code_generator/prompts/prompt-phase-0-complexity.md +85 -0
  29. code_generator/prompts/prompt-phase-1-planning.md +209 -0
  30. code_generator/prompts/prompt-phase-2-issue-review.md +84 -0
  31. code_generator/prompts/prompt-phase-3-implementation.md +191 -0
  32. code_generator/prompts/prompt-phase-5-final-review.md +135 -0
  33. code_generator/prompts/prompt-phase-6-test.md +102 -0
  34. code_generator/prompts/prompt-phase-7-commit.md +103 -0
  35. code_generator/prompts/prompt-review.md +124 -0
  36. code_generator/runner/__init__.py +26 -0
  37. code_generator/runner/rate_limit.py +113 -0
  38. code_generator/runner/retry.py +165 -0
  39. code_generator/runner/sdk_runner.py +267 -0
  40. code_generator/runner/subprocess_runner.py +200 -0
  41. code_generator/state.py +178 -0
  42. code_generator/templates/__init__.py +1 -0
  43. code_generator/templates/angular.md +12 -0
  44. code_generator/templates/base.md +28 -0
  45. code_generator/templates/fastapi.md +12 -0
  46. code_generator/templates/finance.md +9 -0
  47. code_generator/templates/fullstack.md +24 -0
  48. code_generator/templates/nestjs.md +9 -0
  49. code_generator/templates/python-cli.md +9 -0
@@ -0,0 +1,126 @@
1
+ """Phase 2 — Per-issue review with circuit breaker.
2
+
3
+ Each open issue is reviewed by Opus 4.6 in its own invocation.
4
+ If the circuit breaker trips for a specific issue, a ``needs-manual-review``
5
+ label is added and processing continues with the next issue.
6
+
7
+ Non-negotiable #8: model is hard-coded to ``claude-opus-4-6``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from code_generator import gh
15
+ from code_generator.logging_setup import setup_phase_logger
16
+ from code_generator.prompts import load_prompt
17
+ from code_generator.runner import rate_limit, retry
18
+
19
+ if TYPE_CHECKING:
20
+ import logging
21
+ from pathlib import Path
22
+
23
+ from code_generator.state import CycleState, IssueState, State
24
+
25
+
26
+ def _get_issues(state: State, cycle: CycleState | None) -> list[IssueState]:
27
+ """Return the list of issues for the active scope.
28
+
29
+ Args:
30
+ state: Root state.
31
+ cycle: Active cycle or ``None`` for single mode.
32
+
33
+ Returns:
34
+ List of IssueState objects from the cycle or the root state.
35
+ """
36
+ if cycle is not None:
37
+ return cycle.issues
38
+ return state.issues
39
+
40
+
41
+ async def run(
42
+ state: State,
43
+ cycle: CycleState | None,
44
+ project_dir: Path,
45
+ *,
46
+ runner_module: Any,
47
+ logger: logging.Logger | None = None,
48
+ ) -> None:
49
+ """Execute phase 2: per-issue review.
50
+
51
+ Iterates open issues and reviews each with a fresh Opus 4.6 session.
52
+ A :class:`~code_generator.runner.retry.CircuitBreaker` with
53
+ ``max_failures=3`` wraps each issue. When the breaker trips, the issue
54
+ is tagged ``needs-manual-review`` and skipped.
55
+
56
+ Args:
57
+ state: Root state object.
58
+ cycle: Active cycle (multi-cycle) or ``None`` (single mode).
59
+ project_dir: Project root directory.
60
+ runner_module: Runner module from ``get_runner()``.
61
+ logger: Optional phase logger.
62
+ """
63
+ if logger is None:
64
+ logger = setup_phase_logger("phase2", project_dir)
65
+
66
+ state_path = project_dir / ".code-generator" / "state.json"
67
+
68
+ try:
69
+ from claude_agent_sdk import ClaudeAgentOptions # type: ignore[import-not-found]
70
+ except ImportError: # pragma: no cover
71
+ ClaudeAgentOptions = type( # type: ignore[assignment,misc]
72
+ "ClaudeAgentOptions",
73
+ (),
74
+ {"__init__": lambda self, **kw: self.__dict__.update(kw)},
75
+ )
76
+
77
+ issues = _get_issues(state, cycle)
78
+
79
+ for issue in issues:
80
+ if issue.status != "open":
81
+ continue
82
+
83
+ logger.info("Phase 2: reviewing issue #%d (agent=%s).", issue.number, issue.agent)
84
+
85
+ prompt = load_prompt(
86
+ "prompt-phase-2-issue-review.md",
87
+ ISSUE_NUMBER=str(issue.number),
88
+ PRIMARY_AGENT=issue.agent,
89
+ )
90
+
91
+ options = ClaudeAgentOptions(
92
+ model="claude-opus-4-6",
93
+ allowed_tools=["Read", "Bash", "Glob", "Grep"],
94
+ cwd=str(project_dir),
95
+ max_turns=15,
96
+ )
97
+
98
+ breaker = retry.CircuitBreaker(max_failures=3)
99
+
100
+ try:
101
+ await retry.with_backoff(
102
+ lambda p=prompt, o=options: rate_limit.main_loop(
103
+ runner_module,
104
+ p,
105
+ o,
106
+ state_path=state_path,
107
+ logger=logger,
108
+ ),
109
+ breaker=breaker,
110
+ )
111
+ except retry.CircuitOpen:
112
+ logger.error(
113
+ "Phase 2: circuit breaker opened for issue #%d — tagging for manual review.",
114
+ issue.number,
115
+ )
116
+ try:
117
+ gh.add_label(issue.number, "needs-manual-review")
118
+ except gh.GhError as exc:
119
+ logger.warning(
120
+ "Could not add needs-manual-review label to #%d: %s",
121
+ issue.number,
122
+ exc,
123
+ )
124
+ continue
125
+
126
+ logger.info("Phase 2: issue #%d reviewed.", issue.number)
@@ -0,0 +1,164 @@
1
+ """Phases 3 & 4 — Implementation loop (TDD, fresh session per issue).
2
+
3
+ Each open issue gets its own SDK session (non-negotiable #6: no ``resume``).
4
+ The issue agent is taken from state; skills default to ``solid-principles``
5
+ augmented by whatever ``agents.detect()`` returns for the requirements file.
6
+
7
+ Non-negotiable #8: model is hard-coded to ``claude-sonnet-4-6``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from code_generator import agents as _agents
15
+ from code_generator import state as _state
16
+ from code_generator.logging_setup import setup_phase_logger
17
+ from code_generator.prompts import load_prompt
18
+ from code_generator.runner import rate_limit
19
+
20
+ if TYPE_CHECKING:
21
+ import logging
22
+ from pathlib import Path
23
+
24
+ from code_generator.state import CycleState, IssueState, State
25
+
26
+
27
+ def _get_issues(state: State, cycle: CycleState | None) -> list[IssueState]:
28
+ if cycle is not None:
29
+ return cycle.issues
30
+ return state.issues
31
+
32
+
33
+ def _default_skills(project_dir: Path) -> str:
34
+ """Derive skill names from the requirements file (or use generic fallback).
35
+
36
+ Args:
37
+ project_dir: Project root; the requirements file lives at
38
+ ``<project_dir>/.code-generator/requirements.md``.
39
+
40
+ Returns:
41
+ Comma-joined skill names string.
42
+ """
43
+ req_path = project_dir / ".code-generator" / "requirements.md"
44
+ selection = _agents.detect(req_path)
45
+ skills = selection.skills or ("solid-principles",)
46
+ return ",".join(skills)
47
+
48
+
49
+ async def run(
50
+ state: State,
51
+ cycle: CycleState | None,
52
+ project_dir: Path,
53
+ *,
54
+ runner_module: Any,
55
+ logger: logging.Logger | None = None,
56
+ max_retries: int = 3,
57
+ ) -> None:
58
+ """Execute phases 3 and 4: implementation loop.
59
+
60
+ For each open issue:
61
+
62
+ 1. Builds a fresh set of options (no ``resume`` — non-negotiable #6).
63
+ 2. Loads ``prompt-phase-3-implementation.md`` with the issue number,
64
+ agent, and skills.
65
+ 3. Runs via ``rate_limit.main_loop``.
66
+ 4. On success: marks the issue ``closed`` in state and persists.
67
+ 5. On failure: retries up to *max_retries* times with an escalating
68
+ error context appended to the prompt. After all retries the issue
69
+ is left open and an ERROR is logged.
70
+
71
+ Args:
72
+ state: Root state object (mutated in place).
73
+ cycle: Active cycle (multi-cycle) or ``None`` (single mode).
74
+ project_dir: Project root directory.
75
+ runner_module: Runner module from ``get_runner()``.
76
+ logger: Optional phase logger.
77
+ max_retries: Maximum implementation retries per issue (default 3).
78
+ """
79
+ if logger is None:
80
+ logger = setup_phase_logger("phase3_4", project_dir)
81
+
82
+ state_path = project_dir / ".code-generator" / "state.json"
83
+ skills = _default_skills(project_dir)
84
+
85
+ try:
86
+ from claude_agent_sdk import ClaudeAgentOptions # type: ignore[import-not-found]
87
+ except ImportError: # pragma: no cover
88
+ ClaudeAgentOptions = type( # type: ignore[assignment,misc]
89
+ "ClaudeAgentOptions",
90
+ (),
91
+ {"__init__": lambda self, **kw: self.__dict__.update(kw)},
92
+ )
93
+
94
+ issues = _get_issues(state, cycle)
95
+
96
+ for issue in issues:
97
+ if issue.status != "open":
98
+ continue
99
+
100
+ agent = issue.agent or "python-pro"
101
+ logger.info("Phase 3/4: implementing issue #%d (agent=%s).", issue.number, agent)
102
+
103
+ base_prompt = load_prompt(
104
+ "prompt-phase-3-implementation.md",
105
+ ISSUE_NUMBER=str(issue.number),
106
+ PRIMARY_AGENT=agent,
107
+ SKILLS=skills,
108
+ )
109
+
110
+ success = False
111
+ last_err: str = ""
112
+
113
+ for attempt in range(max_retries):
114
+ # Non-negotiable #6: fresh session per issue — never set resume.
115
+ # Clear session_id before each attempt.
116
+ state.session_id = None
117
+ _state.save_state(state_path, state)
118
+
119
+ prompt = base_prompt
120
+ if attempt > 0:
121
+ prompt = (
122
+ f"{base_prompt}\n\nPrevious attempt failed: {last_err}. "
123
+ "Be more explicit this time."
124
+ )
125
+
126
+ options = ClaudeAgentOptions(
127
+ model="claude-sonnet-4-6",
128
+ allowed_tools=["Read", "Edit", "Write", "Bash", "Glob", "Grep"],
129
+ cwd=str(project_dir),
130
+ max_turns=80,
131
+ )
132
+
133
+ try:
134
+ await rate_limit.main_loop(
135
+ runner_module,
136
+ prompt,
137
+ options,
138
+ state_path=state_path,
139
+ logger=logger,
140
+ )
141
+ success = True
142
+ break
143
+ except Exception as exc: # noqa: BLE001
144
+ last_err = str(exc)
145
+ logger.warning(
146
+ "Phase 3/4: attempt %d/%d failed for issue #%d: %s",
147
+ attempt + 1,
148
+ max_retries,
149
+ issue.number,
150
+ last_err,
151
+ )
152
+
153
+ if success:
154
+ issue.status = "closed"
155
+ # Non-negotiable #6: clear session_id after each issue.
156
+ state.session_id = None
157
+ _state.save_state(state_path, state)
158
+ logger.info("Phase 3/4: issue #%d closed.", issue.number)
159
+ else:
160
+ logger.error(
161
+ "Phase 3/4: issue #%d could not be implemented after %d retries.",
162
+ issue.number,
163
+ max_retries,
164
+ )
@@ -0,0 +1,154 @@
1
+ """Phase 5 — Closure and cross-module review.
2
+
3
+ Runs Opus 4.6 to review the full codebase, close any straggling issues,
4
+ and optionally create new ``fix:`` issues. After the session, re-fetches
5
+ open issues and returns a :class:`FixResult` so the orchestrator can
6
+ re-enter phases 3/4 for any newly-created fix issues.
7
+
8
+ Non-negotiable #8: model is hard-coded to ``claude-opus-4-6``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from code_generator import gh
17
+ from code_generator import state as _state
18
+ from code_generator.logging_setup import setup_phase_logger
19
+ from code_generator.prompts import load_prompt
20
+ from code_generator.runner import rate_limit
21
+
22
+ if TYPE_CHECKING:
23
+ import logging
24
+ from pathlib import Path
25
+
26
+ from code_generator.state import CycleState, IssueState, State
27
+
28
+
29
+ @dataclass
30
+ class FixResult:
31
+ """Returned by :func:`run` to signal new fix issues to the orchestrator.
32
+
33
+ Attributes:
34
+ new_issues: List of IssueState objects for issues created during
35
+ phase 5 (identified by the ``fix:`` title prefix).
36
+ """
37
+
38
+ new_issues: list[IssueState] = field(default_factory=list)
39
+
40
+
41
+ def _known_numbers(state: State, cycle: CycleState | None) -> set[int]:
42
+ """Return the set of issue numbers already in state/cycle.
43
+
44
+ Args:
45
+ state: Root state.
46
+ cycle: Active cycle or ``None``.
47
+
48
+ Returns:
49
+ Set of known issue numbers.
50
+ """
51
+ issues = cycle.issues if cycle is not None else state.issues
52
+ return {i.number for i in issues}
53
+
54
+
55
+ def _agent_from_labels(labels: list[dict[str, Any]]) -> str: # type: ignore[type-arg]
56
+ for lbl in labels:
57
+ name: str = lbl.get("name", "")
58
+ if name.startswith("agent:"):
59
+ return name[len("agent:"):]
60
+ return "python-pro"
61
+
62
+
63
+ async def run(
64
+ state: State,
65
+ cycle: CycleState | None,
66
+ project_dir: Path,
67
+ *,
68
+ runner_module: Any,
69
+ logger: logging.Logger | None = None,
70
+ ) -> FixResult:
71
+ """Execute phase 5: overall review and closure.
72
+
73
+ Args:
74
+ state: Root state object (mutated in place).
75
+ cycle: Active cycle (multi-cycle) or ``None`` (single mode).
76
+ project_dir: Project root directory.
77
+ runner_module: Runner module from ``get_runner()``.
78
+ logger: Optional phase logger.
79
+
80
+ Returns:
81
+ :class:`FixResult` listing any new fix issues discovered after the
82
+ session. An empty list means no re-entry into phases 3/4 is needed.
83
+ """
84
+ if logger is None:
85
+ logger = setup_phase_logger("phase5", project_dir)
86
+
87
+ state_path = project_dir / ".code-generator" / "state.json"
88
+
89
+ prompt = load_prompt(
90
+ "prompt-phase-5-final-review.md",
91
+ CYCLE_SCOPE=(
92
+ cycle.scope
93
+ if cycle is not None
94
+ else "single-cycle — implement all features from requirements.md"
95
+ ),
96
+ MILESTONE_TITLE=cycle.milestone_title if cycle is not None else "",
97
+ )
98
+
99
+ try:
100
+ from claude_agent_sdk import ClaudeAgentOptions # type: ignore[import-not-found]
101
+ except ImportError: # pragma: no cover
102
+ ClaudeAgentOptions = type( # type: ignore[assignment,misc]
103
+ "ClaudeAgentOptions",
104
+ (),
105
+ {"__init__": lambda self, **kw: self.__dict__.update(kw)},
106
+ )
107
+
108
+ options = ClaudeAgentOptions(
109
+ model="claude-opus-4-6",
110
+ allowed_tools=["Read", "Bash", "Glob", "Grep"],
111
+ cwd=str(project_dir),
112
+ max_turns=30,
113
+ )
114
+
115
+ await rate_limit.main_loop(
116
+ runner_module,
117
+ prompt,
118
+ options,
119
+ state_path=state_path,
120
+ logger=logger,
121
+ )
122
+
123
+ # Re-fetch open issues to detect any new fix: issues.
124
+ known = _known_numbers(state, cycle)
125
+ milestone_title = cycle.milestone_title if cycle is not None else None
126
+
127
+ try:
128
+ open_issues = gh.list_issues(milestone=milestone_title, state="open")
129
+ except gh.GhError as exc:
130
+ logger.warning("Phase 5: could not list issues after session: %s", exc)
131
+ return FixResult()
132
+
133
+ new_issue_states: list[_state.IssueState] = []
134
+ for raw in open_issues:
135
+ num = raw["number"]
136
+ if num in known:
137
+ continue
138
+ # Only pick up new issues — they may be fix: issues created by the session.
139
+ new_state = _state.IssueState(
140
+ number=num,
141
+ status="open",
142
+ agent=_agent_from_labels(raw.get("labels", [])),
143
+ )
144
+ new_issue_states.append(new_state)
145
+
146
+ if new_issue_states:
147
+ if cycle is not None:
148
+ cycle.issues.extend(new_issue_states)
149
+ else:
150
+ state.issues.extend(new_issue_states)
151
+ _state.save_state(state_path, state)
152
+ logger.info("Phase 5: %d new fix issues detected.", len(new_issue_states))
153
+
154
+ return FixResult(new_issues=new_issue_states)
@@ -0,0 +1,98 @@
1
+ """Phase 6 — Test suite runner.
2
+
3
+ Runs Sonnet 4.6 to execute the test suite and fix failures iteratively
4
+ (max 3 attempts, controlled by the prompt). Raises :class:`TestsFailed`
5
+ if the session text contains the sentinel string ``"TESTS FAILED"``.
6
+
7
+ Non-negotiable #8: model is hard-coded to ``claude-sonnet-4-6``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from code_generator.logging_setup import setup_phase_logger
15
+ from code_generator.prompts import load_prompt
16
+ from code_generator.runner import rate_limit
17
+
18
+ if TYPE_CHECKING:
19
+ import logging
20
+ from pathlib import Path
21
+
22
+ from code_generator.state import CycleState, State
23
+
24
+
25
+ # Sentinel emitted by the prompt when all retries are exhausted.
26
+ _FAIL_SENTINEL = "TESTS FAILED"
27
+
28
+
29
+ class TestsFailed(RuntimeError): # noqa: N818
30
+ """Raised when the test-runner session reports persistent test failures.
31
+
32
+ The orchestrator should skip phase 7 (commit) when this is raised.
33
+ """
34
+
35
+ __test__ = False # pytest: not a test class
36
+
37
+
38
+ async def run(
39
+ state: State,
40
+ cycle: CycleState | None,
41
+ project_dir: Path,
42
+ *,
43
+ runner_module: Any,
44
+ logger: logging.Logger | None = None,
45
+ ) -> None:
46
+ """Execute phase 6: test suite runner.
47
+
48
+ Loads the phase-6 prompt (with ``MAX_RETRIES="3"``), runs it via
49
+ the rate-limit loop, and inspects the result text for the sentinel.
50
+
51
+ Args:
52
+ state: Root state object.
53
+ cycle: Active cycle (multi-cycle) or ``None`` (single mode).
54
+ project_dir: Project root directory.
55
+ runner_module: Runner module from ``get_runner()``.
56
+ logger: Optional phase logger.
57
+
58
+ Raises:
59
+ TestsFailed: When the result text contains ``"TESTS FAILED"``.
60
+ """
61
+ if logger is None:
62
+ logger = setup_phase_logger("phase6", project_dir)
63
+
64
+ state_path = project_dir / ".code-generator" / "state.json"
65
+
66
+ prompt = load_prompt("prompt-phase-6-test.md", MAX_RETRIES="3")
67
+
68
+ try:
69
+ from claude_agent_sdk import ClaudeAgentOptions # type: ignore[import-not-found]
70
+ except ImportError: # pragma: no cover
71
+ ClaudeAgentOptions = type( # type: ignore[assignment,misc]
72
+ "ClaudeAgentOptions",
73
+ (),
74
+ {"__init__": lambda self, **kw: self.__dict__.update(kw)},
75
+ )
76
+
77
+ options = ClaudeAgentOptions(
78
+ model="claude-sonnet-4-6",
79
+ allowed_tools=["Read", "Edit", "Bash"],
80
+ cwd=str(project_dir),
81
+ max_turns=40,
82
+ )
83
+
84
+ result = await rate_limit.main_loop(
85
+ runner_module,
86
+ prompt,
87
+ options,
88
+ state_path=state_path,
89
+ logger=logger,
90
+ )
91
+
92
+ if _FAIL_SENTINEL in result.text:
93
+ logger.error("Phase 6: test runner reported persistent failures.")
94
+ raise TestsFailed(
95
+ "Test suite failed after all retries. Inspect the output above and fix manually."
96
+ )
97
+
98
+ logger.info("Phase 6 complete: all tests passed.")
@@ -0,0 +1,167 @@
1
+ """Phase 7 — Commit message generation and git push.
2
+
3
+ Uses Haiku 4.5 to generate a commit message, then performs
4
+ ``git add . → git commit -m <msg> → git push origin <branch>``
5
+ with one rebase-and-retry on push rejection.
6
+
7
+ Non-negotiable #8: model is hard-coded to ``claude-haiku-4-5``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import subprocess
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from code_generator.logging_setup import setup_phase_logger
16
+ from code_generator.prompts import load_prompt
17
+ from code_generator.runner import rate_limit
18
+
19
+ if TYPE_CHECKING:
20
+ import logging
21
+ from pathlib import Path
22
+
23
+ from code_generator.state import CycleState, IssueState, State
24
+
25
+
26
+ def _current_branch(project_dir: Path) -> str:
27
+ """Return the current git branch name.
28
+
29
+ Args:
30
+ project_dir: Directory to run git in.
31
+
32
+ Returns:
33
+ Branch name string.
34
+ """
35
+ result = subprocess.run(
36
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
37
+ cwd=project_dir,
38
+ capture_output=True,
39
+ text=True,
40
+ check=True,
41
+ )
42
+ return result.stdout.strip()
43
+
44
+
45
+ def _has_staged_changes(project_dir: Path) -> bool:
46
+ """Return True when the git index has staged changes.
47
+
48
+ Args:
49
+ project_dir: Directory to run git in.
50
+
51
+ Returns:
52
+ ``True`` if there are staged changes, ``False`` otherwise.
53
+ """
54
+ result = subprocess.run(
55
+ ["git", "diff", "--cached", "--quiet"],
56
+ cwd=project_dir,
57
+ check=False,
58
+ )
59
+ # Exit code 0 means no diff (nothing staged); 1 means there are changes.
60
+ return result.returncode != 0
61
+
62
+
63
+ def _closed_issue_numbers(state: State, cycle: CycleState | None) -> list[int]:
64
+ """Collect issue numbers marked as closed in the current scope.
65
+
66
+ Args:
67
+ state: Root state.
68
+ cycle: Active cycle or ``None`` for single mode.
69
+
70
+ Returns:
71
+ Sorted list of closed issue numbers.
72
+ """
73
+ issues: list[IssueState] = cycle.issues if cycle is not None else state.issues
74
+ return sorted(i.number for i in issues if i.status == "closed")
75
+
76
+
77
+ async def run(
78
+ state: State,
79
+ cycle: CycleState | None,
80
+ project_dir: Path,
81
+ *,
82
+ runner_module: Any,
83
+ logger: logging.Logger | None = None,
84
+ ) -> None:
85
+ """Execute phase 7: commit and push.
86
+
87
+ Steps:
88
+ 1. ``git add .``
89
+ 2. ``git diff --cached --quiet`` — skip when nothing staged.
90
+ 3. Run Haiku 4.5 via the rate-limit loop to generate a commit message.
91
+ 4. ``git commit -m <msg>``
92
+ 5. ``git push origin <branch>``; on non-zero exit: ``git pull --rebase
93
+ && git push origin <branch>`` (one retry).
94
+
95
+ Args:
96
+ state: Root state object.
97
+ cycle: Active cycle (multi-cycle) or ``None`` (single mode).
98
+ project_dir: Project root directory.
99
+ runner_module: Runner module from ``get_runner()``.
100
+ logger: Optional phase logger.
101
+ """
102
+ if logger is None:
103
+ logger = setup_phase_logger("phase7", project_dir)
104
+
105
+ state_path = project_dir / ".code-generator" / "state.json"
106
+
107
+ # Stage everything.
108
+ subprocess.run(["git", "add", "."], cwd=project_dir, check=True)
109
+
110
+ # Skip commit when there is nothing staged.
111
+ if not _has_staged_changes(project_dir):
112
+ logger.info("Phase 7: nothing staged — skipping commit and push.")
113
+ return
114
+
115
+ closed_numbers = _closed_issue_numbers(state, cycle)
116
+ issues_closed = ", ".join(f"#{n}" for n in closed_numbers) if closed_numbers else "none"
117
+ cycle_name = cycle.name if cycle is not None else ""
118
+
119
+ prompt = load_prompt(
120
+ "prompt-phase-7-commit.md",
121
+ CYCLE_NAME=cycle_name,
122
+ ISSUES_CLOSED=issues_closed,
123
+ )
124
+
125
+ try:
126
+ from claude_agent_sdk import ClaudeAgentOptions # type: ignore[import-not-found]
127
+ except ImportError: # pragma: no cover
128
+ ClaudeAgentOptions = type( # type: ignore[assignment,misc]
129
+ "ClaudeAgentOptions",
130
+ (),
131
+ {"__init__": lambda self, **kw: self.__dict__.update(kw)},
132
+ )
133
+
134
+ options = ClaudeAgentOptions(
135
+ model="claude-haiku-4-5",
136
+ allowed_tools=["Read", "Bash"],
137
+ cwd=str(project_dir),
138
+ max_turns=5,
139
+ )
140
+
141
+ result = await rate_limit.main_loop(
142
+ runner_module,
143
+ prompt,
144
+ options,
145
+ state_path=state_path,
146
+ logger=logger,
147
+ )
148
+
149
+ commit_msg = result.text.strip()
150
+ if not commit_msg:
151
+ commit_msg = f"chore: cycle {cycle_name or 'single'} complete"
152
+
153
+ subprocess.run(["git", "commit", "-m", commit_msg], cwd=project_dir, check=True)
154
+
155
+ branch = _current_branch(project_dir)
156
+
157
+ push_result = subprocess.run(
158
+ ["git", "push", "origin", branch],
159
+ cwd=project_dir,
160
+ check=False,
161
+ )
162
+ if push_result.returncode != 0:
163
+ logger.warning("Phase 7: push rejected — attempting rebase and retry.")
164
+ subprocess.run(["git", "pull", "--rebase"], cwd=project_dir, check=True)
165
+ subprocess.run(["git", "push", "origin", branch], cwd=project_dir, check=True)
166
+
167
+ logger.info("Phase 7 complete: committed and pushed to %s.", branch)