galangal-orchestrate 0.2.11__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.
Potentially problematic release.
This version of galangal-orchestrate might be problematic. Click here for more details.
- galangal/__init__.py +8 -0
- galangal/__main__.py +6 -0
- galangal/ai/__init__.py +6 -0
- galangal/ai/base.py +55 -0
- galangal/ai/claude.py +278 -0
- galangal/ai/gemini.py +38 -0
- galangal/cli.py +296 -0
- galangal/commands/__init__.py +42 -0
- galangal/commands/approve.py +187 -0
- galangal/commands/complete.py +268 -0
- galangal/commands/init.py +173 -0
- galangal/commands/list.py +20 -0
- galangal/commands/pause.py +40 -0
- galangal/commands/prompts.py +98 -0
- galangal/commands/reset.py +43 -0
- galangal/commands/resume.py +29 -0
- galangal/commands/skip.py +216 -0
- galangal/commands/start.py +144 -0
- galangal/commands/status.py +62 -0
- galangal/commands/switch.py +28 -0
- galangal/config/__init__.py +13 -0
- galangal/config/defaults.py +133 -0
- galangal/config/loader.py +113 -0
- galangal/config/schema.py +155 -0
- galangal/core/__init__.py +18 -0
- galangal/core/artifacts.py +66 -0
- galangal/core/state.py +248 -0
- galangal/core/tasks.py +170 -0
- galangal/core/workflow.py +835 -0
- galangal/prompts/__init__.py +5 -0
- galangal/prompts/builder.py +166 -0
- galangal/prompts/defaults/design.md +54 -0
- galangal/prompts/defaults/dev.md +39 -0
- galangal/prompts/defaults/docs.md +46 -0
- galangal/prompts/defaults/pm.md +75 -0
- galangal/prompts/defaults/qa.md +49 -0
- galangal/prompts/defaults/review.md +65 -0
- galangal/prompts/defaults/security.md +68 -0
- galangal/prompts/defaults/test.md +59 -0
- galangal/ui/__init__.py +5 -0
- galangal/ui/console.py +123 -0
- galangal/ui/tui.py +1065 -0
- galangal/validation/__init__.py +5 -0
- galangal/validation/runner.py +395 -0
- galangal_orchestrate-0.2.11.dist-info/METADATA +278 -0
- galangal_orchestrate-0.2.11.dist-info/RECORD +49 -0
- galangal_orchestrate-0.2.11.dist-info/WHEEL +4 -0
- galangal_orchestrate-0.2.11.dist-info/entry_points.txt +2 -0
- galangal_orchestrate-0.2.11.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""CLI commands."""
|
|
2
|
+
|
|
3
|
+
from galangal.commands.init import cmd_init
|
|
4
|
+
from galangal.commands.start import cmd_start
|
|
5
|
+
from galangal.commands.resume import cmd_resume
|
|
6
|
+
from galangal.commands.status import cmd_status
|
|
7
|
+
from galangal.commands.list import cmd_list
|
|
8
|
+
from galangal.commands.switch import cmd_switch
|
|
9
|
+
from galangal.commands.pause import cmd_pause
|
|
10
|
+
from galangal.commands.approve import cmd_approve, cmd_approve_design
|
|
11
|
+
from galangal.commands.skip import (
|
|
12
|
+
cmd_skip_design,
|
|
13
|
+
cmd_skip_security,
|
|
14
|
+
cmd_skip_migration,
|
|
15
|
+
cmd_skip_contract,
|
|
16
|
+
cmd_skip_benchmark,
|
|
17
|
+
cmd_skip_to,
|
|
18
|
+
)
|
|
19
|
+
from galangal.commands.reset import cmd_reset
|
|
20
|
+
from galangal.commands.complete import cmd_complete
|
|
21
|
+
from galangal.commands.prompts import cmd_prompts
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"cmd_init",
|
|
25
|
+
"cmd_start",
|
|
26
|
+
"cmd_resume",
|
|
27
|
+
"cmd_status",
|
|
28
|
+
"cmd_list",
|
|
29
|
+
"cmd_switch",
|
|
30
|
+
"cmd_pause",
|
|
31
|
+
"cmd_approve",
|
|
32
|
+
"cmd_approve_design",
|
|
33
|
+
"cmd_skip_design",
|
|
34
|
+
"cmd_skip_security",
|
|
35
|
+
"cmd_skip_migration",
|
|
36
|
+
"cmd_skip_contract",
|
|
37
|
+
"cmd_skip_benchmark",
|
|
38
|
+
"cmd_skip_to",
|
|
39
|
+
"cmd_reset",
|
|
40
|
+
"cmd_complete",
|
|
41
|
+
"cmd_prompts",
|
|
42
|
+
]
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""
|
|
2
|
+
galangal approve - Record approval for plans and designs.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
from rich.prompt import Prompt
|
|
9
|
+
|
|
10
|
+
from galangal.core.state import Stage, load_state, save_state
|
|
11
|
+
from galangal.core.tasks import get_active_task
|
|
12
|
+
from galangal.core.artifacts import artifact_exists, read_artifact, write_artifact
|
|
13
|
+
from galangal.core.workflow import run_workflow
|
|
14
|
+
from galangal.ui.console import console, print_error, print_success, print_info
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def prompt_plan_approval(task_name: str, state) -> str:
|
|
18
|
+
"""
|
|
19
|
+
Interactive approval prompt for PLAN.md.
|
|
20
|
+
Returns: 'approved', 'rejected', or 'quit'
|
|
21
|
+
"""
|
|
22
|
+
console.print("\n" + "=" * 60)
|
|
23
|
+
console.print("[bold yellow]⏸️ APPROVAL REQUIRED[/bold yellow]")
|
|
24
|
+
console.print("=" * 60)
|
|
25
|
+
console.print(f"\nTask: {task_name}")
|
|
26
|
+
console.print(f"Stage: PM → [yellow]APPROVAL GATE[/yellow] → DESIGN")
|
|
27
|
+
|
|
28
|
+
# Show PLAN.md content
|
|
29
|
+
plan = read_artifact("PLAN.md", task_name)
|
|
30
|
+
if plan:
|
|
31
|
+
console.print("\n[bold]PLAN.md Preview:[/bold]")
|
|
32
|
+
console.print("[dim]" + "-" * 40 + "[/dim]")
|
|
33
|
+
# Show first 1500 chars
|
|
34
|
+
preview = plan[:1500] + ("..." if len(plan) > 1500 else "")
|
|
35
|
+
console.print(preview)
|
|
36
|
+
console.print("[dim]" + "-" * 40 + "[/dim]")
|
|
37
|
+
|
|
38
|
+
console.print("\n[bold]Options:[/bold]")
|
|
39
|
+
console.print(" [green]y[/green] - Approve plan and continue to DESIGN")
|
|
40
|
+
console.print(" [red]n[/red] - Reject and restart PM stage")
|
|
41
|
+
console.print(" [yellow]q[/yellow] - Quit/pause (resume later)")
|
|
42
|
+
|
|
43
|
+
while True:
|
|
44
|
+
choice = Prompt.ask("Your choice", default="y").strip().lower()
|
|
45
|
+
|
|
46
|
+
if choice in ["y", "yes", "approve"]:
|
|
47
|
+
approver = Prompt.ask("Approver name", default="")
|
|
48
|
+
|
|
49
|
+
approval_content = f"""# Plan Approval
|
|
50
|
+
|
|
51
|
+
- **Status:** Approved
|
|
52
|
+
- **Approved By:** {approver or "Not specified"}
|
|
53
|
+
- **Date:** {datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")}
|
|
54
|
+
"""
|
|
55
|
+
write_artifact("APPROVAL.md", approval_content, task_name)
|
|
56
|
+
print_success("Plan approved!")
|
|
57
|
+
return "approved"
|
|
58
|
+
|
|
59
|
+
elif choice in ["n", "no", "reject"]:
|
|
60
|
+
reason = Prompt.ask("Rejection reason", default="Needs revision")
|
|
61
|
+
state.stage = Stage.PM
|
|
62
|
+
state.attempt = 1
|
|
63
|
+
state.last_failure = f"Plan rejected: {reason}"
|
|
64
|
+
save_state(state)
|
|
65
|
+
print_info("Plan rejected. Restarting PM stage.")
|
|
66
|
+
return "rejected"
|
|
67
|
+
|
|
68
|
+
elif choice in ["q", "quit", "pause"]:
|
|
69
|
+
return "quit"
|
|
70
|
+
|
|
71
|
+
else:
|
|
72
|
+
print_error("Invalid choice. Enter y/n/q")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def prompt_design_approval(task_name: str, state) -> str:
|
|
76
|
+
"""
|
|
77
|
+
Interactive approval prompt for DESIGN.md.
|
|
78
|
+
Returns: 'approved', 'rejected', or 'quit'
|
|
79
|
+
"""
|
|
80
|
+
console.print("\n" + "=" * 60)
|
|
81
|
+
console.print("[bold yellow]⏸️ DESIGN REVIEW REQUIRED[/bold yellow]")
|
|
82
|
+
console.print("=" * 60)
|
|
83
|
+
console.print(f"\nTask: {task_name}")
|
|
84
|
+
console.print(f"Stage: DESIGN → [yellow]REVIEW GATE[/yellow] → DEV")
|
|
85
|
+
|
|
86
|
+
# Show DESIGN.md content
|
|
87
|
+
design = read_artifact("DESIGN.md", task_name)
|
|
88
|
+
if design:
|
|
89
|
+
console.print("\n[bold]DESIGN.md Preview:[/bold]")
|
|
90
|
+
console.print("[dim]" + "-" * 40 + "[/dim]")
|
|
91
|
+
preview = design[:2000] + ("..." if len(design) > 2000 else "")
|
|
92
|
+
console.print(preview)
|
|
93
|
+
console.print("[dim]" + "-" * 40 + "[/dim]")
|
|
94
|
+
|
|
95
|
+
console.print("\n[bold]Options:[/bold]")
|
|
96
|
+
console.print(" [green]y[/green] - Approve design and continue to DEV")
|
|
97
|
+
console.print(" [red]n[/red] - Reject and restart DESIGN stage")
|
|
98
|
+
console.print(" [yellow]q[/yellow] - Quit/pause (resume later)")
|
|
99
|
+
|
|
100
|
+
while True:
|
|
101
|
+
choice = Prompt.ask("Your choice", default="y").strip().lower()
|
|
102
|
+
|
|
103
|
+
if choice in ["y", "yes", "approve"]:
|
|
104
|
+
approver = Prompt.ask("Reviewer name", default="")
|
|
105
|
+
|
|
106
|
+
review_content = f"""# Design Review
|
|
107
|
+
|
|
108
|
+
**Status:** Approved
|
|
109
|
+
**Date:** {datetime.now(timezone.utc).isoformat()}
|
|
110
|
+
**Reviewer:** {approver or "Not specified"}
|
|
111
|
+
|
|
112
|
+
## Review Notes
|
|
113
|
+
|
|
114
|
+
Design reviewed and approved for implementation.
|
|
115
|
+
"""
|
|
116
|
+
write_artifact("DESIGN_REVIEW.md", review_content, task_name)
|
|
117
|
+
print_success("Design approved!")
|
|
118
|
+
return "approved"
|
|
119
|
+
|
|
120
|
+
elif choice in ["n", "no", "reject"]:
|
|
121
|
+
reason = Prompt.ask("Rejection reason", default="Needs revision")
|
|
122
|
+
state.stage = Stage.DESIGN
|
|
123
|
+
state.attempt = 1
|
|
124
|
+
state.last_failure = f"Design rejected: {reason}"
|
|
125
|
+
save_state(state)
|
|
126
|
+
print_info("Design rejected. Restarting DESIGN stage.")
|
|
127
|
+
return "rejected"
|
|
128
|
+
|
|
129
|
+
elif choice in ["q", "quit", "pause"]:
|
|
130
|
+
return "quit"
|
|
131
|
+
|
|
132
|
+
else:
|
|
133
|
+
print_error("Invalid choice. Enter y/n/q")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def cmd_approve(args: argparse.Namespace) -> int:
|
|
137
|
+
"""Record human approval for active task (fallback command)."""
|
|
138
|
+
active = get_active_task()
|
|
139
|
+
if not active:
|
|
140
|
+
print_error("No active task.")
|
|
141
|
+
return 1
|
|
142
|
+
|
|
143
|
+
state = load_state(active)
|
|
144
|
+
if state is None:
|
|
145
|
+
print_error(f"Could not load state for '{active}'.")
|
|
146
|
+
return 1
|
|
147
|
+
|
|
148
|
+
if state.stage != Stage.DESIGN or artifact_exists("APPROVAL.md", active):
|
|
149
|
+
print_error("Approval not needed at this stage.")
|
|
150
|
+
return 1
|
|
151
|
+
|
|
152
|
+
result = prompt_plan_approval(active, state)
|
|
153
|
+
if result == "approved":
|
|
154
|
+
run_workflow(state)
|
|
155
|
+
return 0 if result == "approved" else 1
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def cmd_approve_design(args: argparse.Namespace) -> int:
|
|
159
|
+
"""Record design review approval for active task (fallback command)."""
|
|
160
|
+
active = get_active_task()
|
|
161
|
+
if not active:
|
|
162
|
+
print_error("No active task.")
|
|
163
|
+
return 1
|
|
164
|
+
|
|
165
|
+
state = load_state(active)
|
|
166
|
+
if state is None:
|
|
167
|
+
print_error(f"Could not load state for '{active}'.")
|
|
168
|
+
return 1
|
|
169
|
+
|
|
170
|
+
if state.stage != Stage.DEV:
|
|
171
|
+
print_error(
|
|
172
|
+
f"Design approval is for DEV stage. Current stage: {state.stage.value}"
|
|
173
|
+
)
|
|
174
|
+
return 1
|
|
175
|
+
|
|
176
|
+
if artifact_exists("DESIGN_REVIEW.md", active):
|
|
177
|
+
print_error("DESIGN_REVIEW.md already exists.")
|
|
178
|
+
return 1
|
|
179
|
+
|
|
180
|
+
if not artifact_exists("DESIGN.md", active):
|
|
181
|
+
print_error("DESIGN.md not found. Design stage may not have completed.")
|
|
182
|
+
return 1
|
|
183
|
+
|
|
184
|
+
result = prompt_design_approval(active, state)
|
|
185
|
+
if result == "approved":
|
|
186
|
+
run_workflow(state)
|
|
187
|
+
return 0 if result == "approved" else 1
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""
|
|
2
|
+
galangal complete - Complete a task, commit, and create PR.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from rich.prompt import Prompt
|
|
12
|
+
|
|
13
|
+
from galangal.config.loader import get_project_root, get_config, get_done_dir
|
|
14
|
+
from galangal.core.state import Stage, load_state, get_task_dir
|
|
15
|
+
from galangal.core.tasks import get_active_task, clear_active_task, get_current_branch
|
|
16
|
+
from galangal.core.artifacts import read_artifact, run_command
|
|
17
|
+
from galangal.ai.claude import ClaudeBackend
|
|
18
|
+
from galangal.ui.console import console, print_error, print_info, print_success, print_warning
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def generate_pr_title(task_name: str, description: str, task_type: str) -> str:
|
|
22
|
+
"""Generate a concise PR title using AI."""
|
|
23
|
+
backend = ClaudeBackend()
|
|
24
|
+
|
|
25
|
+
prompt = f"""Generate a concise pull request title for this task.
|
|
26
|
+
|
|
27
|
+
Task: {task_name}
|
|
28
|
+
Type: {task_type}
|
|
29
|
+
Description: {description[:500]}
|
|
30
|
+
|
|
31
|
+
Requirements:
|
|
32
|
+
1. Max 72 characters
|
|
33
|
+
2. Start with type prefix based on task type:
|
|
34
|
+
- Feature → "feat: ..."
|
|
35
|
+
- Bug Fix → "fix: ..."
|
|
36
|
+
- Refactor → "refactor: ..."
|
|
37
|
+
- Chore → "chore: ..."
|
|
38
|
+
- Docs → "docs: ..."
|
|
39
|
+
- Hotfix → "fix: ..."
|
|
40
|
+
3. Be specific about what changed
|
|
41
|
+
4. Use imperative mood ("Add feature" not "Added feature")
|
|
42
|
+
5. No period at end
|
|
43
|
+
|
|
44
|
+
Output ONLY the title, nothing else."""
|
|
45
|
+
|
|
46
|
+
title = backend.generate_text(prompt, timeout=30)
|
|
47
|
+
if title:
|
|
48
|
+
title = title.split("\n")[0].strip()
|
|
49
|
+
return title[:72] if len(title) > 72 else title
|
|
50
|
+
|
|
51
|
+
# Fallback
|
|
52
|
+
return description[:72] if len(description) > 72 else description
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def generate_commit_summary(task_name: str, description: str) -> str:
|
|
56
|
+
"""Generate a commit message summary using AI."""
|
|
57
|
+
backend = ClaudeBackend()
|
|
58
|
+
|
|
59
|
+
spec = read_artifact("SPEC.md", task_name) or ""
|
|
60
|
+
plan = read_artifact("PLAN.md", task_name) or ""
|
|
61
|
+
|
|
62
|
+
code, diff_stat, _ = run_command(["git", "diff", "--stat", "main...HEAD"])
|
|
63
|
+
code, changed_files, _ = run_command(["git", "diff", "--name-only", "main...HEAD"])
|
|
64
|
+
|
|
65
|
+
prompt = f"""Generate a concise git commit message for this task. Follow conventional commit format.
|
|
66
|
+
|
|
67
|
+
Task: {task_name}
|
|
68
|
+
Description: {description}
|
|
69
|
+
|
|
70
|
+
Specification summary:
|
|
71
|
+
{spec[:1000] if spec else "(none)"}
|
|
72
|
+
|
|
73
|
+
Implementation plan summary:
|
|
74
|
+
{plan[:800] if plan else "(none)"}
|
|
75
|
+
|
|
76
|
+
Files changed:
|
|
77
|
+
{changed_files[:1000] if changed_files else "(none)"}
|
|
78
|
+
|
|
79
|
+
Requirements:
|
|
80
|
+
1. First line: type(scope): brief description (max 72 chars)
|
|
81
|
+
- Types: feat, fix, refactor, chore, docs, test, style, perf
|
|
82
|
+
2. Blank line
|
|
83
|
+
3. Body: 2-4 bullet points summarizing key changes
|
|
84
|
+
4. Do NOT include any co-authored-by or generated-by lines
|
|
85
|
+
|
|
86
|
+
Output ONLY the commit message, nothing else."""
|
|
87
|
+
|
|
88
|
+
summary = backend.generate_text(prompt, timeout=60)
|
|
89
|
+
if summary:
|
|
90
|
+
return summary.strip()
|
|
91
|
+
|
|
92
|
+
return f"{description[:72]}"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def create_pull_request(task_name: str, description: str, task_type: str) -> tuple[bool, str]:
|
|
96
|
+
"""Create a pull request for the task branch."""
|
|
97
|
+
config = get_config()
|
|
98
|
+
branch_name = config.branch_pattern.format(task_name=task_name)
|
|
99
|
+
base_branch = config.pr.base_branch
|
|
100
|
+
|
|
101
|
+
code, current_branch, _ = run_command(["git", "branch", "--show-current"])
|
|
102
|
+
current_branch = current_branch.strip()
|
|
103
|
+
|
|
104
|
+
if current_branch != branch_name:
|
|
105
|
+
code, _, err = run_command(["git", "checkout", branch_name])
|
|
106
|
+
if code != 0:
|
|
107
|
+
return False, f"Could not switch to branch {branch_name}: {err}"
|
|
108
|
+
|
|
109
|
+
code, out, err = run_command(["git", "push", "-u", "origin", branch_name])
|
|
110
|
+
if code != 0:
|
|
111
|
+
if "Everything up-to-date" not in out and "Everything up-to-date" not in err:
|
|
112
|
+
return False, f"Failed to push branch: {err or out}"
|
|
113
|
+
|
|
114
|
+
spec_content = read_artifact("SPEC.md", task_name) or description
|
|
115
|
+
|
|
116
|
+
console.print("[dim]Generating PR title...[/dim]")
|
|
117
|
+
pr_title = generate_pr_title(task_name, description, task_type)
|
|
118
|
+
|
|
119
|
+
# Build PR body
|
|
120
|
+
pr_body = f"""## Summary
|
|
121
|
+
{spec_content[:1500] if len(spec_content) > 1500 else spec_content}
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
"""
|
|
125
|
+
# Add codex review if configured
|
|
126
|
+
if config.pr.codex_review:
|
|
127
|
+
pr_body += "@codex review\n"
|
|
128
|
+
|
|
129
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f:
|
|
130
|
+
f.write(pr_body)
|
|
131
|
+
body_file = f.name
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
code, out, err = run_command(
|
|
135
|
+
[
|
|
136
|
+
"gh",
|
|
137
|
+
"pr",
|
|
138
|
+
"create",
|
|
139
|
+
"--title",
|
|
140
|
+
pr_title,
|
|
141
|
+
"--body-file",
|
|
142
|
+
body_file,
|
|
143
|
+
"--base",
|
|
144
|
+
base_branch,
|
|
145
|
+
]
|
|
146
|
+
)
|
|
147
|
+
finally:
|
|
148
|
+
Path(body_file).unlink(missing_ok=True)
|
|
149
|
+
|
|
150
|
+
if code != 0:
|
|
151
|
+
combined_output = (out + err).lower()
|
|
152
|
+
if "already exists" in combined_output:
|
|
153
|
+
return True, "PR already exists"
|
|
154
|
+
if "pull request create failed" in combined_output:
|
|
155
|
+
code2, pr_url, _ = run_command(
|
|
156
|
+
["gh", "pr", "view", "--json", "url", "-q", ".url"]
|
|
157
|
+
)
|
|
158
|
+
if code2 == 0 and pr_url.strip():
|
|
159
|
+
return True, pr_url.strip()
|
|
160
|
+
return False, f"Failed to create PR: {err or out}"
|
|
161
|
+
|
|
162
|
+
return True, out.strip()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def commit_changes(task_name: str, description: str) -> tuple[bool, str]:
|
|
166
|
+
"""Commit all changes for a task."""
|
|
167
|
+
code, status_out, _ = run_command(["git", "status", "--porcelain"])
|
|
168
|
+
if code != 0:
|
|
169
|
+
return False, "Failed to check git status"
|
|
170
|
+
|
|
171
|
+
if not status_out.strip():
|
|
172
|
+
return True, "No changes to commit"
|
|
173
|
+
|
|
174
|
+
changes = [line for line in status_out.strip().split("\n") if line.strip()]
|
|
175
|
+
change_count = len(changes)
|
|
176
|
+
|
|
177
|
+
console.print(f"[dim]Committing {change_count} changed files...[/dim]")
|
|
178
|
+
|
|
179
|
+
code, _, err = run_command(["git", "add", "-A"])
|
|
180
|
+
if code != 0:
|
|
181
|
+
return False, f"Failed to stage changes: {err}"
|
|
182
|
+
|
|
183
|
+
console.print("[dim]Generating commit summary...[/dim]")
|
|
184
|
+
summary = generate_commit_summary(task_name, description)
|
|
185
|
+
|
|
186
|
+
commit_msg = f"""{summary}
|
|
187
|
+
|
|
188
|
+
Task: {task_name}
|
|
189
|
+
Changes: {change_count} files"""
|
|
190
|
+
|
|
191
|
+
code, out, err = run_command(["git", "commit", "-m", commit_msg])
|
|
192
|
+
if code != 0:
|
|
193
|
+
return False, f"Failed to commit: {err or out}"
|
|
194
|
+
|
|
195
|
+
return True, f"Committed {change_count} files"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def finalize_task(task_name: str, state, force: bool = False) -> bool:
|
|
199
|
+
"""Finalize a completed task: move to done/, commit, create PR."""
|
|
200
|
+
config = get_config()
|
|
201
|
+
project_root = get_project_root()
|
|
202
|
+
done_dir = get_done_dir()
|
|
203
|
+
|
|
204
|
+
# 1. Move to done/
|
|
205
|
+
task_dir = get_task_dir(task_name)
|
|
206
|
+
done_dir.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
dest = done_dir / task_name
|
|
208
|
+
|
|
209
|
+
if dest.exists():
|
|
210
|
+
dest = done_dir / f"{task_name}-{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
|
211
|
+
|
|
212
|
+
console.print(f"[dim]Moving task to {dest.relative_to(project_root)}/...[/dim]")
|
|
213
|
+
shutil.move(str(task_dir), str(dest))
|
|
214
|
+
clear_active_task()
|
|
215
|
+
|
|
216
|
+
# 2. Commit changes
|
|
217
|
+
console.print("[dim]Committing changes...[/dim]")
|
|
218
|
+
success, msg = commit_changes(task_name, state.task_description)
|
|
219
|
+
if success:
|
|
220
|
+
print_success(msg)
|
|
221
|
+
else:
|
|
222
|
+
print_warning(msg)
|
|
223
|
+
if not force:
|
|
224
|
+
confirm = Prompt.ask("Continue anyway? [y/N]", default="n").strip().lower()
|
|
225
|
+
if confirm != "y":
|
|
226
|
+
shutil.move(str(dest), str(task_dir))
|
|
227
|
+
from galangal.core.tasks import set_active_task
|
|
228
|
+
set_active_task(task_name)
|
|
229
|
+
print_info("Aborted. Task restored to original location.")
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
# 3. Create PR
|
|
233
|
+
console.print("[dim]Creating pull request...[/dim]")
|
|
234
|
+
success, msg = create_pull_request(task_name, state.task_description, state.task_type.display_name())
|
|
235
|
+
if success:
|
|
236
|
+
console.print(f"[green]PR:[/green] {msg}")
|
|
237
|
+
else:
|
|
238
|
+
print_warning(f"Could not create PR: {msg}")
|
|
239
|
+
console.print("You may need to create the PR manually.")
|
|
240
|
+
|
|
241
|
+
print_success(f"Task '{task_name}' completed and moved to {config.tasks_dir}/done/")
|
|
242
|
+
|
|
243
|
+
# 4. Switch back to main
|
|
244
|
+
run_command(["git", "checkout", config.pr.base_branch])
|
|
245
|
+
console.print(f"[dim]Switched back to {config.pr.base_branch} branch[/dim]")
|
|
246
|
+
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def cmd_complete(args: argparse.Namespace) -> int:
|
|
251
|
+
"""Move completed task to done/, commit, create PR."""
|
|
252
|
+
active = get_active_task()
|
|
253
|
+
if not active:
|
|
254
|
+
print_error("No active task.")
|
|
255
|
+
return 1
|
|
256
|
+
|
|
257
|
+
state = load_state(active)
|
|
258
|
+
if state is None:
|
|
259
|
+
print_error(f"Could not load state for '{active}'.")
|
|
260
|
+
return 1
|
|
261
|
+
|
|
262
|
+
if state.stage != Stage.COMPLETE:
|
|
263
|
+
print_error(f"Task is at stage {state.stage.value}, not COMPLETE.")
|
|
264
|
+
console.print("Run 'resume' to continue the workflow.")
|
|
265
|
+
return 1
|
|
266
|
+
|
|
267
|
+
success = finalize_task(active, state, force=args.force)
|
|
268
|
+
return 0 if success else 1
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""
|
|
2
|
+
galangal init - Initialize galangal in a project.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from rich.prompt import Prompt, Confirm
|
|
9
|
+
|
|
10
|
+
from galangal.config.defaults import generate_default_config
|
|
11
|
+
from galangal.config.loader import find_project_root
|
|
12
|
+
from galangal.ui.console import console, print_success, print_error, print_info
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def detect_stacks(project_root: Path) -> list[dict[str, str]]:
|
|
16
|
+
"""Detect technology stacks in the project."""
|
|
17
|
+
stacks = []
|
|
18
|
+
|
|
19
|
+
# Python detection
|
|
20
|
+
if (project_root / "pyproject.toml").exists() or (project_root / "setup.py").exists():
|
|
21
|
+
framework = None
|
|
22
|
+
# Check for framework
|
|
23
|
+
if (project_root / "requirements.txt").exists():
|
|
24
|
+
reqs = (project_root / "requirements.txt").read_text().lower()
|
|
25
|
+
if "fastapi" in reqs:
|
|
26
|
+
framework = "fastapi"
|
|
27
|
+
elif "django" in reqs:
|
|
28
|
+
framework = "django"
|
|
29
|
+
elif "flask" in reqs:
|
|
30
|
+
framework = "flask"
|
|
31
|
+
stacks.append({"language": "python", "framework": framework, "root": None})
|
|
32
|
+
|
|
33
|
+
# Check subdirectories for separate stacks
|
|
34
|
+
for subdir in ["backend", "api", "server"]:
|
|
35
|
+
subpath = project_root / subdir
|
|
36
|
+
if subpath.exists():
|
|
37
|
+
if (subpath / "pyproject.toml").exists() or (subpath / "requirements.txt").exists():
|
|
38
|
+
framework = None
|
|
39
|
+
if (subpath / "requirements.txt").exists():
|
|
40
|
+
reqs = (subpath / "requirements.txt").read_text().lower()
|
|
41
|
+
if "fastapi" in reqs:
|
|
42
|
+
framework = "fastapi"
|
|
43
|
+
elif "django" in reqs:
|
|
44
|
+
framework = "django"
|
|
45
|
+
# Only add if not already detected at root
|
|
46
|
+
if not any(s.get("root") is None and s["language"] == "python" for s in stacks):
|
|
47
|
+
stacks.append({"language": "python", "framework": framework, "root": f"{subdir}/"})
|
|
48
|
+
|
|
49
|
+
# TypeScript/JavaScript detection
|
|
50
|
+
if (project_root / "package.json").exists():
|
|
51
|
+
pkg = (project_root / "package.json").read_text().lower()
|
|
52
|
+
framework = None
|
|
53
|
+
if "vite" in pkg:
|
|
54
|
+
framework = "vite"
|
|
55
|
+
elif "next" in pkg:
|
|
56
|
+
framework = "next"
|
|
57
|
+
elif "react" in pkg:
|
|
58
|
+
framework = "react"
|
|
59
|
+
elif "vue" in pkg:
|
|
60
|
+
framework = "vue"
|
|
61
|
+
elif "angular" in pkg:
|
|
62
|
+
framework = "angular"
|
|
63
|
+
stacks.append({"language": "typescript", "framework": framework, "root": None})
|
|
64
|
+
|
|
65
|
+
# Check subdirectories for frontend
|
|
66
|
+
for subdir in ["frontend", "admin", "web", "client", "app"]:
|
|
67
|
+
subpath = project_root / subdir
|
|
68
|
+
if subpath.exists() and (subpath / "package.json").exists():
|
|
69
|
+
pkg = (subpath / "package.json").read_text().lower()
|
|
70
|
+
framework = None
|
|
71
|
+
if "vite" in pkg:
|
|
72
|
+
framework = "vite"
|
|
73
|
+
elif "next" in pkg:
|
|
74
|
+
framework = "next"
|
|
75
|
+
elif "react" in pkg:
|
|
76
|
+
framework = "react"
|
|
77
|
+
# Only add if not already detected at root
|
|
78
|
+
if not any(s.get("root") is None and s["language"] == "typescript" for s in stacks):
|
|
79
|
+
stacks.append({"language": "typescript", "framework": framework, "root": f"{subdir}/"})
|
|
80
|
+
|
|
81
|
+
# PHP detection
|
|
82
|
+
if (project_root / "composer.json").exists():
|
|
83
|
+
composer = (project_root / "composer.json").read_text().lower()
|
|
84
|
+
framework = None
|
|
85
|
+
if "symfony" in composer:
|
|
86
|
+
framework = "symfony"
|
|
87
|
+
elif "laravel" in composer:
|
|
88
|
+
framework = "laravel"
|
|
89
|
+
stacks.append({"language": "php", "framework": framework, "root": None})
|
|
90
|
+
|
|
91
|
+
# Go detection
|
|
92
|
+
if (project_root / "go.mod").exists():
|
|
93
|
+
stacks.append({"language": "go", "framework": None, "root": None})
|
|
94
|
+
|
|
95
|
+
# Rust detection
|
|
96
|
+
if (project_root / "Cargo.toml").exists():
|
|
97
|
+
stacks.append({"language": "rust", "framework": None, "root": None})
|
|
98
|
+
|
|
99
|
+
return stacks
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
103
|
+
"""Initialize galangal in the current project."""
|
|
104
|
+
console.print("\n[bold cyan]╔══════════════════════════════════════════════════════════════╗[/bold cyan]")
|
|
105
|
+
console.print("[bold cyan]║[/bold cyan] [bold]Galangal Orchestrate[/bold] [bold cyan]║[/bold cyan]")
|
|
106
|
+
console.print("[bold cyan]║[/bold cyan] AI-Driven Development Workflow [bold cyan]║[/bold cyan]")
|
|
107
|
+
console.print("[bold cyan]╚══════════════════════════════════════════════════════════════╝[/bold cyan]\n")
|
|
108
|
+
|
|
109
|
+
project_root = find_project_root()
|
|
110
|
+
galangal_dir = project_root / ".galangal"
|
|
111
|
+
|
|
112
|
+
if galangal_dir.exists():
|
|
113
|
+
print_info(f"Galangal already initialized in {project_root}")
|
|
114
|
+
if not Confirm.ask("Reinitialize?", default=False):
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
console.print(f"[dim]Project root: {project_root}[/dim]")
|
|
118
|
+
console.print("\n[bold]Scanning project structure...[/bold]\n")
|
|
119
|
+
|
|
120
|
+
# Detect stacks
|
|
121
|
+
stacks = detect_stacks(project_root)
|
|
122
|
+
|
|
123
|
+
if stacks:
|
|
124
|
+
console.print("[bold]Detected stacks:[/bold]")
|
|
125
|
+
for i, stack in enumerate(stacks, 1):
|
|
126
|
+
framework = f"/{stack['framework']}" if stack.get("framework") else ""
|
|
127
|
+
root = f" ({stack['root']})" if stack.get("root") else ""
|
|
128
|
+
console.print(f" [{i}] {stack['language'].title()}{framework}{root}")
|
|
129
|
+
|
|
130
|
+
if not Confirm.ask("\nIs this correct?", default=True):
|
|
131
|
+
console.print("[dim]You can edit .galangal/config.yaml after initialization.[/dim]")
|
|
132
|
+
else:
|
|
133
|
+
console.print("[yellow]No stacks detected. You can configure them in .galangal/config.yaml[/yellow]")
|
|
134
|
+
stacks = [{"language": "python", "framework": None, "root": None}]
|
|
135
|
+
|
|
136
|
+
# Get project name
|
|
137
|
+
default_name = project_root.name
|
|
138
|
+
project_name = Prompt.ask("Project name", default=default_name)
|
|
139
|
+
|
|
140
|
+
# Create .galangal directory
|
|
141
|
+
galangal_dir.mkdir(exist_ok=True)
|
|
142
|
+
(galangal_dir / "prompts").mkdir(exist_ok=True)
|
|
143
|
+
|
|
144
|
+
# Generate config
|
|
145
|
+
config_content = generate_default_config(
|
|
146
|
+
project_name=project_name,
|
|
147
|
+
stacks=stacks,
|
|
148
|
+
)
|
|
149
|
+
(galangal_dir / "config.yaml").write_text(config_content)
|
|
150
|
+
|
|
151
|
+
print_success(f"Created .galangal/config.yaml")
|
|
152
|
+
print_success(f"Created .galangal/prompts/ (empty - uses defaults)")
|
|
153
|
+
|
|
154
|
+
# Add to .gitignore
|
|
155
|
+
gitignore = project_root / ".gitignore"
|
|
156
|
+
tasks_entry = "galangal-tasks/"
|
|
157
|
+
if gitignore.exists():
|
|
158
|
+
content = gitignore.read_text()
|
|
159
|
+
if tasks_entry not in content:
|
|
160
|
+
with open(gitignore, "a") as f:
|
|
161
|
+
f.write(f"\n# Galangal task artifacts\n{tasks_entry}\n")
|
|
162
|
+
print_success(f"Added {tasks_entry} to .gitignore")
|
|
163
|
+
else:
|
|
164
|
+
gitignore.write_text(f"# Galangal task artifacts\n{tasks_entry}\n")
|
|
165
|
+
print_success(f"Created .gitignore with {tasks_entry}")
|
|
166
|
+
|
|
167
|
+
console.print("\n[bold green]Initialization complete![/bold green]\n")
|
|
168
|
+
console.print("To customize prompts for your project:")
|
|
169
|
+
console.print(" [cyan]galangal prompts export[/cyan] # Export defaults to .galangal/prompts/")
|
|
170
|
+
console.print("\nNext steps:")
|
|
171
|
+
console.print(" [cyan]galangal start \"Your first task\"[/cyan]")
|
|
172
|
+
|
|
173
|
+
return 0
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
galangal list - List all tasks.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from galangal.core.tasks import get_active_task, list_tasks
|
|
8
|
+
from galangal.ui.console import console, display_task_list
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
12
|
+
"""List all tasks."""
|
|
13
|
+
tasks = list_tasks()
|
|
14
|
+
active = get_active_task()
|
|
15
|
+
|
|
16
|
+
display_task_list(tasks, active)
|
|
17
|
+
|
|
18
|
+
if active:
|
|
19
|
+
console.print("\n[dim]→ = active task[/dim]")
|
|
20
|
+
return 0
|