summaryrefslogtreecommitdiff
path: root/skills/todo
diff options
context:
space:
mode:
Diffstat (limited to 'skills/todo')
-rw-r--r--skills/todo/SKILL.md16
-rwxr-xr-xskills/todo/todo.py218
2 files changed, 234 insertions, 0 deletions
diff --git a/skills/todo/SKILL.md b/skills/todo/SKILL.md
new file mode 100644
index 0000000..8e9d815
--- /dev/null
+++ b/skills/todo/SKILL.md
@@ -0,0 +1,16 @@
+---
+description: Track a per-session todo list when a task has several steps to add, check off, or remove.
+---
+
+Run the script next to this file to keep a todo list for the current session:
+
+```sh
+~/.config/hax/skills/todo/todo.py {list [ID...] | add TEXT | done [ID...] | remove [ID...]}
+```
+
+- `list` prints a truncated view of every item; `list ID...` prints just those fully.
+- `add TEXT` appends an item.
+- `done ID...` marks items done; `remove ID...` deletes them.
+- Items have stable ids, shown in the output and never reused, so they stay valid across additions and removals. `add`/`done`/`remove` echo the last few items so you can confirm ids without a separate `list`.
+- Bulk views truncate long text; `list ID...` and the per-item reports of `done`/`remove` show the full text. Stored text is always intact.
+- Keep items short. Use this only for genuine multi-step work, not for a single action.
diff --git a/skills/todo/todo.py b/skills/todo/todo.py
new file mode 100755
index 0000000..1aa740e
--- /dev/null
+++ b/skills/todo/todo.py
@@ -0,0 +1,218 @@
+#!/usr/bin/env python3
+
+import argparse
+import json
+import os
+import sys
+import tempfile
+import time
+from dataclasses import asdict, dataclass, field
+from pathlib import Path
+
+ITEM_WIDTH = 100
+TAIL = 5
+
+
+@dataclass
+class TodoItem:
+ text: str
+ done: bool = False
+ created: int = field(default_factory=lambda: int(time.time()))
+
+ @classmethod
+ def from_json(cls, obj: object) -> "TodoItem":
+ if not isinstance(obj, dict):
+ return cls(text="")
+ return cls(
+ text=str(obj.get("text", "")),
+ done=bool(obj["done"]),
+ created=int(obj["created"]),
+ )
+
+
+def _stat_fields(pid: int) -> tuple[str, int, int]:
+ with open(f"/proc/{pid}/stat", "rb") as fh:
+ data = fh.read()
+ # comm sits in parens and may contain spaces; split after the final ')'.
+ head, _, rest = data.rpartition(b") ")
+ comm = head.split(b"(", 1)[1].decode("utf-8", "replace")
+ fields = rest.split()
+ return comm, int(fields[1]), int(fields[19]) # comm, ppid, starttime
+
+
+def harness_pid() -> int | None:
+ pid = os.getpid()
+ for _ in range(64):
+ try:
+ _comm, ppid, _start = _stat_fields(pid)
+ except (OSError, IndexError, ValueError):
+ return None
+ if ppid <= 1 or ppid == pid:
+ return None
+ try:
+ comm, _, _ = _stat_fields(ppid)
+ except (OSError, IndexError, ValueError):
+ return None
+ if comm == "hax":
+ return ppid
+ pid = ppid
+ return None
+
+
+def state_path() -> Path:
+ pid = harness_pid()
+ if pid is not None:
+ try:
+ _comm, _ppid, start = _stat_fields(pid)
+ key = f"{pid}-{start}"
+ except (OSError, IndexError, ValueError):
+ key = str(pid)
+ else:
+ key = "manual"
+ root = Path(tempfile.gettempdir()) / f"hax-todo-{os.getuid()}"
+ return root / f"{key}.json"
+
+
+def truncate(text: str) -> str:
+ text = " ".join(text.split())
+ if len(text) > ITEM_WIDTH:
+ text = text[: ITEM_WIDTH - 1] + "…"
+ return text
+
+
+class Todo:
+ def __init__(self, path: Path) -> None:
+ self.path = path
+ self.items: dict[int, TodoItem]
+ self.next_id: int
+ self.items, self.next_id = self._read()
+
+ @classmethod
+ def open(cls) -> "Todo":
+ return cls(state_path())
+
+ def _read(self) -> tuple[dict[int, TodoItem], int]:
+ try:
+ data = json.loads(self.path.read_text())
+ except (OSError, ValueError):
+ return {}, 0
+ if not isinstance(data, dict) or not isinstance(data.get("items"), dict):
+ return {}, 0
+ items = {}
+ for key, obj in data["items"].items():
+ try:
+ items[int(key)] = TodoItem.from_json(obj)
+ except ValueError:
+ continue
+ try:
+ next_id = int(data["next_id"])
+ except (KeyError, TypeError, ValueError):
+ next_id = max(items, default=-1) + 1
+ return items, next_id
+
+ def _write(self) -> None:
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = self.path.with_name(self.path.name + ".tmp")
+ payload = {
+ "next_id": self.next_id,
+ "items": {str(id): asdict(item) for id, item in self.items.items()},
+ }
+ tmp.write_text(json.dumps(payload, indent=1))
+ os.replace(tmp, self.path)
+
+ def _line(self, id: int, *, full: bool) -> str:
+ item = self.items[id]
+ mark = "x" if item.done else " "
+ text = item.text if full else truncate(item.text)
+ return f"{id:3d}. [{mark}] {text}"
+
+ def render(self, ids: list[int]) -> str:
+ if not self.items:
+ return "(no todos)"
+ if ids:
+ lines = [self._line(id, full=True) for id in ids]
+ else:
+ lines = [self._line(id, full=False) for id in sorted(self.items)]
+ return "\n".join(lines) if lines else "(no todos)"
+
+ def tail_view(self) -> str:
+ lines = [self._line(id, full=False) for id in sorted(self.items)[-TAIL:]]
+ return "\n".join(lines) if lines else "(no todos)"
+
+ def checked(self, ids: list[int]) -> list[int]:
+ checked = []
+ for id in ids:
+ if id not in self.items:
+ sys.exit(f"error: no todo with id {id} (have {len(self.items)})")
+ checked.append(id)
+ return checked
+
+ def add(self, text: str) -> tuple[int, TodoItem]:
+ item = TodoItem(text=text)
+ id = self.next_id
+ self.next_id += 1
+ self.items[id] = item
+ self._write()
+ return id, item
+
+ def mark_done(self, ids: list[int]) -> list[tuple[int, TodoItem]]:
+ marked = [(id, self.items[id]) for id in self.checked(ids)]
+ for _id, item in marked:
+ item.done = True
+ self._write()
+ return marked
+
+ def remove(self, ids: list[int]) -> list[tuple[int, TodoItem]]:
+ removed = [(id, self.items[id]) for id in self.checked(ids)]
+ for id in set(ids):
+ del self.items[id]
+ self._write()
+ return removed
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="todo", description="Per-session todo list for the hax harness."
+ )
+ sub = parser.add_subparsers(dest="command", required=True)
+
+ sub_list = sub.add_parser("list", help="print every item, or specific ones in full")
+ sub_list.add_argument("id", nargs="*", type=int, metavar="ID")
+
+ sub_add = sub.add_parser("add", help="append an item")
+ sub_add.add_argument("text", nargs="+", metavar="TEXT")
+
+ sub_done = sub.add_parser("done", help="mark items done")
+ sub_done.add_argument("id", nargs="+", type=int, metavar="ID")
+
+ sub_remove = sub.add_parser("remove", help="delete items")
+ sub_remove.add_argument("id", nargs="+", type=int, metavar="ID")
+
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ args = build_parser().parse_args(argv)
+ todo = Todo.open()
+
+ match args.command:
+ case "list":
+ print(todo.render(todo.checked(args.id)))
+ case "add":
+ id, item = todo.add(" ".join(args.text))
+ print(f"added {id}: {truncate(item.text)}")
+ print(todo.tail_view())
+ case "done":
+ for id, item in todo.mark_done(args.id):
+ print(f"done {id}: {item.text}")
+ print(todo.tail_view())
+ case "remove":
+ for id, item in todo.remove(args.id):
+ print(f"removed {id}: {item.text}")
+ print(todo.tail_view())
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())