up-cli 0.1.1__py3-none-any.whl → 0.5.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 (55) hide show
  1. up/__init__.py +1 -1
  2. up/ai_cli.py +229 -0
  3. up/cli.py +75 -4
  4. up/commands/agent.py +521 -0
  5. up/commands/bisect.py +343 -0
  6. up/commands/branch.py +350 -0
  7. up/commands/dashboard.py +248 -0
  8. up/commands/init.py +195 -6
  9. up/commands/learn.py +1741 -0
  10. up/commands/memory.py +545 -0
  11. up/commands/new.py +108 -10
  12. up/commands/provenance.py +267 -0
  13. up/commands/review.py +239 -0
  14. up/commands/start.py +1124 -0
  15. up/commands/status.py +360 -0
  16. up/commands/summarize.py +122 -0
  17. up/commands/sync.py +317 -0
  18. up/commands/vibe.py +304 -0
  19. up/context.py +421 -0
  20. up/core/__init__.py +69 -0
  21. up/core/checkpoint.py +479 -0
  22. up/core/provenance.py +364 -0
  23. up/core/state.py +678 -0
  24. up/events.py +512 -0
  25. up/git/__init__.py +37 -0
  26. up/git/utils.py +270 -0
  27. up/git/worktree.py +331 -0
  28. up/learn/__init__.py +155 -0
  29. up/learn/analyzer.py +227 -0
  30. up/learn/plan.py +374 -0
  31. up/learn/research.py +511 -0
  32. up/learn/utils.py +117 -0
  33. up/memory.py +1096 -0
  34. up/parallel.py +551 -0
  35. up/summarizer.py +407 -0
  36. up/templates/__init__.py +70 -2
  37. up/templates/config/__init__.py +502 -20
  38. up/templates/docs/SKILL.md +28 -0
  39. up/templates/docs/__init__.py +341 -0
  40. up/templates/docs/standards/HEADERS.md +24 -0
  41. up/templates/docs/standards/STRUCTURE.md +18 -0
  42. up/templates/docs/standards/TEMPLATES.md +19 -0
  43. up/templates/learn/__init__.py +567 -14
  44. up/templates/loop/__init__.py +546 -27
  45. up/templates/mcp/__init__.py +474 -0
  46. up/templates/projects/__init__.py +786 -0
  47. up/ui/__init__.py +14 -0
  48. up/ui/loop_display.py +650 -0
  49. up/ui/theme.py +137 -0
  50. up_cli-0.5.0.dist-info/METADATA +519 -0
  51. up_cli-0.5.0.dist-info/RECORD +55 -0
  52. up_cli-0.1.1.dist-info/METADATA +0 -186
  53. up_cli-0.1.1.dist-info/RECORD +0 -14
  54. {up_cli-0.1.1.dist-info → up_cli-0.5.0.dist-info}/WHEEL +0 -0
  55. {up_cli-0.1.1.dist-info → up_cli-0.5.0.dist-info}/entry_points.txt +0 -0
