tloop-cli 0.5.0__tar.gz → 0.6.0__tar.gz

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 (28) hide show
  1. {tloop_cli-0.5.0 → tloop_cli-0.6.0}/PKG-INFO +34 -1
  2. {tloop_cli-0.5.0 → tloop_cli-0.6.0}/README.md +33 -0
  3. {tloop_cli-0.5.0 → tloop_cli-0.6.0}/pyproject.toml +1 -1
  4. {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/claude_runner.py +15 -6
  5. {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/cmd_edit.py +2 -0
  6. {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/config.py +13 -2
  7. tloop_cli-0.6.0/src/runner/claude.py +89 -0
  8. {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/task.py +18 -5
  9. {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/tloop_cli.egg-info/PKG-INFO +34 -1
  10. {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/tloop_cli.egg-info/SOURCES.txt +1 -0
  11. tloop_cli-0.6.0/test/test_claude_runner.py +89 -0
  12. {tloop_cli-0.5.0 → tloop_cli-0.6.0}/test/test_cli.py +119 -0
  13. tloop_cli-0.5.0/src/runner/claude.py +0 -10
  14. {tloop_cli-0.5.0 → tloop_cli-0.6.0}/LICENSE +0 -0
  15. {tloop_cli-0.5.0 → tloop_cli-0.6.0}/setup.cfg +0 -0
  16. {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/cmd_archive.py +0 -0
  17. {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/cmd_migrate.py +0 -0
  18. {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/cmd_run.py +0 -0
  19. {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/git_ops.py +0 -0
  20. {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/main.py +0 -0
  21. {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/runner/__init__.py +0 -0
  22. {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/runner/cybervisor.py +0 -0
  23. {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/state.py +0 -0
  24. {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/tloop_cli.egg-info/dependency_links.txt +0 -0
  25. {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/tloop_cli.egg-info/entry_points.txt +0 -0
  26. {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/tloop_cli.egg-info/requires.txt +0 -0
  27. {tloop_cli-0.5.0 → tloop_cli-0.6.0}/src/tloop_cli.egg-info/top_level.txt +0 -0
  28. {tloop_cli-0.5.0 → tloop_cli-0.6.0}/test/test_git_ops.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tloop-cli
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Automated Claude Code task runner
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.9
@@ -60,6 +60,8 @@ tasks:
60
60
  | `prompt_file` | prompt 文件路径(解析顺序:绝对路径 → `~/.tloop/` 相对 → 任务目录相对) |
61
61
  | `model` | 覆盖该任务的模型 |
62
62
  | `branch` | `true`(自动 `feature-YYYYMMDD-NNN`)、`"custom/name"`、或 `false`(跳过分支) |
63
+ | `use` | 任务执行器:`cybervisor`(默认,多阶段复杂任务)或 `claude`(定向任务,支持循环) |
64
+ | `max_rounds` | `use: claude` 时生效,最大迭代次数(默认 5);到达上限前未收到 `<promise>COMPLETE</promise>` 信号则任务失败 |
63
65
 
64
66
  ## 用法
65
67
 
@@ -76,6 +78,37 @@ tloop archive --latest # 显示最近一次归档详情
76
78
  tloop migrate # 迁移旧的项目本地数据到 ~/.tloop/
77
79
  ```
78
80
 
81
+ ## 执行器
82
+
83
+ t-loop 支持两种任务执行器,通过 `use` 字段选择:
84
+
85
+ | 执行器 | 说明 | 适用场景 |
86
+ |--------|------|----------|
87
+ | `cybervisor` | 默认,执行 `cybervisor run`,适合复杂多阶段任务 | 大型重构、多步骤分析 |
88
+ | `claude` | 执行 `claude -p --dangerously-skip-permissions`,支持循环迭代 | 定向 bug 修复、明确目标的任务 |
89
+
90
+ ### ClaudeRunner 循环机制
91
+
92
+ `use: claude` 时,任务以多轮迭代方式执行:
93
+
94
+ 1. 每轮启动一次 `claude -p --dangerously-skip-permissions`,传入 prompt
95
+ 2. 如果输出包含 `<promise>COMPLETE</promise>`,立即结束并标记成功
96
+ 3. 否则等待 2 秒,继续下一轮,直到达到 `max_rounds`(默认 5)
97
+ 4. `max_rounds` 用尽仍未检测到完成信号,任务标记失败
98
+
99
+ 使用示例:
100
+
101
+ ```yaml
102
+ tasks:
103
+ - name: 修复空指针 bug
104
+ dir: ~/proj
105
+ use: claude
106
+ max_rounds: 3
107
+ prompt_file: bugfix.md
108
+ ```
109
+
110
+ 在 prompt 文件中加入 `<promise>COMPLETE</promise>` 即可让 Claude 主动退出循环。
111
+
79
112
  ## 工作原理
80
113
 
81
114
  每个任务的执行流程:
@@ -49,6 +49,8 @@ tasks:
49
49
  | `prompt_file` | prompt 文件路径(解析顺序:绝对路径 → `~/.tloop/` 相对 → 任务目录相对) |
50
50
  | `model` | 覆盖该任务的模型 |
51
51
  | `branch` | `true`(自动 `feature-YYYYMMDD-NNN`)、`"custom/name"`、或 `false`(跳过分支) |
52
+ | `use` | 任务执行器:`cybervisor`(默认,多阶段复杂任务)或 `claude`(定向任务,支持循环) |
53
+ | `max_rounds` | `use: claude` 时生效,最大迭代次数(默认 5);到达上限前未收到 `<promise>COMPLETE</promise>` 信号则任务失败 |
52
54
 
53
55
  ## 用法
54
56
 
@@ -65,6 +67,37 @@ tloop archive --latest # 显示最近一次归档详情
65
67
  tloop migrate # 迁移旧的项目本地数据到 ~/.tloop/
66
68
  ```
67
69
 
70
+ ## 执行器
71
+
72
+ t-loop 支持两种任务执行器,通过 `use` 字段选择:
73
+
74
+ | 执行器 | 说明 | 适用场景 |
75
+ |--------|------|----------|
76
+ | `cybervisor` | 默认,执行 `cybervisor run`,适合复杂多阶段任务 | 大型重构、多步骤分析 |
77
+ | `claude` | 执行 `claude -p --dangerously-skip-permissions`,支持循环迭代 | 定向 bug 修复、明确目标的任务 |
78
+
79
+ ### ClaudeRunner 循环机制
80
+
81
+ `use: claude` 时,任务以多轮迭代方式执行:
82
+
83
+ 1. 每轮启动一次 `claude -p --dangerously-skip-permissions`,传入 prompt
84
+ 2. 如果输出包含 `<promise>COMPLETE</promise>`,立即结束并标记成功
85
+ 3. 否则等待 2 秒,继续下一轮,直到达到 `max_rounds`(默认 5)
86
+ 4. `max_rounds` 用尽仍未检测到完成信号,任务标记失败
87
+
88
+ 使用示例:
89
+
90
+ ```yaml
91
+ tasks:
92
+ - name: 修复空指针 bug
93
+ dir: ~/proj
94
+ use: claude
95
+ max_rounds: 3
96
+ prompt_file: bugfix.md
97
+ ```
98
+
99
+ 在 prompt 文件中加入 `<promise>COMPLETE</promise>` 即可让 Claude 主动退出循环。
100
+
68
101
  ## 工作原理
69
102
 
70
103
  每个任务的执行流程:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tloop-cli"
7
- version = "0.5.0"
7
+ version = "0.6.0"
8
8
  description = "Automated Claude Code task runner"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -45,12 +45,21 @@ def run_claude(prompt, cwd, max_retries=DEFAULT_MAX_RETRIES, verify_fn=None, log
45
45
  else:
46
46
  attempt_cmd = cmd
47
47
 
48
- result = subprocess.run(
49
- attempt_cmd,
50
- cwd=cwd,
51
- capture_output=True,
52
- text=True,
53
- )
48
+ print(f" Running claude (attempt {attempt}/{max_retries})...")
49
+ try:
50
+ result = subprocess.run(
51
+ attempt_cmd,
52
+ cwd=cwd,
53
+ capture_output=True,
54
+ text=True,
55
+ timeout=300,
56
+ )
57
+ except subprocess.TimeoutExpired:
58
+ print(f"{YELLOW} Claude timed out after 300s (attempt {attempt}/{max_retries}){RESET}")
59
+ if attempt >= max_retries:
60
+ print(f"{RED} Max retries reached. Giving up.{RESET}")
61
+ return False
62
+ continue
54
63
 
55
64
  if log_file:
56
65
  with open(log_file, "a") as log:
@@ -31,6 +31,8 @@ Task file format (~/.tloop/tasks.yaml):
31
31
  # OR:
32
32
  prompt_file: ./prompts/my-task.md
33
33
  branch: true # true=auto, "custom/name", false=skip
34
+ use: cybervisor # cybervisor (default) or claude
35
+ max_rounds: 5 # only for use: claude
34
36
 
35
37
  Each task runs in the specified directory. Completed tasks are
36
38
  archived to ~/.tloop/archive/ after each run cycle.
@@ -39,5 +39,16 @@ def load_config():
39
39
  print(f"{RED}Error: {TASKS_FILE} not found{RESET}")
40
40
  print(f"Run tloop run to initialize, then edit tasks.yaml.")
41
41
  sys.exit(1)
42
- with open(TASKS_FILE) as f:
43
- return yaml.safe_load(f) or {}
42
+ try:
43
+ with open(TASKS_FILE) as f:
44
+ return yaml.safe_load(f) or {}
45
+ except yaml.YAMLError as e:
46
+ location = ""
47
+ if hasattr(e, "problem_mark") and e.problem_mark:
48
+ mark = e.problem_mark
49
+ location = f" (line {mark.line + 1}, column {mark.column + 1})"
50
+ print(f"{RED}Error: {TASKS_FILE} 解析失败{location}{RESET}")
51
+ if hasattr(e, "problem") and e.problem:
52
+ print(f" {e.problem}")
53
+ print("请检查 YAML 格式,常见问题:含冒号/特殊字符的值需要用引号包裹。")
54
+ sys.exit(1)
@@ -0,0 +1,89 @@
1
+ """Claude Code runner backend with round-loop execution."""
2
+
3
+ import subprocess
4
+ import time
5
+
6
+ from runner import Runner
7
+
8
+ COMPLETION_SUFFIX = (
9
+
10
+ "\n\nWhen you have fully completed all the requested work, "
11
+ "output the following on its own line to signal completion:\n"
12
+ "<promise>COMPLETE</promise>\n"
13
+ "Do NOT output this unless you have finished everything. "
14
+ "If there is still work to do, end your response normally — "
15
+ "another iteration will pick up where you left off."
16
+ )
17
+
18
+
19
+ class ClaudeRunner(Runner):
20
+ def run(self, prompt, cwd, log_file=None, max_rounds=5, prompt_file=None):
21
+ """
22
+ Run Claude Code in a loop with configurable round limit.
23
+
24
+ Args:
25
+ prompt: The prompt to send to Claude Code
26
+ cwd: Working directory for the task
27
+ log_file: Optional path to log file
28
+ max_rounds: Maximum number of loop iterations (default 5)
29
+ prompt_file: Optional path to prompt file (used as stdin like CybervisorRunner)
30
+
31
+ Returns:
32
+ 0 on success (completion signal detected), non-zero on failure
33
+ """
34
+ enriched_prompt = prompt + COMPLETION_SUFFIX
35
+
36
+ log = open(log_file, "a") if log_file else open("/dev/null", "a")
37
+ try:
38
+ for round_num in range(1, max_rounds + 1):
39
+ log.write(f"\n{'='*60}\n")
40
+ log.write(f"Round {round_num}/{max_rounds}\n")
41
+ log.write(f"{'='*60}\n\n")
42
+ log.flush()
43
+
44
+ print(f"\n{'='*60}")
45
+ print(f" Round {round_num}/{max_rounds} ")
46
+ print(f"{'='*60}")
47
+
48
+ process = subprocess.Popen(
49
+ ["claude", "-p", "--dangerously-skip-permissions"],
50
+ cwd=cwd,
51
+ stdin=subprocess.PIPE,
52
+ stdout=subprocess.PIPE,
53
+ stderr=subprocess.STDOUT,
54
+ text=True,
55
+ )
56
+
57
+ output_parts = []
58
+ if prompt_file:
59
+ with open(prompt_file, "r") as f:
60
+ process.stdin.write(f.read())
61
+ process.stdin.write(COMPLETION_SUFFIX)
62
+ else:
63
+ process.stdin.write(enriched_prompt)
64
+ process.stdin.close()
65
+
66
+ for line in process.stdout:
67
+ print(line, end="")
68
+ log.write(line)
69
+ output_parts.append(line)
70
+
71
+ process.wait()
72
+ log.flush()
73
+
74
+ accumulated = "".join(output_parts)
75
+ if "<promise>COMPLETE</promise>" in accumulated:
76
+ log.write("\n[Completion signal detected - exiting loop]\n")
77
+ log.flush()
78
+ return 0
79
+
80
+ if round_num < max_rounds:
81
+ log.write(f"\n[Round {round_num} complete, sleeping 2s before next round]\n")
82
+ log.flush()
83
+ time.sleep(2)
84
+
85
+ log.write(f"\n[All {max_rounds} rounds exhausted without completion signal]\n")
86
+ log.flush()
87
+ return 1
88
+ finally:
89
+ log.close()
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
  import config
8
8
  from git_ops import ensure_clean_git, create_task_branch
9
9
  from runner.cybervisor import CybervisorRunner
10
+ from runner.claude import ClaudeRunner
10
11
  from state import save_state
11
12
 
12
13
 
@@ -100,12 +101,24 @@ def run_task(task, index, state):
100
101
  try:
101
102
  with open(log_file, "a") as log:
102
103
  log.write(f"Started: {started}\n")
103
- log.write(f"Command: cybervisor run < {'<prompt_file>' if resolved_pf else '<prompt>'}\n")
104
- log.write("-" * 60 + "\n\n")
105
104
  log.flush()
106
105
 
107
- runner = CybervisorRunner()
108
- returncode = runner.run(prompt, dir_path, log_file=log_file, prompt_file=resolved_pf)
106
+ runner_name = task.get("use", "cybervisor")
107
+ if runner_name == "claude":
108
+ runner = ClaudeRunner()
109
+ max_rounds = task.get("max_rounds", 5)
110
+ log.write(f"Runner: ClaudeRunner (max_rounds={max_rounds})\n")
111
+ log.write(f"Command: claude -p --dangerously-skip-permissions\n")
112
+ log.write("-" * 60 + "\n\n")
113
+ log.flush()
114
+ returncode = runner.run(prompt, dir_path, log_file=log_file, max_rounds=max_rounds, prompt_file=resolved_pf)
115
+ else:
116
+ runner = CybervisorRunner()
117
+ log.write(f"Runner: CybervisorRunner\n")
118
+ log.write(f"Command: cybervisor run < {'<prompt_file>' if resolved_pf else '<prompt>'}\n")
119
+ log.write("-" * 60 + "\n\n")
120
+ log.flush()
121
+ returncode = runner.run(prompt, dir_path, log_file=log_file, prompt_file=resolved_pf)
109
122
 
110
123
  if returncode == 0:
111
124
  state["tasks"][str(index)] = {
@@ -139,4 +152,4 @@ def run_task(task, index, state):
139
152
  print(f"\n{config.RED}❌ Task [{index + 1}] error: {e}{config.RESET}")
140
153
  return False
141
154
 
142
- return True
155
+ return True
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tloop-cli
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Automated Claude Code task runner
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.9
@@ -60,6 +60,8 @@ tasks:
60
60
  | `prompt_file` | prompt 文件路径(解析顺序:绝对路径 → `~/.tloop/` 相对 → 任务目录相对) |
61
61
  | `model` | 覆盖该任务的模型 |
62
62
  | `branch` | `true`(自动 `feature-YYYYMMDD-NNN`)、`"custom/name"`、或 `false`(跳过分支) |
63
+ | `use` | 任务执行器:`cybervisor`(默认,多阶段复杂任务)或 `claude`(定向任务,支持循环) |
64
+ | `max_rounds` | `use: claude` 时生效,最大迭代次数(默认 5);到达上限前未收到 `<promise>COMPLETE</promise>` 信号则任务失败 |
63
65
 
64
66
  ## 用法
65
67
 
@@ -76,6 +78,37 @@ tloop archive --latest # 显示最近一次归档详情
76
78
  tloop migrate # 迁移旧的项目本地数据到 ~/.tloop/
77
79
  ```
78
80
 
81
+ ## 执行器
82
+
83
+ t-loop 支持两种任务执行器,通过 `use` 字段选择:
84
+
85
+ | 执行器 | 说明 | 适用场景 |
86
+ |--------|------|----------|
87
+ | `cybervisor` | 默认,执行 `cybervisor run`,适合复杂多阶段任务 | 大型重构、多步骤分析 |
88
+ | `claude` | 执行 `claude -p --dangerously-skip-permissions`,支持循环迭代 | 定向 bug 修复、明确目标的任务 |
89
+
90
+ ### ClaudeRunner 循环机制
91
+
92
+ `use: claude` 时,任务以多轮迭代方式执行:
93
+
94
+ 1. 每轮启动一次 `claude -p --dangerously-skip-permissions`,传入 prompt
95
+ 2. 如果输出包含 `<promise>COMPLETE</promise>`,立即结束并标记成功
96
+ 3. 否则等待 2 秒,继续下一轮,直到达到 `max_rounds`(默认 5)
97
+ 4. `max_rounds` 用尽仍未检测到完成信号,任务标记失败
98
+
99
+ 使用示例:
100
+
101
+ ```yaml
102
+ tasks:
103
+ - name: 修复空指针 bug
104
+ dir: ~/proj
105
+ use: claude
106
+ max_rounds: 3
107
+ prompt_file: bugfix.md
108
+ ```
109
+
110
+ 在 prompt 文件中加入 `<promise>COMPLETE</promise>` 即可让 Claude 主动退出循环。
111
+
79
112
  ## 工作原理
80
113
 
81
114
  每个任务的执行流程:
@@ -20,5 +20,6 @@ src/tloop_cli.egg-info/dependency_links.txt
20
20
  src/tloop_cli.egg-info/entry_points.txt
21
21
  src/tloop_cli.egg-info/requires.txt
22
22
  src/tloop_cli.egg-info/top_level.txt
23
+ test/test_claude_runner.py
23
24
  test/test_cli.py
24
25
  test/test_git_ops.py
@@ -0,0 +1,89 @@
1
+ """Tests for ClaudeRunner."""
2
+
3
+ import subprocess
4
+ import unittest
5
+ from unittest.mock import patch, MagicMock
6
+
7
+ from runner.claude import ClaudeRunner
8
+
9
+
10
+ class MockProcess:
11
+ def __init__(self, stdout_lines, returncode=0):
12
+ self._stdout_lines = stdout_lines
13
+ self.returncode = returncode
14
+ self.stdin = MagicMock()
15
+
16
+ @property
17
+ def stdout(self):
18
+ return iter(self._stdout_lines)
19
+
20
+ def wait(self):
21
+ pass
22
+
23
+
24
+ class ClaudeRunnerTests(unittest.TestCase):
25
+
26
+ def test_completion_signal_exits_early(self):
27
+ runner = ClaudeRunner()
28
+ mock_process = MockProcess(["Some output\n", "<promise>COMPLETE</promise>\n", "More output\n"])
29
+ with patch("subprocess.Popen", return_value=mock_process):
30
+ with patch("time.sleep"):
31
+ returncode = runner.run("test prompt", "/tmp", max_rounds=5)
32
+ self.assertEqual(returncode, 0)
33
+
34
+ def test_no_signal_exhausts_rounds(self):
35
+ runner = ClaudeRunner()
36
+ mock_process = MockProcess(["Some output\n", "No completion here\n"])
37
+ with patch("subprocess.Popen", return_value=mock_process):
38
+ with patch("time.sleep"):
39
+ returncode = runner.run("test prompt", "/tmp", max_rounds=3)
40
+ self.assertEqual(returncode, 1)
41
+
42
+ def test_default_max_rounds_is_5(self):
43
+ runner = ClaudeRunner()
44
+ calls = []
45
+ mock_process = MockProcess(["No signal\n"])
46
+
47
+ def track_popen(*args, **kwargs):
48
+ calls.append(args)
49
+ return mock_process
50
+
51
+ with patch("subprocess.Popen", side_effect=track_popen):
52
+ with patch("time.sleep"):
53
+ runner.run("test prompt", "/tmp")
54
+ self.assertEqual(len(calls), 5)
55
+
56
+ def test_custom_max_rounds_respected(self):
57
+ runner = ClaudeRunner()
58
+ calls = []
59
+ mock_process = MockProcess(["No signal\n"])
60
+
61
+ def track_popen(*args, **kwargs):
62
+ calls.append(args)
63
+ return mock_process
64
+
65
+ with patch("subprocess.Popen", side_effect=track_popen):
66
+ with patch("time.sleep"):
67
+ runner.run("test prompt", "/tmp", max_rounds=3)
68
+ self.assertEqual(len(calls), 3)
69
+
70
+ def test_first_round_complete_exits_early(self):
71
+ runner = ClaudeRunner()
72
+ mock_process = MockProcess(["<promise>COMPLETE</promise>\n"])
73
+ with patch("subprocess.Popen", return_value=mock_process):
74
+ with patch("time.sleep") as mock_sleep:
75
+ returncode = runner.run("test prompt", "/tmp", max_rounds=5)
76
+ self.assertEqual(returncode, 0)
77
+ mock_sleep.assert_not_called()
78
+
79
+ def test_sleep_between_rounds(self):
80
+ runner = ClaudeRunner()
81
+ mock_process = MockProcess(["No signal\n"])
82
+ with patch("subprocess.Popen", return_value=mock_process):
83
+ with patch("time.sleep") as mock_sleep:
84
+ runner.run("test prompt", "/tmp", max_rounds=3)
85
+ self.assertEqual(mock_sleep.call_count, 2)
86
+
87
+
88
+ if __name__ == "__main__":
89
+ unittest.main()
@@ -814,6 +814,125 @@ class RunTaskGitIntegrationTests(unittest.TestCase):
814
814
  self.assertEqual(state["tasks"]["0"]["status"], "failed")
815
815
 
816
816
 
817
+ class RunnerSelectionTests(unittest.TestCase):
818
+ """T5.11: Tests for runner selection based on task 'use' field."""
819
+
820
+ def setUp(self):
821
+ self.tmpdir = tempfile.mkdtemp()
822
+ _init_git_repo(self.tmpdir)
823
+ self.home = Path(tempfile.mkdtemp()) / "tloop"
824
+ self.home.mkdir(parents=True)
825
+ (self.home / "logs").mkdir()
826
+ (self.home / "archive").mkdir()
827
+ self.patches = [
828
+ patch.object(config, "TLOOP_HOME", self.home),
829
+ patch.object(config, "TASKS_FILE", self.home / "tasks.yaml"),
830
+ patch.object(config, "STATE_FILE", self.home / "state.json"),
831
+ patch.object(config, "LOGS_DIR", self.home / "logs"),
832
+ patch.object(config, "ARCHIVE_DIR", self.home / "archive"),
833
+ ]
834
+ for p in self.patches:
835
+ p.start()
836
+
837
+ def tearDown(self):
838
+ for p in self.patches:
839
+ p.stop()
840
+
841
+ @patch.object(task_mod, "ensure_clean_git", return_value=True)
842
+ @patch.object(task_mod, "create_task_branch", return_value=True)
843
+ @patch("runner.claude.subprocess.Popen")
844
+ def test_use_claude_selects_claude_runner(self, mock_popen, mock_branch, mock_clean):
845
+ mock_proc = MagicMock()
846
+ mock_proc.stdout = iter(["<promise>COMPLETE</promise>\n"])
847
+ mock_proc.returncode = 0
848
+ mock_proc.stdin = MagicMock()
849
+ mock_proc.wait.return_value = 0
850
+ mock_popen.return_value = mock_proc
851
+
852
+ state = {"tasks": {}, "version": 1}
853
+ task = {
854
+ "name": "test claude task",
855
+ "dir": self.tmpdir,
856
+ "prompt": "do something",
857
+ "use": "claude",
858
+ "branch": "feat/test",
859
+ }
860
+ result = task_mod.run_task(task, 0, state)
861
+ self.assertTrue(result)
862
+ self.assertEqual(state["tasks"]["0"]["status"], "done")
863
+
864
+ @patch.object(task_mod, "ensure_clean_git", return_value=True)
865
+ @patch.object(task_mod, "create_task_branch", return_value=True)
866
+ @patch("runner.claude.subprocess.Popen")
867
+ def test_use_claude_max_rounds_respected(self, mock_popen, mock_branch, mock_clean):
868
+ mock_proc = MagicMock()
869
+ mock_proc.stdout = iter(["no completion\n"])
870
+ mock_proc.returncode = 0
871
+ mock_proc.stdin = MagicMock()
872
+ mock_proc.wait.return_value = 0
873
+ mock_popen.return_value = mock_proc
874
+
875
+ state = {"tasks": {}, "version": 1}
876
+ task = {
877
+ "name": "test claude task",
878
+ "dir": self.tmpdir,
879
+ "prompt": "do something",
880
+ "use": "claude",
881
+ "max_rounds": 3,
882
+ "branch": "feat/test",
883
+ }
884
+ with patch("time.sleep"):
885
+ result = task_mod.run_task(task, 0, state)
886
+ self.assertFalse(result)
887
+ self.assertEqual(state["tasks"]["0"]["status"], "failed")
888
+ self.assertEqual(mock_popen.call_count, 3)
889
+
890
+ @patch.object(task_mod, "ensure_clean_git", return_value=True)
891
+ @patch.object(task_mod, "create_task_branch", return_value=True)
892
+ @patch("runner.cybervisor.subprocess.Popen")
893
+ def test_use_cybervisor_selects_cybervisor_runner(self, mock_popen, mock_branch, mock_clean):
894
+ mock_proc = MagicMock()
895
+ mock_proc.stdout = iter(["output\n"])
896
+ mock_proc.returncode = 0
897
+ mock_proc.stdin = MagicMock()
898
+ mock_proc.wait.return_value = 0
899
+ mock_popen.return_value = mock_proc
900
+
901
+ state = {"tasks": {}, "version": 1}
902
+ task = {
903
+ "name": "test cybervisor task",
904
+ "dir": self.tmpdir,
905
+ "prompt": "do something",
906
+ "use": "cybervisor",
907
+ "branch": "feat/test",
908
+ }
909
+ result = task_mod.run_task(task, 0, state)
910
+ self.assertTrue(result)
911
+ self.assertEqual(state["tasks"]["0"]["status"], "done")
912
+
913
+ @patch.object(task_mod, "ensure_clean_git", return_value=True)
914
+ @patch.object(task_mod, "create_task_branch", return_value=True)
915
+ @patch("runner.cybervisor.subprocess.Popen")
916
+ def test_missing_use_field_defaults_to_cybervisor(self, mock_popen, mock_branch, mock_clean):
917
+ mock_proc = MagicMock()
918
+ mock_proc.stdout = iter(["output\n"])
919
+ mock_proc.returncode = 0
920
+ mock_proc.stdin = MagicMock()
921
+ mock_proc.wait.return_value = 0
922
+ mock_popen.return_value = mock_proc
923
+
924
+ state = {"tasks": {}, "version": 1}
925
+ task = {
926
+ "name": "test default task",
927
+ "dir": self.tmpdir,
928
+ "prompt": "do something",
929
+ "branch": "feat/test",
930
+ }
931
+ result = task_mod.run_task(task, 0, state)
932
+ self.assertTrue(result)
933
+ self.assertEqual(state["tasks"]["0"]["status"], "done")
934
+
935
+
817
936
  class EditorSelectionTests(unittest.TestCase):
818
937
  """Tests for editor selection and persistence in cmd_edit."""
819
938
 
@@ -1,10 +0,0 @@
1
- """Claude Code runner backend (placeholder)."""
2
-
3
- from runner import Runner
4
-
5
-
6
- class ClaudeRunner(Runner):
7
- """Claude Code runner (not yet implemented)."""
8
-
9
- def run(self, prompt, cwd, model=None, log_file=None):
10
- raise NotImplementedError("Claude Code runner not yet implemented")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes