claude-task-master 0.1.4__py3-none-any.whl → 0.1.6__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 (34) hide show
  1. claude_task_master/__init__.py +1 -1
  2. claude_task_master/api/models.py +309 -0
  3. claude_task_master/api/routes.py +229 -0
  4. claude_task_master/api/routes_repo.py +317 -0
  5. claude_task_master/bin/claudetm +1 -1
  6. claude_task_master/cli.py +3 -1
  7. claude_task_master/cli_commands/mailbox.py +295 -0
  8. claude_task_master/cli_commands/workflow.py +37 -0
  9. claude_task_master/core/__init__.py +5 -0
  10. claude_task_master/core/agent_phases.py +1 -1
  11. claude_task_master/core/config.py +3 -3
  12. claude_task_master/core/orchestrator.py +432 -9
  13. claude_task_master/core/parallel.py +4 -4
  14. claude_task_master/core/plan_updater.py +199 -0
  15. claude_task_master/core/pr_context.py +176 -62
  16. claude_task_master/core/prompts.py +4 -0
  17. claude_task_master/core/prompts_plan_update.py +148 -0
  18. claude_task_master/core/prompts_planning.py +6 -2
  19. claude_task_master/core/state.py +5 -1
  20. claude_task_master/core/task_runner.py +73 -34
  21. claude_task_master/core/workflow_stages.py +229 -22
  22. claude_task_master/github/client_pr.py +86 -20
  23. claude_task_master/mailbox/__init__.py +23 -0
  24. claude_task_master/mailbox/merger.py +163 -0
  25. claude_task_master/mailbox/models.py +95 -0
  26. claude_task_master/mailbox/storage.py +209 -0
  27. claude_task_master/mcp/server.py +183 -0
  28. claude_task_master/mcp/tools.py +921 -0
  29. claude_task_master/webhooks/events.py +356 -2
  30. {claude_task_master-0.1.4.dist-info → claude_task_master-0.1.6.dist-info}/METADATA +223 -4
  31. {claude_task_master-0.1.4.dist-info → claude_task_master-0.1.6.dist-info}/RECORD +34 -26
  32. {claude_task_master-0.1.4.dist-info → claude_task_master-0.1.6.dist-info}/WHEEL +1 -1
  33. {claude_task_master-0.1.4.dist-info → claude_task_master-0.1.6.dist-info}/entry_points.txt +0 -0
  34. {claude_task_master-0.1.4.dist-info → claude_task_master-0.1.6.dist-info}/top_level.txt +0 -0
@@ -117,6 +117,72 @@ class UpdateConfigResult(BaseModel):
117
117
  error: str | None = None
118
118
 
119
119
 
120
+ class SendMessageResult(BaseModel):
121
+ """Result from send_message mailbox tool."""
122
+
123
+ success: bool
124
+ message_id: str | None = None
125
+ message: str | None = None
126
+ error: str | None = None
127
+
128
+
129
+ class MailboxStatusResult(BaseModel):
130
+ """Result from check_mailbox tool."""
131
+
132
+ success: bool
133
+ count: int = 0
134
+ previews: list[dict[str, Any]] = []
135
+ last_checked: str | None = None
136
+ total_messages_received: int = 0
137
+ error: str | None = None
138
+
139
+
140
+ class ClearMailboxResult(BaseModel):
141
+ """Result from clear_mailbox tool."""
142
+
143
+ success: bool
144
+ messages_cleared: int = 0
145
+ message: str | None = None
146
+ error: str | None = None
147
+
148
+
149
+ class CloneRepoResult(BaseModel):
150
+ """Result from clone_repo tool."""
151
+
152
+ success: bool
153
+ message: str
154
+ repo_url: str | None = None
155
+ target_dir: str | None = None
156
+ branch: str | None = None
157
+ error: str | None = None
158
+
159
+
160
+ class SetupRepoResult(BaseModel):
161
+ """Result from setup_repo tool."""
162
+
163
+ success: bool
164
+ message: str
165
+ work_dir: str | None = None
166
+ steps_completed: list[str] = []
167
+ venv_path: str | None = None
168
+ dependencies_installed: bool = False
169
+ setup_scripts_run: list[str] = []
170
+ error: str | None = None
171
+
172
+
173
+ class PlanRepoResult(BaseModel):
174
+ """Result from plan_repo tool."""
175
+
176
+ success: bool
177
+ message: str
178
+ work_dir: str | None = None
179
+ goal: str | None = None
180
+ plan: str | None = None
181
+ criteria: str | None = None
182
+ run_id: str | None = None
183
+ error: str | None = None
184
+
185
+
120
186
  # =============================================================================
121
187
  # Tool Implementations
122
188
  # =============================================================================
