aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLouis Burda <dev@sinitax.com>2026-03-03 01:10:09 +0100
committerLouis Burda <dev@sinitax.com>2026-03-03 01:10:09 +0100
commitb8694d1456a591e8ca20c76b17f27dbd0cfe1857 (patch)
tree7d9979ab90ce9fa97aae96254d78efc4ee2afef0
parentf8720efcc51355b44b28440a6d22c6ca4c56d165 (diff)
downloadclaude-task-main.tar.gz
claude-task-main.zip
Add claude run loop to feed tasks toHEADmain
-rw-r--r--README.md11
-rw-r--r--src/ctask/cli.py361
2 files changed, 290 insertions, 82 deletions
diff --git a/README.md b/README.md
index 2120150..66874dc 100644
--- a/README.md
+++ b/README.md
@@ -102,7 +102,9 @@ Options:
```bash
ctask mod 8 --status in_progress
ctask mod 8 --name "New name" --description "New description"
-ctask mod 8 --add-before 7 --add-after 5
+ctask mod 8 --after 5 # add dependency
+ctask mod 8 --unblock # remove all blockers
+ctask mod 8 --unblock --after 5 # replace blockers with only task 5
```
Options:
@@ -110,8 +112,9 @@ Options:
- `--description`: Update description
- `--status`: Update status
- `--active-form`: Update active form
-- `--add-blocks` / `--add-before`: Add task IDs this blocks (this comes before them)
-- `--add-blocked-by` / `--add-after`: Add task IDs blocking this (this comes after them)
+- `--blocks` / `--before`: Set task IDs this blocks (replaces existing)
+- `--blocked-by` / `--after`: Set/add task IDs blocking this (adds to existing, or replaces if used with --unblock)
+- `--unblock`: Clear all blockers before applying --blocked-by
### Edit a task
```bash
@@ -162,7 +165,7 @@ Task IDs are automatically incremented when creating new tasks. The ID is determ
- The maximum of existing task IDs + 1
- The value in `.highwatermark` file + 1 (if it exists)
-The `.highwatermark` file in each session directory tracks the highest task ID to prevent ID conflicts when tasks are deleted.
+The `.highwatermark` file in each session directory is managed by Claude and tracks the highest task ID to prevent ID conflicts.
## Session State
diff --git a/src/ctask/cli.py b/src/ctask/cli.py
index fe1d19f..45e76c2 100644
--- a/src/ctask/cli.py
+++ b/src/ctask/cli.py
@@ -1,7 +1,12 @@
+import datetime
import json
import os
+import shutil
+import signal
import subprocess
import sys
+import tempfile
+import time
from pathlib import Path
from typing import Optional
@@ -17,29 +22,6 @@ console = Console()
def get_tasks_base_dir() -> Path:
return Path.home() / ".claude" / "tasks"
-def load_sessions_index() -> dict:
- projects_dir = Path.home() / ".claude" / "projects"
- sessions_data = {}
-
- if not projects_dir.exists():
- return sessions_data
-
- for project_dir in projects_dir.iterdir():
- if project_dir.is_dir():
- index_file = project_dir / "sessions-index.json"
- if index_file.exists():
- try:
- with open(index_file) as f:
- data = json.load(f)
- if "entries" in data:
- for entry in data["entries"]:
- session_id = entry.get("sessionId")
- if session_id:
- sessions_data[session_id] = entry
- except Exception:
- continue
-
- return sessions_data
def get_session_state_file() -> Path:
return Path(os.path.expanduser("~/.claude/ctask_state.json"))
@@ -85,6 +67,41 @@ def load_task(task_file: Path) -> dict:
with open(task_file) as f:
return json.load(f)
+def task_to_md(task: dict) -> str:
+ lines = ["---", f"subject: {task.get('subject', '')}"]
+ blocks = task.get("blocks", [])
+ if blocks:
+ lines.append(f"blocks: {', '.join(blocks)}")
+ blocked_by = task.get("blockedBy", [])
+ if blocked_by:
+ lines.append(f"blockedBy: {', '.join(blocked_by)}")
+ lines += ["---", "", task.get("description", "")]
+ return "\n".join(lines)
+
+def md_to_task(text: str, original: dict) -> dict:
+ lines = text.split("\n")
+ if not lines or lines[0].strip() != "---":
+ raise ValueError("Missing opening frontmatter delimiter")
+ try:
+ end = next(i for i in range(1, len(lines)) if lines[i].strip() == "---")
+ except StopIteration:
+ raise ValueError("Missing closing frontmatter delimiter")
+ fm_lines = lines[1:end]
+ body = "\n".join(lines[end + 1:]).strip()
+ fm = {}
+ for line in fm_lines:
+ if ":" in line:
+ k, _, v = line.partition(":")
+ fm[k.strip()] = v.strip()
+ def parse_ids(s):
+ return [x.strip() for x in s.split(",") if x.strip()] if s else []
+ task = dict(original)
+ task["subject"] = fm.get("subject", task.get("subject", ""))
+ task["blocks"] = parse_ids(fm.get("blocks", ""))
+ task["blockedBy"] = parse_ids(fm.get("blockedBy", ""))
+ task["description"] = body
+ return task
+
def save_task(task_file: Path, task: dict):
with open(task_file, 'w') as f:
json.dump(task, f, indent=2)
@@ -103,34 +120,107 @@ def get_all_tasks(task_dir: Path) -> list[tuple[Path, dict]]:
return tasks
def get_next_task_id(task_dir: Path) -> int:
- highwatermark_file = task_dir / ".highwatermark"
- highwatermark = 0
-
- if highwatermark_file.exists():
- try:
- with open(highwatermark_file) as f:
- highwatermark = int(f.read().strip())
- except (ValueError, IOError):
- highwatermark = 0
-
tasks = get_all_tasks(task_dir)
- max_existing_id = 0
if tasks:
- max_existing_id = max(int(task["id"]) for _, task in tasks)
-
- next_id = max(highwatermark + 1, max_existing_id + 1)
-
+ return max(int(task["id"]) for _, task in tasks) + 1
+ return 1
+
+def _run_impl(resume: bool = False):
+ ctask_bin = sys.argv[0] if sys.argv[0] != "-c" else (shutil.which("ctask") or "ctask")
+ settings = {
+ "hooks": {
+ "Stop": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": f"{ctask_bin} --stop-hook"
+ }
+ ]
+ }
+ ]
+ }
+ }
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".json", prefix="ctask_settings_", delete=False
+ ) as tmp:
+ json.dump(settings, tmp, indent=2)
+ tmp_path = Path(tmp.name)
try:
- with open(highwatermark_file, 'w') as f:
- f.write(str(next_id))
- except IOError:
- pass
-
- return next_id
+ cmd = ["claude"]
+ if resume:
+ cmd.append("--resume")
+ cmd += ["--settings", str(tmp_path), "Use the tasks tool to check for tasks and run them"]
+ result = subprocess.run(cmd)
+ sys.exit(result.returncode)
+ finally:
+ tmp_path.unlink(missing_ok=True)
+
+def _stop_hook_impl():
+ try:
+ hook_input = json.loads(sys.stdin.read())
+ except Exception:
+ hook_input = {}
+
+ with open("/tmp/log", "w") as _log:
+ print(json.dumps(hook_input, indent=2), file=_log)
+ session_id = hook_input.get("session_id")
+ if not session_id:
+ sys.exit(0)
+ session_path = get_tasks_base_dir() / session_id
+
+ def get_unblocked_tasks() -> list[dict]:
+ tasks = get_all_tasks(session_path)
+ if not tasks:
+ return []
+ completed_ids = {t["id"] for _, t in tasks if t.get("status") == "completed"}
+ return [
+ task for _, task in tasks
+ if task.get("status") not in ("completed", "deleted")
+ and all(bid in completed_ids for bid in task.get("blockedBy", []))
+ ]
+
+ def block_stop(tasks: list[dict]):
+ task_lines = "\n".join(
+ f"- #{t['id']}: {t.get('subject', '(no subject)')} [{t.get('status', 'pending')}]"
+ for t in tasks
+ )
+ reason = f"Use the tasks tool to check for tasks and run them\n\nPending tasks:\n{task_lines}"
+ print(json.dumps({"decision": "block", "reason": reason}))
+ sys.exit(0)
+
+ pending = get_unblocked_tasks()
+ if pending:
+ block_stop(pending)
+
+ def handle_signal(signum, frame):
+ sys.exit(0)
+
+ signal.signal(signal.SIGINT, handle_signal)
+ signal.signal(signal.SIGTERM, handle_signal)
+
+ def dir_snapshot() -> dict:
+ if not session_path.exists():
+ return {}
+ return {str(f): f.stat().st_mtime for f in session_path.glob("*.json")}
+
+ snapshot = dir_snapshot()
+ while True:
+ time.sleep(1)
+ new_snapshot = dir_snapshot()
+ if new_snapshot != snapshot:
+ snapshot = new_snapshot
+ pending = get_unblocked_tasks()
+ if pending:
+ block_stop(pending)
@click.group(invoke_without_command=True, context_settings={"help_option_names": ["-h", "--help"]})
+@click.option("--stop-hook", "do_stop_hook", is_flag=True, hidden=True)
@click.pass_context
-def main(ctx):
+def main(ctx, do_stop_hook):
+ if do_stop_hook:
+ _stop_hook_impl()
+ return
if ctx.invoked_subcommand is None:
ctx.invoke(list_tasks)
@@ -187,6 +277,45 @@ def ls_tasks():
_list_tasks_impl()
def _create_task_impl(name, description, status, active_form, blocks, blocked_by):
+ if name is None:
+ skeleton = {
+ "subject": "",
+ "status": status,
+ "activeForm": active_form or "",
+ "blocks": list(blocks),
+ "blockedBy": list(blocked_by),
+ "description": description,
+ }
+ md_content = task_to_md(skeleton)
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".md", prefix="ctask_new_", delete=False
+ ) as tmp:
+ tmp.write(md_content)
+ tmp_path = Path(tmp.name)
+ try:
+ mtime_before = tmp_path.stat().st_mtime
+ editor = os.environ.get("EDITOR", "vi")
+ subprocess.run([editor, str(tmp_path)])
+ mtime_after = tmp_path.stat().st_mtime
+ if mtime_after == mtime_before:
+ console.print("[yellow]No changes made, task not created[/yellow]")
+ return
+ parsed = md_to_task(tmp_path.read_text(), skeleton)
+ except ValueError as e:
+ console.print(f"[red]Parse error: {e}[/red]")
+ sys.exit(1)
+ finally:
+ tmp_path.unlink(missing_ok=True)
+ name = parsed["subject"].strip()
+ if not name:
+ console.print("[yellow]No subject provided, task not created[/yellow]")
+ return
+ description = parsed["description"]
+ status = parsed["status"]
+ active_form = parsed["activeForm"]
+ blocks = parsed["blocks"]
+ blocked_by = parsed["blockedBy"]
+
task_dir = require_session()
task_dir.mkdir(parents=True, exist_ok=True)
@@ -207,7 +336,7 @@ def _create_task_impl(name, description, status, active_form, blocks, blocked_by
console.print(f"[dim]{task_file}[/dim]")
@main.command("create")
-@click.argument("name")
+@click.argument("name", required=False, default=None)
@click.argument("description", required=False, default="")
@click.option("--status", default="pending", help="Task status")
@click.option("--active-form", help="Active form description")
@@ -217,7 +346,7 @@ def create_task(name, description, status, active_form, blocks, blocked_by):
_create_task_impl(name, description, status, active_form, blocks, blocked_by)
@main.command("add")
-@click.argument("name")
+@click.argument("name", required=False, default=None)
@click.argument("description", required=False, default="")
@click.option("--status", default="pending", help="Task status")
@click.option("--active-form", help="Active form description")
@@ -232,9 +361,10 @@ def add_task(name, description, status, active_form, blocks, blocked_by):
@click.option("--description", help="New description")
@click.option("--status", help="New status")
@click.option("--active-form", help="New active form")
-@click.option("--add-blocks", "--add-before", multiple=True, help="Add task IDs this blocks (comes before)")
-@click.option("--add-blocked-by", "--add-after", multiple=True, help="Add task IDs blocking this (comes after)")
-def modify_task(task_id, name, description, status, active_form, add_blocks, add_blocked_by):
+@click.option("--blocks", "--before", multiple=True, help="Set task IDs this blocks (comes before)")
+@click.option("--blocked-by", "--after", multiple=True, help="Set task IDs blocking this (comes after)")
+@click.option("--unblock", is_flag=True, help="Clear all blockers before applying --blocked-by")
+def modify_task(task_id, name, description, status, active_form, blocks, blocked_by, unblock):
task_dir = require_session()
task_file = task_dir / f"{task_id}.json"
@@ -252,17 +382,23 @@ def modify_task(task_id, name, description, status, active_form, add_blocks, add
task["status"] = status
if active_form:
task["activeForm"] = active_form
- if add_blocks:
- task["blocks"].extend(add_blocks)
- if add_blocked_by:
- task["blockedBy"].extend(add_blocked_by)
+ if unblock:
+ task["blockedBy"] = []
+ if blocks:
+ task["blocks"] = list(blocks)
+ if blocked_by:
+ if unblock:
+ task["blockedBy"] = list(blocked_by)
+ else:
+ task["blockedBy"].extend(blocked_by)
save_task(task_file, task)
console.print(f"[green]Modified task {task_id}[/green]")
@main.command("edit")
@click.argument("task_id")
-def edit_task(task_id):
+@click.option("--json", "use_json", is_flag=True, help="Edit raw JSON instead of markdown")
+def edit_task(task_id, use_json):
task_dir = require_session()
task_file = task_dir / f"{task_id}.json"
@@ -271,7 +407,38 @@ def edit_task(task_id):
sys.exit(1)
editor = os.environ.get("EDITOR", "vi")
- subprocess.run([editor, str(task_file)])
+
+ if use_json:
+ subprocess.run([editor, str(task_file)])
+ return
+
+ task = load_task(task_file)
+ md_content = task_to_md(task)
+
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".md", prefix=f"ctask_{task_id}_", delete=False
+ ) as tmp:
+ tmp.write(md_content)
+ tmp_path = Path(tmp.name)
+
+ try:
+ mtime_before = tmp_path.stat().st_mtime
+ subprocess.run([editor, str(tmp_path)])
+ mtime_after = tmp_path.stat().st_mtime
+
+ if mtime_after == mtime_before:
+ console.print("[yellow]No changes made[/yellow]")
+ return
+
+ updated_text = tmp_path.read_text()
+ updated_task = md_to_task(updated_text, task)
+ save_task(task_file, updated_task)
+ console.print(f"[green]Saved task {task_id}[/green]")
+ except ValueError as e:
+ console.print(f"[red]Parse error: {e}[/red]")
+ sys.exit(1)
+ finally:
+ tmp_path.unlink(missing_ok=True)
def _delete_task_impl(task_id):
task_dir = require_session()
@@ -296,32 +463,65 @@ def delete_task(task_id):
def rm_task(task_id):
_delete_task_impl(task_id)
-def get_available_sessions() -> list[dict]:
- tasks_base = get_tasks_base_dir()
+def _relative_time(mtime: float) -> str:
+ diff = time.time() - mtime
+ if diff < 60:
+ return "just now"
+ elif diff < 3600:
+ n = int(diff / 60)
+ return f"{n} minute{'s' if n != 1 else ''} ago"
+ elif diff < 86400:
+ n = int(diff / 3600)
+ return f"{n} hour{'s' if n != 1 else ''} ago"
+ elif diff < 86400 * 7:
+ n = int(diff / 86400)
+ return f"{n} day{'s' if n != 1 else ''} ago"
+ else:
+ n = int(diff / (86400 * 7))
+ return f"{n} week{'s' if n != 1 else ''} ago"
+
+def _transcript_cwd(transcript: Path) -> Optional[str]:
+ try:
+ with open(transcript) as f:
+ for line in f:
+ line = line.strip()
+ if not line:
+ continue
+ entry = json.loads(line)
+ if "cwd" in entry:
+ return entry["cwd"]
+ except Exception:
+ pass
+ return None
- if not tasks_base.exists():
+def get_available_sessions() -> list[dict]:
+ projects_dir = Path.home() / ".claude" / "projects"
+ if not projects_dir.exists():
return []
- sessions_index = load_sessions_index()
sessions = []
-
- for entry in tasks_base.iterdir():
- if entry.is_dir():
- task_files = list(entry.glob("*.json"))
- if task_files:
- session_id = entry.name
- mtime = entry.stat().st_mtime
-
- description = None
- if session_id in sessions_index:
- description = sessions_index[session_id].get("firstPrompt")
-
- sessions.append({
- "name": session_id,
- "path": str(entry),
- "description": description,
- "mtime": mtime
- })
+ for project_dir in projects_dir.iterdir():
+ if not project_dir.is_dir():
+ continue
+ for transcript in project_dir.glob("*.jsonl"):
+ cwd = _transcript_cwd(transcript)
+ mtime = transcript.stat().st_mtime
+ session_id = transcript.stem
+ task_path = get_tasks_base_dir() / session_id
+ if cwd:
+ parts = Path(cwd).parts
+ name = str(Path(*parts[-2:])) if len(parts) >= 2 else cwd
+ else:
+ name = project_dir.name
+ name = f"{name} {session_id[:8]}"
+ dt = datetime.datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M")
+ description = f"{dt} ({_relative_time(mtime)})"
+ sessions.append({
+ "name": name,
+ "path": str(task_path),
+ "description": description,
+ "mtime": mtime,
+ })
sessions.sort(key=lambda x: x["mtime"], reverse=True)
return [{"name": s["name"], "path": s["path"], "description": s["description"]} for s in sessions]
@@ -378,5 +578,10 @@ def select_session():
console.print("\n[yellow]Selection cancelled[/yellow]")
sys.exit(1)
+@main.command("run")
+@click.option("--resume", "resume", is_flag=True, help="Resume the last Claude session")
+def run_claude(resume):
+ _run_impl(resume=resume)
+
if __name__ == "__main__":
main()