feat(tasks): use template file in tasks_dir

This commit is contained in:
ayo 2026-07-11 14:34:13 +02:00
parent 39a9f95432
commit 0aa39b1c37
5 changed files with 117 additions and 47 deletions

View file

@ -30,9 +30,19 @@ alias yo='~/Projects/scripts/ayo.sh'
## Tasks
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` app uses a `tasks_dir` variable in the configuration file. It looks for plain text files that follow the task template.
The tasks text files need to be in one of the status directories: `backlog`, `next`, `in-progress`, or `done`.
The template is a single source of truth that defines a task's shape. Its location is set by the `tasks_template` config variable, which is derived from `tasks_dir` (default `${tasks_dir}/_TEMPLATE.md`). `tasks` renders every new task from it (auto-creating it with a sensible default the first time it is missing); edit that file to change what new tasks look like. Its frontmatter fields are:
- `id` — stable creation-stamped handle, never changes on later renames
- `title` — one-line task title
- `priority``P0` urgent · `P1` high · `P2` normal · `P3` someday
- `context``@code` · `@calls` · `@errand` · `@home` · `@admin` · `@waiting`
- `project` — optional ID(s) of related project(s)
- `created` — creation date
- `links` — related tasks or output files
The tasks text files need to be in one of the status directories: `backlog`, `next`, `in-progress`, or `done`. Status is the folder a task lives in, and the filename's date is its last-updated date.
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`.

View file

@ -1,26 +0,0 @@
---
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
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
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. 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.
## Log
- YYYY-MM-DD — created.

2
ayo.sh
View file

@ -81,7 +81,7 @@ case $1 in
. ${scripts_dir}/notes.sh $@
;;
t | tasks)
TASKS_HOME="${tasks_dir}" ${scripts_dir}/tasks "${@:2}"
TASKS_HOME="${tasks_dir}" TASKS_TEMPLATE="${tasks_template}" ${scripts_dir}/tasks "${@:2}"
;;
j | journal)
. ${scripts_dir}/journal.sh "$@"

View file

@ -1,5 +1,6 @@
notes_dir="${HOME}/Notes"
tasks_dir="${HOME}/Notes/Tasks"
tasks_template="${tasks_dir}/_TEMPLATE.md"
scripts_dir="${HOME}/Projects/scripts"
editor="vim"
skip_flatpak=true

119
tasks
View file

