feat(tasks): single-user no redaction
This commit is contained in:
parent
36388ef5e5
commit
2a0f424aed
4 changed files with 69 additions and 185 deletions
20
README.md
20
README.md
|
|
@ -22,28 +22,28 @@ $ cp example.conf ~/ayo.conf
|
||||||
1. `timeout` - install on mac via coreutils: `brew install coreutils`
|
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)
|
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
|
## 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`.
|
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`.
|
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
|
## Examples
|
||||||
|
|
||||||
To ask the AI a question
|
To ask the AI a question
|
||||||
|
|
||||||
```
|
```
|
||||||
ayo ai "Why is the sky blue?"
|
yo 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?"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
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.
|
1. Operating System stuff like, update packages, adjust display to some preferences, etc.
|
||||||
|
|
||||||
## Scripts
|
## 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. 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. append (ja) - append one thought at the end of the day's entry
|
||||||
1. notes - notes management
|
1. notes - notes management
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,6 @@
|
||||||
id: YYYY-MM-DD-short-slug # stable handle (creation-stamped); unchanged by later renames
|
id: YYYY-MM-DD-short-slug # stable handle (creation-stamped); unchanged by later renames
|
||||||
title: One-line task title
|
title: One-line task title
|
||||||
priority: P2 # P0 urgent · P1 high · P2 normal · P3 someday
|
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
|
context: @admin # @code | @calls | @errand | @home | @admin | @waiting
|
||||||
project: # optional — ID(s) of related project(s), e.g. garden-planner or [a, b]
|
project: # optional — ID(s) of related project(s), e.g. garden-planner or [a, b]
|
||||||
created: YYYY-MM-DD
|
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 = 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)
|
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"
|
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
|
is a context (context: @waiting), not a folder. Link a task to a project via the
|
||||||
operations/projects/; link a task to one via the `project:` field above. -->
|
`project:` field above. -->
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
What done looks like, in one or two sentences.
|
What done looks like, in one or two sentences.
|
||||||
|
|
||||||
|
## Next action
|
||||||
|
The single concrete next step — a physical verb.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
Context, sub-steps, blockers.
|
Context, sub-steps, blockers.
|
||||||
|
|
||||||
|
|
|
||||||
2
ayo.sh
2
ayo.sh
|
|
@ -81,7 +81,7 @@ case $1 in
|
||||||
. ${scripts_dir}/notes.sh $@
|
. ${scripts_dir}/notes.sh $@
|
||||||
;;
|
;;
|
||||||
t | tasks)
|
t | tasks)
|
||||||
TASKS_HOME="${tasks_dir}" /${scripts_dir}/task-tui "${@:2}"
|
TASKS_HOME="${tasks_dir}" ${scripts_dir}/tasks "${@:2}"
|
||||||
;;
|
;;
|
||||||
j | journal)
|
j | journal)
|
||||||
. ${scripts_dir}/journal.sh "$@"
|
. ${scripts_dir}/journal.sh "$@"
|
||||||
|
|
|
||||||
222
task-tui → tasks
222
task-tui → tasks
|
|
@ -1,47 +1,27 @@
|
||||||
#!/usr/bin/env python3
|
#!/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)
|
THE STORE
|
||||||
• "mine" — Ayo's own tasks (owner: Ayo). Lives OUTSIDE ~/Company, in the Notes so
|
Tasks live under $TASKS_HOME (set from your `tasks_dir` config). Status is the
|
||||||
it syncs across devices: $TASKS_HOME (default ~/inputs/Notes/Tasks).
|
FOLDER a task lives in (backlog -> next -> in-progress -> done); priority and
|
||||||
This is the DEFAULT store — the one Ayo works in via the TUI.
|
context live in frontmatter; the FILENAME date is the *last-updated* date; the
|
||||||
• "agents" — the agency's own work items (owner: Hermes | Claude). Lives in
|
`id:` is stable and never changes. This tool enforces those rules so they can't
|
||||||
$COMPANY_ROOT/operations/tasks. Reached with --agents (a rare view for
|
drift — it re-dates filenames on every mutation, stamps the ## Log, uses `git mv`
|
||||||
Ayo). The redaction gate ( sanitized-by ) only ever applies here.
|
(in whichever repo the file lives), and never touches `id:`.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
USAGE
|
USAGE
|
||||||
task-tui Launch the TUI on your store (Notes/Tasks)
|
tasks Launch the TUI
|
||||||
task-tui --agents Launch the TUI on the agents' store (operations/tasks)
|
tasks list [--status S] Print a table (headless)
|
||||||
task-tui list [--status S] [--owner O] Print a table (headless)
|
tasks show <id|file> Print one task
|
||||||
task-tui show <id|file> Print one task
|
tasks new --title T [--priority P2] [--context @admin]
|
||||||
task-tui new --title T [--priority P2] [--owner Ayo] [--context @admin]
|
[--project X] [--status backlog] [--goal ...] [--next ...]
|
||||||
[--project X] [--status backlog] [--goal ...] [--next ...]
|
[--notes ...] [--links ...]
|
||||||
[--notes ...] [--links ...]
|
tasks move <id|file> <status> Move between status folders (git mv)
|
||||||
task-tui move <id|file> <status> Move between status folders (git mv)
|
tasks selftest Exercise the core logic on a temp store; print OK
|
||||||
task-tui selftest Exercise the core logic on temp stores; print OK
|
tasks help
|
||||||
task-tui help
|
|
||||||
|
|
||||||
Any subcommand takes --agents (or --store agents|mine) to pick the store; default mine.
|
Env: TASKS_HOME = your tasks directory (from `tasks_dir` in your config)
|
||||||
Env: TASKS_HOME = your store root (default ~/inputs/Notes/Tasks)
|
|
||||||
COMPANY_ROOT = Company root (default ~/Company) — parent of the agents' store
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import curses
|
import curses
|
||||||
|
|
@ -54,54 +34,27 @@ import sys
|
||||||
import textwrap
|
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"]
|
STATUSES = ["backlog", "next", "in-progress", "done"]
|
||||||
PRIORITIES = ["P0", "P1", "P2", "P3"]
|
PRIORITIES = ["P0", "P1", "P2", "P3"]
|
||||||
OWNERS = ["Ayo", "Hermes", "Claude"]
|
|
||||||
CONTEXTS = ["@code", "@calls", "@errand", "@home", "@admin", "@waiting"]
|
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 = [
|
FIELD_ORDER = [
|
||||||
"id", "title", "priority", "owner", "sanitized-by", "sanitized-on",
|
"id", "title", "priority", "context", "project", "created", "links",
|
||||||
"context", "project", "created", "links",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}-")
|
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():
|
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():
|
def today():
|
||||||
|
|
@ -173,19 +126,10 @@ class Task:
|
||||||
def priority(self):
|
def priority(self):
|
||||||
return self.get("priority") or "?"
|
return self.get("priority") or "?"
|
||||||
|
|
||||||
@property
|
|
||||||
def owner(self):
|
|
||||||
return self.get("owner") or "-"
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def context(self):
|
def context(self):
|
||||||
return self.get("context") or "-"
|
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):
|
def prio_rank(self):
|
||||||
try:
|
try:
|
||||||
return PRIORITIES.index(self.priority)
|
return PRIORITIES.index(self.priority)
|
||||||
|
|
@ -300,7 +244,7 @@ def load_all():
|
||||||
|
|
||||||
|
|
||||||
def find_one(ref):
|
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):
|
if os.path.isfile(ref):
|
||||||
return Task(ref)
|
return Task(ref)
|
||||||
for t in load_all():
|
for t in load_all():
|
||||||
|
|
@ -309,7 +253,7 @@ def find_one(ref):
|
||||||
return None
|
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="",
|
project="", status="backlog", goal="", next_action="",
|
||||||
notes="", links=""):
|
notes="", links=""):
|
||||||
ensure_dirs()
|
ensure_dirs()
|
||||||
|
|
@ -322,8 +266,7 @@ def create_task(title, priority="P2", owner="Ayo", context="@admin",
|
||||||
t.path = path
|
t.path = path
|
||||||
t.status = status
|
t.status = status
|
||||||
t.fields = {
|
t.fields = {
|
||||||
"id": tid, "title": title, "priority": priority, "owner": owner,
|
"id": tid, "title": title, "priority": priority, "context": context,
|
||||||
"sanitized-by": "", "sanitized-on": "", "context": context,
|
|
||||||
"project": project, "created": today(), "links": links or "[]",
|
"project": project, "created": today(), "links": links or "[]",
|
||||||
}
|
}
|
||||||
t.body = (
|
t.body = (
|
||||||
|
|
@ -341,28 +284,23 @@ def create_task(title, priority="P2", owner="Ayo", context="@admin",
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def cli_list(argv):
|
def cli_list(argv):
|
||||||
status = owner = None
|
status = None
|
||||||
it = iter(argv)
|
it = iter(argv)
|
||||||
for a in it:
|
for a in it:
|
||||||
if a == "--status":
|
if a == "--status":
|
||||||
status = next(it, None)
|
status = next(it, None)
|
||||||
elif a == "--owner":
|
tasks = [t for t in load_all() if not status or t.status == status]
|
||||||
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.sort(key=lambda t: (STATUSES.index(t.status), t.prio_rank()))
|
tasks.sort(key=lambda t: (STATUSES.index(t.status), t.prio_rank()))
|
||||||
print(f"[store: {store_label()}]")
|
print(f"{'PRI':3} {'STATUS':11} {'CONTEXT':9} TASK")
|
||||||
print(f"{'PRI':3} {'OWNER':6} {'STATUS':11} {'CONTEXT':9} TASK")
|
|
||||||
for t in tasks:
|
for t in tasks:
|
||||||
flag = " ⚠ un-attested" if t.unattested else ""
|
print(f"{t.priority:3} {t.status:11} {t.context:9} {t.title}")
|
||||||
print(f"{t.priority:3} {t.owner:6} {t.status:11} {t.context:9} {t.title}{flag}")
|
print(f" {t.path}")
|
||||||
print(f" {t.path}")
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def cli_show(argv):
|
def cli_show(argv):
|
||||||
if not 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
|
return 2
|
||||||
t = find_one(argv[0])
|
t = find_one(argv[0])
|
||||||
if not t:
|
if not t:
|
||||||
|
|
@ -374,7 +312,7 @@ def cli_show(argv):
|
||||||
|
|
||||||
def cli_new(argv):
|
def cli_new(argv):
|
||||||
kw, it = {}, iter(argv)
|
kw, it = {}, iter(argv)
|
||||||
keymap = {"--title": "title", "--priority": "priority", "--owner": "owner",
|
keymap = {"--title": "title", "--priority": "priority",
|
||||||
"--context": "context", "--project": "project", "--status": "status",
|
"--context": "context", "--project": "project", "--status": "status",
|
||||||
"--goal": "goal", "--next": "next_action", "--notes": "notes",
|
"--goal": "goal", "--next": "next_action", "--notes": "notes",
|
||||||
"--links": "links"}
|
"--links": "links"}
|
||||||
|
|
@ -382,20 +320,17 @@ def cli_new(argv):
|
||||||
if a in keymap:
|
if a in keymap:
|
||||||
kw[keymap[a]] = next(it, "")
|
kw[keymap[a]] = next(it, "")
|
||||||
if not kw.get("title"):
|
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)
|
file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
t = create_task(**kw)
|
t = create_task(**kw)
|
||||||
print(f"created ({store_label()}): {t.path}")
|
print(f"created: {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)
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def cli_move(argv):
|
def cli_move(argv):
|
||||||
if len(argv) < 2:
|
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
|
return 2
|
||||||
t = find_one(argv[0])
|
t = find_one(argv[0])
|
||||||
if not t:
|
if not t:
|
||||||
|
|
@ -412,18 +347,12 @@ def cli_move(argv):
|
||||||
|
|
||||||
def cli_selftest(argv):
|
def cli_selftest(argv):
|
||||||
import tempfile
|
import tempfile
|
||||||
base = tempfile.mkdtemp(prefix="task-tui-selftest-")
|
base = tempfile.mkdtemp(prefix="tasks-selftest-")
|
||||||
os.environ["TASKS_HOME"] = os.path.join(base, "notes-tasks")
|
os.environ["TASKS_HOME"] = os.path.join(base, "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.
|
|
||||||
os.makedirs(os.environ["TASKS_HOME"], exist_ok=True)
|
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)
|
subprocess.run(["git", "-C", base, "init", "-q"], check=False, capture_output=True)
|
||||||
try:
|
try:
|
||||||
# --- "mine" store (default): Ayo's task ---
|
t = create_task("Book the blood-sugar appointment", priority="P1",
|
||||||
set_store("mine")
|
|
||||||
t = create_task("Book the blood-sugar appointment", priority="P1", owner="Ayo",
|
|
||||||
context="@calls", status="backlog", goal="Appt booked",
|
context="@calls", status="backlog", goal="Appt booked",
|
||||||
next_action="Call the clinic")
|
next_action="Call the clinic")
|
||||||
assert t.get("id").endswith("-book-the-blood-sugar-appointment"), t.get("id")
|
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 "moved to in-progress." in Task(t.path).body
|
||||||
assert len(load_all()) == 1
|
assert len(load_all()) == 1
|
||||||
|
|
||||||
# --- "agents" store: redaction gate still applies ---
|
print("selftest: OK (create/redate/log/move pass)")
|
||||||
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)")
|
|
||||||
return 0
|
return 0
|
||||||
except AssertionError as e:
|
except AssertionError as e:
|
||||||
print(f"selftest: FAIL — {e}", file=sys.stderr)
|
print(f"selftest: FAIL — {e}", file=sys.stderr)
|
||||||
|
|
@ -465,23 +385,21 @@ def cli_selftest(argv):
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
HELP_TEXT = [
|
HELP_TEXT = [
|
||||||
"task-tui — keys",
|
"tasks — keys",
|
||||||
"",
|
"",
|
||||||
" j / ↓ down k / ↑ up",
|
" j / ↓ down k / ↑ up",
|
||||||
" Tab cycle status filter (all → each folder)",
|
" Tab cycle status filter (all → each folder)",
|
||||||
" s cycle sort (status+priority / priority / updated)",
|
" s cycle sort (status+priority / priority / updated)",
|
||||||
" S switch store (yours ↔ agents) — agents view is the rare case",
|
|
||||||
" Enter view full task",
|
" Enter view full task",
|
||||||
" n new task",
|
" n new task",
|
||||||
" e edit in $EDITOR (re-dates filename, logs 'edited')",
|
" e edit in $EDITOR (re-dates filename, logs 'edited')",
|
||||||
" m move to another status folder",
|
" 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",
|
" l add a log entry",
|
||||||
" r reload from disk",
|
" r reload from disk",
|
||||||
" ? this help q quit",
|
" ? this help q quit",
|
||||||
"",
|
"",
|
||||||
" Status = folder · filename date = last-updated · id never changes.",
|
" Status = folder · filename date = last-updated · id never changes.",
|
||||||
" ⚠ = owner:Claude with no sanitized-by (agents store; Hermes must redact/attest).",
|
|
||||||
"",
|
"",
|
||||||
" (press any key)",
|
" (press any key)",
|
||||||
]
|
]
|
||||||
|
|
@ -528,7 +446,7 @@ class TUI:
|
||||||
vis = self.visible()
|
vis = self.visible()
|
||||||
flt = self.filter_status or "all"
|
flt = self.filter_status or "all"
|
||||||
sortname = ["status+prio", "priority", "updated"][self.sort_mode]
|
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)
|
self.scr.addnstr(0, 0, header.ljust(w), w - 1, curses.A_REVERSE)
|
||||||
|
|
||||||
list_h = max(3, (h - 4) * 3 // 5)
|
list_h = max(3, (h - 4) * 3 // 5)
|
||||||
|
|
@ -540,8 +458,7 @@ class TUI:
|
||||||
row = 1
|
row = 1
|
||||||
for i in range(self.top, min(len(vis), self.top + list_h)):
|
for i in range(self.top, min(len(vis), self.top + list_h)):
|
||||||
t = vis[i]
|
t = vis[i]
|
||||||
flag = "⚠" if t.unattested else " "
|
line = f" {t.priority:2} {t.status:11} {t.context:8} {t.title}"
|
||||||
line = f"{flag} {t.priority:2} {t.owner:6} {t.status:11} {t.context:8} {t.title}"
|
|
||||||
attr = curses.A_REVERSE if i == self.sel else curses.A_NORMAL
|
attr = curses.A_REVERSE if i == self.sel else curses.A_NORMAL
|
||||||
self.scr.addnstr(row, 0, line.ljust(w - 1), w - 1, attr)
|
self.scr.addnstr(row, 0, line.ljust(w - 1), w - 1, attr)
|
||||||
row += 1
|
row += 1
|
||||||
|
|
@ -551,7 +468,7 @@ class TUI:
|
||||||
self.scr.addnstr(div, 0, "─" * (w - 1), w - 1)
|
self.scr.addnstr(div, 0, "─" * (w - 1), w - 1)
|
||||||
self._draw_preview(div + 1, h - 2, w)
|
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.addnstr(h - 1, 0, footer.ljust(w - 1), w - 1, curses.A_REVERSE)
|
||||||
self.scr.refresh()
|
self.scr.refresh()
|
||||||
|
|
||||||
|
|
@ -560,10 +477,7 @@ class TUI:
|
||||||
if not t:
|
if not t:
|
||||||
self.scr.addnstr(y0, 0, " (no task selected)", w - 1)
|
self.scr.addnstr(y0, 0, " (no task selected)", w - 1)
|
||||||
return
|
return
|
||||||
lines = [f" {t.filename}", f" id: {t.get('id')} created: {t.get('created')}"]
|
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("")
|
|
||||||
for bl in t.body.splitlines():
|
for bl in t.body.splitlines():
|
||||||
if bl.strip():
|
if bl.strip():
|
||||||
lines.extend(" " + s for s in textwrap.wrap(bl, w - 4))
|
lines.extend(" " + s for s in textwrap.wrap(bl, w - 4))
|
||||||
|
|
@ -615,16 +529,14 @@ class TUI:
|
||||||
self.flash("cancelled")
|
self.flash("cancelled")
|
||||||
return
|
return
|
||||||
pri = self.choose("Priority", PRIORITIES) or "P2"
|
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"
|
ctx = self.choose("Context", CONTEXTS) or "@admin"
|
||||||
status = self.choose("Status", STATUSES) or "backlog"
|
status = self.choose("Status", STATUSES) or "backlog"
|
||||||
goal = self.prompt("Goal (optional):")
|
goal = self.prompt("Goal (optional):")
|
||||||
nxt = self.prompt("Next action (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)
|
status=status, goal=goal, next_action=nxt)
|
||||||
self.reload()
|
self.reload()
|
||||||
self.flash(f"created {t.filename}"
|
self.flash(f"created {t.filename}")
|
||||||
+ (" ⚠ needs Hermes redaction" if t.unattested else ""))
|
|
||||||
|
|
||||||
def act_edit(self):
|
def act_edit(self):
|
||||||
t = self.current()
|
t = self.current()
|
||||||
|
|
@ -659,8 +571,6 @@ class TUI:
|
||||||
if val is None:
|
if val is None:
|
||||||
return
|
return
|
||||||
t.set_field(key, val)
|
t.set_field(key, val)
|
||||||
if key == "owner":
|
|
||||||
t.append_log(f"owner set to {val}.")
|
|
||||||
self.reload()
|
self.reload()
|
||||||
self.flash(f"{key} = {val}")
|
self.flash(f"{key} = {val}")
|
||||||
|
|
||||||
|
|
@ -686,12 +596,6 @@ class TUI:
|
||||||
self.reload()
|
self.reload()
|
||||||
self.flash("logged")
|
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):
|
def view_full(self):
|
||||||
t = self.current()
|
t = self.current()
|
||||||
if not t:
|
if not t:
|
||||||
|
|
@ -737,8 +641,6 @@ class TUI:
|
||||||
elif ch == ord("s"):
|
elif ch == ord("s"):
|
||||||
self.sort_mode = (self.sort_mode + 1) % 3
|
self.sort_mode = (self.sort_mode + 1) % 3
|
||||||
self._sort()
|
self._sort()
|
||||||
elif ch == ord("S"):
|
|
||||||
self.act_switch_store()
|
|
||||||
elif ch in (curses.KEY_ENTER, 10, 13):
|
elif ch in (curses.KEY_ENTER, 10, 13):
|
||||||
self.view_full()
|
self.view_full()
|
||||||
elif ch == ord("n"):
|
elif ch == ord("n"):
|
||||||
|
|
@ -749,8 +651,6 @@ class TUI:
|
||||||
self.act_move()
|
self.act_move()
|
||||||
elif ch == ord("p"):
|
elif ch == ord("p"):
|
||||||
self.act_cycle_priority()
|
self.act_cycle_priority()
|
||||||
elif ch == ord("o"):
|
|
||||||
self.act_field("owner", OWNERS)
|
|
||||||
elif ch == ord("c"):
|
elif ch == ord("c"):
|
||||||
self.act_field("context", CONTEXTS)
|
self.act_field("context", CONTEXTS)
|
||||||
elif ch == ord("l"):
|
elif ch == ord("l"):
|
||||||
|
|
@ -764,7 +664,7 @@ class TUI:
|
||||||
|
|
||||||
def run_tui():
|
def run_tui():
|
||||||
if not sys.stdout.isatty():
|
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)
|
"(list/show/new/move) or run in a terminal.", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
curses.wrapper(lambda scr: TUI(scr).run())
|
curses.wrapper(lambda scr: TUI(scr).run())
|
||||||
|
|
@ -775,23 +675,7 @@ def run_tui():
|
||||||
# entry point
|
# 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):
|
def main(argv):
|
||||||
argv = _extract_store(argv)
|
|
||||||
if not argv:
|
if not argv:
|
||||||
return run_tui()
|
return run_tui()
|
||||||
cmd, rest = argv[0], argv[1:]
|
cmd, rest = argv[0], argv[1:]
|
||||||
|
|
@ -806,7 +690,7 @@ def main(argv):
|
||||||
return 0
|
return 0
|
||||||
if cmd in ("tui", "ui"):
|
if cmd in ("tui", "ui"):
|
||||||
return run_tui()
|
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
|
return 2
|
||||||
|
|
||||||
|
|
||||||
Loading…
Reference in a new issue