diff --git a/README.md b/README.md index 45fe114..be8e78a 100644 --- a/README.md +++ b/README.md @@ -22,28 +22,28 @@ $ cp example.conf ~/ayo.conf 1. `timeout` - install on mac via coreutils: `brew install coreutils` 1. `ollama` - running a locall LLM via ollama. See [installation on linux](https://ollama.com/download/linux) -4\. update your .bashrc to add an alias for the parent command `scripts/ayo.sh` +4\. update your `.bashrc` to add a `yo` alias for the parent command `scripts/ayo.sh` + +``` bash +alias yo='~/Projects/scripts/ayo.sh' +``` ## Tasks -The `task-tui` uses a `tasks_dir` variable in the configuration file. It looks for plain text files that follows the [template](./TASK_TEMPLATE.md). +The `tasks` app uses a `tasks_dir` variable in the configuration file. It looks for plain text files that follows the [template](./TASK_TEMPLATE.md). The tasks text files need to be in one of the status directories: `backlog`, `next`, `in-progress`, or `done`. After setting the `tasks_dir` variable in the configuration file `ayo.conf` and an alias to `ayo.sh` with `yo` in your `.bashrc` file, you can run the app with `yo tasks`. +Beyond the interactive TUI, `tasks` also has headless subcommands for scripting: `list`, `show`, `new`, and `move` (run `yo tasks help` for the full usage). + ## Examples To ask the AI a question ``` -ayo ai "Why is the sky blue?" -``` - -I also set a shortcut alias to `ayo` with `yo` because I'm lazy - -``` -yo ai "But why is the sky orange on sunrise / sunset?" +yo ai "Why is the sky blue?" ``` If I want to use the `journal` script, I can use the shortcut `j`. The following will create a new file for the current day if it doesn't exist yet, otherwise it open an editor to the existing entry @@ -58,7 +58,7 @@ yo j 1. Operating System stuff like, update packages, adjust display to some preferences, etc. ## Scripts -1. ayo - parent command, lists all available scripts or accepts parameters +1. ayo.sh (`yo`) - parent command, lists all available scripts or accepts parameters 1. journal (j) - creates a new journal entry if it doesn’t exist yet; opens on editor 1. append (ja) - append one thought at the end of the day's entry 1. notes - notes management diff --git a/TASK_TEMPLATE.md b/TASK_TEMPLATE.md index f77fb91..7158ca0 100644 --- a/TASK_TEMPLATE.md +++ b/TASK_TEMPLATE.md @@ -2,9 +2,6 @@ id: YYYY-MM-DD-short-slug # stable handle (creation-stamped); unchanged by later renames title: One-line task title priority: P2 # P0 urgent · P1 high · P2 normal · P3 someday -owner: Hermes # Hermes | Claude | Ayo -sanitized-by: # set to "Hermes" ONLY after redaction — REQUIRED before owner: Claude (redaction gate, operations/README.md) -sanitized-on: # YYYY-MM-DD Hermes redacted; pairs with sanitized-by context: @admin # @code | @calls | @errand | @home | @admin | @waiting project: # optional — ID(s) of related project(s), e.g. garden-planner or [a, b] created: YYYY-MM-DD @@ -13,12 +10,15 @@ links: [] # related tasks, policy docs, or output files + is a context (context: @waiting), not a folder. Link a task to a project via the + `project:` field above. --> ## Goal What done looks like, in one or two sentences. +## Next action +The single concrete next step — a physical verb. + ## Notes Context, sub-steps, blockers. diff --git a/ayo.sh b/ayo.sh index 3ac05ec..9673d6f 100755 --- a/ayo.sh +++ b/ayo.sh @@ -81,7 +81,7 @@ case $1 in . ${scripts_dir}/notes.sh $@ ;; t | tasks) - TASKS_HOME="${tasks_dir}" /${scripts_dir}/task-tui "${@:2}" + TASKS_HOME="${tasks_dir}" ${scripts_dir}/tasks "${@:2}" ;; j | journal) . ${scripts_dir}/journal.sh "$@" diff --git a/task-tui b/tasks similarity index 70% rename from task-tui rename to tasks index 832da6a..ee2b8bf 100755 --- a/task-tui +++ b/tasks @@ -1,47 +1,27 @@ #!/usr/bin/env python3 """ -task-tui — a terminal UI for managing task files across two stores. +tasks — a terminal UI for managing your task files in a single store. -TWO STORES (split by owner) - • "mine" — Ayo's own tasks (owner: Ayo). Lives OUTSIDE ~/Company, in the Notes so - it syncs across devices: $TASKS_HOME (default ~/inputs/Notes/Tasks). - This is the DEFAULT store — the one Ayo works in via the TUI. - • "agents" — the agency's own work items (owner: Hermes | Claude). Lives in - $COMPANY_ROOT/operations/tasks. Reached with --agents (a rare view for - Ayo). The redaction gate ( sanitized-by ) only ever applies here. - - Both stores use the same layout: status is the FOLDER a task lives in - (backlog -> next -> in-progress -> done); priority/owner/context live in frontmatter; - the FILENAME date is the *last-updated* date; the `id:` is stable and never changes. - This tool enforces those rules so they can't drift — it re-dates filenames on every - mutation, stamps the ## Log, uses `git mv` (in whichever repo the file lives), and - never touches `id:`. - -WHO RUNS IT - A human (Ayo) or Hermes (Internal), locally — like find-task/company it reads files on - disk; nothing leaves the machine. It is NOT run by the Outsourced agent (Claude), whose - context leaves the machine and which has zero access to the Notes ("mine" store). - - The redaction gate is respected, not bypassed: this tool will NOT stamp - `sanitized-by`/`sanitized-on` (only Hermes may, after redacting). In the agents' store - an owner: Claude task without that attestation is flagged ⚠ un-attested , mirroring - find-task's refusal to surface it to the cloud. The "mine" store holds no Claude tasks. +THE STORE + Tasks live under $TASKS_HOME (set from your `tasks_dir` config). Status is the + FOLDER a task lives in (backlog -> next -> in-progress -> done); priority and + context live in frontmatter; the FILENAME date is the *last-updated* date; the + `id:` is stable and never changes. This tool enforces those rules so they can't + drift — it re-dates filenames on every mutation, stamps the ## Log, uses `git mv` + (in whichever repo the file lives), and never touches `id:`. USAGE - task-tui Launch the TUI on your store (Notes/Tasks) - task-tui --agents Launch the TUI on the agents' store (operations/tasks) - task-tui list [--status S] [--owner O] Print a table (headless) - task-tui show Print one task - task-tui new --title T [--priority P2] [--owner Ayo] [--context @admin] - [--project X] [--status backlog] [--goal ...] [--next ...] - [--notes ...] [--links ...] - task-tui move Move between status folders (git mv) - task-tui selftest Exercise the core logic on temp stores; print OK - task-tui help + tasks Launch the TUI + tasks list [--status S] Print a table (headless) + tasks show Print one task + tasks new --title T [--priority P2] [--context @admin] + [--project X] [--status backlog] [--goal ...] [--next ...] + [--notes ...] [--links ...] + tasks move Move between status folders (git mv) + tasks selftest Exercise the core logic on a temp store; print OK + tasks help - Any subcommand takes --agents (or --store agents|mine) to pick the store; default mine. - Env: TASKS_HOME = your store root (default ~/inputs/Notes/Tasks) - COMPANY_ROOT = Company root (default ~/Company) — parent of the agents' store + Env: TASKS_HOME = your tasks directory (from `tasks_dir` in your config) """ import curses @@ -54,54 +34,27 @@ import sys import textwrap # --------------------------------------------------------------------------- -# Config / format constants (kept in sync with operations/README.md task format) +# Config / format constants (kept in sync with the task file format) # --------------------------------------------------------------------------- STATUSES = ["backlog", "next", "in-progress", "done"] PRIORITIES = ["P0", "P1", "P2", "P3"] -OWNERS = ["Ayo", "Hermes", "Claude"] CONTEXTS = ["@code", "@calls", "@errand", "@home", "@admin", "@waiting"] -# Canonical frontmatter field order (matches the template in operations/README.md). +# Canonical frontmatter field order (matches the task template). FIELD_ORDER = [ - "id", "title", "priority", "owner", "sanitized-by", "sanitized-on", - "context", "project", "created", "links", + "id", "title", "priority", "context", "project", "created", "links", ] DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}-") -# Which store the process is operating on ("mine" default, "agents" via --agents). -_STORE = "mine" - - -def company_root(): - root = os.environ.get("COMPANY_ROOT") or os.path.expanduser("~/Company") - if not os.path.isdir(root) and os.path.isdir("/home/claude/Company"): - root = "/home/claude/Company" - return root - - -def mine_root(): - return os.environ.get("TASKS_HOME") or os.path.expanduser("~/inputs/Notes/Tasks") - - -def agents_root(): - return os.path.join(company_root(), "operations", "tasks") - - -def set_store(name): - global _STORE - if name not in ("mine", "agents"): - raise ValueError(f"unknown store: {name}") - _STORE = name - - -def store_label(): - return "yours · Notes/Tasks" if _STORE == "mine" else "agents · operations/tasks" - def tasks_dir(): - return mine_root() if _STORE == "mine" else agents_root() + root = os.environ.get("TASKS_HOME") + if not root: + sys.exit("tasks: TASKS_HOME is not set — point it at your tasks_dir " + "(see example.conf / ayo.sh).") + return root def today(): @@ -173,19 +126,10 @@ class Task: def priority(self): return self.get("priority") or "?" - @property - def owner(self): - return self.get("owner") or "-" - @property def context(self): return self.get("context") or "-" - @property - def unattested(self): - """owner: Claude with no sanitized-by attestation (redaction gate).""" - return self.owner == "Claude" and not self.get("sanitized-by") - def prio_rank(self): try: return PRIORITIES.index(self.priority) @@ -300,7 +244,7 @@ def load_all(): def find_one(ref): - """Resolve a task by `id:`, filename, slug, or path (within the current store).""" + """Resolve a task by `id:`, filename, slug, or path.""" if os.path.isfile(ref): return Task(ref) for t in load_all(): @@ -309,7 +253,7 @@ def find_one(ref): return None -def create_task(title, priority="P2", owner="Ayo", context="@admin", +def create_task(title, priority="P2", context="@admin", project="", status="backlog", goal="", next_action="", notes="", links=""): ensure_dirs() @@ -322,8 +266,7 @@ def create_task(title, priority="P2", owner="Ayo", context="@admin", t.path = path t.status = status t.fields = { - "id": tid, "title": title, "priority": priority, "owner": owner, - "sanitized-by": "", "sanitized-on": "", "context": context, + "id": tid, "title": title, "priority": priority, "context": context, "project": project, "created": today(), "links": links or "[]", } t.body = ( @@ -341,28 +284,23 @@ def create_task(title, priority="P2", owner="Ayo", context="@admin", # --------------------------------------------------------------------------- def cli_list(argv): - status = owner = None + status = None it = iter(argv) for a in it: if a == "--status": status = next(it, None) - elif a == "--owner": - owner = next(it, None) - tasks = [t for t in load_all() - if (not status or t.status == status) and (not owner or t.owner == owner)] + tasks = [t for t in load_all() if not status or t.status == status] tasks.sort(key=lambda t: (STATUSES.index(t.status), t.prio_rank())) - print(f"[store: {store_label()}]") - print(f"{'PRI':3} {'OWNER':6} {'STATUS':11} {'CONTEXT':9} TASK") + print(f"{'PRI':3} {'STATUS':11} {'CONTEXT':9} TASK") for t in tasks: - flag = " ⚠ un-attested" if t.unattested else "" - print(f"{t.priority:3} {t.owner:6} {t.status:11} {t.context:9} {t.title}{flag}") - print(f" {t.path}") + print(f"{t.priority:3} {t.status:11} {t.context:9} {t.title}") + print(f" {t.path}") return 0 def cli_show(argv): if not argv: - print("usage: task-tui show ", file=sys.stderr) + print("usage: tasks show ", file=sys.stderr) return 2 t = find_one(argv[0]) if not t: @@ -374,7 +312,7 @@ def cli_show(argv): def cli_new(argv): kw, it = {}, iter(argv) - keymap = {"--title": "title", "--priority": "priority", "--owner": "owner", + keymap = {"--title": "title", "--priority": "priority", "--context": "context", "--project": "project", "--status": "status", "--goal": "goal", "--next": "next_action", "--notes": "notes", "--links": "links"} @@ -382,20 +320,17 @@ def cli_new(argv): if a in keymap: kw[keymap[a]] = next(it, "") if not kw.get("title"): - print("usage: task-tui new --title T [--priority P2] [--owner Ayo] ...", + print("usage: tasks new --title T [--priority P2] [--context @admin] ...", file=sys.stderr) return 2 t = create_task(**kw) - print(f"created ({store_label()}): {t.path}") - if t.unattested: - print(" ⚠ owner: Claude with no sanitized-by — Hermes must redact & attest " - "before this can be surfaced to the cloud.", file=sys.stderr) + print(f"created: {t.path}") return 0 def cli_move(argv): if len(argv) < 2: - print("usage: task-tui move ", file=sys.stderr) + print("usage: tasks move ", file=sys.stderr) return 2 t = find_one(argv[0]) if not t: @@ -412,18 +347,12 @@ def cli_move(argv): def cli_selftest(argv): import tempfile - base = tempfile.mkdtemp(prefix="task-tui-selftest-") - os.environ["TASKS_HOME"] = os.path.join(base, "notes-tasks") - os.environ["COMPANY_ROOT"] = os.path.join(base, "company") - # Create both roots up front so company_root()'s "fall back to the real ~/Company - # if the configured root is missing" guard never fires during the test. + base = tempfile.mkdtemp(prefix="tasks-selftest-") + os.environ["TASKS_HOME"] = os.path.join(base, "tasks") os.makedirs(os.environ["TASKS_HOME"], exist_ok=True) - os.makedirs(os.path.join(os.environ["COMPANY_ROOT"], "operations", "tasks"), exist_ok=True) subprocess.run(["git", "-C", base, "init", "-q"], check=False, capture_output=True) try: - # --- "mine" store (default): Ayo's task --- - set_store("mine") - t = create_task("Book the blood-sugar appointment", priority="P1", owner="Ayo", + t = create_task("Book the blood-sugar appointment", priority="P1", context="@calls", status="backlog", goal="Appt booked", next_action="Call the clinic") assert t.get("id").endswith("-book-the-blood-sugar-appointment"), t.get("id") @@ -442,16 +371,7 @@ def cli_selftest(argv): assert "moved to in-progress." in Task(t.path).body assert len(load_all()) == 1 - # --- "agents" store: redaction gate still applies --- - set_store("agents") - assert len(load_all()) == 0, "stores must be independent" - c = create_task("Sanitize inbox rule", owner="Claude", status="backlog") - assert c.unattested, "owner:Claude without sanitized-by must be un-attested" - assert c.path.startswith(os.environ["COMPANY_ROOT"]), c.path - - set_store("mine") # mine store unaffected by agents' add - assert len(load_all()) == 1 - print("selftest: OK (two stores · create/redate/log/move/redaction-gate pass)") + print("selftest: OK (create/redate/log/move pass)") return 0 except AssertionError as e: print(f"selftest: FAIL — {e}", file=sys.stderr) @@ -465,23 +385,21 @@ def cli_selftest(argv): # --------------------------------------------------------------------------- HELP_TEXT = [ - "task-tui — keys", + "tasks — keys", "", " j / ↓ down k / ↑ up", " Tab cycle status filter (all → each folder)", " s cycle sort (status+priority / priority / updated)", - " S switch store (yours ↔ agents) — agents view is the rare case", " Enter view full task", " n new task", " e edit in $EDITOR (re-dates filename, logs 'edited')", " m move to another status folder", - " p cycle priority o set owner c set context", + " p cycle priority c set context", " l add a log entry", " r reload from disk", " ? this help q quit", "", " Status = folder · filename date = last-updated · id never changes.", - " ⚠ = owner:Claude with no sanitized-by (agents store; Hermes must redact/attest).", "", " (press any key)", ] @@ -528,7 +446,7 @@ class TUI: vis = self.visible() flt = self.filter_status or "all" sortname = ["status+prio", "priority", "updated"][self.sort_mode] - header = f" task-tui [{store_label()}] filter:{flt} sort:{sortname} {len(vis)} tasks " + header = f" tasks filter:{flt} sort:{sortname} {len(vis)} tasks " self.scr.addnstr(0, 0, header.ljust(w), w - 1, curses.A_REVERSE) list_h = max(3, (h - 4) * 3 // 5) @@ -540,8 +458,7 @@ class TUI: row = 1 for i in range(self.top, min(len(vis), self.top + list_h)): t = vis[i] - flag = "⚠" if t.unattested else " " - line = f"{flag} {t.priority:2} {t.owner:6} {t.status:11} {t.context:8} {t.title}" + line = f" {t.priority:2} {t.status:11} {t.context:8} {t.title}" attr = curses.A_REVERSE if i == self.sel else curses.A_NORMAL self.scr.addnstr(row, 0, line.ljust(w - 1), w - 1, attr) row += 1 @@ -551,7 +468,7 @@ class TUI: self.scr.addnstr(div, 0, "─" * (w - 1), w - 1) self._draw_preview(div + 1, h - 2, w) - footer = self.msg or " n:new e:edit m:move p:prio o:owner c:ctx l:log S:store Enter:view ?:help q:quit " + footer = self.msg or " n:new e:edit m:move p:prio c:ctx l:log Enter:view ?:help q:quit " self.scr.addnstr(h - 1, 0, footer.ljust(w - 1), w - 1, curses.A_REVERSE) self.scr.refresh() @@ -560,10 +477,7 @@ class TUI: if not t: self.scr.addnstr(y0, 0, " (no task selected)", w - 1) return - lines = [f" {t.filename}", f" id: {t.get('id')} created: {t.get('created')}"] - if t.unattested: - lines.append(" ⚠ owner:Claude — no sanitized-by attestation (Hermes must redact)") - lines.append("") + lines = [f" {t.filename}", f" id: {t.get('id')} created: {t.get('created')}", ""] for bl in t.body.splitlines(): if bl.strip(): lines.extend(" " + s for s in textwrap.wrap(bl, w - 4)) @@ -615,16 +529,14 @@ class TUI: self.flash("cancelled") return pri = self.choose("Priority", PRIORITIES) or "P2" - owner = self.choose("Owner", OWNERS) or ("Ayo" if _STORE == "mine" else "Hermes") ctx = self.choose("Context", CONTEXTS) or "@admin" status = self.choose("Status", STATUSES) or "backlog" goal = self.prompt("Goal (optional):") nxt = self.prompt("Next action (optional):") - t = create_task(title, priority=pri, owner=owner, context=ctx, + t = create_task(title, priority=pri, context=ctx, status=status, goal=goal, next_action=nxt) self.reload() - self.flash(f"created {t.filename}" - + (" ⚠ needs Hermes redaction" if t.unattested else "")) + self.flash(f"created {t.filename}") def act_edit(self): t = self.current() @@ -659,8 +571,6 @@ class TUI: if val is None: return t.set_field(key, val) - if key == "owner": - t.append_log(f"owner set to {val}.") self.reload() self.flash(f"{key} = {val}") @@ -686,12 +596,6 @@ class TUI: self.reload() self.flash("logged") - def act_switch_store(self): - set_store("agents" if _STORE == "mine" else "mine") - self.sel = self.top = 0 - self.reload() - self.flash(f"store: {store_label()}") - def view_full(self): t = self.current() if not t: @@ -737,8 +641,6 @@ class TUI: elif ch == ord("s"): self.sort_mode = (self.sort_mode + 1) % 3 self._sort() - elif ch == ord("S"): - self.act_switch_store() elif ch in (curses.KEY_ENTER, 10, 13): self.view_full() elif ch == ord("n"): @@ -749,8 +651,6 @@ class TUI: self.act_move() elif ch == ord("p"): self.act_cycle_priority() - elif ch == ord("o"): - self.act_field("owner", OWNERS) elif ch == ord("c"): self.act_field("context", CONTEXTS) elif ch == ord("l"): @@ -764,7 +664,7 @@ class TUI: def run_tui(): if not sys.stdout.isatty(): - print("task-tui: not a TTY — use the headless subcommands " + print("tasks: not a TTY — use the headless subcommands " "(list/show/new/move) or run in a terminal.", file=sys.stderr) return 2 curses.wrapper(lambda scr: TUI(scr).run()) @@ -775,23 +675,7 @@ def run_tui(): # entry point # --------------------------------------------------------------------------- -def _extract_store(argv): - """Pull a --agents / --store X flag out of argv; set the store; return the rest.""" - out, it = [], iter(argv) - for a in it: - if a == "--agents": - set_store("agents") - elif a == "--mine": - set_store("mine") - elif a == "--store": - set_store(next(it, "mine")) - else: - out.append(a) - return out - - def main(argv): - argv = _extract_store(argv) if not argv: return run_tui() cmd, rest = argv[0], argv[1:] @@ -806,7 +690,7 @@ def main(argv): return 0 if cmd in ("tui", "ui"): return run_tui() - print(f"task-tui: unknown command '{cmd}' (try 'help')", file=sys.stderr) + print(f"tasks: unknown command '{cmd}' (try 'help')", file=sys.stderr) return 2