@ -10,6 +10,12 @@ THE STORE
drift — it re-dates filenames on every mutation, stamps the ## Log, uses `git mv`
(in whichever repo the file lives), and never touches `id:`.
New tasks are rendered from a single template file — the one source of truth for
a task's shape. Its location is $TASKS_TEMPLATE (from your config, derived from
`tasks_dir`), defaulting to $TASKS_HOME/_TEMPLATE.md. Edit it to change what new
tasks look like; it is auto-created with a sensible default the first time it is
missing.
USAGE
tasks Launch the TUI
tasks list [--status S] Print a table (headless)
@ -22,6 +28,8 @@ USAGE
tasks help
Env: TASKS_HOME = your tasks directory (from `tasks_dir` in your config)
TASKS_TEMPLATE = the template file (from `tasks_template`, default
$TASKS_HOME/_TEMPLATE.md)
"""
import curses
@ -48,6 +56,38 @@ FIELD_ORDER = [
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}-")
# The task template is a single source of truth: one file in the tasks root that
# both `new`/`c` render from and that you edit to change what a new task looks
# like. DEFAULT_TEMPLATE only seeds it the first time (e.g. a fresh store).
TEMPLATE_NAME = "_TEMPLATE.md"
DEFAULT_TEMPLATE = """\
---
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
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
links: [] # related tasks 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. "Waiting" is a context (context: @waiting), not a folder. -->
## 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.
## Log
- YYYY-MM-DD — created.
"""
def tasks_dir():
root = os.environ.get("TASKS_HOME")
@ -57,6 +97,23 @@ def tasks_dir():
return root
def template_path():
# Location comes from the config (TASKS_TEMPLATE, derived from tasks_dir);
# falls back to <tasks_dir>/_TEMPLATE.md when run without the launcher.
return os.environ.get("TASKS_TEMPLATE") or os.path.join(tasks_dir(), TEMPLATE_NAME)
def read_template():
"""Return the template text, seeding the store with the default if absent."""
path = template_path()
if not os.path.isfile(path):
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
fh.write(DEFAULT_TEMPLATE)
with open(path, "r", encoding="utf-8") as fh:
return fh.read()
def today():
return datetime.date.today().isoformat()
@ -98,6 +155,12 @@ class Task:
text = fh.read()
except OSError:
return
self.fields, self.body = self._split(text)
@staticmethod
def _split(text):
"""Split raw markdown into (frontmatter fields, body)."""
fields, body = {}, text
if text.startswith("---"):
parts = text.split("\n")
end = None
@ -109,10 +172,9 @@ class Task:
for line in parts[1:end]:
m = re.match(r"^([A-Za-z0-9_-]+):\s?(.*)$", line)
if m:
self.fields[m.group(1)] = m.group(2).strip()
self.body = "\n".join(parts[end + 1:]).lstrip("\n")
return
self.body = text
fields[m.group(1)] = m.group(2).strip()
body = "\n".join(parts[end + 1:]).lstrip("\n")
return fields, body
# --- convenience accessors -------------------------------------------
def get(self, key, default=""):
@ -253,28 +315,51 @@ def find_one(ref):
return None
def create_task(title, priority="P2", context="@admin",
project="", status="backlog", goal="", next_action="",
notes="", links=""):
def _set_section(body, name, text):
"""Replace the text under a `## <name>` heading; leave the rest untouched."""
pat = re.compile(rf"(?ms)^(##\s+{re.escape(name)}\s*\n).*?(?=^##\s|\Z)")
repl = lambda m: m.group(1) + text.rstrip("\n") + "\n\n"
new, n = pat.subn(repl, body)
return new if n else body
def render_template(title, tid):
"""Load the store's template and fill in the dynamic bits for a new task.
The template stays self-documenting: inline `# ...` field comments and any
HTML comments are stripped here so the created task file is clean.
"""
fields, body = Task._split(read_template())
fields = {k: re.sub(r"(?:^|\s+)#.*$", "", v).strip() for k, v in fields.items()}
body = re.sub(r"(?s)<!--.*?-->\n?", "", body).lstrip("\n")
fields["id"] = tid
fields["title"] = title
fields["created"] = today()
body = body.replace("YYYY-MM-DD — created", f"{today()} — created")
return fields, body
def create_task(title, priority=None, context=None, project=None, links=None,
status="backlog", goal=None, next_action=None, notes=None):
ensure_dirs()
if status not in STATUSES:
raise ValueError(f"unknown status: {status}")
slug = slugify(title)
tid = f"{today()}-{slug}"
fields, body = render_template(title, tid)
for key, val in (("priority", priority), ("context", context),
("project", project), ("links", links)):
if val is not None:
fields[key] = val
for name, text in (("Goal", goal), ("Next action", next_action), ("Notes", notes)):
if text:
body = _set_section(body, name, text)
path = _unique_path(os.path.join(tasks_dir(), status, f"{today()}-{slug}.md"))
t = Task.__new__(Task)
t.path = path
t.status = status
t.fields = {
"id": tid, "title": title, "priority": priority, "context": context,
"project": project, "created": today(), "links": links or "[]",
}
t.body = (
"## Goal\n" + (goal or "What done looks like, in one or two sentences.") + "\n\n"
"## Next action\n" + (next_action or "The single concrete next step — a physical verb.") + "\n\n"
"## Notes\n" + (notes or "Context, sub-steps, blockers.") + "\n\n"
"## Log\n" + f"- {today()} — created.\n"
)
t.fields = fields
t.body = body
t.save()
return t