up-cli 0.2.0__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 (46) hide show
  1. up/__init__.py +1 -1
  2. up/ai_cli.py +229 -0
  3. up/cli.py +54 -9
  4. up/commands/agent.py +521 -0
  5. up/commands/bisect.py +343 -0
  6. up/commands/branch.py +350 -0
  7. up/commands/init.py +195 -6
  8. up/commands/learn.py +1392 -32
  9. up/commands/memory.py +545 -0
  10. up/commands/provenance.py +267 -0
  11. up/commands/review.py +239 -0
  12. up/commands/start.py +752 -42
  13. up/commands/status.py +173 -18
  14. up/commands/sync.py +317 -0
  15. up/commands/vibe.py +304 -0
  16. up/context.py +64 -10
  17. up/core/__init__.py +69 -0
  18. up/core/checkpoint.py +479 -0
  19. up/core/provenance.py +364 -0
  20. up/core/state.py +678 -0
  21. up/events.py +512 -0
  22. up/git/__init__.py +37 -0
  23. up/git/utils.py +270 -0
  24. up/git/worktree.py +331 -0
  25. up/learn/__init__.py +155 -0
  26. up/learn/analyzer.py +227 -0
  27. up/learn/plan.py +374 -0
  28. up/learn/research.py +511 -0
  29. up/learn/utils.py +117 -0
  30. up/memory.py +1096 -0
  31. up/parallel.py +551 -0
  32. up/templates/config/__init__.py +1 -1
  33. up/templates/docs/SKILL.md +28 -0
  34. up/templates/docs/__init__.py +341 -0
  35. up/templates/docs/standards/HEADERS.md +24 -0
  36. up/templates/docs/standards/STRUCTURE.md +18 -0
  37. up/templates/docs/standards/TEMPLATES.md +19 -0
  38. up/templates/loop/__init__.py +92 -32
  39. up/ui/__init__.py +14 -0
  40. up/ui/loop_display.py +650 -0
  41. up/ui/theme.py +137 -0
  42. {up_cli-0.2.0.dist-info → up_cli-0.5.0.dist-info}/METADATA +160 -15
  43. up_cli-0.5.0.dist-info/RECORD +55 -0
  44. up_cli-0.2.0.dist-info/RECORD +0 -23
  45. {up_cli-0.2.0.dist-info → up_cli-0.5.0.dist-info}/WHEEL +0 -0
  46. {up_cli-0.2.0.dist-info → up_cli-0.5.0.dist-info}/entry_points.txt +0 -0
up/commands/status.py CHANGED
@@ -44,21 +44,128 @@ def collect_status(workspace: Path) -> dict:
44
44
  status = {
45
45
  "workspace": str(workspace),
46
46
  "initialized": False,
47
+ "state_version": None,
47
48
  "context_budget": None,
48
49
  "loop_state": None,
49
50
  "circuit_breaker": None,
51
+ "checkpoints": None,
52
+ "agents": None,
53
+ "doom_loop": None,
50
54
  "skills": [],
55
+ "hooks": None,
56
+ "memory": None,
51
57
  }
52
58
 
53
59
  # Check if initialized
54
60
  claude_dir = workspace / ".claude"
55
61
  cursor_dir = workspace / ".cursor"
56
- status["initialized"] = claude_dir.exists() or cursor_dir.exists()
62
+ up_dir = workspace / ".up"
63
+ status["initialized"] = claude_dir.exists() or cursor_dir.exists() or up_dir.exists()
57
64
 
58
65
  if not status["initialized"]:
59
66
  return status
60
67
 
