diff options
Diffstat (limited to 'skills')
| -rw-r--r-- | skills/todo/SKILL.md | 16 | ||||
| -rwxr-xr-x | skills/todo/todo.py | 218 |
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()) |