up/git/utils.py ADDED
@@ -0,0 +1,270 @@
1
+ """Shared Git utilities for up-cli.
2
+
3
+ This module provides common Git operations used across the codebase,
4
+ eliminating duplication between worktree.py and agent.py.
5
+ """
6
+
7
+ import subprocess
8
+ from pathlib import Path
9
+ from typing import Optional, List, Tuple
10
+
11
+ # Standard branch prefix for all agent/worktree operations
12
+ BRANCH_PREFIX = "agent"
13
+
14
+ # Default timeout for git operations (seconds)
15
+ DEFAULT_GIT_TIMEOUT = 60
16
+
17
+
18
+ # =============================================================================
19
+ # Exceptions
20
+ # =============================================================================
21
+
22
+ class GitError(Exception):
23
+ """Base exception for Git operations."""
24
+ pass
25
+
26
+
27
+ class GitNotInstalledError(GitError):
28
+ """Git is not installed or not in PATH."""
29
+ pass
30
+
31
+
32
+ class GitTimeoutError(GitError):
33
+ """Git command timed out."""
34
+
35
+ def __init__(self, message: str, timeout: int):
36
+ super().__init__(message)
37
+ self.timeout = timeout
38
+
39
+
40
+ class GitCommandError(GitError):
41
+ """Git command failed with non-zero exit code."""
42
+
43
+ def __init__(self, message: str, returncode: int, stderr: str = ""):
44
+ super().__init__(message)
45
+ self.returncode = returncode
46
+ self.stderr = stderr
47
+
48
+
49
+ # =============================================================================
50
+ # Core Functions
51
+ # =============================================================================
52
+
53
+
54
+ def is_git_repo(path: Optional[Path] = None) -> bool:
55
+ """Check if path is inside a Git repository.
56
+
57
+ Args:
58
+ path: Directory to check (defaults to cwd)
59
+
60
+ Returns:
61
+ True if path is in a Git repository
62
+
63
+ Note:
64
+ Returns False if git is not installed (does not raise).
65
+ """
66
+ try:
67
+ result = run_git("rev-parse", "--git-dir", cwd=path, timeout=10)
68
+ return result.returncode == 0
69
+ except (GitNotInstalledError, GitTimeoutError, GitError):
70
+ return False
71
+ except Exception:
72
+ return False
73
+
74
+
75
+ def get_current_branch(path: Optional[Path] = None) -> str:
76
+ """Get current Git branch name.
77
+
78
+ Args:
79
+ path: Repository path (defaults to cwd)
80
+
81
+ Returns:
82
+ Current branch name
83
+
84
+ Raises:
85
+ GitNotInstalledError: If git is not installed
86
+ GitTimeoutError: If command times out
87
+ """
88
+ result = run_git("rev-parse", "--abbrev-ref", "HEAD", cwd=path)
89
+ return result.stdout.strip()
90
+
91
+
92
+ def count_commits_since(path: Path, base: str = "main") -> int:
93
+ """Count commits since branching from base.
94
+
95
+ Args:
96
+ path: Repository path
97
+ base: Base branch to compare against
98
+
99
+ Returns:
100
+ Number of commits since base (0 if error)
101
+ """
102
+ try:
103
+ result = run_git("rev-list", "--count", f"{base}..HEAD", cwd=path)
104
+ return int(result.stdout.strip())
105
+ except (GitError, ValueError):
106
+ return 0
107
+
108
+
109
+ def get_repo_root(path: Optional[Path] = None) -> Optional[Path]:
110
+ """Get the root directory of the Git repository.
111
+
112
+ Args:
113
+ path: Starting path (defaults to cwd)
114
+
115
+ Returns:
116
+ Repository root path or None if not in a repo
117
+ """
118
+ try:
119
+ result = run_git("rev-parse", "--show-toplevel", cwd=path)
120
+ if result.returncode == 0:
121
+ return Path(result.stdout.strip())
122
+ return None
123
+ except GitError:
124
+ return None
125
+
126
+
127
+ def make_branch_name(name: str) -> str:
128
+ """Create a standardized branch name.
129
+
130
+ Args:
131
+ name: Agent or task name
132
+
133
+ Returns:
134
+ Branch name with standard prefix
135
+ """
136
+ return f"{BRANCH_PREFIX}/{name}"
137
+
138
+
139
+ def run_git(
140
+ *args,
141
+ cwd: Optional[Path] = None,
142
+ check: bool = False,
143
+ timeout: int = DEFAULT_GIT_TIMEOUT
144
+ ) -> subprocess.CompletedProcess:
145
+ """Run a git command with standard options.
146
+
147
+ Args:
148
+ *args: Git command arguments
149
+ cwd: Working directory
150
+ check: Raise exception on failure
151
+ timeout: Command timeout in seconds
152
+
153
+ Returns:
154
+ CompletedProcess result
155
+
156
+ Raises:
157
+ GitNotInstalledError: If git is not installed
158
+ GitTimeoutError: If command times out
159
+ GitCommandError: If check=True and command fails
160
+ """
161
+ cmd = ["git"] + list(args)
162
+ cmd_str = " ".join(cmd)
163
+
164
+ try:
165
+ result = subprocess.run(
166
+ cmd,
167
+ cwd=cwd or Path.cwd(),
168
+ capture_output=True,
169
+ text=True,
170
+ timeout=timeout
171
+ )
172
+ if check and result.returncode != 0:
173
+ raise GitCommandError(
174
+ f"Git command failed: {cmd_str}\n{result.stderr}",
175
+ returncode=result.returncode,
176
+ stderr=result.stderr
177
+ )
178
+ return result
179
+ except FileNotFoundError:
180
+ raise GitNotInstalledError(
181
+ "Git is not installed or not in PATH. "
182
+ "Please install git: https://git-scm.com/downloads"
183
+ )
184
+ except subprocess.TimeoutExpired:
185
+ raise GitTimeoutError(
186
+ f"Git command timed out after {timeout}s: {cmd_str}",
187
+ timeout=timeout
188
+ )
189
+
190
+
191
+ # Legacy branch prefix for migration
192
+ LEGACY_BRANCH_PREFIX = "worktree"
193
+
194
+
195
+ def migrate_legacy_branch(name: str, cwd: Optional[Path] = None) -> bool:
196
+ """Migrate a legacy worktree/ branch to agent/ prefix.
197
+
198
+ Args:
199
+ name: Branch name without prefix
200
+ cwd: Repository path
201
+
202
+ Returns:
203
+ True if migration successful or not needed
204
+ """
205
+ old_branch = f"{LEGACY_BRANCH_PREFIX}/{name}"
206
+ new_branch = make_branch_name(name)
207
+
208
+ # Check if old branch exists
209
+ result = run_git("branch", "--list", old_branch, cwd=cwd)
210
+ if not result.stdout.strip():
211
+ return True # No migration needed
212
+
213
+ # Check if new branch already exists
214
+ result = run_git("branch", "--list", new_branch, cwd=cwd)
215
+ if result.stdout.strip():
216
+ return True # Already migrated
217
+
218
+ # Rename branch
219
+ result = run_git("branch", "-m", old_branch, new_branch, cwd=cwd)
220
+ return result.returncode == 0
221
+
222
+
223
+ def preview_merge(
224
+ source_branch: str,
225
+ target_branch: str = "main",
226
+ cwd: Optional[Path] = None
227
+ ) -> Tuple[bool, List[str]]:
228
+ """Preview merge to check for conflicts before actual merge.
229
+
230
+ Args:
231
+ source_branch: Branch to merge from
232
+ target_branch: Branch to merge into
233
+ cwd: Repository path
234
+
235
+ Returns:
236
+ Tuple of (can_merge, conflicting_files)
237
+ """
238
+ workspace = cwd or Path.cwd()
239
+
240
+ # Save current branch
241
+ original_branch = get_current_branch(workspace)
242
+
243
+ # Checkout target branch
244
+ result = run_git("checkout", target_branch, cwd=workspace)
245
+ if result.returncode != 0:
246
+ return False, [f"Cannot checkout {target_branch}"]
247
+
248
+ # Try merge with --no-commit --no-ff
249
+ result = run_git(
250
+ "merge", "--no-commit", "--no-ff", source_branch,
251
+ cwd=workspace
252
+ )
253
+
254
+ conflicts = []
255
+ can_merge = result.returncode == 0
256
+
257
+ if not can_merge:
258
+ # Get list of conflicting files
259
+ status_result = run_git("status", "--porcelain", cwd=workspace)
260
+ for line in status_result.stdout.strip().split("\n"):
261
+ if line.startswith("UU ") or line.startswith("AA "):
262
+ conflicts.append(line[3:])
263
+
264
+ # Always abort the merge attempt
265
+ run_git("merge", "--abort", cwd=workspace)
266
+
267
+ # Return to original branch
268
+ run_git("checkout", original_branch, cwd=workspace)
269
+
270
+ return can_merge, conflicts
up/git/worktree.py ADDED
@@ -0,0 +1,331 @@
1
+ """Git worktree management for parallel task execution.
2
+
3
+ This module provides utilities for creating, managing, and merging
4
+ Git worktrees - enabling parallel AI task execution.
5
+ """
6
+
7
+ import json
8
+ import shutil
9
+ import subprocess
10
+ from dataclasses import dataclass, field, asdict
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ from up.git.utils import (
16
+ is_git_repo,
17
+ get_current_branch,
18
+ count_commits_since,
19
+ make_branch_name,
20
+ run_git,
21
+ BRANCH_PREFIX,
22
+ )
23
+
24
+
25
+ @dataclass
26
+ class WorktreeState:
27
+ """State of a task worktree."""
28
+ task_id: str
29
+ task_title: str
30
+ branch: str
31
+ path: str
32
+ status: str = "created" # created, executing, verifying, passed, failed, merged
33
+ phase: str = "INIT"
34
+ started: str = field(default_factory=lambda: datetime.now().isoformat())
35
+ checkpoints: list = field(default_factory=list)
36
+ ai_invocations: list = field(default_factory=list)
37
+ verification: dict = field(default_factory=lambda: {
38
+ "tests_passed": None,
39
+ "lint_passed": None,
40
+ "type_check_passed": None
41
+ })
42
+ error: Optional[str] = None
43
+
44
+ def save(self, worktree_path: Path):
45
+ """Save state to worktree."""
46
+ state_file = worktree_path / ".agent_state.json"
47
+ state_file.write_text(json.dumps(asdict(self), indent=2))
48
+
49
+ @classmethod
50
+ def load(cls, worktree_path: Path) -> "WorktreeState":
51
+ """Load state from worktree."""
52
+ state_file = worktree_path / ".agent_state.json"
53
+ if state_file.exists():
54
+ data = json.loads(state_file.read_text())
55
+ return cls(**data)
56
+ raise FileNotFoundError(f"No state file in {worktree_path}")
57
+
58
+
59
+ def create_worktree(
60
+ task_id: str,
61
+ task_title: str = "",
62
+ base_branch: str = "main"
63
+ ) -> tuple[Path, WorktreeState]:
64
+ """Create an isolated worktree for a task.
65
+
66
+ Args:
67
+ task_id: Unique task identifier (e.g., "US-004")
68
+ task_title: Human-readable task title
69
+ base_branch: Branch to base the worktree on
70
+
71
+ Returns:
72
+ Tuple of (worktree_path, state)
73
+ """
74
+ branch = make_branch_name(task_id)
75
+ worktree_dir = Path(".worktrees")
76
+ worktree_path = worktree_dir / task_id
77
+
78
+ # Ensure .worktrees directory exists
79
+ worktree_dir.mkdir(exist_ok=True)
80
+
81
+ # Check if worktree already exists
82
+ if worktree_path.exists():
83
+ try:
84
+ state = WorktreeState.load(worktree_path)
85
+ return worktree_path, state
86
+ except FileNotFoundError:
87
+ # Corrupt state, remove and recreate
88
+ remove_worktree(task_id)
89
+
90
+ # Create branch and worktree
91
+ result = subprocess.run(
92
+ ["git", "worktree", "add", "-b", branch, str(worktree_path), base_branch],
93
+ capture_output=True,
94
+ text=True
95
+ )
96
+
97
+ if result.returncode != 0:
98
+ # Branch might already exist, try without -b
99
+ result = subprocess.run(
100
+ ["git", "worktree", "add", str(worktree_path), branch],
101
+ capture_output=True,
102
+ text=True
103
+ )
104
+ if result.returncode != 0:
105
+ raise RuntimeError(f"Failed to create worktree: {result.stderr}")
106
+
107
+ # Copy environment files
108
+ env_files = [".env", ".env.local", ".env.development"]
109
+ for env_file in env_files:
110
+ if Path(env_file).exists():
111
+ shutil.copy(env_file, worktree_path / env_file)
112
+
113
+ # Initialize state
114
+ state = WorktreeState(
115
+ task_id=task_id,
116
+ task_title=task_title or task_id,
117
+ branch=branch,
118
+ path=str(worktree_path),
119
+ status="created"
120
+ )
121
+ state.save(worktree_path)
122
+
123
+ return worktree_path, state
124
+
125
+
126
+ def remove_worktree(task_id: str, force: bool = False):
127
+ """Remove a worktree and its branch.
128
+
129
+ Args:
130
+ task_id: Task identifier
131
+ force: Force removal even if changes exist
132
+ """
133
+ worktree_path = Path(f".worktrees/{task_id}")
134
+ branch = make_branch_name(task_id)
135
+
136
+ # Remove worktree
137
+ if worktree_path.exists():
138
+ cmd = ["git", "worktree", "remove", str(worktree_path)]
139
+ if force:
140
+ cmd.append("--force")
141
+ subprocess.run(cmd, capture_output=True)
142
+
143
+ # Delete branch
144
+ subprocess.run(
145
+ ["git", "branch", "-D" if force else "-d", branch],
146
+ capture_output=True
147
+ )
148
+
149
+
150
+ def list_worktrees() -> list[dict]:
151
+ """List all active worktrees with their state.
152
+
153
+ Returns:
154
+ List of worktree info dicts
155
+ """
156
+ result = subprocess.run(
157
+ ["git", "worktree", "list", "--porcelain"],
158
+ capture_output=True,
159
+ text=True
160
+ )
161
+
162
+ worktrees = []
163
+ current = {}
164
+
165
+ for line in result.stdout.strip().split("\n"):
166
+ if not line:
167
+ if current and ".worktrees" in current.get("worktree", ""):
168
+ # Load state if available
169
+ wt_path = Path(current["worktree"])
170
+ try:
171
+ state = WorktreeState.load(wt_path)
172
+ current["state"] = asdict(state)
173
+ except FileNotFoundError:
174
+ current["state"] = None
175
+ worktrees.append(current)
176
+ current = {}
177
+ elif line.startswith("worktree "):
178
+ current["worktree"] = line[9:]
179
+ elif line.startswith("HEAD "):
180
+ current["head"] = line[5:]
181
+ elif line.startswith("branch "):
182
+ current["branch"] = line[7:]
183
+
184
+ # Don't forget last entry
185
+ if current and ".worktrees" in current.get("worktree", ""):
186
+ wt_path = Path(current["worktree"])
187
+ try:
188
+ state = WorktreeState.load(wt_path)
189
+ current["state"] = asdict(state)
190
+ except FileNotFoundError:
191
+ current["state"] = None
192
+ worktrees.append(current)
193
+
194
+ return worktrees
195
+
196
+
197
+ def merge_worktree(
198
+ task_id: str,
199
+ target_branch: str = "main",
200
+ squash: bool = True,
201
+ message: str = None
202
+ ) -> bool:
203
+ """Merge worktree changes into target branch.
204
+
205
+ Args:
206
+ task_id: Task identifier
207
+ target_branch: Branch to merge into
208
+ squash: Whether to squash commits
209
+ message: Custom commit message
210
+
211
+ Returns:
212
+ True if merge successful
213
+ """
214
+ branch = make_branch_name(task_id)
215
+ worktree_path = Path(f".worktrees/{task_id}")
216
+
217
+ # Load state for commit message
218
+ try:
219
+ state = WorktreeState.load(worktree_path)
220
+ default_message = f"feat({task_id}): {state.task_title}"
221
+ except FileNotFoundError:
222
+ default_message = f"feat({task_id}): Implement task"
223
+
224
+ commit_message = message or default_message
225
+
226
+ # Checkout target branch
227
+ result = subprocess.run(
228
+ ["git", "checkout", target_branch],
229
+ capture_output=True,
230
+ text=True
231
+ )
232
+ if result.returncode != 0:
233
+ return False
234
+
235
+ # Merge
236
+ if squash:
237
+ # Squash merge - combines all commits into staging
238
+ result = subprocess.run(
239
+ ["git", "merge", "--squash", branch],
240
+ capture_output=True,
241
+ text=True
242
+ )
243
+ if result.returncode != 0:
244
+ return False
245
+
246
+ # Commit the squashed changes
247
+ result = subprocess.run(
248
+ ["git", "commit", "-m", commit_message],
249
+ capture_output=True,
250
+ text=True
251
+ )
252
+ else:
253
+ # Regular merge
254
+ result = subprocess.run(
255
+ ["git", "merge", branch, "-m", commit_message],
256
+ capture_output=True,
257
+ text=True
258
+ )
259
+
260
+ if result.returncode != 0:
261
+ return False
262
+
263
+ # Cleanup worktree
264
+ remove_worktree(task_id)
265
+
266
+ return True
267
+
268
+
269
+ # Standard checkpoint tag prefix (matches checkpoint.py)
270
+ CHECKPOINT_TAG_PREFIX = "up-checkpoint"
271
+
272
+
273
+ def create_checkpoint(worktree_path: Path, name: str = None) -> str:
274
+ """Create a checkpoint (commit + tag) in a worktree.
275
+
276
+ Args:
277
+ worktree_path: Path to worktree
278
+ name: Optional checkpoint name
279
+
280
+ Returns:
281
+ Checkpoint identifier
282
+ """
283
+ checkpoint_name = name or f"cp-{datetime.now().strftime('%H%M%S')}"
284
+
285
+ # Stage all changes
286
+ subprocess.run(
287
+ ["git", "add", "-A"],
288
+ cwd=worktree_path,
289
+ capture_output=True
290
+ )
291
+
292
+ # Check if there are changes to commit
293
+ result = subprocess.run(
294
+ ["git", "status", "--porcelain"],
295
+ cwd=worktree_path,
296
+ capture_output=True,
297
+ text=True
298
+ )
299
+
300
+ if result.stdout.strip():
301
+ # Commit changes
302
+ subprocess.run(
303
+ ["git", "commit", "-m", f"checkpoint: {checkpoint_name}"],
304
+ cwd=worktree_path,
305
+ capture_output=True
306
+ )
307
+
308
+ # Create lightweight tag with standard prefix
309
+ subprocess.run(
310
+ ["git", "tag", f"{CHECKPOINT_TAG_PREFIX}/{checkpoint_name}"],
311
+ cwd=worktree_path,
312
+ capture_output=True
313
+ )
314
+
315
+ return checkpoint_name
316
+
317
+
318
+ def reset_to_checkpoint(worktree_path: Path, checkpoint: str = None):
319
+ """Reset worktree to a checkpoint.
320
+
321
+ Args:
322
+ worktree_path: Path to worktree
323
+ checkpoint: Checkpoint name (defaults to HEAD)
324
+ """
325
+ target = f"{CHECKPOINT_TAG_PREFIX}/{checkpoint}" if checkpoint else "HEAD"
326
+
327
+ subprocess.run(
328
+ ["git", "reset", "--hard", target],
329
+ cwd=worktree_path,
330
+ capture_output=True
331
+ )