61
- # Context budget
68
+ # Try to load unified state first
69
+ try:
70
+ from up.core.state import get_state_manager
71
+ manager = get_state_manager(workspace)
72
+ state = manager.state
73
+ status["state_version"] = state.version
74
+
75
+ # Context budget from unified state
76
+ status["context_budget"] = {
77
+ "budget": state.context.budget,
78
+ "total_tokens": state.context.total_tokens,
79
+ "remaining_tokens": state.context.remaining_tokens,
80
+ "usage_percent": state.context.usage_percent,
81
+ "status": state.context.status,
82
+ }
83
+
84
+ # Loop state from unified state
85
+ status["loop_state"] = {
86
+ "iteration": state.loop.iteration,
87
+ "phase": state.loop.phase,
88
+ "current_task": state.loop.current_task,
89
+ "tasks_completed": len(state.loop.tasks_completed),
90
+ "tasks_failed": len(state.loop.tasks_failed),
91
+ "success_rate": state.metrics.success_rate,
92
+ "last_checkpoint": state.loop.last_checkpoint,
93
+ }
94
+
95
+ # Circuit breakers
96
+ status["circuit_breaker"] = {
97
+ name: {"state": cb.state, "failures": cb.failures}
98
+ for name, cb in state.circuit_breakers.items()
99
+ }
100
+
101
+ # Checkpoints
102
+ status["checkpoints"] = {
103
+ "total": len(state.checkpoints),
104
+ "last": state.loop.last_checkpoint,
105
+ "recent": state.checkpoints[-5:] if state.checkpoints else [],
106
+ }
107
+
108
+ # Agents
109
+ if state.agents:
110
+ status["agents"] = {
111
+ task_id: {
112
+ "status": agent.status,
113
+ "phase": agent.phase,
114
+ "worktree": agent.worktree_path,
115
+ }
116
+ for task_id, agent in state.agents.items()
117
+ }
118
+
119
+ # Doom loop detection
120
+ is_doom, doom_msg = manager.check_doom_loop()
121
+ if is_doom or state.loop.consecutive_failures > 0:
122
+ status["doom_loop"] = {
123
+ "triggered": is_doom,
124
+ "consecutive_failures": state.loop.consecutive_failures,
125
+ "threshold": state.loop.doom_loop_threshold,
126
+ "message": doom_msg if is_doom else None,
127
+ }
128
+
129
+ except ImportError:
130
+ # Fallback to old state files
131
+ _collect_legacy_status(workspace, status)
132
+
133
+ # Git hooks status
134
+ from up.commands.sync import check_hooks_installed
135
+ status["hooks"] = check_hooks_installed(workspace)
136
+
137
+ # Memory status
138
+ memory_dir = workspace / ".up" / "memory"
139
+ if memory_dir.exists():
140
+ try:
141
+ from up.memory import MemoryManager
142
+ manager = MemoryManager(workspace, use_vectors=False)
143
+ stats = manager.get_stats()
144
+ status["memory"] = {
145
+ "total": stats.get("total", 0),
146
+ "branch": stats.get("current_branch", "unknown"),
147
+ "commit": stats.get("current_commit", "unknown"),
148
+ }
149
+ except Exception:
150
+ status["memory"] = {"total": 0}
151
+
152
+ # Skills
153
+ skills_dirs = [
154
+ workspace / ".claude/skills",
155
+ workspace / ".cursor/skills",
156
+ ]
157
+ for skills_dir in skills_dirs:
158
+ if skills_dir.exists():
159
+ for skill_dir in skills_dir.iterdir():
160
+ if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
161
+ status["skills"].append(skill_dir.name)
162
+
163
+ return status
164
+
165
+
166
+ def _collect_legacy_status(workspace: Path, status: dict) -> None:
167
+ """Collect status from legacy state files."""
168
+ # Context budget (old location)
62
169
  context_file = workspace / ".claude/context_budget.json"
63
170
  if context_file.exists():
64
171
  try:
@@ -66,7 +173,7 @@ def collect_status(workspace: Path) -> dict:
66
173
  except json.JSONDecodeError:
67
174
  status["context_budget"] = {"error": "Invalid JSON"}
68
175
 
69
- # Loop state
176
+ # Loop state (old location)
70
177
  loop_file = workspace / ".loop_state.json"
71
178
  if loop_file.exists():
72
179
  try:
@@ -82,19 +189,6 @@ def collect_status(workspace: Path) -> dict:
82
189
  status["circuit_breaker"] = data.get("circuit_breaker", {})
83
190
  except json.JSONDecodeError:
84
191
  status["loop_state"] = {"error": "Invalid JSON"}