@@ -838,3 +904,858 @@ def resource_context(work_dir: Path) -> str:
838
904
  return state_manager.load_context()
839
905
  except Exception:
840
906
  return "Error loading context"
907
+
908
+
909
+ # =============================================================================
910
+ # Mailbox Tool Implementations
911
+ # =============================================================================
912
+
913
+
914
+ def send_message(
915
+ work_dir: Path,
916
+ content: str,
917
+ sender: str = "anonymous",
918
+ priority: int = 1,
919
+ state_dir: str | None = None,
920
+ ) -> dict[str, Any]:
921
+ """Send a message to the claudetm mailbox.
922
+
923
+ Messages in the mailbox will be processed after the current task completes.
924
+ Multiple messages are merged into a single change request that updates
925
+ the plan before continuing work.
926
+
927
+ Args:
928
+ work_dir: Working directory for the server.
929
+ content: The message content describing the change request.
930
+ sender: Identifier of the sender (default: "anonymous").
931
+ priority: Message priority (0=low, 1=normal, 2=high, 3=urgent).
932
+ state_dir: Optional custom state directory path.
933
+
934
+ Returns:
935
+ Dictionary containing the message_id on success, or error info.
936
+ """
937
+ from claude_task_master.mailbox import MailboxStorage
938
+
939
+ state_path = Path(state_dir) if state_dir else work_dir / ".claude-task-master"
940
+
941
+ # Validate content
942
+ if not content or not content.strip():
943
+ return SendMessageResult(
944
+ success=False,
945
+ error="Message content cannot be empty",
946
+ ).model_dump()
947
+
948
+ # Validate priority range
949
+ if priority < 0 or priority > 3:
950
+ return SendMessageResult(
951
+ success=False,
952
+ error="Priority must be between 0 (low) and 3 (urgent)",
953
+ ).model_dump()
954
+
955
+ try:
956
+ mailbox = MailboxStorage(state_dir=state_path)
957
+ message_id = mailbox.add_message(
958
+ content=content.strip(),
959
+ sender=sender,
960
+ priority=priority,
961
+ )
962
+
963
+ return SendMessageResult(
964
+ success=True,
965
+ message_id=message_id,
966
+ message=f"Message sent successfully (id: {message_id})",
967
+ ).model_dump()
968
+ except Exception as e:
969
+ return SendMessageResult(
970
+ success=False,
971
+ error=f"Failed to send message: {e}",
972
+ ).model_dump()
973
+
974
+
975
+ def check_mailbox(
976
+ work_dir: Path,
977
+ state_dir: str | None = None,
978
+ ) -> dict[str, Any]:
979
+ """Check the status of the claudetm mailbox.
980
+
981
+ Returns the number of pending messages and previews of each.
982
+
983
+ Args:
984
+ work_dir: Working directory for the server.
985
+ state_dir: Optional custom state directory path.
986
+
987
+ Returns:
988
+ Dictionary containing mailbox status information.
989
+ """
990
+ from claude_task_master.mailbox import MailboxStorage
991
+
992
+ state_path = Path(state_dir) if state_dir else work_dir / ".claude-task-master"
993
+
994
+ try:
995
+ mailbox = MailboxStorage(state_dir=state_path)
996
+ status = mailbox.get_status()
997
+
998
+ return MailboxStatusResult(
999
+ success=True,
1000
+ count=status["count"],
1001
+ previews=status["previews"],
1002
+ last_checked=status["last_checked"],
1003
+ total_messages_received=status["total_messages_received"],
1004
+ ).model_dump()
1005
+ except Exception as e:
1006
+ return MailboxStatusResult(
1007
+ success=False,
1008
+ error=f"Failed to check mailbox: {e}",
1009
+ ).model_dump()
1010
+
1011
+
1012
+ def clear_mailbox(
1013
+ work_dir: Path,
1014
+ state_dir: str | None = None,
1015
+ ) -> dict[str, Any]:
1016
+ """Clear all messages from the claudetm mailbox.
1017
+
1018
+ Args:
1019
+ work_dir: Working directory for the server.
1020
+ state_dir: Optional custom state directory path.
1021
+
1022
+ Returns:
1023
+ Dictionary indicating success and number of messages cleared.
1024
+ """
1025
+ from claude_task_master.mailbox import MailboxStorage
1026
+
1027
+ state_path = Path(state_dir) if state_dir else work_dir / ".claude-task-master"
1028
+
1029
+ try:
1030
+ mailbox = MailboxStorage(state_dir=state_path)
1031
+ count = mailbox.clear()
1032
+
1033
+ return ClearMailboxResult(
1034
+ success=True,
1035
+ messages_cleared=count,
1036
+ message=f"Cleared {count} message(s) from mailbox",
1037
+ ).model_dump()
1038
+ except Exception as e:
1039
+ return ClearMailboxResult(
1040
+ success=False,
1041
+ error=f"Failed to clear mailbox: {e}",
1042
+ ).model_dump()
1043
+
1044
+
1045
+ # =============================================================================
1046
+ # Repo Setup Tool Implementations
1047
+ # =============================================================================
1048
+
1049
+ # Default workspace base for repo setup
1050
+ DEFAULT_WORKSPACE_BASE = Path.home() / "workspace" / "claude-task-master"
1051
+
1052
+
1053
+ def _extract_repo_name(url: str) -> str:
1054
+ """Extract repository name from a git URL.
1055
+
1056
+ Supports both HTTPS and SSH URLs:
1057
+ - https://github.com/user/repo.git -> repo
1058
+ - git@github.com:user/repo.git -> repo
1059
+ - https://github.com/user/repo -> repo
1060
+
1061
+ Args:
1062
+ url: Git repository URL.
1063
+
1064
+ Returns:
1065
+ Repository name without .git suffix.
1066
+ """
1067
+ # Remove trailing .git if present
1068
+ clean_url = url.rstrip("/")
1069
+ if clean_url.endswith(".git"):
1070
+ clean_url = clean_url[:-4]
1071
+
1072
+ # Extract repo name from path
1073
+ # For SSH: git@github.com:user/repo
1074
+ if ":" in clean_url and "@" in clean_url:
1075
+ repo_name = clean_url.split("/")[-1]
1076
+ else:
1077
+ # For HTTPS: https://github.com/user/repo
1078
+ repo_name = clean_url.split("/")[-1]
1079
+
1080
+ return repo_name
1081
+
1082
+
1083
+ def clone_repo(
1084
+ url: str,
1085
+ target_dir: str | None = None,
1086
+ branch: str | None = None,
1087
+ ) -> dict[str, Any]:
1088
+ """Clone a git repository to the workspace.
1089
+
1090
+ Clones the repository to ~/workspace/claude-task-master/{project-name}
1091
+ by default, or to a custom target directory if specified.
1092
+
1093
+ Args:
1094
+ url: Git repository URL (HTTPS or SSH).
1095
+ target_dir: Optional custom target directory path. If not provided,
1096
+ defaults to ~/workspace/claude-task-master/{repo-name}.
1097
+ branch: Optional branch to checkout after cloning.
1098
+
1099
+ Returns:
1100
+ Dictionary containing clone result with success status and details.
1101
+ """
1102
+ import subprocess
1103
+
1104
+ # Validate URL
1105
+ if not url or not url.strip():
1106
+ return CloneRepoResult(
1107
+ success=False,
1108
+ message="Repository URL is required",
1109
+ error="Repository URL cannot be empty",
1110
+ ).model_dump()
1111
+
1112
+ url = url.strip()
1113
+
1114
+ # Basic URL validation
1115
+ if not (
1116
+ url.startswith("https://")
1117
+ or url.startswith("git@")
1118
+ or url.startswith("git://")
1119
+ or url.startswith("ssh://")
1120
+ ):
1121
+ return CloneRepoResult(
1122
+ success=False,
1123
+ message="Invalid repository URL format",
1124
+ repo_url=url,
1125
+ error="URL must start with https://, git@, git://, or ssh://",
1126
+ ).model_dump()
1127
+
1128
+ # Determine target directory
1129
+ repo_name = _extract_repo_name(url)
1130
+ if target_dir:
1131
+ target_path = Path(target_dir).expanduser().resolve()
1132
+ else:
1133
+ # Default to ~/workspace/claude-task-master/{repo-name}
1134
+ target_path = DEFAULT_WORKSPACE_BASE / repo_name
1135
+
1136
+ # Check if target already exists
1137
+ if target_path.exists():
1138
+ return CloneRepoResult(
1139
+ success=False,
1140
+ message=f"Target directory already exists: {target_path}",
1141
+ repo_url=url,
1142
+ target_dir=str(target_path),
1143
+ error="Target directory already exists. Remove it first or specify a different target.",
1144
+ ).model_dump()
1145
+
1146
+ # Ensure parent directory exists
1147
+ try:
1148
+ target_path.parent.mkdir(parents=True, exist_ok=True)
1149
+ except PermissionError as e:
1150
+ return CloneRepoResult(
1151
+ success=False,
1152
+ message=f"Permission denied creating parent directory: {target_path.parent}",
1153
+ repo_url=url,
1154
+ target_dir=str(target_path),
1155
+ error=str(e),
1156
+ ).model_dump()
1157
+ except OSError as e:
1158
+ return CloneRepoResult(
1159
+ success=False,
1160
+ message=f"Failed to create parent directory: {target_path.parent}",
1161
+ repo_url=url,
1162
+ target_dir=str(target_path),
1163
+ error=str(e),
1164
+ ).model_dump()
1165
+
1166
+ # Build git clone command
1167
+ cmd = ["git", "clone"]
1168
+ if branch:
1169
+ cmd.extend(["--branch", branch])
1170
+ cmd.extend([url, str(target_path)])
1171
+
1172
+ # Execute git clone
1173
+ try:
1174
+ result = subprocess.run(
1175
+ cmd,
1176
+ capture_output=True,
1177
+ text=True,
1178
+ check=False,
1179
+ timeout=300, # 5 minute timeout for large repos
1180
+ )
1181
+
1182
+ if result.returncode != 0:
1183
+ # Clean up partial clone if it exists
1184
+ if target_path.exists():
1185
+ shutil.rmtree(target_path, ignore_errors=True)
1186
+
1187
+ error_msg = result.stderr.strip() if result.stderr else "Unknown error"
1188
+ return CloneRepoResult(
1189
+ success=False,
1190
+ message=f"Git clone failed: {error_msg}",
1191
+ repo_url=url,
1192
+ target_dir=str(target_path),
1193
+ branch=branch,
1194
+ error=error_msg,
1195
+ ).model_dump()
1196
+
1197
+ return CloneRepoResult(
1198
+ success=True,
1199
+ message=f"Successfully cloned {repo_name} to {target_path}",
1200
+ repo_url=url,
1201
+ target_dir=str(target_path),
1202
+ branch=branch,
1203
+ ).model_dump()
1204
+
1205
+ except subprocess.TimeoutExpired:
1206
+ # Clean up partial clone
1207
+ if target_path.exists():
1208
+ shutil.rmtree(target_path, ignore_errors=True)
1209
+
1210
+ return CloneRepoResult(
1211
+ success=False,
1212
+ message="Git clone timed out (5 minute limit exceeded)",
1213
+ repo_url=url,
1214
+ target_dir=str(target_path),
1215
+ branch=branch,
1216
+ error="Clone operation timed out - repository may be too large or network is slow",
1217
+ ).model_dump()
1218
+
1219
+ except FileNotFoundError:
1220
+ return CloneRepoResult(
1221
+ success=False,
1222
+ message="Git is not installed or not in PATH",
1223
+ repo_url=url,
1224
+ target_dir=str(target_path),
1225
+ branch=branch,
1226
+ error="git command not found - please install git",
1227
+ ).model_dump()
1228
+
1229
+ except Exception as e:
1230
+ # Clean up partial clone
1231
+ if target_path.exists():
1232
+ shutil.rmtree(target_path, ignore_errors=True)
1233
+
1234
+ return CloneRepoResult(
1235
+ success=False,
1236
+ message=f"Clone failed: {e}",
1237
+ repo_url=url,
1238
+ target_dir=str(target_path),
1239
+ branch=branch,
1240
+ error=str(e),
1241
+ ).model_dump()
1242
+
1243
+
1244
+ def setup_repo(
1245
+ work_dir: str | Path,
1246
+ ) -> dict[str, Any]:
1247
+ """Set up a cloned repository for development.
1248
+
1249
+ Detects the project type and performs appropriate setup:
1250
+ - Creates virtual environment (for Python projects)
1251
+ - Installs dependencies (pip, npm, etc.)
1252
+ - Runs setup scripts (setup-hooks.sh, etc.)
1253
+
1254
+ Args:
1255
+ work_dir: Path to the cloned repository directory.
1256
+
1257
+ Returns:
1258
+ Dictionary containing setup result with success status and details.
1259
+ """
1260
+ import subprocess
1261
+ import sys
1262
+
1263
+ work_path = Path(work_dir).expanduser().resolve()
1264
+ steps_completed: list[str] = []
1265
+ setup_scripts_run: list[str] = []
1266
+ venv_path: str | None = None
1267
+ dependencies_installed = False
1268
+
1269
+ # Validate work directory
1270
+ if not work_path.exists():
1271
+ return SetupRepoResult(
1272
+ success=False,
1273
+ message=f"Directory does not exist: {work_path}",
1274
+ work_dir=str(work_path),
1275
+ error="Work directory not found",
1276
+ ).model_dump()
1277
+
1278
+ if not work_path.is_dir():
1279
+ return SetupRepoResult(
1280
+ success=False,
1281
+ message=f"Path is not a directory: {work_path}",
1282
+ work_dir=str(work_path),
1283
+ error="Path is not a directory",
1284
+ ).model_dump()
1285
+
1286
+ # Detect project type based on manifest files
1287
+ is_python = (work_path / "pyproject.toml").exists() or (work_path / "setup.py").exists()
1288
+ is_node = (work_path / "package.json").exists()
1289
+ has_requirements = (work_path / "requirements.txt").exists()
1290
+ has_uv_lock = (work_path / "uv.lock").exists()
1291
+
1292
+ # Detect setup scripts
1293
+ setup_scripts: list[Path] = []
1294
+ scripts_dir = work_path / "scripts"
1295
+ if scripts_dir.exists():
1296
+ # Look for common setup scripts
1297
+ for script_name in ["setup-hooks.sh", "setup.sh", "install.sh", "bootstrap.sh"]:
1298
+ script_path = scripts_dir / script_name
1299
+ if script_path.exists() and script_path.is_file():
1300
+ setup_scripts.append(script_path)
1301
+
1302
+ # Also check root directory for setup scripts
1303
+ for script_name in ["setup.sh", "install.sh", "bootstrap.sh"]:
1304
+ script_path = work_path / script_name
1305
+ if script_path.exists() and script_path.is_file():
1306
+ setup_scripts.append(script_path)
1307
+
1308
+ try:
1309
+ # === Python Project Setup ===
1310
+ if is_python:
1311
+ steps_completed.append("Detected Python project")
1312
+
1313
+ # Check for uv (preferred) or fall back to standard venv + pip
1314
+ has_uv = shutil.which("uv") is not None
1315
+
1316
+ if has_uv:
1317
+ # Use uv for virtual environment and dependency management
1318
+ steps_completed.append("Using uv for dependency management")
1319
+
1320
+ # Create venv with uv if not exists
1321
+ venv_dir = work_path / ".venv"
1322
+ if not venv_dir.exists():
1323
+ result = subprocess.run(
1324
+ ["uv", "venv", str(venv_dir)],
1325
+ cwd=work_path,
1326
+ capture_output=True,
1327
+ text=True,
1328
+ check=False,
1329
+ timeout=60,
1330
+ )
1331
+ if result.returncode != 0:
1332
+ return SetupRepoResult(
1333
+ success=False,
1334
+ message=f"Failed to create venv with uv: {result.stderr}",
1335
+ work_dir=str(work_path),
1336
+ steps_completed=steps_completed,
1337
+ error=result.stderr,
1338
+ ).model_dump()
1339
+ steps_completed.append("Created virtual environment with uv")
1340
+ else:
1341
+ steps_completed.append("Virtual environment already exists")
1342
+
1343
+ venv_path = str(venv_dir)
1344
+
1345
+ # Install dependencies with uv
1346
+ # Prefer uv sync for uv-managed projects, otherwise uv pip install
1347
+ if has_uv_lock or (work_path / "pyproject.toml").exists():
1348
+ # Use uv sync for projects with pyproject.toml
1349
+ sync_cmd = ["uv", "sync"]
1350
+ # Try to install all extras if available
1351
+ if (work_path / "pyproject.toml").exists():
1352
+ sync_cmd.append("--all-extras")
1353
+
1354
+ result = subprocess.run(
1355
+ sync_cmd,
1356
+ cwd=work_path,
1357
+ capture_output=True,
1358
+ text=True,
1359
+ check=False,
1360
+ timeout=300,
1361
+ )
1362
+ if result.returncode == 0:
1363
+ dependencies_installed = True
1364
+ steps_completed.append("Installed dependencies with uv sync")
1365
+ else:
1366
+ # Fall back to basic uv sync without extras
1367
+ result = subprocess.run(
1368
+ ["uv", "sync"],
1369
+ cwd=work_path,
1370
+ capture_output=True,
1371
+ text=True,
1372
+ check=False,
1373
+ timeout=300,
1374
+ )
1375
+ if result.returncode == 0:
1376
+ dependencies_installed = True
1377
+ steps_completed.append("Installed dependencies with uv sync (basic)")
1378
+ else:
1379
+ steps_completed.append(f"Warning: uv sync failed: {result.stderr}")
1380
+ elif has_requirements:
1381
+ result = subprocess.run(
1382
+ ["uv", "pip", "install", "-r", "requirements.txt"],
1383
+ cwd=work_path,
1384
+ capture_output=True,
1385
+ text=True,
1386
+ check=False,
1387
+ timeout=300,
1388
+ )
1389
+ if result.returncode == 0:
1390
+ dependencies_installed = True
1391
+ steps_completed.append("Installed dependencies from requirements.txt")
1392
+ else:
1393
+ steps_completed.append(f"Warning: pip install failed: {result.stderr}")
1394
+ else:
1395
+ # Fall back to standard Python venv + pip
1396
+ steps_completed.append("Using standard venv + pip")
1397
+
1398
+ venv_dir = work_path / ".venv"
1399
+ if not venv_dir.exists():
1400
+ result = subprocess.run(
1401
+ ["python3", "-m", "venv", str(venv_dir)],
1402
+ cwd=work_path,
1403
+ capture_output=True,
1404
+ text=True,
1405
+ check=False,
1406
+ timeout=120,
1407
+ )
1408
+ if result.returncode != 0:
1409
+ return SetupRepoResult(
1410
+ success=False,
1411
+ message=f"Failed to create venv: {result.stderr}",
1412
+ work_dir=str(work_path),
1413
+ steps_completed=steps_completed,
1414
+ error=result.stderr,
1415
+ ).model_dump()
1416
+ steps_completed.append("Created virtual environment")
1417
+ else:
1418
+ steps_completed.append("Virtual environment already exists")
1419
+
1420
+ venv_path = str(venv_dir)
1421
+ # Use platform-appropriate path for pip
1422
+ pip_path = (
1423
+ venv_dir
1424
+ / ("Scripts" if sys.platform == "win32" else "bin")
1425
+ / ("pip.exe" if sys.platform == "win32" else "pip")
1426
+ )
1427
+
1428
+ # Install dependencies with pip
1429
+ if (work_path / "pyproject.toml").exists():
1430
+ # Install project in editable mode with dev extras
1431
+ result = subprocess.run(
1432
+ [str(pip_path), "install", "-e", ".[dev]"],
1433
+ cwd=work_path,
1434
+ capture_output=True,
1435
+ text=True,
1436
+ check=False,
1437
+ timeout=300,
1438
+ )
1439
+ if result.returncode == 0:
1440
+ dependencies_installed = True
1441
+ steps_completed.append("Installed project with pip (editable + dev)")
1442
+ else:
1443
+ # Try without dev extras
1444
+ result = subprocess.run(
1445
+ [str(pip_path), "install", "-e", "."],
1446
+ cwd=work_path,
1447
+ capture_output=True,
1448
+ text=True,
1449
+ check=False,
1450
+ timeout=300,
1451
+ )
1452
+ if result.returncode == 0:
1453
+ dependencies_installed = True
1454
+ steps_completed.append("Installed project with pip (editable)")
1455
+ else:
1456
+ steps_completed.append(f"Warning: pip install failed: {result.stderr}")
1457
+ elif has_requirements:
1458
+ result = subprocess.run(
1459
+ [str(pip_path), "install", "-r", "requirements.txt"],
1460
+ cwd=work_path,
1461
+ capture_output=True,
1462
+ text=True,
1463
+ check=False,
1464
+ timeout=300,
1465
+ )
1466
+ if result.returncode == 0:
1467
+ dependencies_installed = True
1468
+ steps_completed.append("Installed dependencies from requirements.txt")
1469
+ else:
1470
+ steps_completed.append(f"Warning: pip install failed: {result.stderr}")
1471
+
1472
+ # === Node.js Project Setup ===
1473
+ if is_node:
1474
+ steps_completed.append("Detected Node.js project")
1475
+
1476
+ # Check for package managers (lock files indicate preferred manager)
1477
+ has_pnpm_lock = (work_path / "pnpm-lock.yaml").exists()
1478
+ has_yarn_lock = (work_path / "yarn.lock").exists()
1479
+ has_bun_lock = (work_path / "bun.lockb").exists()
1480
+
1481
+ # Determine which package manager to use
1482
+ if has_bun_lock and shutil.which("bun"):
1483
+ pkg_manager = "bun"
1484
+ install_cmd = ["bun", "install"]
1485
+ elif has_pnpm_lock and shutil.which("pnpm"):
1486
+ pkg_manager = "pnpm"
1487
+ install_cmd = ["pnpm", "install"]
1488
+ elif has_yarn_lock and shutil.which("yarn"):
1489
+ pkg_manager = "yarn"
1490
+ install_cmd = ["yarn", "install"]
1491
+ elif shutil.which("npm"):
1492
+ pkg_manager = "npm"
1493
+ install_cmd = ["npm", "install"]
1494
+ else:
1495
+ steps_completed.append("Warning: No Node.js package manager found")
1496
+ pkg_manager = None
1497
+ install_cmd = None
1498
+
1499
+ if install_cmd:
1500
+ result = subprocess.run(
1501
+ install_cmd,
1502
+ cwd=work_path,
1503
+ capture_output=True,
1504
+ text=True,
1505
+ check=False,
1506
+ timeout=300,
1507
+ )
1508
+ if result.returncode == 0:
1509
+ dependencies_installed = True
1510
+ steps_completed.append(f"Installed dependencies with {pkg_manager}")
1511
+ else:
1512
+ steps_completed.append(
1513
+ f"Warning: {pkg_manager} install failed: {result.stderr}"
1514
+ )
1515
+
1516
+ # === Run Setup Scripts ===
1517
+ for script in setup_scripts:
1518
+ try:
1519
+ # Make script executable (skip on Windows where chmod is not needed)
1520
+ if sys.platform != "win32":
1521
+ script.chmod(script.stat().st_mode | 0o755)
1522
+
1523
+ result = subprocess.run(
1524
+ [str(script)],
1525
+ cwd=work_path,
1526
+ capture_output=True,
1527
+ text=True,
1528
+ check=False,
1529
+ timeout=120,
1530
+ )
1531
+ if result.returncode == 0:
1532
+ setup_scripts_run.append(str(script.relative_to(work_path)))
1533
+ steps_completed.append(f"Ran setup script: {script.name}")
1534
+ else:
1535
+ steps_completed.append(
1536
+ f"Warning: Setup script {script.name} failed: {result.stderr}"
1537
+ )
1538
+ except Exception as e:
1539
+ steps_completed.append(f"Warning: Could not run {script.name}: {e}")
1540
+
1541
+ # Determine overall success
1542
+ # Success if we detected a project type and either installed deps or ran scripts
1543
+ success = len(steps_completed) > 0 and (
1544
+ dependencies_installed or len(setup_scripts_run) > 0
1545
+ )
1546
+
1547
+ if not is_python and not is_node:
1548
+ steps_completed.append("No recognized project type (Python or Node.js)")
1549
+ if setup_scripts_run:
1550
+ success = True # Still success if we ran setup scripts
1551
+ else:
1552
+ success = False
1553
+
1554
+ message = (
1555
+ f"Setup completed for {work_path.name}"
1556
+ if success
1557
+ else f"Setup incomplete for {work_path.name}"
1558
+ )
1559
+
1560
+ return SetupRepoResult(
1561
+ success=success,
1562
+ message=message,
1563
+ work_dir=str(work_path),
1564
+ steps_completed=steps_completed,
1565
+ venv_path=venv_path,
1566
+ dependencies_installed=dependencies_installed,
1567
+ setup_scripts_run=setup_scripts_run,
1568
+ ).model_dump()
1569
+
1570
+ except subprocess.TimeoutExpired as e:
1571
+ return SetupRepoResult(
1572
+ success=False,
1573
+ message=f"Setup timed out: {e}",
1574
+ work_dir=str(work_path),
1575
+ steps_completed=steps_completed,
1576
+ venv_path=venv_path,
1577
+ dependencies_installed=dependencies_installed,
1578
+ setup_scripts_run=setup_scripts_run,
1579
+ error="Operation timed out",
1580
+ ).model_dump()
1581
+
1582
+ except Exception as e:
1583
+ return SetupRepoResult(
1584
+ success=False,
1585
+ message=f"Setup failed: {e}",
1586
+ work_dir=str(work_path),
1587
+ steps_completed=steps_completed,
1588
+ venv_path=venv_path,
1589
+ dependencies_installed=dependencies_installed,
1590
+ setup_scripts_run=setup_scripts_run,
1591
+ error=str(e),
1592
+ ).model_dump()
1593
+
1594
+
1595
+ def plan_repo(
1596
+ work_dir: str | Path,
1597
+ goal: str,
1598
+ model: str = "opus",
1599
+ ) -> dict[str, Any]:
1600
+ """Create a plan for a repository without executing any work.
1601
+
1602
+ This is a plan-only mode that reads the codebase using read-only tools
1603
+ (Read, Glob, Grep, Bash) and outputs a structured plan with tasks and
1604
+ success criteria. No changes are made to the repository.
1605
+
1606
+ Use this after `clone_repo` and `setup_repo` to plan work before execution,
1607
+ or to get a plan for a new goal in an existing repository.
1608
+
1609
+ Args:
1610
+ work_dir: Path to the repository directory to plan for.
1611
+ goal: The goal/task description to plan for.
1612
+ model: Model to use for planning (default: "opus" for best quality).
1613
+
1614
+ Returns:
1615
+ Dictionary containing planning result with success status, plan, and criteria.
1616
+ """
1617
+ work_path = Path(work_dir).expanduser().resolve()
1618
+
1619
+ # Validate work directory
1620
+ if not work_path.exists():
1621
+ return PlanRepoResult(
1622
+ success=False,
1623
+ message=f"Directory does not exist: {work_path}",
1624
+ work_dir=str(work_path),
1625
+ goal=goal,
1626
+ error="Work directory not found",
1627
+ ).model_dump()
1628
+
1629
+ if not work_path.is_dir():
1630
+ return PlanRepoResult(
1631
+ success=False,
1632
+ message=f"Path is not a directory: {work_path}",
1633
+ work_dir=str(work_path),
1634
+ goal=goal,
1635
+ error="Path is not a directory",
1636
+ ).model_dump()
1637
+
1638
+ # Validate goal
1639
+ if not goal or not goal.strip():
1640
+ return PlanRepoResult(
1641
+ success=False,
1642
+ message="Goal is required",
1643
+ work_dir=str(work_path),
1644
+ error="Goal cannot be empty",
1645
+ ).model_dump()
1646
+
1647
+ goal = goal.strip()
1648
+
1649
+ # Initialize state manager for this repo
1650
+ state_path = work_path / ".claude-task-master"
1651
+ state_manager = StateManager(state_dir=state_path)
1652
+
1653
+ # Check if task already exists
1654
+ if state_manager.exists():
1655
+ # Load existing state to check if we can replan
1656
+ try:
1657
+ existing_state = state_manager.load_state()
1658
+ # If task is in progress, don't overwrite
1659
+ if existing_state.status in ("planning", "working"):
1660
+ return PlanRepoResult(
1661
+ success=False,
1662
+ message=f"Task already in progress (status: {existing_state.status})",
1663
+ work_dir=str(work_path),
1664
+ goal=goal,
1665
+ run_id=existing_state.run_id,
1666
+ error="Cannot create new plan while task is active. Use clean_task first.",
1667
+ ).model_dump()
1668
+ except Exception:
1669
+ pass # State exists but couldn't be loaded - will be overwritten
1670
+
1671
+ try:
1672
+ # Import credentials and agent here to avoid circular imports
1673
+ from claude_task_master.core.agent import AgentWrapper
1674
+ from claude_task_master.core.agent_models import ModelType
1675
+ from claude_task_master.core.credentials import CredentialManager
1676
+
1677
+ # Get valid access token
1678
+ cred_manager = CredentialManager()
1679
+ access_token = cred_manager.get_valid_token()
1680
+
1681
+ # Map model string to ModelType
1682
+ model_map = {
1683
+ "opus": ModelType.OPUS,
1684
+ "sonnet": ModelType.SONNET,
1685
+ "haiku": ModelType.HAIKU,
1686
+ }
1687
+ model_type = model_map.get(model.lower(), ModelType.OPUS)
1688
+
1689
+ # Initialize task state
1690
+ options = TaskOptions(
1691
+ auto_merge=False, # Plan-only mode
1692
+ max_sessions=1,
1693
+ pause_on_pr=True,
1694
+ )
1695
+ state = state_manager.initialize(goal=goal, model=model, options=options)
1696
+
1697
+ # Update status to planning
1698
+ state.status = "planning"
1699
+ state_manager.save_state(state)
1700
+
1701
+ # Create agent wrapper
1702
+ agent = AgentWrapper(
1703
+ access_token=access_token,
1704
+ model=model_type,
1705
+ working_dir=str(work_path),
1706
+ enable_safety_hooks=True,
1707
+ )
1708
+
1709
+ # Run planning phase (read-only)
1710
+ result = agent.run_planning_phase(goal=goal, context="")
1711
+
1712
+ # Extract plan and criteria
1713
+ plan = result.get("plan", "")
1714
+ criteria = result.get("criteria", "")
1715
+
1716
+ # Save plan and criteria to state
1717
+ if plan:
1718
+ state_manager.save_plan(plan)
1719
+ if criteria:
1720
+ state_manager.save_criteria(criteria)
1721
+
1722
+ # Update state to paused (plan complete, ready for work)
1723
+ state.status = "paused"
1724
+ state_manager.save_state(state)
1725
+
1726
+ return PlanRepoResult(
1727
+ success=True,
1728
+ message=f"Successfully created plan for: {goal}",
1729
+ work_dir=str(work_path),
1730
+ goal=goal,
1731
+ plan=plan,
1732
+ criteria=criteria,
1733
+ run_id=state.run_id,
1734
+ ).model_dump()
1735
+
1736
+ except ImportError as e:
1737
+ return PlanRepoResult(
1738
+ success=False,
1739
+ message="Failed to import required modules",
1740
+ work_dir=str(work_path),
1741
+ goal=goal,
1742
+ error=f"Import error: {e}. Ensure claude-agent-sdk is installed.",
1743
+ ).model_dump()
1744
+
1745
+ except Exception as e:
1746
+ # Try to update state to failed if possible
1747
+ try:
1748
+ if state_manager.exists():
1749
+ state = state_manager.load_state()
1750
+ state.status = "blocked"
1751
+ state_manager.save_state(state)
1752
+ except Exception:
1753
+ pass # State update failed, continue with error return
1754
+
1755
+ return PlanRepoResult(
1756
+ success=False,
1757
+ message=f"Planning failed: {e}",
1758
+ work_dir=str(work_path),
1759
+ goal=goal,
1760
+ error=str(e),
1761
+ ).model_dump()