tloop-cli 0.4.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.
- claude_runner.py +82 -0
- cmd_archive.py +15 -0
- cmd_edit.py +45 -0
- cmd_migrate.py +71 -0
- cmd_run.py +83 -0
- config.py +69 -0
- git_ops.py +151 -0
- main.py +38 -0
- runner/__init__.py +10 -0
- runner/claude.py +10 -0
- runner/cybervisor.py +28 -0
- state.py +141 -0
- task.py +142 -0
- tloop_cli-0.4.0.dist-info/METADATA +107 -0
- tloop_cli-0.4.0.dist-info/RECORD +19 -0
- tloop_cli-0.4.0.dist-info/WHEEL +5 -0
- tloop_cli-0.4.0.dist-info/entry_points.txt +2 -0
- tloop_cli-0.4.0.dist-info/licenses/LICENSE +21 -0
- tloop_cli-0.4.0.dist-info/top_level.txt +11 -0
claude_runner.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Reusable Claude CLI runner with retry and verification."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
GREEN = "\033[92m"
|
|
6
|
+
RED = "\033[91m"
|
|
7
|
+
YELLOW = "\033[93m"
|
|
8
|
+
RESET = "\033[0m"
|
|
9
|
+
|
|
10
|
+
DEFAULT_MAX_RETRIES = 3
|
|
11
|
+
|
|
12
|
+
# Append this to prompts where Claude might just plan instead of executing.
|
|
13
|
+
EXECUTION_SUFFIX = (
|
|
14
|
+
"\n\nIMPORTANT: Execute the steps above immediately. "
|
|
15
|
+
"Do NOT ask for confirmation, do NOT just describe what you would do. "
|
|
16
|
+
"Perform every step now using your available tools."
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_claude(prompt, cwd, max_retries=DEFAULT_MAX_RETRIES, verify_fn=None, log_file=None):
|
|
21
|
+
"""Run `claude -p` with --dangerously-skip-permissions and optional retry loop.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
prompt: The prompt text to send to Claude.
|
|
25
|
+
cwd: Working directory for the subprocess.
|
|
26
|
+
max_retries: Max attempts before giving up.
|
|
27
|
+
verify_fn: Optional callable(cwd) -> bool that checks if the work was actually done.
|
|
28
|
+
log_file: Optional path to append logs.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
True if Claude succeeded (and passed verification if provided), False otherwise.
|
|
32
|
+
"""
|
|
33
|
+
enriched_prompt = prompt + EXECUTION_SUFFIX
|
|
34
|
+
cmd = ["claude", "--dangerously-skip-permissions", "--print", enriched_prompt]
|
|
35
|
+
|
|
36
|
+
for attempt in range(1, max_retries + 1):
|
|
37
|
+
if attempt > 1:
|
|
38
|
+
hint = (
|
|
39
|
+
f"This is attempt {attempt}/{max_retries}. "
|
|
40
|
+
"The previous attempt did not complete the task. "
|
|
41
|
+
"You MUST execute the steps now, not describe them."
|
|
42
|
+
)
|
|
43
|
+
attempt_prompt = prompt + "\n\n" + hint + EXECUTION_SUFFIX
|
|
44
|
+
attempt_cmd = ["claude", "--dangerously-skip-permissions", "--print", attempt_prompt]
|
|
45
|
+
else:
|
|
46
|
+
attempt_cmd = cmd
|
|
47
|
+
|
|
48
|
+
result = subprocess.run(
|
|
49
|
+
attempt_cmd,
|
|
50
|
+
cwd=cwd,
|
|
51
|
+
capture_output=True,
|
|
52
|
+
text=True,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if log_file:
|
|
56
|
+
with open(log_file, "a") as log:
|
|
57
|
+
log.write(f"[claude_runner] attempt {attempt}/{max_retries} exit={result.returncode}\n")
|
|
58
|
+
if result.stdout:
|
|
59
|
+
log.write(result.stdout + "\n")
|
|
60
|
+
if result.stderr:
|
|
61
|
+
log.write(result.stderr + "\n")
|
|
62
|
+
log.flush()
|
|
63
|
+
|
|
64
|
+
if result.returncode != 0:
|
|
65
|
+
print(f"{YELLOW} Claude exited with code {result.returncode} (attempt {attempt}/{max_retries}){RESET}")
|
|
66
|
+
if attempt < max_retries:
|
|
67
|
+
continue
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
# If no verification function, trust the exit code.
|
|
71
|
+
if verify_fn is None:
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
if verify_fn(cwd):
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
print(f"{YELLOW} Claude completed but verification failed (attempt {attempt}/{max_retries}){RESET}")
|
|
78
|
+
if attempt >= max_retries:
|
|
79
|
+
print(f"{RED} Max retries reached. Giving up.{RESET}")
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
return False
|
cmd_archive.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""tloop archive — view archived task runs."""
|
|
2
|
+
|
|
3
|
+
import config
|
|
4
|
+
from state import show_archives
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def add_parser(subparsers):
|
|
8
|
+
p = subparsers.add_parser("archive", help="View archived task runs")
|
|
9
|
+
p.add_argument("--latest", action="store_true", help="Show most recent archive details")
|
|
10
|
+
p.set_defaults(func=handle)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def handle(args):
|
|
14
|
+
config.ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
show_archives(latest=args.latest)
|
cmd_edit.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
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)])
|
cmd_migrate.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""tloop migrate — migrate old data files to ~/.tloop/."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
import config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def add_parser(subparsers):
|
|
13
|
+
p = subparsers.add_parser("migrate", help="Migrate old data to ~/.tloop/")
|
|
14
|
+
p.set_defaults(func=handle)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def handle(args):
|
|
18
|
+
project_root = Path(__file__).resolve().parent.parent.parent
|
|
19
|
+
|
|
20
|
+
old_tasks = project_root / "tasks.yaml"
|
|
21
|
+
old_state = project_root / ".tloop-state.json"
|
|
22
|
+
old_logs = project_root / "logs"
|
|
23
|
+
|
|
24
|
+
found = []
|
|
25
|
+
if old_tasks.exists():
|
|
26
|
+
found.append(old_tasks)
|
|
27
|
+
if old_state.exists():
|
|
28
|
+
found.append(old_state)
|
|
29
|
+
if old_logs.exists() and old_logs.is_dir():
|
|
30
|
+
found.append(old_logs)
|
|
31
|
+
|
|
32
|
+
if not found:
|
|
33
|
+
print(f"{config.YELLOW}No old data files found in {project_root}{config.RESET}")
|
|
34
|
+
print("Nothing to migrate.")
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
if config.TASKS_FILE.exists():
|
|
38
|
+
print(f"{config.RED}Conflict: {config.TASKS_FILE} already exists{config.RESET}")
|
|
39
|
+
print("Resolve the conflict manually before migrating.")
|
|
40
|
+
sys.exit(1)
|
|
41
|
+
|
|
42
|
+
config.TLOOP_HOME.mkdir(exist_ok=True)
|
|
43
|
+
config.LOGS_DIR.mkdir(exist_ok=True)
|
|
44
|
+
config.ARCHIVE_DIR.mkdir(exist_ok=True)
|
|
45
|
+
|
|
46
|
+
if old_tasks.exists():
|
|
47
|
+
shutil.copy2(old_tasks, config.TASKS_FILE)
|
|
48
|
+
print(f" Migrated: tasks.yaml → {config.TASKS_FILE}")
|
|
49
|
+
|
|
50
|
+
data = yaml.safe_load(open(old_tasks)) or {}
|
|
51
|
+
for i, task in enumerate(data.get("tasks", [])):
|
|
52
|
+
pf = task.get("prompt_file")
|
|
53
|
+
if pf and not Path(pf).expanduser().is_absolute():
|
|
54
|
+
print(
|
|
55
|
+
f" {config.YELLOW}Warning: task '{task.get('name', f'Task {i + 1}')}' "
|
|
56
|
+
f"has relative prompt_file '{pf}'{config.RESET}"
|
|
57
|
+
)
|
|
58
|
+
print(f" Will resolve from {config.TLOOP_HOME} first, then the task's dir.")
|
|
59
|
+
|
|
60
|
+
if old_state.exists():
|
|
61
|
+
shutil.copy2(old_state, config.STATE_FILE)
|
|
62
|
+
print(f" Migrated: .tloop-state.json → {config.STATE_FILE}")
|
|
63
|
+
|
|
64
|
+
if old_logs.exists() and old_logs.is_dir():
|
|
65
|
+
for log_file in old_logs.iterdir():
|
|
66
|
+
if log_file.is_file():
|
|
67
|
+
shutil.copy2(log_file, config.LOGS_DIR / log_file.name)
|
|
68
|
+
print(f" Migrated: logs/ → {config.LOGS_DIR}/")
|
|
69
|
+
|
|
70
|
+
print(f"\n{config.GREEN}Migration complete.{config.RESET}")
|
|
71
|
+
print(f"Old files still exist in {project_root} — remove them manually when ready.")
|
cmd_run.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""tloop run — execute tasks defined in ~/.tloop/tasks.yaml."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import config
|
|
6
|
+
from config import ensure_tloop_home, load_config
|
|
7
|
+
from state import load_state, save_state, show_status, archive_completed_tasks
|
|
8
|
+
from task import run_task
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def add_parser(subparsers):
|
|
12
|
+
p = subparsers.add_parser("run", help="Run tasks defined in ~/.tloop/tasks.yaml")
|
|
13
|
+
p.add_argument("--status", "-s", action="store_true", help="Show task status")
|
|
14
|
+
p.add_argument("--reset", action="store_true", help="Reset all tasks to pending")
|
|
15
|
+
p.add_argument("--only", type=int, help="Run only task #N (1-based)")
|
|
16
|
+
p.add_argument("--confirm", "-i", action="store_true", help="Confirm before each task")
|
|
17
|
+
p.add_argument("--continue", "-c", dest="continue_on_fail", action="store_true",
|
|
18
|
+
help="Continue even if a task fails")
|
|
19
|
+
p.set_defaults(func=handle)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def handle(args):
|
|
23
|
+
ensure_tloop_home()
|
|
24
|
+
|
|
25
|
+
cfg = load_config()
|
|
26
|
+
tasks = cfg.get("tasks", [])
|
|
27
|
+
defaults = cfg.get("defaults", {})
|
|
28
|
+
|
|
29
|
+
if not tasks:
|
|
30
|
+
print("No tasks defined in tasks.yaml")
|
|
31
|
+
sys.exit(0)
|
|
32
|
+
|
|
33
|
+
state = load_state()
|
|
34
|
+
|
|
35
|
+
if args.status:
|
|
36
|
+
show_status(tasks, state)
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
if args.reset:
|
|
40
|
+
state = {"tasks": {}, "version": 1}
|
|
41
|
+
save_state(state)
|
|
42
|
+
print(f"{config.GREEN}State reset. All tasks are pending.{config.RESET}")
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
if args.only is not None:
|
|
46
|
+
if args.only < 1 or args.only > len(tasks):
|
|
47
|
+
print(f"{config.RED}Invalid task number: {args.only}{config.RESET}")
|
|
48
|
+
sys.exit(1)
|
|
49
|
+
indices = [args.only - 1]
|
|
50
|
+
else:
|
|
51
|
+
indices = list(range(len(tasks)))
|
|
52
|
+
|
|
53
|
+
ran_any = False
|
|
54
|
+
for i in indices:
|
|
55
|
+
ts = state.get("tasks", {}).get(str(i), {})
|
|
56
|
+
status = ts.get("status", "pending")
|
|
57
|
+
|
|
58
|
+
if status == "done" and args.only is None:
|
|
59
|
+
print(f"⏭️ Task [{i + 1}] already done, skipping")
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
if args.confirm:
|
|
63
|
+
name = tasks[i].get("name", f"Task {i + 1}")
|
|
64
|
+
try:
|
|
65
|
+
resp = input(f"\nRun task [{i + 1}] '{name}'? [y/N] ")
|
|
66
|
+
except (EOFError, KeyboardInterrupt):
|
|
67
|
+
print("\nAborted.")
|
|
68
|
+
break
|
|
69
|
+
if resp.lower() != "y":
|
|
70
|
+
print("Skipped.")
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
ran_any = True
|
|
74
|
+
success = run_task(tasks[i], i, state, defaults)
|
|
75
|
+
if not success and not args.continue_on_fail:
|
|
76
|
+
print(f"\n{config.YELLOW}Stopped. Use -c to continue after failures.{config.RESET}")
|
|
77
|
+
break
|
|
78
|
+
|
|
79
|
+
if ran_any:
|
|
80
|
+
print(f"\n{config.BOLD}--- Final Status ---{config.RESET}")
|
|
81
|
+
show_status(tasks, state)
|
|
82
|
+
|
|
83
|
+
archive_completed_tasks(cfg, state)
|
config.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Configuration constants and helpers for t-loop."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
TLOOP_HOME = Path.home() / ".tloop"
|
|
9
|
+
TASKS_FILE = TLOOP_HOME / "tasks.yaml"
|
|
10
|
+
STATE_FILE = TLOOP_HOME / "state.json"
|
|
11
|
+
LOGS_DIR = TLOOP_HOME / "logs"
|
|
12
|
+
ARCHIVE_DIR = TLOOP_HOME / "archive"
|
|
13
|
+
|
|
14
|
+
GREEN = "\033[92m"
|
|
15
|
+
RED = "\033[91m"
|
|
16
|
+
YELLOW = "\033[93m"
|
|
17
|
+
CYAN = "\033[96m"
|
|
18
|
+
BOLD = "\033[1m"
|
|
19
|
+
RESET = "\033[0m"
|
|
20
|
+
|
|
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
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def ensure_tloop_home():
|
|
52
|
+
TLOOP_HOME.mkdir(exist_ok=True)
|
|
53
|
+
LOGS_DIR.mkdir(exist_ok=True)
|
|
54
|
+
ARCHIVE_DIR.mkdir(exist_ok=True)
|
|
55
|
+
|
|
56
|
+
if not TASKS_FILE.exists():
|
|
57
|
+
TASKS_FILE.write_text(SAMPLE_TASKS_YAML)
|
|
58
|
+
print(f"{GREEN}Created {TASKS_FILE}{RESET}")
|
|
59
|
+
print(f"\nEdit ~/.tloop/tasks.yaml to define your tasks, then run tloop run.")
|
|
60
|
+
sys.exit(0)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_config():
|
|
64
|
+
if not TASKS_FILE.exists():
|
|
65
|
+
print(f"{RED}Error: {TASKS_FILE} not found{RESET}")
|
|
66
|
+
print(f"Run tloop run to initialize, then edit tasks.yaml.")
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
with open(TASKS_FILE) as f:
|
|
69
|
+
return yaml.safe_load(f) or {}
|
git_ops.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Git operations for t-loop: auto-commit, branch management, and safety checks."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from claude_runner import run_claude
|
|
7
|
+
|
|
8
|
+
GREEN = "\033[92m"
|
|
9
|
+
RED = "\033[91m"
|
|
10
|
+
YELLOW = "\033[93m"
|
|
11
|
+
RESET = "\033[0m"
|
|
12
|
+
|
|
13
|
+
COMMIT_STAGED_PROMPT = (
|
|
14
|
+
"You are performing a protective auto-commit of STAGED changes in this repository.\n"
|
|
15
|
+
"Follow these steps exactly:\n"
|
|
16
|
+
"1. Run `git diff --cached --name-only` to see what is staged.\n"
|
|
17
|
+
"2. Check each file for sensitive content (e.g., .env, credentials, secret keys, API tokens, private keys). "
|
|
18
|
+
"If you find any sensitive files staged, unstage them with `git restore --staged <file>` and report what you excluded.\n"
|
|
19
|
+
"3. If any files remain staged after the sensitive-file check, commit them with a Chinese conventional-commit message "
|
|
20
|
+
"(format: `<type>: <description>` where type is one of: feat, fix, refactor, docs, style, test, chore, perf, ci, build). "
|
|
21
|
+
"Do NOT use the `--no-verify` flag.\n"
|
|
22
|
+
"4. If nothing remains staged after the sensitive-file check, report that there is nothing to commit.\n"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
COMMIT_WORKDIR_PROMPT = (
|
|
26
|
+
"You are performing a protective auto-commit of all remaining UNSTAGED changes in this repository.\n"
|
|
27
|
+
"Follow these steps exactly:\n"
|
|
28
|
+
"1. Run `git add -A` to stage all working-directory changes.\n"
|
|
29
|
+
"2. Run `git diff --cached --name-only` to see what is now staged.\n"
|
|
30
|
+
"3. Check each file for sensitive content (e.g., .env, credentials, secret keys, API tokens, private keys). "
|
|
31
|
+
"If you find any sensitive files staged, unstage them with `git restore --staged <file>` and report what you excluded.\n"
|
|
32
|
+
"4. If any files remain staged after the sensitive-file check, commit them with a Chinese conventional-commit message "
|
|
33
|
+
"(format: `<type>: <description>` where type is one of: feat, fix, refactor, docs, style, test, chore, perf, ci, build). "
|
|
34
|
+
"Do NOT use the `--no-verify` flag.\n"
|
|
35
|
+
"5. If nothing remains staged after the sensitive-file check, report that there is nothing to commit.\n"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _git(dir_path, *args):
|
|
40
|
+
result = subprocess.run(
|
|
41
|
+
["git"] + list(args),
|
|
42
|
+
cwd=dir_path,
|
|
43
|
+
capture_output=True,
|
|
44
|
+
text=True,
|
|
45
|
+
)
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_git_repo(dir_path):
|
|
50
|
+
result = _git(dir_path, "rev-parse", "--is-inside-work-tree")
|
|
51
|
+
return result.returncode == 0 and result.stdout.strip() == "true"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def is_git_clean(dir_path):
|
|
55
|
+
result = _git(dir_path, "status", "--porcelain")
|
|
56
|
+
return result.returncode == 0 and result.stdout.strip() == ""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def has_staged_changes(dir_path):
|
|
60
|
+
result = _git(dir_path, "diff", "--cached", "--quiet")
|
|
61
|
+
return result.returncode == 1
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_detached_head(dir_path):
|
|
65
|
+
result = _git(dir_path, "symbolic-ref", "-q", "HEAD")
|
|
66
|
+
return result.returncode != 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def branch_exists(dir_path, name):
|
|
70
|
+
result = _git(dir_path, "branch", "--list", name)
|
|
71
|
+
return result.stdout.strip() != ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def ensure_clean_git(dir_path, task_name, log_file=None):
|
|
75
|
+
if not is_git_repo(dir_path):
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
if is_git_clean(dir_path):
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
if has_staged_changes(dir_path):
|
|
82
|
+
print(f"{YELLOW} Dirty working tree detected. Auto-committing staged changes...{RESET}")
|
|
83
|
+
run_claude(
|
|
84
|
+
COMMIT_STAGED_PROMPT,
|
|
85
|
+
cwd=dir_path,
|
|
86
|
+
verify_fn=is_git_clean,
|
|
87
|
+
log_file=log_file,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if not is_git_clean(dir_path):
|
|
91
|
+
print(f"{YELLOW} Committing remaining working-directory changes...{RESET}")
|
|
92
|
+
run_claude(
|
|
93
|
+
COMMIT_WORKDIR_PROMPT,
|
|
94
|
+
cwd=dir_path,
|
|
95
|
+
verify_fn=is_git_clean,
|
|
96
|
+
log_file=log_file,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if is_git_clean(dir_path):
|
|
100
|
+
print(f"{GREEN} Working tree is now clean.{RESET}")
|
|
101
|
+
return True
|
|
102
|
+
|
|
103
|
+
print(f"{RED} Failed to clean working tree after auto-commit. Skipping task.{RESET}")
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def find_next_available_branch(dir_path, prefix):
|
|
108
|
+
for n in range(1, 1000):
|
|
109
|
+
name = f"{prefix}-{n:03d}"
|
|
110
|
+
if not branch_exists(dir_path, name):
|
|
111
|
+
return name
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def create_task_branch(dir_path, branch_config):
|
|
116
|
+
if not is_git_repo(dir_path):
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
if branch_config is False:
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
if is_detached_head(dir_path):
|
|
123
|
+
print(f"{RED} Cannot create branch: repository is in detached HEAD state.{RESET}")
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
today = datetime.now().strftime("%Y%m%d")
|
|
127
|
+
|
|
128
|
+
if branch_config is True or branch_config is None:
|
|
129
|
+
prefix = f"feature-{today}"
|
|
130
|
+
name = find_next_available_branch(dir_path, prefix)
|
|
131
|
+
if name is None:
|
|
132
|
+
print(f"{RED} Could not find an available branch name with prefix {prefix}{RESET}")
|
|
133
|
+
return False
|
|
134
|
+
else:
|
|
135
|
+
custom = str(branch_config)
|
|
136
|
+
if not branch_exists(dir_path, custom):
|
|
137
|
+
name = custom
|
|
138
|
+
else:
|
|
139
|
+
prefix = custom
|
|
140
|
+
name = find_next_available_branch(dir_path, prefix)
|
|
141
|
+
if name is None:
|
|
142
|
+
print(f"{RED} Could not find an available branch name with prefix {prefix}{RESET}")
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
result = _git(dir_path, "checkout", "-b", name)
|
|
146
|
+
if result.returncode != 0:
|
|
147
|
+
print(f"{RED} Failed to create branch '{name}': {result.stderr.strip()}{RESET}")
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
print(f"{GREEN} Created and checked out branch: {name}{RESET}")
|
|
151
|
+
return True
|
main.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""t-loop entry point."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
__version__ = "0.4.0"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
parser = argparse.ArgumentParser(
|
|
10
|
+
prog="tloop",
|
|
11
|
+
description="t-loop: Automated Claude Code task runner",
|
|
12
|
+
)
|
|
13
|
+
parser.add_argument("-v", "--version", action="version",
|
|
14
|
+
version=f"tloop {__version__}")
|
|
15
|
+
|
|
16
|
+
subparsers = parser.add_subparsers(title="commands")
|
|
17
|
+
|
|
18
|
+
from cmd_run import add_parser as add_run
|
|
19
|
+
from cmd_edit import add_parser as add_edit
|
|
20
|
+
from cmd_migrate import add_parser as add_migrate
|
|
21
|
+
from cmd_archive import add_parser as add_archive
|
|
22
|
+
|
|
23
|
+
add_run(subparsers)
|
|
24
|
+
add_edit(subparsers)
|
|
25
|
+
add_migrate(subparsers)
|
|
26
|
+
add_archive(subparsers)
|
|
27
|
+
|
|
28
|
+
args = parser.parse_args()
|
|
29
|
+
|
|
30
|
+
if not hasattr(args, "func"):
|
|
31
|
+
parser.print_help()
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
args.func(args)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
main()
|
runner/__init__.py
ADDED
runner/claude.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
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")
|
runner/cybervisor.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
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
|
state.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""State management and archiving for t-loop."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
import config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_state():
|
|
13
|
+
if config.STATE_FILE.exists():
|
|
14
|
+
with open(config.STATE_FILE) as f:
|
|
15
|
+
return json.load(f)
|
|
16
|
+
return {"tasks": {}, "version": 1}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def save_state(state):
|
|
20
|
+
with open(config.STATE_FILE, "w") as f:
|
|
21
|
+
json.dump(state, f, indent=2, ensure_ascii=False)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_status_icon(status):
|
|
25
|
+
return {"pending": "⏳", "running": "🔄", "done": "✅", "failed": "❌"}.get(
|
|
26
|
+
status, "?"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def show_status(tasks, state):
|
|
31
|
+
if not tasks:
|
|
32
|
+
print(" (no tasks)")
|
|
33
|
+
return
|
|
34
|
+
for i, task in enumerate(tasks):
|
|
35
|
+
name = task.get("name", f"Task {i + 1}")
|
|
36
|
+
ts = state.get("tasks", {}).get(str(i), {})
|
|
37
|
+
status = ts.get("status", "pending")
|
|
38
|
+
icon = get_status_icon(status)
|
|
39
|
+
extra = ""
|
|
40
|
+
if status == "done" and "finished_at" in ts:
|
|
41
|
+
extra = f" ({ts['finished_at'][:16]})"
|
|
42
|
+
elif status == "failed":
|
|
43
|
+
extra = " (see logs/)"
|
|
44
|
+
print(f" {icon} [{i + 1}] {name}{config.RESET} {config.CYAN}{status}{config.RESET}{extra}")
|
|
45
|
+
print()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def archive_completed_tasks(config_data, state):
|
|
49
|
+
tasks = config_data.get("tasks", [])
|
|
50
|
+
|
|
51
|
+
completed = []
|
|
52
|
+
remaining = []
|
|
53
|
+
for i, task in enumerate(tasks):
|
|
54
|
+
ts = state.get("tasks", {}).get(str(i), {})
|
|
55
|
+
status = ts.get("status", "pending")
|
|
56
|
+
if status == "done":
|
|
57
|
+
completed.append({"task": task, "index": i, "result": ts})
|
|
58
|
+
else:
|
|
59
|
+
remaining.append(task)
|
|
60
|
+
|
|
61
|
+
if not completed:
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
total = len(tasks)
|
|
65
|
+
done_count = len(completed)
|
|
66
|
+
failed_count = sum(
|
|
67
|
+
1 for i in range(total)
|
|
68
|
+
if state.get("tasks", {}).get(str(i), {}).get("status") == "failed"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
72
|
+
archive_data = {
|
|
73
|
+
"archived_at": datetime.now().isoformat(),
|
|
74
|
+
"run_summary": {
|
|
75
|
+
"total": total,
|
|
76
|
+
"done": done_count,
|
|
77
|
+
"failed": failed_count,
|
|
78
|
+
"pending": total - done_count - failed_count,
|
|
79
|
+
},
|
|
80
|
+
"tasks": completed,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
config.ARCHIVE_DIR.mkdir(exist_ok=True)
|
|
84
|
+
archive_file = config.ARCHIVE_DIR / f"run-{timestamp}.yaml"
|
|
85
|
+
with open(archive_file, "w") as f:
|
|
86
|
+
yaml.dump(archive_data, f, default_flow_style=False, allow_unicode=True)
|
|
87
|
+
|
|
88
|
+
remaining_config = dict(config_data)
|
|
89
|
+
remaining_config["tasks"] = remaining
|
|
90
|
+
with open(config.TASKS_FILE, "w") as f:
|
|
91
|
+
yaml.dump(remaining_config, f, default_flow_style=False, allow_unicode=True)
|
|
92
|
+
|
|
93
|
+
save_state({"tasks": {}, "version": 1})
|
|
94
|
+
|
|
95
|
+
print(f"\n{config.GREEN}Archived {done_count} completed task(s) to {archive_file.name}{config.RESET}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def show_archives(latest=False):
|
|
99
|
+
if not config.ARCHIVE_DIR.exists():
|
|
100
|
+
print("No archive files found.")
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
archives = sorted(config.ARCHIVE_DIR.glob("run-*.yaml"), reverse=True)
|
|
104
|
+
if not archives:
|
|
105
|
+
print("No archive files found.")
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
if latest:
|
|
109
|
+
with open(archives[0]) as f:
|
|
110
|
+
data = yaml.safe_load(f)
|
|
111
|
+
print(f"{config.BOLD}Latest archive: {archives[0].name}{config.RESET}")
|
|
112
|
+
print(f" Archived at: {data.get('archived_at', 'unknown')}")
|
|
113
|
+
summary = data.get("run_summary", {})
|
|
114
|
+
print(
|
|
115
|
+
f" Total: {summary.get('total', 0)}, "
|
|
116
|
+
f"Done: {summary.get('done', 0)}, "
|
|
117
|
+
f"Failed: {summary.get('failed', 0)}, "
|
|
118
|
+
f"Pending: {summary.get('pending', 0)}"
|
|
119
|
+
)
|
|
120
|
+
print()
|
|
121
|
+
for entry in data.get("tasks", []):
|
|
122
|
+
task = entry.get("task", {})
|
|
123
|
+
result = entry.get("result", {})
|
|
124
|
+
name = task.get("name", "Unnamed")
|
|
125
|
+
status = result.get("status", "unknown")
|
|
126
|
+
icon = get_status_icon(status)
|
|
127
|
+
finished = result.get("finished_at", "")
|
|
128
|
+
extra = f" ({finished[:16]})" if finished else ""
|
|
129
|
+
print(f" {icon} {name}{extra}")
|
|
130
|
+
else:
|
|
131
|
+
print(f"{config.BOLD}Archive files:{config.RESET}")
|
|
132
|
+
for archive in archives:
|
|
133
|
+
with open(archive) as f:
|
|
134
|
+
data = yaml.safe_load(f)
|
|
135
|
+
summary = data.get("run_summary", {})
|
|
136
|
+
print(
|
|
137
|
+
f" {archive.name} "
|
|
138
|
+
f"(done: {summary.get('done', 0)}, "
|
|
139
|
+
f"failed: {summary.get('failed', 0)}, "
|
|
140
|
+
f"total: {summary.get('total', 0)})"
|
|
141
|
+
)
|
task.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Single task execution for t-loop."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import config
|
|
8
|
+
from git_ops import ensure_clean_git, create_task_branch
|
|
9
|
+
from runner.cybervisor import CybervisorRunner
|
|
10
|
+
from state import save_state
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def expand_dir(d):
|
|
14
|
+
return os.path.expandvars(os.path.expanduser(d))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def resolve_prompt_file(prompt_file, dir_path):
|
|
18
|
+
"""Resolve prompt_file: ~ expansion, TLOOP_HOME-relative, then dir-relative."""
|
|
19
|
+
pf = Path(prompt_file).expanduser()
|
|
20
|
+
if pf.is_absolute():
|
|
21
|
+
return pf
|
|
22
|
+
candidate = config.TLOOP_HOME / prompt_file
|
|
23
|
+
if candidate.exists():
|
|
24
|
+
return candidate
|
|
25
|
+
candidate = Path(dir_path) / prompt_file
|
|
26
|
+
if candidate.exists():
|
|
27
|
+
return candidate
|
|
28
|
+
return Path(prompt_file)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run_task(task, index, state, defaults):
|
|
32
|
+
name = task.get("name", f"Task {index + 1}")
|
|
33
|
+
dir_path = expand_dir(task.get("dir", defaults.get("dir", ".")))
|
|
34
|
+
prompt = task.get("prompt", "")
|
|
35
|
+
prompt_file = task.get("prompt_file")
|
|
36
|
+
model = task.get("model", defaults.get("model"))
|
|
37
|
+
branch_config = task.get("branch", True)
|
|
38
|
+
|
|
39
|
+
if not os.path.isdir(dir_path):
|
|
40
|
+
print(f"{config.RED} Directory not found: {dir_path}{config.RESET}")
|
|
41
|
+
state.setdefault("tasks", {})[str(index)] = {
|
|
42
|
+
"status": "failed",
|
|
43
|
+
"error": f"Directory not found: {dir_path}",
|
|
44
|
+
"updated_at": datetime.now().isoformat(),
|
|
45
|
+
}
|
|
46
|
+
save_state(state)
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
if prompt_file:
|
|
50
|
+
pf = resolve_prompt_file(prompt_file, dir_path)
|
|
51
|
+
if pf.exists():
|
|
52
|
+
prompt = pf.read_text()
|
|
53
|
+
else:
|
|
54
|
+
print(f"{config.RED} Prompt file not found: {prompt_file}{config.RESET}")
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
if not prompt.strip():
|
|
58
|
+
print(f"{config.RED} No prompt defined for task: {name}{config.RESET}")
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
print(f"\n{config.BOLD}{'=' * 60}{config.RESET}")
|
|
62
|
+
print(f"{config.BOLD} Task [{index + 1}]: {name}{config.RESET}")
|
|
63
|
+
print(f" Directory: {config.CYAN}{dir_path}{config.RESET}")
|
|
64
|
+
print(f"{config.BOLD}{'=' * 60}{config.RESET}\n")
|
|
65
|
+
|
|
66
|
+
config.LOGS_DIR.mkdir(exist_ok=True)
|
|
67
|
+
safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in name)
|
|
68
|
+
log_file = config.LOGS_DIR / f"{index + 1:03d}-{safe_name}.log"
|
|
69
|
+
|
|
70
|
+
with open(log_file, "w") as log:
|
|
71
|
+
log.write(f"Task: {name}\n")
|
|
72
|
+
log.write(f"Directory: {dir_path}\n")
|
|
73
|
+
log.write("-" * 60 + "\n\n")
|
|
74
|
+
|
|
75
|
+
if not ensure_clean_git(dir_path, name, log_file):
|
|
76
|
+
state.setdefault("tasks", {})[str(index)] = {
|
|
77
|
+
"status": "failed",
|
|
78
|
+
"error": "Failed to clean working tree via auto-commit",
|
|
79
|
+
"updated_at": datetime.now().isoformat(),
|
|
80
|
+
}
|
|
81
|
+
save_state(state)
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
if not create_task_branch(dir_path, branch_config):
|
|
85
|
+
state.setdefault("tasks", {})[str(index)] = {
|
|
86
|
+
"status": "failed",
|
|
87
|
+
"error": "Failed to create task branch",
|
|
88
|
+
"updated_at": datetime.now().isoformat(),
|
|
89
|
+
}
|
|
90
|
+
save_state(state)
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
started = datetime.now().isoformat()
|
|
94
|
+
state.setdefault("tasks", {})[str(index)] = {
|
|
95
|
+
"status": "running",
|
|
96
|
+
"started_at": started,
|
|
97
|
+
}
|
|
98
|
+
save_state(state)
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
with open(log_file, "a") as log:
|
|
102
|
+
log.write(f"Started: {started}\n")
|
|
103
|
+
log.write(f"Command: cybervisor run <prompt>\n")
|
|
104
|
+
log.write("-" * 60 + "\n\n")
|
|
105
|
+
log.flush()
|
|
106
|
+
|
|
107
|
+
runner = CybervisorRunner()
|
|
108
|
+
returncode = runner.run(prompt, dir_path, model=model, log_file=log_file)
|
|
109
|
+
|
|
110
|
+
if returncode == 0:
|
|
111
|
+
state["tasks"][str(index)] = {
|
|
112
|
+
"status": "done",
|
|
113
|
+
"started_at": started,
|
|
114
|
+
"finished_at": datetime.now().isoformat(),
|
|
115
|
+
}
|
|
116
|
+
save_state(state)
|
|
117
|
+
print(f"\n{config.GREEN}✅ Task [{index + 1}] done{config.RESET}")
|
|
118
|
+
else:
|
|
119
|
+
state["tasks"][str(index)] = {
|
|
120
|
+
"status": "failed",
|
|
121
|
+
"started_at": started,
|
|
122
|
+
"finished_at": datetime.now().isoformat(),
|
|
123
|
+
"returncode": returncode,
|
|
124
|
+
}
|
|
125
|
+
save_state(state)
|
|
126
|
+
print(
|
|
127
|
+
f"\n{config.RED}❌ Task [{index + 1}] failed (exit code: {returncode}){config.RESET}"
|
|
128
|
+
)
|
|
129
|
+
print(f" Log: {log_file}")
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
except Exception as e:
|
|
133
|
+
state["tasks"][str(index)] = {
|
|
134
|
+
"status": "failed",
|
|
135
|
+
"started_at": started,
|
|
136
|
+
"error": str(e),
|
|
137
|
+
}
|
|
138
|
+
save_state(state)
|
|
139
|
+
print(f"\n{config.RED}❌ Task [{index + 1}] error: {e}{config.RESET}")
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
return True
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tloop-cli
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Automated Claude Code task runner
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: pyyaml>=6.0
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
# t-loop
|
|
13
|
+
|
|
14
|
+
自动化 Claude Code 任务运行器。用 YAML 定义任务,t-loop 按顺序执行,自带 git 安全保护。
|
|
15
|
+
|
|
16
|
+
## 安装
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install t-loop # 从 PyPI 安装
|
|
20
|
+
pip install -e . # 本地开发安装
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
需要 Python >=3.9,唯一外部依赖:`pyyaml`。
|
|
24
|
+
|
|
25
|
+
## 快速开始
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# 首次运行会创建 ~/.tloop/tasks.yaml 示例配置
|
|
29
|
+
tloop run
|
|
30
|
+
|
|
31
|
+
# 编辑任务后运行
|
|
32
|
+
tloop edit
|
|
33
|
+
tloop run
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## 任务配置
|
|
37
|
+
|
|
38
|
+
编辑 `~/.tloop/tasks.yaml`:
|
|
39
|
+
|
|
40
|
+
```yaml
|
|
41
|
+
tasks:
|
|
42
|
+
- name: 我的第一个任务
|
|
43
|
+
dir: ~/projects/my-project
|
|
44
|
+
prompt: |
|
|
45
|
+
描述 Claude 应该做什么。
|
|
46
|
+
|
|
47
|
+
- name: 使用 prompt 文件的任务
|
|
48
|
+
dir: ~/projects/my-project
|
|
49
|
+
prompt_file: ./prompts/my-task.md
|
|
50
|
+
branch: feat/login # 自定义分支名
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### 任务字段
|
|
54
|
+
|
|
55
|
+
| 字段 | 说明 |
|
|
56
|
+
|-------|-------------|
|
|
57
|
+
| `name` | 任务显示名称 |
|
|
58
|
+
| `dir` | 任务工作目录 |
|
|
59
|
+
| `prompt` | 内联 prompt 文本 |
|
|
60
|
+
| `prompt_file` | prompt 文件路径(解析顺序:绝对路径 → `~/.tloop/` 相对 → 任务目录相对) |
|
|
61
|
+
| `model` | 覆盖该任务的模型 |
|
|
62
|
+
| `branch` | `true`(自动 `feature-YYYYMMDD-NNN`)、`"custom/name"`、或 `false`(跳过分支) |
|
|
63
|
+
|
|
64
|
+
## 用法
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
tloop run # 运行所有待执行任务
|
|
68
|
+
tloop run --status # 查看任务状态
|
|
69
|
+
tloop run --only 2 # 只运行第 2 个任务
|
|
70
|
+
tloop run --confirm # 每个任务前确认
|
|
71
|
+
tloop run -c # 失败后继续执行
|
|
72
|
+
tloop run --reset # 重置所有任务为待执行
|
|
73
|
+
tloop edit # 用 $EDITOR 打开 tasks.yaml
|
|
74
|
+
tloop archive # 列出归档记录
|
|
75
|
+
tloop archive --latest # 显示最近一次归档详情
|
|
76
|
+
tloop migrate # 迁移旧的项目本地数据到 ~/.tloop/
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## 工作原理
|
|
80
|
+
|
|
81
|
+
每个任务的执行流程:
|
|
82
|
+
|
|
83
|
+
1. **自动提交** — 如果工作目录有未提交的更改,t-loop 会用 Claude 先提交暂存区内容,再提交剩余更改。敏感文件(.env、密钥等)会被排除。
|
|
84
|
+
2. **创建分支** — 创建任务分支(默认 `feature-YYYYMMDD-NNN`)隔离更改。
|
|
85
|
+
3. **执行任务** — 在目标目录运行 `cybervisor run <prompt>`。
|
|
86
|
+
4. **归档** — 已完成的任务移至 `~/.tloop/archive/`,并从 `tasks.yaml` 中移除。
|
|
87
|
+
|
|
88
|
+
## 文件位置
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
~/.tloop/
|
|
92
|
+
├── tasks.yaml # 任务定义
|
|
93
|
+
├── state.json # 运行时状态(任务状态)
|
|
94
|
+
├── logs/ # 执行日志
|
|
95
|
+
└── archive/ # 已完成任务的归档
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## 开发
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pip install -e .
|
|
102
|
+
python -m pytest test/ -v
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## 许可证
|
|
106
|
+
|
|
107
|
+
MIT
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
claude_runner.py,sha256=Tga-aOaKnpRMts-wW__f2FvtZoTCbrV_DjrSNQjt8-g,2899
|
|
2
|
+
cmd_archive.py,sha256=mg1Ltg8YCYAlWzlk-ABPwDTqhzeixgg3FMyzhGRB-FM,440
|
|
3
|
+
cmd_edit.py,sha256=Ns0-7C2fTHYOWk-Vr7yINRPpCyoT_S-WEPBPOli_MqA,1099
|
|
4
|
+
cmd_migrate.py,sha256=dfPNXVsdTebZAj8uKhC6mMA1xj6aNGc6az9aG2wx2qw,2435
|
|
5
|
+
cmd_run.py,sha256=9SqUlY_uL88mxxkvRil15VpugwUPwqIvLHNZwaCPMtM,2724
|
|
6
|
+
config.py,sha256=OBlwVP4rz6WaNk5YNtwmJ0XzCNQA1olh9LjVVaMyZoM,1773
|
|
7
|
+
git_ops.py,sha256=iOvtqYstAeqd37G77vzTcrlgiYRsIgca761AHuWJtr8,5430
|
|
8
|
+
main.py,sha256=joFC2oL0sDCpc-fbagTGxFeEOJG_AnRInnpzjZ7iEHw,866
|
|
9
|
+
state.py,sha256=ierDMWgPRuEiQIk_yGL027291zFnU9XAJNjSc1wTHyk,4518
|
|
10
|
+
task.py,sha256=QLSbIfY2aMQZxG3QEZGeOM4A59Qikiq8kCXwl7X2Gt8,4739
|
|
11
|
+
runner/__init__.py,sha256=qC1bhmC9Qncx6ZRGD0_Gz2_kjUeAGHDW01sI8kzQrsA,266
|
|
12
|
+
runner/claude.py,sha256=y211t1vdN3l-MQf-ykh4kuxCCcyu4-bCr5jivuZb-jM,293
|
|
13
|
+
runner/cybervisor.py,sha256=dDbgtuPVzx4ubVcIAdlJRCQ0Pis06dG3_vQhVZRqMiU,762
|
|
14
|
+
tloop_cli-0.4.0.dist-info/licenses/LICENSE,sha256=RK9GmSj0e7HTwoxX88OHwqjgh76C1LcyDUFxt5dYHAo,1069
|
|
15
|
+
tloop_cli-0.4.0.dist-info/METADATA,sha256=bTvQ7ogNoQztxcZR9n2F9i1yTcuzIyGyTL6S8ijTK1o,2880
|
|
16
|
+
tloop_cli-0.4.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
17
|
+
tloop_cli-0.4.0.dist-info/entry_points.txt,sha256=pz8TL9_xikjuXCJsEJvFNJtmk6PjOUDU3m1tAA_wA8k,36
|
|
18
|
+
tloop_cli-0.4.0.dist-info/top_level.txt,sha256=PQkmT-TbTpWGdRdrDvw3GUW6gAppl4Naex9DGFDIfYs,93
|
|
19
|
+
tloop_cli-0.4.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 XiaodongTong
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|