C0: retire Todoist blumeops-tasks; point task discovery at heph

Replace the Todoist-backed blumeops-tasks mise task with
`heph list --project Blumeops --json` (hephaestus, now at v1 prototype
on gilbert). Update task-discovery, rotation-reminder, and zk
references across docs; note the zk zettelkasten is migrating into
heph docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-03 21:32:10 -07:00
commit 2148714584
10 changed files with 16 additions and 226 deletions

View file

@ -65,7 +65,7 @@ See [[agent-change-process]] for the full methodology.
./pulumi/ # Pulumi IaC (tailnet ACLs, dns, cloud)
~/.config/{nvim,fish} # user's shell config, managed by chezmoi
~/code/personal/ # user's projects
~/code/personal/zk # user's Obsidian-sync managed zettelkasten. Potential source for reference data.
~/code/personal/zk # user's zettelkasten (Obsidian-sync). Reference-data source; migrating into heph docs (hephaestus).
~/code/3rd/ # mirrored external projects
~/code/work # FORBIDDEN
```
@ -147,10 +147,16 @@ Create a new spork: `mise run spork-create <mirror-name>`
## Task Discovery
BlumeOps tasks live in [hephaestus](https://github.com/eblume/hephaestus) (`heph`),
the user's self-hosted context/task system. Fetch them with the CLI:
```fish
mise run blumeops-tasks # fetch from Todoist, sorted by priority
heph list --project Blumeops --json # outstanding Blumeops tasks as JSON
```
Most tasks are stored in `./mise-tasks/`. For scripts with any logic or
(This replaced the retired `blumeops-tasks` mise task, which read from Todoist.)
Most operational scripts are stored in `./mise-tasks/`. For scripts with any logic or
complexity, use uv run --script 's with explicit dependencies. Complex
workflows with artifacts should become dagger pipelines. Mise tasks are for
development processes and operations - tools for the user or the agent.

View file

@ -1 +0,0 @@
`blumeops-tasks` now annotates each task with a human-readable due offset (`5d overdue` / `due in 2d` / `due today`) and a `↻ <recurrence>` marker for recurring tasks, and sorts by overdue-ness (most overdue first, no-due-date last) with priority as tiebreaker.

View file

@ -0,0 +1 @@
Retired the `blumeops-tasks` mise task (Todoist API) in favor of `heph list --project Blumeops --json` from the self-hosted [hephaestus](https://github.com/eblume/hephaestus) system. Updated docs to point task discovery and rotation reminders at heph, and noted that the `~/code/personal/zk` zettelkasten is migrating into heph docs.

View file

@ -14,7 +14,7 @@ How to rotate the Fly.io API token used to deploy [[flyio-proxy]]. The token liv
## When to rotate
- Every 75 days (Todoist recurring task)
- Every 75 days (heph recurring task)
- After any compromise / accidental disclosure
- If `fly deploy` starts returning auth errors

View file

@ -14,7 +14,7 @@ How to rotate the Gandi Personal Access Token. **One PAT** is shared by [[caddy]
## When to rotate
- Every 60 days (Todoist recurring task)
- Every 60 days (heph recurring task)
- After any compromise / accidental disclosure
- Whenever Gandi starts rejecting the PAT (see [Debugging](#debugging))

View file

@ -25,7 +25,7 @@ Daily backup system using Borg backup, running on indri.
## What Gets Backed Up
**Directories:**
- `~/code/personal/zk` - Zettelkasten
- `~/code/personal/zk` - Zettelkasten (migrating into heph docs; see [hephaestus](https://github.com/eblume/hephaestus))
- `/opt/homebrew/var/forgejo` - Git forge data
- `~/.config/borgmatic` - Borgmatic config
- `~/Documents` - Personal documents

View file

@ -22,7 +22,7 @@ Daily automated backups from [[indri]] to [[sifaka|Sifaka]] NAS.
| Path | Description | Priority |
|------|-------------|----------|
| `~/code/personal/zk` | Zettelkasten notes | Critical |
| `~/code/personal/zk` | Zettelkasten notes (migrating into heph docs) | Critical |
| `/opt/homebrew/var/forgejo` | Git repositories | Critical |
| `~/.config/borgmatic` | Backup config | High |
| `~/Documents` | Personal documents (includes [[1password]] encrypted export) | High |

View file

@ -69,7 +69,6 @@ Run `mise tasks --sort name` for the live list with descriptions.
|------|-------------|
| `services-check` | Check all services are online and responding |
| `service-review` | Review the most stale service for version freshness |
| `blumeops-tasks` | List tasks from Todoist sorted by priority |
| `op-backup` | Encrypt 1Password export and send to indri for borgmatic |
## Infrastructure Setup

View file

@ -98,7 +98,6 @@ BlumeOps operations are driven by mise tasks. Run `mise tasks` to list all avail
| `provision-indri` | Deploy changes to [[indri]]-hosted services via Ansible |
| `services-check` | After deployments - verify all services are healthy |
| `pr-comments` | Check unresolved PR comments during review |
| `blumeops-tasks` | Find pending tasks from Todoist |
| `container-list` | View available container images and tags |
| `container-build-and-release` | Trigger container build workflows |
| `dns-preview` | Preview DNS changes before applying |
@ -111,6 +110,8 @@ BlumeOps operations are driven by mise tasks. Run `mise tasks` to list all avail
| `docs-review` | Review the most stale doc by last-reviewed date |
| `runner-logs` | View Forgejo workflow logs (indri or ringtail runner) |
For task discovery, BlumeOps tasks live in [hephaestus](https://github.com/eblume/hephaestus) (`heph`), not Todoist. List outstanding work with `heph list --project Blumeops --json`.
For ArgoCD operations, use the `argocd` CLI directly:
- `argocd app diff <service>` - Preview changes
- `argocd app sync <service>` - Deploy changes

View file

@ -1,216 +0,0 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["httpx==0.28.1", "rich==15.0.0"]
# ///
#MISE description="List Blumeops tasks from Todoist sorted by priority"
"""Fetch and display Blumeops tasks from Todoist, sorted by priority.
This script is specific to Erich Blume's personal development workflow and
is not intended for general use. It requires:
- A 1Password CLI (`op`) configured with access to the author's vault
- A Todoist account with a project named "Blumeops"
The script fetches tasks and displays them sorted by a custom priority order:
p1 (urgent), p2 (high), p4 (normal/default), p3 (backlog). The p3-last ordering
reflects a deliberate choice to treat p3 as "backlog" rather than moderate
priority.
Usage: mise run blumeops-tasks
"""
import subprocess
import sys
from datetime import date
import httpx
from rich.console import Console
from rich.markup import escape
from rich.text import Text
TODOIST_API_BASE = "https://api.todoist.com/api/v1"
PROJECT_NAME = "Blumeops"
# Priority mapping: Todoist API uses 1=normal(p4), 2=moderate(p3), 3=high(p2), 4=urgent(p1)
# User wants order: p1, p2, p4, p3 (p3 is backlog, goes last)
PRIORITY_LABELS = {4: "p1", 3: "p2", 1: "p4", 2: "p3"}
PRIORITY_SORT_ORDER = {4: 1, 3: 2, 1: 3, 2: 4} # Lower = earlier
def get_todoist_token() -> str:
"""Retrieve Todoist API token from 1Password."""
result = subprocess.run(
["op", "read", "op://vg6xf6vvfmoh5hqjjhlhbeoaie/c53h3xnmswhvexa5mntoyvhgpm/credential"],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(f"Failed to get Todoist token from 1Password: {result.stderr}")
return result.stdout.strip()
def get_project_id(client: httpx.Client, project_name: str) -> str:
"""Find project ID by name."""
cursor = None
while True:
params = {}
if cursor:
params["cursor"] = cursor
response = client.get(f"{TODOIST_API_BASE}/projects", params=params)
response.raise_for_status()
data = response.json()
for project in data.get("results", data if isinstance(data, list) else []):
if project["name"] == project_name:
return project["id"]
cursor = data.get("next_cursor") if isinstance(data, dict) else None
if not cursor:
break
raise RuntimeError(f"Project '{project_name}' not found in Todoist")
def get_tasks(client: httpx.Client, project_id: str) -> list[dict]:
"""Get all tasks for a project."""
tasks = []
cursor = None
while True:
params = {"project_id": project_id}
if cursor:
params["cursor"] = cursor
response = client.get(f"{TODOIST_API_BASE}/tasks", params=params)
response.raise_for_status()
data = response.json()
tasks.extend(data.get("results", data if isinstance(data, list) else []))
cursor = data.get("next_cursor") if isinstance(data, dict) else None
if not cursor:
break
return tasks
def is_due(task: dict) -> bool:
"""Check if a task should be displayed based on its due date.
Tasks without a due date are always shown. Tasks with a due date
are only shown when the date is today or in the past.
"""
due = task.get("due")
if due is None:
return True
due_date = date.fromisoformat(due["date"][:10])
return due_date <= date.today()
def days_until_due(task: dict) -> int | None:
"""Return signed days offset from today, or None if no due date.
Negative = days remaining before due (e.g. -2 = due in 2 days).
Positive = days past due (overdue). Zero = due today.
"""
due = task.get("due")
if due is None:
return None
due_date = date.fromisoformat(due["date"][:10])
return (date.today() - due_date).days
def recurrence_string(task: dict) -> str | None:
"""Return the Todoist natural-language recurrence string, or None.
Todoist's REST API doesn't expose RFC 5545 RRULE; the natural-language
`due.string` (e.g. "every monday", "every 2 weeks") is the terse form.
"""
due = task.get("due")
if due is None or not due.get("is_recurring"):
return None
return due.get("string")
def sort_tasks(tasks: list[dict]) -> list[dict]:
"""Sort by overdue-ness, then priority.
Most overdue first (largest +N); tasks with no due date come last.
Within a given day, tiebreaker is the custom priority order p1, p2, p4, p3.
"""
def key(task: dict) -> tuple[int, int, int]:
days = days_until_due(task)
no_due = 1 if days is None else 0
days_key = -(days if days is not None else 0) # descending
return (no_due, days_key, PRIORITY_SORT_ORDER.get(task["priority"], 5))
return sorted(tasks, key=key)
def main() -> int:
console = Console()
# Get API token
try:
token = get_todoist_token()
except RuntimeError as e:
console.print(f"[red]Error:[/red] {e}")
return 1
# Create HTTP client with auth header
with httpx.Client(headers={"Authorization": f"Bearer {token}"}) as client:
# Find project
try:
project_id = get_project_id(client, PROJECT_NAME)
except RuntimeError as e:
console.print(f"[red]Error:[/red] {e}")
return 1
# Get, filter, and sort tasks
tasks = get_tasks(client, project_id)
tasks = [t for t in tasks if is_due(t)]
sorted_tasks = sort_tasks(tasks)
if not sorted_tasks:
console.print("No tasks found in Blumeops project")
return 0
# Display tasks
console.print(f"[bold]Blumeops Tasks[/bold] ({len(sorted_tasks)} tasks)")
console.print("=" * 40)
console.print()
for task in sorted_tasks:
priority = task["priority"]
label = PRIORITY_LABELS.get(priority, "p?")
content = task["content"]
description = task.get("description", "")
# Header line with priority and content
header = Text()
header.append(f"[{label}]", style="bold")
header.append(f" {content}")
meta = []
days = days_until_due(task)
if days is not None:
if days == 0:
meta.append("due today")
elif days > 0:
meta.append(f"{days}d overdue")
else:
meta.append(f"due in {-days}d")
recurrence = recurrence_string(task)
if recurrence:
meta.append(f"↻ {recurrence}")
if meta:
header.append(f" ({', '.join(meta)})", style="dim")
console.print(header)
# Description indented (escape rich markup to preserve brackets)
if description:
for line in description.split("\n"):
console.print(f" {escape(line)}", style="dim")
console.print()
return 0
if __name__ == "__main__":
sys.exit(main())