feat(tasks): single-user no redaction

This commit is contained in:
ayo 2026-07-11 12:33:09 +02:00
parent 36388ef5e5
commit 2a0f424aed
4 changed files with 69 additions and 185 deletions

View file

@ -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 doesnt exist yet; opens on editor
1. append (ja) - append one thought at the end of the day's entry
1. notes - notes management

View file

@ -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
<!-- Status = which folder this file is in (backlog/next/in-progress/done); there is no
status field. The FILENAME's date is the last-updated date — rename it (YYYY-MM-DD-slug)
whenever the task changes, e.g. to the completion date when it moves to done/. "Waiting"
is a context (context: @waiting), not a folder. Projects are separate flat documents in
operations/projects/; link a task to one via the `project:` field above. -->
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.

2
ayo.sh
View file

@ -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 "$@"

View file

@ -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 <id|file> Print one task
task-tui new --title T [--priority P2] [--owner Ayo] [--context @admin]
tasks Launch the TUI
tasks list [--status S] Print a table (headless)
tasks show <id|file> Print one task
tasks new --title T [--priority P2] [--context @admin]
[--project X] [--status backlog] [--goal ...] [--next ...]
[--notes ...] [--links ...]
task-tui move <id|file> <status> Move between status folders (git mv)
task-tui selftest Exercise the core logic on temp stores; print OK
task-tui help
tasks move <id|file> <status> 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.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 <id|file>", file=sys.stderr)
print("usage: tasks show <id|file>", 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 <id|file> <status>", file=sys.stderr)
print("usage: tasks move <id|file> <status>", 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