85
-
86
- # Skills
87
- skills_dirs = [
88
- workspace / ".claude/skills",
89
- workspace / ".cursor/skills",
90
- ]
91
- for skills_dir in skills_dirs:
92
- if skills_dir.exists():
93
- for skill_dir in skills_dir.iterdir():
94
- if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
95
- status["skills"].append(skill_dir.name)
96
-
97
- return status
98
192
 
99
193
 
100
194
  def display_status(status: dict) -> None:
@@ -162,7 +256,7 @@ def display_status(status: dict) -> None:
162
256
  else:
163
257
  console.print(" [dim]Not active[/]")
164
258
 
165
- # Loop State
259
+ # Product Loop State
166
260
  console.print("\n[bold]Product Loop[/]")
167
261
  if status["loop_state"]:
168
262
  loop = status["loop_state"]
@@ -177,8 +271,9 @@ def display_status(status: dict) -> None:
177
271
  console.print(f" Current Task: [cyan]{current}[/]")
178
272
 
179
273
  completed = loop.get("tasks_completed", 0)
274
+ failed = loop.get("tasks_failed", 0)
180
275
  remaining = loop.get("tasks_remaining", 0)
181
- total = completed + remaining
276
+ total = completed + failed + remaining if remaining else completed + failed
182
277
 
183
278
  if total > 0:
184
279
  progress = completed / total * 100
@@ -189,9 +284,69 @@ def display_status(status: dict) -> None:
189
284
 
190
285
  success_rate = loop.get("success_rate", 1.0)
191
286
  console.print(f" Success Rate: {success_rate * 100:.0f}%")
287
+
288
+ last_cp = loop.get("last_checkpoint")
289
+ if last_cp:
290
+ console.print(f" Last Checkpoint: [cyan]{last_cp}[/]")
192
291
  else:
193
292
  console.print(" [dim]Not active[/]")
194
293
 
294
+ # Doom Loop Detection
295
+ if status.get("doom_loop"):
296
+ doom = status["doom_loop"]
297
+ console.print("\n[bold]Doom Loop Detection[/]")
298
+ if doom["triggered"]:
299
+ console.print(f" [red]⚠ TRIGGERED[/] - {doom['consecutive_failures']}/{doom['threshold']} failures")
300
+ console.print(f" [dim]{doom.get('message', '')}[/]")
301
+ else:
302
+ console.print(f" Consecutive Failures: {doom['consecutive_failures']}/{doom['threshold']}")
303
+
304
+ # Checkpoints
305
+ if status.get("checkpoints"):
306
+ cp = status["checkpoints"]
307
+ console.print("\n[bold]Checkpoints[/]")
308
+ console.print(f" Total: {cp['total']}")
309
+ if cp.get("recent"):
310
+ console.print(f" Recent: {', '.join(cp['recent'][-3:])}")
311
+
312
+ # Active Agents
313
+ if status.get("agents"):
314
+ console.print("\n[bold]Active Agents[/]")
315
+ for task_id, agent in status["agents"].items():
316
+ status_icon = "🟢" if agent["status"] == "passed" else "🟡" if agent["status"] in ["executing", "verifying"] else "🔴"
317
+ console.print(f" {status_icon} {task_id}: {agent['status']} ({agent['phase']})")
318
+
319
+ # Memory
320
+ console.print("\n[bold]Memory[/]")
321
+ if status["memory"]:
322
+ mem = status["memory"]
323
+ total = mem.get("total", 0)
324
+ branch = mem.get("branch", "unknown")
325
+ commit = mem.get("commit", "unknown")
326
+ console.print(f" 📚 {total} entries | Branch: [cyan]{branch}[/] @ {commit}")
327
+ else:
328
+ console.print(" [dim]Not initialized - run [cyan]up memory sync[/][/]")
329
+
330
+ # Git Hooks
331
+ console.print("\n[bold]Auto-Sync (Git Hooks)[/]")
332
+ hooks = status.get("hooks", {})
333
+ if hooks.get("git"):
334
+ post_commit = hooks.get("post_commit", False)
335
+ post_checkout = hooks.get("post_checkout", False)
336
+
337
+ if post_commit and post_checkout:
338
+ console.print(" [green]✓ Enabled[/] - commits auto-indexed to memory")
339
+ else:
340
+ console.print(" [yellow]⚠ Partially installed[/]")
341
+ if not post_commit:
342
+ console.print(" • Missing: post-commit hook")
343
+ if not post_checkout:
344
+ console.print(" • Missing: post-checkout hook")
345
+ console.print("\n [dim]Run [cyan]up hooks[/] to install missing hooks[/]")
346
+ else:
347
+ console.print(" [yellow]✗ Not installed[/]")
348
+ console.print(" [dim]Run [cyan]up hooks[/] to enable auto-sync on commits[/]")
349
+
195
350
  # Skills
