tloop-cli 0.4.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 (30) hide show
  1. {tloop_cli-0.4.0 → tloop_cli-0.6.0}/PKG-INFO +36 -3
  2. {tloop_cli-0.4.0 → tloop_cli-0.6.0}/README.md +35 -2
  3. {tloop_cli-0.4.0 → tloop_cli-0.6.0}/pyproject.toml +1 -1
  4. {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/claude_runner.py +15 -6
  5. tloop_cli-0.6.0/src/cmd_edit.py +110 -0
  6. {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/cmd_run.py +1 -2
  7. {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/config.py +15 -30
  8. {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/runner/__init__.py +1 -1
  9. tloop_cli-0.6.0/src/runner/claude.py +89 -0
  10. tloop_cli-0.6.0/src/runner/cybervisor.py +35 -0
  11. {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/task.py +24 -11
  12. {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/tloop_cli.egg-info/PKG-INFO +36 -3
  13. {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/tloop_cli.egg-info/SOURCES.txt +1 -0
  14. tloop_cli-0.6.0/test/test_claude_runner.py +89 -0
  15. {tloop_cli-0.4.0 → tloop_cli-0.6.0}/test/test_cli.py +180 -5
  16. tloop_cli-0.4.0/src/cmd_edit.py +0 -45
  17. tloop_cli-0.4.0/src/runner/claude.py +0 -10
  18. tloop_cli-0.4.0/src/runner/cybervisor.py +0 -28
  19. {tloop_cli-0.4.0 → tloop_cli-0.6.0}/LICENSE +0 -0
  20. {tloop_cli-0.4.0 → tloop_cli-0.6.0}/setup.cfg +0 -0
  21. {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/cmd_archive.py +0 -0
  22. {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/cmd_migrate.py +0 -0
  23. {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/git_ops.py +0 -0
  24. {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/main.py +0 -0
  25. {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/state.py +0 -0
  26. {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/tloop_cli.egg-info/dependency_links.txt +0 -0
  27. {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/tloop_cli.egg-info/entry_points.txt +0 -0
  28. {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/tloop_cli.egg-info/requires.txt +0 -0
  29. {tloop_cli-0.4.0 → tloop_cli-0.6.0}/src/tloop_cli.egg-info/top_level.txt +0 -0
  30. {tloop_cli-0.4.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.4.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
@@ -16,8 +16,8 @@ Dynamic: license-file
16
16
  ## 安装
17
17
 
18
18
  ```bash
19
- pip install t-loop # 从 PyPI 安装
20
- pip install -e . # 本地开发安装
19
+ pip install tloop-cli # 从 PyPI 安装
20
+ pip install -e . # 本地开发安装
21
21
  ```
22
22
 
23
23
  需要 Python >=3.9,唯一外部依赖:`pyyaml`。
@@ -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
  每个任务的执行流程:
@@ -5,8 +5,8 @@
5
5
  ## 安装
6
6
 
7
7
  ```bash
8
- pip install t-loop # 从 PyPI 安装
9
- pip install -e . # 本地开发安装
8
+ pip install tloop-cli # 从 PyPI 安装
9
+ pip install -e . # 本地开发安装
10
10
  ```
11
11
 
12
12
  需要 Python >=3.9,唯一外部依赖:`pyyaml`。
@@ -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.4.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:
@@ -0,0 +1,110 @@
1
+ """tloop edit — open ~/.tloop/tasks.yaml in editor."""
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+
9
+ import config
10
+
11
+ KNOWN_EDITORS = {
12
+ "code": ("Visual Studio Code", "code"),
13
+ "vim": ("Vim", "vim"),
14
+ "nano": ("Nano", "nano"),
15
+ }
16
+
17
+ EDIT_HELP = """\
18
+ Open ~/.tloop/tasks.yaml in your editor.
19
+
20
+ On first run, you will be prompted to choose an editor (VS Code, Vim, Nano,
21
+ or a custom command). The choice is saved to ~/.tloop/settings.json.
22
+ Override anytime with: tloop edit --editor <command>
23
+
24
+ Task file format (~/.tloop/tasks.yaml):
25
+
26
+ tasks:
27
+ - name: My task
28
+ dir: ~/projects/my-project
29
+ prompt: |
30
+ Describe what Claude should do.
31
+ # OR:
32
+ prompt_file: ./prompts/my-task.md
33
+ branch: true # true=auto, "custom/name", false=skip
34
+ use: cybervisor # cybervisor (default) or claude
35
+ max_rounds: 5 # only for use: claude
36
+
37
+ Each task runs in the specified directory. Completed tasks are
38
+ archived to ~/.tloop/archive/ after each run cycle.
39
+ """
40
+
41
+
42
+ def _load_settings():
43
+ if config.SETTINGS_FILE.exists():
44
+ try:
45
+ return json.loads(config.SETTINGS_FILE.read_text())
46
+ except (json.JSONDecodeError, OSError):
47
+ pass
48
+ return {}
49
+
50
+
51
+ def _save_settings(settings):
52
+ config.TLOOP_HOME.mkdir(exist_ok=True)
53
+ config.SETTINGS_FILE.write_text(json.dumps(settings, indent=2) + "\n")
54
+
55
+
56
+ def _prompt_editor():
57
+ options = []
58
+ for key, (label, cmd) in KNOWN_EDITORS.items():
59
+ if shutil.which(cmd):
60
+ options.append((key, label, cmd))
61
+
62
+ print(f"{config.BOLD}Choose your editor for tasks.yaml:{config.RESET}\n")
63
+ for i, (key, label, _) in enumerate(options, 1):
64
+ print(f" {i}) {label}")
65
+ print(f" {len(options) + 1}) Other (enter command manually)")
66
+ print()
67
+
68
+ while True:
69
+ choice = input(f"Enter number [1-{len(options) + 1}]: ").strip()
70
+ if choice.isdigit():
71
+ idx = int(choice)
72
+ if 1 <= idx <= len(options):
73
+ return options[idx - 1][2]
74
+ if idx == len(options) + 1:
75
+ cmd = input("Enter editor command: ").strip()
76
+ if cmd:
77
+ return cmd
78
+ print("Invalid choice, try again.")
79
+
80
+
81
+ def _resolve_editor(cli_editor=None):
82
+ if cli_editor:
83
+ return cli_editor
84
+ settings = _load_settings()
85
+ if "editor" in settings:
86
+ return settings["editor"]
87
+ editor = _prompt_editor()
88
+ _save_settings({"editor": editor})
89
+ print(f"{config.GREEN}Editor saved. Change anytime with: tloop edit --editor <command>{config.RESET}\n")
90
+ return editor
91
+
92
+
93
+ def add_parser(subparsers):
94
+ p = subparsers.add_parser(
95
+ "edit",
96
+ help="Open ~/.tloop/tasks.yaml in editor",
97
+ description=EDIT_HELP,
98
+ formatter_class=argparse.RawDescriptionHelpFormatter,
99
+ )
100
+ p.add_argument("--editor", help="Override editor command for this session")
101
+ p.set_defaults(func=handle)
102
+
103
+
104
+ def handle(args):
105
+ config.TLOOP_HOME.mkdir(exist_ok=True)
106
+ if not config.TASKS_FILE.exists():
107
+ config.TASKS_FILE.write_text(config.SAMPLE_TASKS_YAML)
108
+
109
+ editor = _resolve_editor(getattr(args, "editor", None))
110
+ subprocess.run([editor, str(config.TASKS_FILE)])
@@ -24,7 +24,6 @@ def handle(args):
24
24
 
25
25
  cfg = load_config()
26
26
  tasks = cfg.get("tasks", [])
27
- defaults = cfg.get("defaults", {})
28
27
 
29
28
  if not tasks:
30
29
  print("No tasks defined in tasks.yaml")
@@ -71,7 +70,7 @@ def handle(args):
71
70
  continue
72
71
 
73
72
  ran_any = True
74
- success = run_task(tasks[i], i, state, defaults)
73
+ success = run_task(tasks[i], i, state)
75
74
  if not success and not args.continue_on_fail:
76
75
  print(f"\n{config.YELLOW}Stopped. Use -c to continue after failures.{config.RESET}")
77
76
  break
@@ -8,6 +8,7 @@ import yaml
8
8
  TLOOP_HOME = Path.home() / ".tloop"
9
9
  TASKS_FILE = TLOOP_HOME / "tasks.yaml"
10
10
  STATE_FILE = TLOOP_HOME / "state.json"
11
+ SETTINGS_FILE = TLOOP_HOME / "settings.json"
11
12
  LOGS_DIR = TLOOP_HOME / "logs"
12
13
  ARCHIVE_DIR = TLOOP_HOME / "archive"
13
14
 
@@ -18,34 +19,7 @@ CYAN = "\033[96m"
18
19
  BOLD = "\033[1m"
19
20
  RESET = "\033[0m"
20
21
 
21
- SAMPLE_TASKS_YAML = """\
22
- # t-loop tasks.yaml
23
- # Define your Claude Code automation tasks here.
24
- #
25
- # Location: ~/.tloop/tasks.yaml
26
- #
27
- # Each task runs "cybervisor run <prompt>" in the specified directory.
28
- # Completed tasks are automatically archived after each run cycle.
29
- # View archives with: tloop archive
30
- #
31
- # prompt_file paths resolve in this order:
32
- # 1. Absolute path (after ~ expansion)
33
- # 2. Relative to ~/.tloop/
34
- # 3. Relative to the task's dir
35
-
36
- defaults:
37
- # model: opus
38
-
39
- tasks: []
40
- # - name: My first task
41
- # dir: ~/projects/my-project
42
- # prompt: |
43
- # Describe what Claude should do here.
44
- #
45
- # - name: Task with prompt file
46
- # dir: ~/projects/my-project
47
- # prompt_file: ./prompts/my-task.md
48
- """
22
+ SAMPLE_TASKS_YAML = "# Run 'tloop edit --help' for details on how to write this file.\ntasks: []\n"
49
23
 
50
24
 
51
25
  def ensure_tloop_home():
@@ -65,5 +39,16 @@ def load_config():
65
39
  print(f"{RED}Error: {TASKS_FILE} not found{RESET}")
66
40
  print(f"Run tloop run to initialize, then edit tasks.yaml.")
67
41
  sys.exit(1)
68
- with open(TASKS_FILE) as f:
69
- 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)
@@ -5,6 +5,6 @@ from abc import ABC, abstractmethod
5
5
 
6
6
  class Runner(ABC):
7
7
  @abstractmethod
8
- def run(self, prompt, cwd, model=None, log_file=None):
8
+ def run(self, prompt, cwd, log_file=None):
9
9
  """Run a task. Returns exit code (0=success)."""
10
10
  ...
@@ -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()
@@ -0,0 +1,35 @@
1
+ """Cybervisor runner backend."""
2
+
3
+ import subprocess
4
+
5
+ from runner import Runner
6
+
7
+
8
+ class CybervisorRunner(Runner):
9
+ def run(self, prompt, cwd, log_file=None, prompt_file=None):
10
+ cmd = ["cybervisor", "run"]
11
+
12
+ with open(log_file, "a") if log_file else open("/dev/null", "a") as log:
13
+ stdin_fh = open(prompt_file, "r") if prompt_file else None
14
+ try:
15
+ process = subprocess.Popen(
16
+ cmd,
17
+ cwd=cwd,
18
+ stdin=stdin_fh if stdin_fh else subprocess.PIPE,
19
+ stdout=subprocess.PIPE,
20
+ stderr=subprocess.STDOUT,
21
+ text=True,
22
+ )
23
+ if not stdin_fh:
24
+ process.stdin.write(prompt)
25
+ process.stdin.close()
26
+ for line in process.stdout:
27
+ print(line, end="")
28
+ log.write(line)
29
+ log.flush()
30
+ process.wait()
31
+ finally:
32
+ if stdin_fh:
33
+ stdin_fh.close()
34
+
35
+ return process.returncode
@@ -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
 
@@ -28,12 +29,11 @@ def resolve_prompt_file(prompt_file, dir_path):
28
29
  return Path(prompt_file)
29
30
 
30
31
 
31
- def run_task(task, index, state, defaults):
32
+ def run_task(task, index, state):
32
33
  name = task.get("name", f"Task {index + 1}")
33
- dir_path = expand_dir(task.get("dir", defaults.get("dir", ".")))
34
+ dir_path = expand_dir(task.get("dir", "."))
34
35
  prompt = task.get("prompt", "")
35
36
  prompt_file = task.get("prompt_file")
36
- model = task.get("model", defaults.get("model"))
37
37
  branch_config = task.get("branch", True)
38
38
 
39
39
  if not os.path.isdir(dir_path):
@@ -46,10 +46,11 @@ def run_task(task, index, state, defaults):
46
46
  save_state(state)
47
47
  return False
48
48
 
49
+ resolved_pf = None
49
50
  if prompt_file:
50
- pf = resolve_prompt_file(prompt_file, dir_path)
51
- if pf.exists():
52
- prompt = pf.read_text()
51
+ resolved_pf = resolve_prompt_file(prompt_file, dir_path)
52
+ if resolved_pf.exists():
53
+ prompt = resolved_pf.read_text()
53
54
  else:
54
55
  print(f"{config.RED} Prompt file not found: {prompt_file}{config.RESET}")
55
56
  return False
@@ -100,12 +101,24 @@ def run_task(task, index, state, defaults):
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>\n")
104
- log.write("-" * 60 + "\n\n")
105
104
  log.flush()
106
105
 
107
- runner = CybervisorRunner()
108
- returncode = runner.run(prompt, dir_path, model=model, log_file=log_file)
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, defaults):
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.4.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
@@ -16,8 +16,8 @@ Dynamic: license-file
16
16
  ## 安装
17
17
 
18
18
  ```bash
19
- pip install t-loop # 从 PyPI 安装
20
- pip install -e . # 本地开发安装
19
+ pip install tloop-cli # 从 PyPI 安装
20
+ pip install -e . # 本地开发安装
21
21
  ```
22
22
 
23
23
  需要 Python >=3.9,唯一外部依赖:`pyyaml`。
@@ -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()
@@ -15,6 +15,7 @@ import config
15
15
  import state as state_mod
16
16
  import task as task_mod
17
17
  import cmd_run
18
+ import cmd_edit
18
19
 
19
20
 
20
21
  def _init_git_repo(tmpdir):
@@ -44,6 +45,7 @@ def _setup_home(tmp_path):
44
45
  patch.object(config, "TLOOP_HOME", home),
45
46
  patch.object(config, "TASKS_FILE", home / "tasks.yaml"),
46
47
  patch.object(config, "STATE_FILE", home / "state.json"),
48
+ patch.object(config, "SETTINGS_FILE", home / "settings.json"),
47
49
  patch.object(config, "LOGS_DIR", home / "logs"),
48
50
  patch.object(config, "ARCHIVE_DIR", home / "archive"),
49
51
  ]
@@ -60,6 +62,7 @@ class EnsureTloopHomeTests(unittest.TestCase):
60
62
  patch.object(config, "TLOOP_HOME", self.home),
61
63
  patch.object(config, "TASKS_FILE", self.home / "tasks.yaml"),
62
64
  patch.object(config, "STATE_FILE", self.home / "state.json"),
65
+ patch.object(config, "SETTINGS_FILE", self.home / "settings.json"),
63
66
  patch.object(config, "LOGS_DIR", self.home / "logs"),
64
67
  patch.object(config, "ARCHIVE_DIR", self.home / "archive"),
65
68
  ]
@@ -86,7 +89,7 @@ class EnsureTloopHomeTests(unittest.TestCase):
86
89
  self.assertTrue(tasks_file.exists())
87
90
  content = tasks_file.read_text()
88
91
  self.assertIn("tasks:", content)
89
- self.assertIn("defaults:", content)
92
+ self.assertTrue(content.startswith("# Run 'tloop edit --help'"))
90
93
 
91
94
  def test_idempotent_no_exit_when_tasks_exist(self):
92
95
  self.home.mkdir(parents=True, exist_ok=True)
@@ -765,7 +768,7 @@ class RunTaskGitIntegrationTests(unittest.TestCase):
765
768
  "prompt": "do something",
766
769
  "branch": "feat/test",
767
770
  }
768
- result = task_mod.run_task(task, 0, state, {})
771
+ result = task_mod.run_task(task, 0, state)
769
772
  self.assertTrue(result)
770
773
  mock_clean.assert_called_once()
771
774
  mock_branch.assert_called_once_with(self.tmpdir, "feat/test")
@@ -778,7 +781,7 @@ class RunTaskGitIntegrationTests(unittest.TestCase):
778
781
  "dir": self.tmpdir,
779
782
  "prompt": "do something",
780
783
  }
781
- result = task_mod.run_task(task, 0, state, {})
784
+ result = task_mod.run_task(task, 0, state)
782
785
  self.assertFalse(result)
783
786
  self.assertEqual(state["tasks"]["0"]["status"], "failed")
784
787
  self.assertIn("auto-commit", state["tasks"]["0"]["error"])
@@ -793,7 +796,7 @@ class RunTaskGitIntegrationTests(unittest.TestCase):
793
796
  "prompt": "do something",
794
797
  "branch": True,
795
798
  }
796
- result = task_mod.run_task(task, 0, state, {})
799
+ result = task_mod.run_task(task, 0, state)
797
800
  self.assertFalse(result)
798
801
  self.assertEqual(state["tasks"]["0"]["status"], "failed")
799
802
  self.assertIn("branch", state["tasks"]["0"]["error"])
@@ -806,10 +809,182 @@ class RunTaskGitIntegrationTests(unittest.TestCase):
806
809
  "dir": self.tmpdir,
807
810
  "prompt": "do something",
808
811
  }
809
- task_mod.run_task(task, 0, state, {})
812
+ task_mod.run_task(task, 0, state)
810
813
  self.assertNotEqual(state["tasks"]["0"]["status"], "running")
811
814
  self.assertEqual(state["tasks"]["0"]["status"], "failed")
812
815
 
813
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
+
936
+ class EditorSelectionTests(unittest.TestCase):
937
+ """Tests for editor selection and persistence in cmd_edit."""
938
+
939
+ def setUp(self):
940
+ self.tmpdir = tempfile.mkdtemp()
941
+ self.home = Path(self.tmpdir) / "tloop"
942
+ self.home.mkdir(parents=True)
943
+ self.patches = [
944
+ patch.object(config, "TLOOP_HOME", self.home),
945
+ patch.object(config, "SETTINGS_FILE", self.home / "settings.json"),
946
+ ]
947
+ for p in self.patches:
948
+ p.start()
949
+
950
+ def tearDown(self):
951
+ for p in self.patches:
952
+ p.stop()
953
+
954
+ def test_cli_editor_overrides_settings(self):
955
+ cmd_edit._save_settings({"editor": "vim"})
956
+ result = cmd_edit._resolve_editor("code")
957
+ self.assertEqual(result, "code")
958
+
959
+ def test_saved_editor_used_when_no_cli_override(self):
960
+ cmd_edit._save_settings({"editor": "code"})
961
+ result = cmd_edit._resolve_editor()
962
+ self.assertEqual(result, "code")
963
+
964
+ def test_prompt_on_first_run(self):
965
+ with patch("builtins.input", side_effect=["1"]):
966
+ with patch.object(cmd_edit, "KNOWN_EDITORS", {"code": ("VS Code", "code")}):
967
+ with patch("shutil.which", return_value="/usr/local/bin/code"):
968
+ result = cmd_edit._resolve_editor()
969
+ self.assertEqual(result, "code")
970
+ settings = json.loads((self.home / "settings.json").read_text())
971
+ self.assertEqual(settings["editor"], "code")
972
+
973
+ def test_custom_editor_via_prompt(self):
974
+ with patch("builtins.input", side_effect=["2", "subl"]):
975
+ with patch.object(cmd_edit, "KNOWN_EDITORS", {"code": ("VS Code", "code")}):
976
+ with patch("shutil.which", return_value="/usr/local/bin/code"):
977
+ result = cmd_edit._resolve_editor()
978
+ self.assertEqual(result, "subl")
979
+
980
+ def test_corrupt_settings_file_triggers_prompt(self):
981
+ (self.home / "settings.json").write_text("not json")
982
+ with patch("builtins.input", side_effect=["1"]):
983
+ with patch.object(cmd_edit, "KNOWN_EDITORS", {"code": ("VS Code", "code")}):
984
+ with patch("shutil.which", return_value="/usr/local/bin/code"):
985
+ result = cmd_edit._resolve_editor()
986
+ self.assertEqual(result, "code")
987
+
988
+
814
989
  if __name__ == "__main__":
815
990
  unittest.main()
@@ -1,45 +0,0 @@
1
- """tloop edit — open ~/.tloop/tasks.yaml in editor."""
2
-
3
- import argparse
4
- import os
5
- import subprocess
6
-
7
- import config
8
-
9
-
10
- EDIT_HELP = """\
11
- Open ~/.tloop/tasks.yaml in your editor ($EDITOR, defaults to vi).
12
-
13
- Task file format (~/.tloop/tasks.yaml):
14
-
15
- defaults:
16
- model: opus # optional default model
17
-
18
- tasks:
19
- - name: My task
20
- dir: ~/projects/my-project
21
- prompt: |
22
- Describe what Claude should do.
23
- # OR:
24
- prompt_file: ./prompts/my-task.md
25
- model: opus # optional override
26
- branch: true # true=auto, "custom/name", false=skip
27
-
28
- Each task runs in the specified directory. Completed tasks are
29
- archived to ~/.tloop/archive/ after each run cycle.
30
- """
31
-
32
-
33
- def add_parser(subparsers):
34
- p = subparsers.add_parser(
35
- "edit",
36
- help="Open ~/.tloop/tasks.yaml in editor",
37
- description=EDIT_HELP,
38
- formatter_class=argparse.RawDescriptionHelpFormatter,
39
- )
40
- p.set_defaults(func=handle)
41
-
42
-
43
- def handle(args):
44
- editor = os.environ.get("EDITOR", "vi")
45
- subprocess.run([editor, str(config.TASKS_FILE)])
@@ -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")
@@ -1,28 +0,0 @@
1
- """Cybervisor runner backend."""
2
-
3
- import subprocess
4
-
5
- from runner import Runner
6
-
7
-
8
- class CybervisorRunner(Runner):
9
- def run(self, prompt, cwd, model=None, log_file=None):
10
- cmd = ["cybervisor", "run", prompt]
11
- if model:
12
- cmd.extend(["--model", model])
13
-
14
- with open(log_file, "a") if log_file else open("/dev/null", "a") as log:
15
- process = subprocess.Popen(
16
- cmd,
17
- cwd=cwd,
18
- stdout=subprocess.PIPE,
19
- stderr=subprocess.STDOUT,
20
- text=True,
21
- )
22
- for line in process.stdout:
23
- print(line, end="")
24
- log.write(line)
25
- log.flush()
26
- process.wait()
27
-
28
- return process.returncode
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes