diff --git a/.project/PROMPT.md b/.project/PROMPT.md index e3101f5..449e1a9 100644 --- a/.project/PROMPT.md +++ b/.project/PROMPT.md @@ -16,7 +16,7 @@ If HANDOFF.md doesn't exist, this is the first iteration. Read `.project/SPEC.md ### 2. Pick a Task ```bash -.project/task next +python .project/task next ``` This returns the highest-priority task with satisfied dependencies. If it returns an in-progress task, a previous iteration crashed — pick up where it left off. @@ -30,7 +30,7 @@ echo "DONE: All tasks complete" > .project/STOP ### 3. Read the Task ```bash -.project/task +python .project/task ``` Read the full task file. Understand the acceptance criteria before writing any code. @@ -38,7 +38,7 @@ Read the full task file. Understand the acceptance criteria before writing any c ### 4. Implement ```bash -.project/task progress +python .project/task progress ``` Mark it in-progress, then implement. Follow existing code patterns. Make the smallest change that satisfies the acceptance criteria. @@ -55,7 +55,7 @@ Run the project's quality checks (lint, typecheck, test — whatever applies). D ### 6. Complete ```bash -.project/task done +python .project/task done ``` Commit your changes: @@ -83,13 +83,13 @@ git add -A && git commit -m "feat(): - " ## Remaining Work - + ``` ### 8. Check for Completion ```bash -.project/task list +python .project/task list ``` If all tasks are done (nothing under OPEN or IN PROGRESS), write a STOP file: @@ -117,3 +117,4 @@ echo "CLARIFY: " > .project/STOP - **Always write HANDOFF.md last.** It's the next iteration's memory. - **Don't be clever.** Simple, obvious code that works. - **Commit after completing each task.** Never leave uncommitted work. +- **Use the Python venv.** If a `.venv` directory does not yet exist, create one with `python -m venv .venv` and activate it with `source .venv/Scripts/activate` (Windows) or `source .venv/bin/activate` (Linux/macOS) before installing packages or running Python commands. Always activate the venv before running pip, pytest, ruff, or any project Python code. Check for the venv at the start of every iteration. diff --git a/.project/loop b/.project/loop index 25de07b..27f85ea 100755 --- a/.project/loop +++ b/.project/loop @@ -30,7 +30,7 @@ LOGS_DIR = os.path.join(SCRIPT_DIR, "logs") def read_file(path): - with open(path, "r") as f: + with open(path, "r", encoding="utf-8") as f: return f.read() @@ -42,7 +42,7 @@ def clean_stale_stop(): def ensure_learnings(): if not os.path.exists(LEARNINGS_FILE): - with open(LEARNINGS_FILE, "w") as f: + with open(LEARNINGS_FILE, "w", encoding="utf-8") as f: f.write("# Learnings\n\nPatterns and gotchas discovered during implementation.\n") @@ -178,7 +178,7 @@ def run_iteration(iteration, model=None, verbose=False): cwd=PROJECT_DIR, ) - with open(jsonl_path, "w") as jsonl_file: + with open(jsonl_path, "w", encoding="utf-8") as jsonl_file: for raw_line in proc.stdout: line = raw_line.decode("utf-8", errors="replace") # Write raw JSON to log @@ -244,7 +244,7 @@ def run_iteration(iteration, model=None, verbose=False): def _write_summary(path, header, model, tools, texts, result, elapsed, error=None): """Write a human-readable iteration summary.""" - with open(path, "w") as f: + with open(path, "w", encoding="utf-8") as f: f.write(f"# {header}\n\n") f.write(f"- **Model**: {model}\n") f.write(f"- **Duration**: {elapsed:.0f}s\n") @@ -300,7 +300,7 @@ def main(): # Re-read result from jsonl for accurate cost jsonl_path = log_path.replace(".md", ".jsonl") if os.path.exists(jsonl_path): - with open(jsonl_path) as f: + with open(jsonl_path, encoding="utf-8") as f: for line in f: try: obj = json.loads(line) diff --git a/.project/task b/.project/task index 81068ae..5d5527e 100755 --- a/.project/task +++ b/.project/task @@ -1,179 +1,237 @@ -#!/bin/bash -# .project/task — task management via filesystem -# State: .md = open, .progress.md = in-progress, done/*.md = completed - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -TASKS="$SCRIPT_DIR/tasks" -DONE="$TASKS/done" -mkdir -p "$DONE" - -# Extract frontmatter value from a task file -# Usage: fm_value file key -fm_value() { - sed -n '/^---$/,/^---$/p' "$1" 2>/dev/null \ - | grep "^${2}:" \ - | head -1 \ - | sed "s/^${2}:[[:space:]]*//" -} - -# Extract title (first # heading after frontmatter) -task_title() { - awk 'BEGIN{fm=0} /^---$/{fm++; next} fm>=2 && /^#/{sub(/^#+[[:space:]]*/, ""); print; exit}' "$1" 2>/dev/null -} - -# Parse depends list: "depends: [FOO-001, FOO-002]" -> space-separated IDs -parse_depends() { - local raw - raw=$(fm_value "$1" "depends") - [ -z "$raw" ] && return 0 - echo "$raw" | tr -d '[]' | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -v '^$' || true -} - -# Check if all dependencies for a task are satisfied (in done/) -deps_satisfied() { - local file="$1" - local dep - while IFS= read -r dep; do - [ -z "$dep" ] && continue - if [ ! -f "$DONE/$dep.md" ]; then - return 1 - fi - done < <(parse_depends "$file") - return 0 -} - -# Find task file by ID (checks all states) -find_task() { - local id="$1" - if [ -f "$TASKS/$id.progress.md" ]; then - echo "$TASKS/$id.progress.md" - elif [ -f "$TASKS/$id.md" ]; then - echo "$TASKS/$id.md" - elif [ -f "$DONE/$id.md" ]; then - echo "$DONE/$id.md" - fi -} - -case "${1:-help}" in - next) +#!/usr/bin/env python3 +""" +.project/task — task management via filesystem + +State: .md = open, .progress.md = in-progress, done/*.md = completed +""" + +import glob +import os +import re +import shutil +import sys + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +TASKS = os.path.join(SCRIPT_DIR, "tasks") +DONE = os.path.join(TASKS, "done") +os.makedirs(DONE, exist_ok=True) + + +def fm_value(filepath, key): + """Extract a frontmatter value from a task file.""" + try: + with open(filepath, "r", encoding="utf-8") as f: + content = f.read() + except OSError: + return "" + + # Match content between --- fences + m = re.match(r"^---\s*\n(.*?\n)---\s*\n", content, re.DOTALL) + if not m: + return "" + + for line in m.group(1).splitlines(): + if line.startswith(f"{key}:"): + return line[len(key) + 1:].strip() + return "" + + +def task_title(filepath): + """Extract the first # heading after frontmatter.""" + try: + with open(filepath, "r", encoding="utf-8") as f: + content = f.read() + except OSError: + return "" + + # Skip frontmatter + m = re.match(r"^---\s*\n.*?\n---\s*\n", content, re.DOTALL) + body = content[m.end():] if m else content + + for line in body.splitlines(): + if line.startswith("#"): + return re.sub(r"^#+\s*", "", line) + return "" + + +def parse_depends(filepath): + """Parse depends list: 'depends: [FOO-001, FOO-002]' -> list of IDs.""" + raw = fm_value(filepath, "depends") + if not raw: + return [] + raw = raw.strip("[]") + return [d.strip() for d in raw.split(",") if d.strip()] + + +def deps_satisfied(filepath): + """Check if all dependencies for a task are completed.""" + for dep in parse_depends(filepath): + if not os.path.isfile(os.path.join(DONE, f"{dep}.md")): + return False + return True + + +def find_task(task_id): + """Find task file by ID (checks all states).""" + progress = os.path.join(TASKS, f"{task_id}.progress.md") + if os.path.isfile(progress): + return progress + open_f = os.path.join(TASKS, f"{task_id}.md") + if os.path.isfile(open_f): + return open_f + done_f = os.path.join(DONE, f"{task_id}.md") + if os.path.isfile(done_f): + return done_f + return None + + +def cmd_next(): + """Return the next task to work on.""" # Return in-progress tasks first (crash recovery) - for f in "$TASKS"/*.progress.md; do - [ -f "$f" ] || continue - basename "$f" .progress.md - exit 0 - done + for f in sorted(glob.glob(os.path.join(TASKS, "*.progress.md"))): + task_id = os.path.basename(f).replace(".progress.md", "") + print(task_id) + return # Find lowest-priority open task with satisfied dependencies - best_id="" - best_pri=999999 - for f in "$TASKS"/*.md; do - [ -f "$f" ] || continue - [[ "$f" == *.progress.md ]] && continue - - # Check dependencies - deps_satisfied "$f" || continue - - # Check priority (default 999 if missing) - pri=$(fm_value "$f" "priority") - pri=${pri:-999} - - if [ "$pri" -lt "$best_pri" ] 2>/dev/null; then - best_pri=$pri - best_id=$(basename "$f" .md) - fi - done - - if [ -n "$best_id" ]; then echo "$best_id"; fi - ;; - - list) - echo "IN PROGRESS" - found=0 - for f in "$TASKS"/*.progress.md; do - [ -f "$f" ] || continue - id=$(basename "$f" .progress.md) - title=$(task_title "$f") - echo " > $id $title" - found=1 - done - if [ $found -eq 0 ]; then echo " (none)"; fi - - echo "" - echo "OPEN" - found=0 - for f in "$TASKS"/*.md; do - [ -f "$f" ] || continue - [[ "$f" == *.progress.md ]] && continue - id=$(basename "$f" .md) - title=$(task_title "$f") - if deps_satisfied "$f"; then - echo " o $id $title" - else - deps=$(parse_depends "$f" | tr '\n' ',' | sed 's/,$//') - echo " x $id $title [blocked by: $deps]" - fi - found=1 - done - if [ $found -eq 0 ]; then echo " (none)"; fi - - echo "" - echo "DONE" - found=0 - for f in "$DONE"/*.md; do - [ -f "$f" ] || continue - id=$(basename "$f" .md) - title=$(task_title "$f") - echo " + $id $title" - found=1 - done - if [ $found -eq 0 ]; then echo " (none)"; fi - ;; - - help|"") - echo "Usage: .project/task " - echo "" - echo " next Next available task (priority + deps)" - echo " list All tasks by status" - echo " View task details" - echo " progress Mark in-progress" - echo " done Mark complete (move to done/)" - ;; - - *) - ID="$1" - case "${2:-view}" in - progress) - if [ -f "$TASKS/$ID.md" ]; then - mv "$TASKS/$ID.md" "$TASKS/$ID.progress.md" - echo "> $ID in progress" - else - echo "error: $ID not found as open task" >&2 - exit 1 - fi - ;; - done) - if [ -f "$TASKS/$ID.progress.md" ]; then - mv "$TASKS/$ID.progress.md" "$DONE/$ID.md" - echo "+ $ID done" - elif [ -f "$TASKS/$ID.md" ]; then - mv "$TASKS/$ID.md" "$DONE/$ID.md" - echo "+ $ID done" - else - echo "error: $ID not found" >&2 - exit 1 - fi - ;; - view|*) - file=$(find_task "$ID") - if [ -n "$file" ]; then - cat "$file" - else - echo "error: $ID not found" >&2 - exit 1 - fi - ;; - esac - ;; -esac + best_id = None + best_pri = 999999 + + for f in sorted(glob.glob(os.path.join(TASKS, "*.md"))): + if f.endswith(".progress.md"): + continue + + if not deps_satisfied(f): + continue + + pri_str = fm_value(f, "priority") + try: + pri = int(pri_str) if pri_str else 999 + except ValueError: + pri = 999 + + if pri < best_pri: + best_pri = pri + best_id = os.path.basename(f).replace(".md", "") + + if best_id: + print(best_id) + + +def cmd_list(): + """List all tasks by status.""" + print("IN PROGRESS") + found = False + for f in sorted(glob.glob(os.path.join(TASKS, "*.progress.md"))): + task_id = os.path.basename(f).replace(".progress.md", "") + title = task_title(f) + print(f" > {task_id} {title}") + found = True + if not found: + print(" (none)") + + print() + print("OPEN") + found = False + for f in sorted(glob.glob(os.path.join(TASKS, "*.md"))): + if f.endswith(".progress.md"): + continue + task_id = os.path.basename(f).replace(".md", "") + title = task_title(f) + if deps_satisfied(f): + print(f" o {task_id} {title}") + else: + deps = ", ".join(parse_depends(f)) + print(f" x {task_id} {title} [blocked by: {deps}]") + found = True + if not found: + print(" (none)") + + print() + print("DONE") + found = False + for f in sorted(glob.glob(os.path.join(DONE, "*.md"))): + task_id = os.path.basename(f).replace(".md", "") + title = task_title(f) + print(f" + {task_id} {title}") + found = True + if not found: + print(" (none)") + + +def cmd_progress(task_id): + """Mark a task as in-progress.""" + src = os.path.join(TASKS, f"{task_id}.md") + dst = os.path.join(TASKS, f"{task_id}.progress.md") + if os.path.isfile(src): + shutil.move(src, dst) + print(f"> {task_id} in progress") + else: + print(f"error: {task_id} not found as open task", file=sys.stderr) + sys.exit(1) + + +def cmd_done(task_id): + """Mark a task as complete.""" + progress = os.path.join(TASKS, f"{task_id}.progress.md") + open_f = os.path.join(TASKS, f"{task_id}.md") + + if os.path.isfile(progress): + shutil.move(progress, os.path.join(DONE, f"{task_id}.md")) + print(f"+ {task_id} done") + elif os.path.isfile(open_f): + shutil.move(open_f, os.path.join(DONE, f"{task_id}.md")) + print(f"+ {task_id} done") + else: + print(f"error: {task_id} not found", file=sys.stderr) + sys.exit(1) + + +def cmd_view(task_id): + """View a task's contents.""" + filepath = find_task(task_id) + if filepath: + with open(filepath, "r", encoding="utf-8") as f: + print(f.read()) + else: + print(f"error: {task_id} not found", file=sys.stderr) + sys.exit(1) + + +def cmd_help(): + print("Usage: python .project/task ") + print() + print(" next Next available task (priority + deps)") + print(" list All tasks by status") + print(" View task details") + print(" progress Mark in-progress") + print(" done Mark complete (move to done/)") + + +def main(): + args = sys.argv[1:] + + if not args or args[0] == "help": + cmd_help() + return + + command = args[0] + + if command == "next": + cmd_next() + elif command == "list": + cmd_list() + else: + task_id = command + action = args[1] if len(args) > 1 else "view" + + if action == "progress": + cmd_progress(task_id) + elif action == "done": + cmd_done(task_id) + else: + cmd_view(task_id) + + +if __name__ == "__main__": + main() diff --git a/README.md b/README.md index 6b422e6..af6b8cc 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Autonomous agent loop for [Claude Code](https://docs.anthropic.com/en/docs/claude-code). Decomposes a spec into tasks, executes them one per iteration with crash recovery and handoff context. -Inspired by [snarktank/ralph](https://github.com/snarktank/ralph), rebuilt from scratch with filesystem-based state, bash task management, and agent-controlled exit signals. +Inspired by [snarktank/ralph](https://github.com/snarktank/ralph), rebuilt from scratch with filesystem-based state, Python task management, and agent-controlled exit signals. ## What's in the box @@ -10,7 +10,7 @@ Inspired by [snarktank/ralph](https://github.com/snarktank/ralph), rebuilt from .project/ PROMPT.md # Agent instructions (the system prompt for each iteration) SPEC.md # Your project spec (replace with your own) - task # Bash task runner — manages task state via the filesystem + task # Python task runner — manages task state via the filesystem loop # Python loop runner — calls `claude --print` in a loop tasks/ # Task files live here (one .md per task) FS-001.md # Example tasks from a sample "filestats" spec @@ -68,11 +68,11 @@ Logs go to `.project/logs/` (raw JSONL + human-readable summaries per iteration) You don't have to use the loop. The task runner works standalone: ```bash -.project/task next # Next available task (priority + deps) -.project/task list # All tasks by status -.project/task FS-001 # View task details -.project/task FS-001 progress # Mark in-progress -.project/task FS-001 done # Mark complete (moves to tasks/done/) +python .project/task next # Next available task (priority + deps) +python .project/task list # All tasks by status +python .project/task FS-001 # View task details +python .project/task FS-001 progress # Mark in-progress +python .project/task FS-001 done # Mark complete (moves to tasks/done/) ``` ## Task file format