196
351
  console.print("\n[bold]Skills[/]")
197
352
  if status["skills"]:
up/commands/sync.py ADDED
@@ -0,0 +1,317 @@
1
+ """up sync - Sync all systems."""
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+ from rich.console import Console
7
+ from rich.panel import Panel
8
+
9
+ console = Console()
10
+
11
+
12
+ @click.command()
13
+ @click.option("--memory/--no-memory", default=True, help="Sync memory system")
14
+ @click.option("--docs/--no-docs", default=True, help="Update docs")
15
+ @click.option("--full", is_flag=True, help="Full sync including file re-indexing")
16
+ def sync_cmd(memory: bool, docs: bool, full: bool):
17
+ """Sync all up systems.
18
+
19
+ Updates memory index, refreshes docs, and ensures all systems
20
+ are in sync with current project state.
21
+
22
+ \b
23
+ Examples:
24
+ up sync # Standard sync
25
+ up sync --full # Full sync with file re-indexing
26
+ up sync --no-docs # Memory only
27
+ """
28
+ cwd = Path.cwd()
29
+
30
+ console.print(Panel.fit(
31
+ "[bold blue]Syncing Systems[/]",
32
+ border_style="blue"
33
+ ))
34
+
35
+ results = {}
36
+
37
+ # Initialize event system
38
+ from up.events import initialize_event_system
39
+ initialize_event_system(cwd)
40
+
41
+ steps = []
42
+ if memory:
43
+ steps.append(("Memory", _sync_memory))
44
+ if docs:
45
+ steps.append(("Docs", _sync_docs))
46
+ if full:
47
+ steps.append(("Files", _sync_files))
48
+
49
+ for name, func in steps:
50
+ console.print(f"[dim]Syncing {name}...[/]", end=" ")
51
+ try:
52
+ result = func(cwd, full)
53
+ results[name.lower()] = result
54
+ details = ", ".join(f"{k}={v}" for k, v in result.items() if v)
55
+ console.print(f"[green]✓[/] {details or 'done'}")
56
+ except Exception as e:
57
+ results[name.lower()] = {"error": str(e)}
58
+ console.print(f"[red]✗[/] {e}")
59
+
60
+ # Summary
61
+ console.print()
62
+ errors = [k for k, v in results.items() if "error" in v]
63
+ if errors:
64
+ console.print(f"[yellow]Completed with {len(errors)} error(s)[/]")
65
+ else:
66
+ console.print("[green]✓[/] All systems synced")
67
+
68
+
69
+ def _sync_memory(workspace: Path, full: bool) -> dict:
70
+ """Sync memory system."""
71
+ import os
72
+ import sys
73
+ import warnings
74
+ import logging
75
+ from up.memory import MemoryManager, _check_chromadb
76
+
77
+ # Suppress noisy warnings and logs
78
+ os.environ["TOKENIZERS_PARALLELISM"] = "false"
79
+ warnings.filterwarnings("ignore")
80
+ logging.getLogger("chromadb").setLevel(logging.ERROR)
81
+
82
+ # Redirect stderr temporarily to suppress ChromaDB noise
83
+ old_stderr = sys.stderr
84
+ sys.stderr = open(os.devnull, 'w')
85
+
86
+ try:
87
+ # Use JSON backend if ChromaDB not available (faster, no model download)
88
+ use_vectors = _check_chromadb()
89
+
90
+ manager = MemoryManager(workspace, use_vectors=use_vectors)
91
+ results = manager.sync()
92
+
93
+ return {
94
+ "commits": results.get("commits_indexed", 0),
95
+ "files": results.get("files_indexed", 0),
96
+ "backend": manager._backend,
97
+ }
98
+ finally:
99
+ sys.stderr.close()
100
+ sys.stderr = old_stderr
101
+
102
+
103
+ def _sync_docs(workspace: Path, full: bool) -> dict:
104
+ """Sync documentation."""
105
+ from datetime import date
106
+ import re
107
+
108
+ updated = 0
109
+
110
+ # Update CONTEXT.md date
111
+ context_file = workspace / "docs" / "CONTEXT.md"
112
+ if context_file.exists():
113
+ content = context_file.read_text()
114
+ today = date.today().isoformat()
115
+ new_content = re.sub(
116
+ r'\*\*Updated\*\*:\s*[\d-]+',
117
+ f'**Updated**: {today}',
118
+ content
119
+ )
120
+ if new_content != content:
121
+ context_file.write_text(new_content)
122
+ updated += 1
123
+
124
+ # Check INDEX.md exists
125
+ index_file = workspace / "docs" / "INDEX.md"
126
+ if index_file.exists():
127
+ updated += 0 # Just checking
128
+
129
+ return {"updated": updated}
130
+
131
+
132
+ def _sync_files(workspace: Path, full: bool) -> dict:
133
+ """Sync file index."""
134
+ from up.memory import MemoryManager
135
+
136
+ manager = MemoryManager(workspace)
137
+ indexed = manager.index_file_changes()
138
+
139
+ return {"indexed": indexed}
140
+
141
+
142
+ def check_hooks_installed(workspace: Path) -> dict:
143
+ """Check if up-cli hooks are installed.
144
+
145
+ Returns dict with status of each hook.
146
+ """
147
+ git_dir = workspace / ".git"
148
+ if not git_dir.exists():
149
+ return {"git": False, "post_commit": False, "post_checkout": False}
150
+
151
+ hooks_dir = git_dir / "hooks"
152
+
153
+ result = {"git": True, "post_commit": False, "post_checkout": False}
154
+
155
+ post_commit = hooks_dir / "post-commit"
156
+ if post_commit.exists() and "up-cli" in post_commit.read_text():
157
+ result["post_commit"] = True
158
+
159
+ post_checkout = hooks_dir / "post-checkout"
160
+ if post_checkout.exists() and "up-cli" in post_checkout.read_text():
161
+ result["post_checkout"] = True
162
+
163
+ return result
164
+
165
+
166
+ @click.command()
167
+ @click.option("--uninstall", is_flag=True, help="Remove hooks instead of installing")
168
+ @click.option("--check", is_flag=True, help="Check if hooks are installed")
169
+ def hooks_cmd(uninstall: bool, check: bool):
170
+ """Install or uninstall git hooks for automatic syncing.
171
+
172
+ Installs:
173
+ - post-commit: Auto-index commits to memory
174
+ - post-checkout: Update context on branch switch
175
+
176
+ \b
177
+ Examples:
178
+ up hooks # Install hooks
179
+ up hooks --check # Check status
180
+ up hooks --uninstall # Remove hooks
181
+ """
182
+ cwd = Path.cwd()
183
+ git_dir = cwd / ".git"
184
+
185
+ if not git_dir.exists():
186
+ console.print("[red]Error:[/] Not a git repository")
187
+ raise SystemExit(1)
188
+
189
+ hooks_dir = git_dir / "hooks"
190
+ hooks_dir.mkdir(exist_ok=True)
191
+
192
+ if check:
193
+ status = check_hooks_installed(cwd)
194
+ console.print("\n[bold]Git Hooks Status:[/]")
195
+ console.print(f" post-commit: {'[green]✓ installed[/]' if status['post_commit'] else '[yellow]✗ not installed[/]'}")
196
+ console.print(f" post-checkout: {'[green]✓ installed[/]' if status['post_checkout'] else '[yellow]✗ not installed[/]'}")
197
+
198
+ if not status['post_commit'] or not status['post_checkout']:
199
+ console.print("\n[dim]Run 'up hooks' to install missing hooks[/]")
200
+ return
201
+
202
+ if uninstall:
203
+ _uninstall_hooks(hooks_dir)
204
+ else:
205
+ _install_hooks(hooks_dir)
206
+
207
+
208
+ def _install_hooks(hooks_dir: Path):
209
+ """Install git hooks."""
210
+
211
+ # Post-commit hook
212
+ post_commit = hooks_dir / "post-commit"
213
+ post_commit_content = '''#!/bin/bash
214
+ # up-cli auto-sync hook
215
+ # Indexes commits to memory automatically
216
+
217
+ # Run in background to not slow down commits
218
+ (
219
+ # Wait a moment for git to finish
220
+ sleep 1
221
+
222
+ # Sync memory with latest commit
223
+ if command -v up &> /dev/null; then
224
+ up memory sync 2>/dev/null
225
+ elif command -v python3 &> /dev/null; then
226
+ python3 -m up.memory sync 2>/dev/null
227
+ fi
228
+ ) &
229
+
230
+ exit 0
231
+ '''
232
+
233
+ _write_hook(post_commit, post_commit_content)
234
+ console.print("[green]✓[/] Installed post-commit hook")
235
+
236
+ # Post-checkout hook (for branch switches)
237
+ post_checkout = hooks_dir / "post-checkout"
238
+ post_checkout_content = '''#!/bin/bash
239
+ # up-cli context update hook
240
+ # Updates context when switching branches
241
+
242
+ PREV_HEAD=$1
243
+ NEW_HEAD=$2
244
+ BRANCH_CHECKOUT=$3
245
+
246
+ # Only run on branch checkout, not file checkout
247
+ if [ "$BRANCH_CHECKOUT" = "1" ]; then
248
+ (
249
+ sleep 1
250
+ if command -v up &> /dev/null; then
251
+ up sync --no-memory 2>/dev/null
252
+ fi
253
+ ) &
254
+ fi
255
+
256
+ exit 0
257
+ '''
258
+
259
+ _write_hook(post_checkout, post_checkout_content)
260
+ console.print("[green]✓[/] Installed post-checkout hook")
261
+
262
+ console.print("\n[bold]Hooks installed![/]")
263
+ console.print("Memory will auto-sync on commits.")
264
+
265
+
266
+ def _write_hook(path: Path, content: str):
267
+ """Write hook file with executable permissions."""
268
+ import stat
269
+
270
+ # Check for existing hook
271
+ if path.exists():
272
+ existing = path.read_text()
273
+ if "up-cli" in existing:
274
+ # Already our hook, overwrite
275
+ pass
276
+ else:
277
+ # User has custom hook, append
278
+ content = existing + "\n\n" + content
279
+
280
+ path.write_text(content)
281
+ path.chmod(path.stat().st_mode | stat.S_IEXEC)
282
+
283
+
284
+ def _uninstall_hooks(hooks_dir: Path):
285
+ """Remove up-cli hooks."""
286
+
287
+ for hook_name in ["post-commit", "post-checkout"]:
288
+ hook_path = hooks_dir / hook_name
289
+ if hook_path.exists():
290
+ content = hook_path.read_text()
291
+ if "up-cli" in content:
292
+ # Remove our section
293
+ lines = content.split("\n")
294
+ new_lines = []
295
+ skip = False
296
+ for line in lines:
297
+ if "up-cli" in line:
298
+ skip = True
299
+ elif skip and line.startswith("exit"):
300
+ skip = False
301
+ continue
302
+ elif not skip:
303
+ new_lines.append(line)
304
+
305
+ new_content = "\n".join(new_lines).strip()
306
+ if new_content:
307
+ hook_path.write_text(new_content)
308
+ else:
309
+ hook_path.unlink()
310
+
311
+ console.print(f"[green]✓[/] Removed {hook_name} hook")
312
+
313
+ console.print("\n[bold]Hooks uninstalled![/]")
314
+
315
+
316
+ if __name__ == "__main__":
317
+ sync_cmd()