A working day-orchestration system built around hyperfocus, not against it. Six launchd jobs, one headless Claude, an Obsidian vault, and a Telegram bot — assembled into a coach that meets me where I am instead of demanding I meet the clock.
Why Clock-Time Schedulers Fail an ADHD Brain
Most personal task systems share a hidden assumption: that you are, fundamentally, available at the time you planned to be available. 09:00 means 09:00. A 14:30 alarm means you’ll pause and start the next thing.
For a brain that hyperfocuses, this assumption is wrong in both directions.
Hyperfocus is not “being really productive” — it is, in the cognitive-psychology literature, an “intense state of sustained or selective attention” with “diminished perception of non-task-relevant stimuli” (Ashinoff & Abu-Akel, 2019, Psychological Research). Translated to lived experience: the inability to disengage. A 14:30 alarm fires mid-thought, the mind registers it as noise, attention does not move. By 17:00 the alarm is forgotten and the original task is still going. Repeat for every reminder, every ping. The schedule degrades to background hum.
Procrastination is the same mechanism, mirrored — and it has a clinical name. Russell Barkley’s framing of ADHD as a self-regulation deficit argues that the disorder “returns control of behavior to the temporal now” and produces “a blindness to past, future, and time more generally” (Barkley, 1997, Psychological Bulletin). A 09:00 start time arrives, the brain has not yet committed to the task, the alarm is muted, and the next decisive moment of attention happens — at 11:00, at 14:00, at no fixed point. I did not refuse to start. I simply was not the kind of object that can begin on cue.
Strict-clock systems route around this by adding more clock pressure: secondary alarms, accountability buddies, app blockers. The pressure does not fix the mismatch — it raises the cost of the inevitable misalignment, and after a few rounds the system gets uninstalled.
What worked for me, after years of fighting it, was to stop scheduling against the wall clock entirely for tasks I control, and to schedule against anchors and sequence instead. “Before lunch.” “After Asr.” “Deep block before Isha.” “Wind down before midnight.” These are real events the day already contains — meals, prayers, family routines — and the brain treats them very differently from arbitrary hour-marks. They are not pressure; they are landmarks. There is also a respectable behavior-science cousin: Gollwitzer’s implementation intentions (“when X, I will Y”) have a medium-to-large effect on goal initiation across nearly a hundred studies (Gollwitzer & Sheeran, 2006, meta-analysis, Advances in Experimental Social Psychology). Anchors are if-then plans wearing my own clothes.
The exception is events I do not control: a swimming class at 14:00, a standup at 10:30, Dhuhr at the time the sun crosses the meridian. These remain clock-time, because the world enforces them. Everything I have discretion over — the frog, the deep block, the chore, the catch-up call — gets sequenced against an anchor instead.
This is the principle the rest of the system is built around. Not a productivity hack — an honest accommodation: the brain in question does not respond to fixed-time obligations, so the system does not create them.
The Kaizen Frame
The other principle worth naming is kaizen — the Japanese word for “continuous improvement,” popularised in the West by Masaaki Imai’s 1986 book Kaizen: The Key to Japan’s Competitive Success (Random House) and rooted in Toyota’s manufacturing tradition (Lean Enterprise Institute, “Kaizen”; Toyota’s Vision & Philosophy). The frame is not original; what matters is what it deliberately leaves out.
No streaks. No “did you do your morning routine 30 days in a row?” counter. No red mark for a skipped morning. The system does not flex my adherence as a metric.
There is one ritual: a five-minute brain dump in the morning, scribbled freely. Whatever is on my mind — work, chores, family, things that worried me at 03:00 — goes onto the page. From that page the rest of the day flows.
There is one weekly reflection: Sunday evening, one ping. What worked this week, what didn’t, one small change for next week. Reply or ignore. “Nothing worked, my kids were sick, I need a quiet week” is a complete answer.
No streaks, no shame. This sounds soft, and it is soft on purpose. The harder a system is on its user, the faster it gets uninstalled. A system that says “miss a day, no problem, the day after is the same as any other day” survives long enough to actually help.
Combine the two principles — sequence over clock, kaizen over streaks — and the shape emerges. A coach that arrives in the morning, asks for a brain dump, builds a plan around the day’s anchors, sends a single Telegram message, and then mostly disappears. Checks in at sensible moments, sweeps past-due reminders quietly, picks up changes to the Kanban without bothering me, shows up again the next morning. Not a dashboard.
Architecture Overview
The system, called kaizen internally, has five logical pieces.
- Capture. A handwritten brain dump in Obsidian, written on iPad with Apple Pencil and Scribble — or typed at the desk on bad-handwriting days. The dump lives at
daily/YYYY-MM-DD.md, in a## Brain dumpsection. - Plan. A headless
claude --printinvocation, fired at 10:30 by launchd, reads the brain dump plus yesterday’s notes plus the calendar, runs through a system prompt encoding the design principles above, and writes three artifacts: today’s Kanban board, an updated daily note, and a TSV listing reminders to be created. - Confirm. A Telegram message containing the plan and the proposed reminder list. I reply
go,go skip 3,push 2 to before lunch, or any combination. An interactive Claude session — the same chat-buddy described in a previous article — reads the reply and applies edits. - Execute. Reminders.app fires native macOS notifications at the agreed times. The Kanban board lives in Obsidian as a markdown file I can edit directly with the Kanban community plugin. Native tooling end-to-end.
- Reconcile. Every 30 minutes a silent launchd job,
org.kaizen.sync, diffs the current vault against the morning baseline. New tasks added to the Kanban get auto-staged as Reminders. Cards moved to Done get the matching Reminder marked complete. Cards removed get the Reminder marked complete too. I am not pinged. This is the single most load-bearing piece of the system.
Plus check-ins:
- Noon (12:00): a “frog check — how is it going?” presence ping.
- Wrap (23:00): a wind-down nudge listing any still-undone Kanban cards. Sweeps past-due reminders silently before sending.
- Tahajjud-conditional wrap (19:30): if today’s brain dump contained
tahajjud tonight, a short wrap fires at 19:30 instead, with the 23:00 job staying silent. - Sunday review (20:30): the weekly kaizen ping described above.
That is the full surface area. Six launchd jobs (five user-visible + one silent reconciler), one headless Claude per day, and my interactive Claude session whenever I reply on Telegram. Roughly 1,500 lines of bash and one ~250-line system prompt. No daemon, no web server, no database. Everything that survives a reboot lives in either git or ~/Library/LaunchAgents/.
Stack
| Layer | Choice | Why |
|---|---|---|
| Vault format | Obsidian + plain markdown | Files I can edit with any tool; git-trackable; survives the system being uninstalled |
| Scheduler | macOS launchd | Already on the box, runs without an app open, missed-fire handling on wake |
| Planner | Claude CLI (claude --print), Pro Max subscription | Reuses my already-paid quota; OAuth via keychain; no API key in env |
| Chat | Telegram bot + Claude Code’s --channels plugin | Already on my phone; the chat plugin pushes inbound messages as turns; replies via MCP tool calls |
| Notifications | Reminders.app via osascript | Native macOS surface — same alerts I already get for everything else; no new app to install |
| Calendar | icalbuddy reading iCloud Calendar | Plain-text output, scriptable, no API |
| Prayer times | aladhan.com API | Free, no key, MWL calculation method (18°/17°) |
| Persistence | git push to gitlab on every check-in | Free off-box backup; “diff yourself over time” comes for free |
| Linting | shellcheck + bats + gitleaks via pre-commit hook | Catches the bash bugs that would otherwise show up at 10:30 the next morning |
A note on the Claude tier: the planner runs on the Claude Pro Max subscription via OAuth/keychain auth, not against an API key. Two reasons. First, no token lives in the environment of the cron-fired bash, which keeps the threat surface smaller. Second, the morning fire costs about USD 0.10–0.30 of subscription budget per day depending on dump length and how much yesterday-summary work the planner does — well inside the Pro Max envelope, with the daily fire capped via --max-budget-usd 2.00 as a defensive ceiling. If a single fire ever consumes that much budget, something has gone wrong and the cap stops the bleeding.
Walkthrough — Building It
The rest of this article is the actual code, in roughly the order I wrote it. Every snippet below is taken from the running vault; nothing is rewritten for clarity. Where the real code is too long for a single article, the salient pieces are quoted and the rest is referenced.
The Vault Skeleton
The vault is a normal Obsidian directory rooted at ~/Library/Mobile Documents/iCloud~md~obsidian/Documents/kaizen/ (yes, with a literal space in the path — iCloud Drive insists). The structure:
| |
The .gitignore is small but load-bearing:
| |
Three things are deliberately gitignored: the coach.env config (because it contains my Telegram chat id, which is functionally a secret), the logs (machine-specific), and the per-day flag files (ephemeral state). Everything else is tracked, including the brain dumps and daily logs — the gitlab repo doubles as a “diff yourself over time” archive of my actual life, which is one of the unanticipated benefits of the system.
load-config.sh — One Place To Define Where Everything Lives
Every script in _system/scripts/ starts by sourcing a single file, lib/load-config.sh. Its job is to load coach.env, validate that required variables are present, and establish a few defaults. The interesting part is what it does with the Telegram bot token:
| |
The reasoning: anything in the cron-fired bash’s environment is one stray printenv away from being logged. Token in env → token in log file → token in the gitlab repo on the next push, because one helper writes a debug dump and forgets. Keeping the token strictly inside the subshell of the one helper that needs it (tg_send, below) is cheap, and it materially reduces the blast radius when something goes wrong.
The script’s other useful trick is detecting whether it was sourced or executed:
| |
If sourced, it returns; if exec’d directly, it exits. This pattern repeats across every helper — every script in lib/ is both a sourced library and an executable command, and the bottom of each file checks BASH_SOURCE to switch modes. This is what lets the headless Claude planner exec helpers as commands (reminder.sh "$name" "$when" "$body") while the cron scripts source them as libraries (source lib/reminder.sh; set_reminder "$name" "$when" "$body").
telegram.sh — Send With Three-Retry Backoff
The simplest helper, but it gets the token-scoping right:
| |
Two design choices worth pointing out. First, the entire send happens inside ( … ), a subshell — the set -a; . "$tokenfile"; set +a exports the token only into that subshell, and it disappears when the subshell exits. The caller never sees it.
Second, exponential backoff with three attempts (2s, 4s, 8s) handles the most common Telegram failure modes — a 503 from the API, a transient network blip, a flaky DNS. The cron schedule is forgiving (next fire is hours away), so being patient is cheap insurance. The --max-time 10 on curl prevents a hung request from blocking the whole cron.
reminder.sh — osascript Without Injection
This is the helper that took the longest to get right. The job is “create a Reminders.app entry from the command line,” which sounds trivial — until you notice that the natural way to do it (heredoc into osascript with shell-string interpolation) opens a clean injection path through any double-quote, newline, or backslash in the reminder body.
The fix is osascript -e 'on run argv … end run' arg1 arg2 … — pass the strings as positional arguments, and let AppleScript resolve them inside the script. No shell-to-AppleScript boundary, no quoting concerns:
| |
A few details worth pointing out:
- The regex is strict on ranges, not just on shape.
^([0-9]{4})-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])— month 13 is rejected at the bash boundary, not silently rolled over by AppleScript into next January. AppleScript’s date arithmetic is willing to interpret nonsense; bash refuses to pass it through. - The date is constructed component by component (
set year of rWhen to y; set month of rWhen to mo; …) instead of from a parsed string. This is locale-independent —en_US,en_GB,en_JP, the construction works the same. AppleScript’sdate "..."form, by contrast, expects the user’s locale’s date format. On a Mac with the OS set to English but the region set to Japan, that ends up wanting a Japanese-language date string and fails mysteriously when you give it the obvious one. Constructing piece-by-piece side-steps the whole class of bug. set day of rWhen to 1before setting year/month/day is a known AppleScript dance to avoid month-rollover during partial assignment. If the current day is, say, the 31st and you set the month to February before adjusting the day, AppleScript merrily produces March 3rd. Setting day to 1 first prevents this.
The same file also defines mark_reminder_done (used by the silent sync) and sweep_past_due_reminders (used by the wrap and review crons), and a fuzzy-matcher:
| |
The fuzzy match exists because the morning planner often paraphrases when going from a brain-dump bullet to a Reminder name — “Communicate with roufun (after Asr)” on the Kanban becomes “follow up with roufun” on the reminder, because the planner writes for skim-readability. When I later mark the Kanban card done, exact-match would fail; token-overlap with a 50%-of-significant-words floor catches it.
kanban.sh — Parser And Atomic Roll-Over
The Kanban board is a single markdown file with H2 headings as columns. Reading the unchecked cards requires understanding the file format the Obsidian Kanban plugin emits — and there is a subtle gotcha here.
The plugin operates in two modes: “basic” (each column is a flat list of - [ ] checklist items) and “board” (the same format plus a YAML frontmatter that says kanban-plugin: board and a settings block at the bottom). They are visually identical inside Obsidian, but the file structures differ. If a script writes the basic format into a file the plugin opened in board mode, the plugin politely re-saves it back to board mode the next time the file opens — and any cards added in the meantime get lost in the format conversion. I caught this by accident on the second day of use.
The fix is to always emit board mode:
| |
{“kanban-plugin”:“board”}
| |
The %% kanban:settings %% block at the bottom is what the plugin uses to store its configuration; absent that, the plugin will rewrite the file on next open and clobber anything in flight.
The other interesting piece is roll_over — atomic in the sense that read, archive, and reset are sequenced, not parallel:
| |
The morning planner calls this once and captures stdout (the undone cards from yesterday). The done items go into the weekly archive at kaizen-log/YYYY-Www.md. The board is then reset to empty Now/Next/Later/Done, and the planner proceeds to populate it from yesterday’s carryover plus today’s new tasks. If a single step fails, the script returns non-zero before mutation continues — there is no “half-rolled-over” state where the board is reset but the carryover was not captured.
calendar.sh, deadline.sh, prayer-times.sh
Three smaller helpers. calendar.sh is a one-liner around icalbuddy:
| |
deadline.sh is a regex bank that converts free-form task text into a deadline indicator. Sample output, copy-pasted from the bats test fixture:
| |
The “by-weekday” resolver is the trickiest case: “by Friday” said on a Friday means next Friday, not today. The bats test for this is:
| |
prayer-times.sh calls the aladhan.com API for the Muslim World League calculation method (Fajr 18°, Isha 17°) with hard-coded Sagamihara coordinates (changeable via env var):
| |
A typical run:
| |
TSV is the format because the morning prompt slices it with awk -F'\t'. The Lastthird field is the start of the last third of the night (used for Tahajjud reference); the prayer block in the daily note shows Fajr through Midnight only.
morning-cron.sh — Where The Pieces Come Together
The cron entry point fired by launchd at 10:30 is the longest single script in the system. It does seven things in order:
Prepend
$HOME/.local/binand/opt/homebrew/binto PATH. Not optional. launchd-fired bash inherits a stripped-down PATH that does not include the Claude CLI, icalbuddy, jq, gitleaks, or shellcheck. Without this, every cron silently fails the first time it triesclaude --print— and I would not notice for hours.Check the wall-clock fire window. If the Mac was asleep at 10:30 and woke up at 12:45, firing the morning planner at 12:45 is not useful. Silently exit if more than 2 hours late or more than 10 minutes early. Bypassed by
KAIZEN_BYPASS_TIME_GUARD=1for the smoke runner.Wait up to 90 seconds for the daily note to sync from iPad. The brain dump is written on iPad; iCloud Drive sync is best-effort and sometimes lags. The script nudges sync via
brctl download "$DAILY"(undocumented; stable in practice on macOS 14/15) and retries up to three times with 30s backoff.Pre-flight prompt-injection scan on the brain dump. Defence in depth is cheap:
1 2 3if echo "$DUMP_TEXT" | grep -qiE 'ignore (all |the |your )?(previous|prior|above) (instructions?|prompt|system)|(disregard|override|forget) (all )?(prior|previous|above|your)? *(instructions?|system|prompt)|<\s*system\s*>|new instructions?:|you are now|act as (the )?(system|admin|root)|reveal (your |the )?(system )?prompt|print (your )?(system )?prompt'; then # …refuse, send a Telegram alert, exit 78 fiThe pattern is conservative. A tripped scan sends a Telegram heads-up so the user can inspect.
Substitute
$KAIZEN_VAULTin the prompt with the literal path. The Claude CLI’s Bash tool sandbox does not expand shell variables. Before invoking claude:1PROMPT_CONTENT=$(sed 's|$KAIZEN_VAULT|'"$KAIZEN_VAULT"'|g' "$PROMPT_FILE")The intermixed quoting is load-bearing — single-quoted pattern keeps
$KAIZEN_VAULTliteral in the regex; double-quoted concatenation expands it in the replacement. Get this backwards and the planner sees a literal it cannot resolve.Audit-snapshot before and after the Claude invocation. Stat the Kanban file, the daily note, the pending-reminders TSV, the state-flag count, and the Reminders.app count. The diff between the two snapshots is the actual change, independent of whatever Claude reported in stdout. This caught one bug where the planner reported “Set 4 reminders” but the audit showed
reminder_countunchanged — turned out the AppleScript date construction was failing silently for that locale, and the fix went into the locale-independent rewrite ofset_reminderabove.Invoke claude. The actual command:
1 2 3 4 5 6 7 8 9 10 11claude --print \ --add-dir "$KAIZEN_VAULT" \ --append-system-prompt "$PROMPT_CONTENT" \ --strict-mcp-config \ --mcp-config '{"mcpServers":{}}' \ --exclude-dynamic-system-prompt-sections \ --output-format text \ --max-budget-usd 2.00 \ --no-session-persistence \ "Process today's brain dump and produce the morning plan per your system prompt. Today is $TODAY ($(date '+%A'))." \ >> "$LOG" 2>&1 || rc=$?Three flags worth explaining (all documented in Anthropic’s CLI reference):
--strict-mcp-configplus--mcp-config '{"mcpServers":{}}'is MCP isolation. The headless session must not inherit my interactive~/.claude/settings.jsonMCP set — that contains the Telegram channel plugin, and Telegram’s bot API allows exactly onegetUpdatesconsumer per token: a second poller gets a409 Conflict: terminated by other getUpdates requestuntil one of them backs off (a constraint observable across half-a-dozen library bug trackers — e.g.node-telegram-bot-api#488— and confirmed by Telegram’sgetUpdatesdoc, which insists “There is no need to wait between calls”; in practice, two callers fight). The empty server set silences every MCP for this fire.--add-diradds the vault to the file-tool’s scope. Combined with a broad allowlist in~/.claude/settings.jsonfor kaizen scripts and vault paths, there is no need for--dangerously-skip-permissions.--max-budget-usd 2.00and--no-session-persistencecap cost and prevent this fire’s transcript from polluting my interactive session list.
git-sync the morning’s writes. Calls
git_sync morning-cron:git add -u && git add daily/ kanban/ kaizen-log/ && git commit && git push. Failure to push is non-fatal — the next cron will retry.Update the morning baseline ref. After git_sync:
1 2 3if (( rc == 0 )); then update_morning_baseline # git update-ref refs/kaizen/morning-baseline HEAD fiThe baseline is a git ref, not a snapshot file — single source of truth, since git already tracks every commit. The next section uses it.
sync-cron.sh — The Silent Reconciler
This is the load-bearing piece of the system. Every 30 minutes, a silent launchd job fires sync-cron.sh. It does:
Run
vault_diff— compare the current Kanban + daily note againstrefs/kaizen/morning-baseline. Output is one tagged line per change:+TASK\t<text>,+LOG\t<text>,✓DONE\t<text>,✗DROP\t<text>. If empty, exit silently.Choose a smart fire time for any new tasks:
1 2 3 4 5 6 7 8 9NOW_HOUR=$(date '+%H') if (( 10#$NOW_HOUR < 18 )); then FIRE_WHEN=$(date -j -v+1H '+%Y-%m-%d %H:%M:00') FIRE_LABEL="+1h" else TOMORROW=$(date -j -v+1d '+%Y-%m-%d') FIRE_WHEN="${TOMORROW} 09:00:00" FIRE_LABEL="tomorrow 09:00" fiA task added before 18:00 fires in 1 hour (mid-day, the reminder is wanted soon). A task added at 22:00 fires tomorrow at 09:00 (no point pinging at 22:00 about something I cannot start).
Apply each change:
+TASK— callset_reminderwith the smart fire time.✓DONE/✗DROP— fuzzy-match to an active reminder and callmark_reminder_done. If no match crosses the 50% overlap threshold, log it as unmatched. (A future improvement is to surface unmatched cards in the next user-visible check-in; the current behaviour is silent.)+LOG— no action. Log entries are reflections, not action items.
git_sync any vault changes I made during the past 30 minutes (timestamped log bullets, edits to the brain dump, etc.).
Advance the baseline —
git update-ref refs/kaizen/morning-baseline HEAD. The next sync only sees changes since this point.
I do not see this happen. I drag a card from Now to Done in Obsidian; within 30 minutes the corresponding Reminder is marked complete. I scribble a new bullet into the Kanban; within 30 minutes it is staged as a Reminder for an hour from now. I do not have to confirm anything, do not have to think about whether the system “knows” about my edit. The whole point of the reconciler is that I never have to remember it exists.
This is also what makes the system survive an honest weekend. If I do no work on Saturday, the sync runs every 30 minutes against an unchanged vault, finds nothing, and exits silently. No drift, no nag.
launchd Plists
There are six plists in _system/launchd/. The keys are documented in launchd.plist(5). The morning one, as an example:
| |
The sync plist uses StartInterval instead of StartCalendarInterval:
| |
Every 1800 seconds (30 minutes), starting from when the plist is loaded. RunAtLoad is false for all six because firing on launchctl bootstrap would cause spurious fires every time I reload after editing.
Loading them all is a one-liner:
| |
Permissions: The TCC Pitfall
This is the step the README marks “most-easily-missed.” macOS Transparency, Consent, and Control (TCC) gates access to Reminders, Calendar, and iCloud paths behind explicit user grants (Apple Developer Forums on launchd + TCC attribution; the wider TCC framework is summarised in Huntress’s reference). The intuitive flow is to run osascript -e 'tell application "Reminders" to count reminders' from Terminal, click Allow, repeat for Calendar, done.
Wrong. launchd-fired bash is not the same TCC subject as Terminal-launched bash. The grant applies to Terminal’s bash; the cron’s bash is a different process tree, and TCC denies it silently. The logs show the Reminder being created successfully — except it isn’t.
The fix:
System Settings → Privacy & Security → Full Disk Access →
+→ ⌘-⇧-G → type/bin/bash→ Open → toggle ON.
Granting Full Disk Access to /bin/bash itself covers the cron-fired path. While there, also grant Reminders Automation via Privacy & Security → Automation → Terminal → Reminders.
This was the single biggest day-one productivity sink. Every other bug had an obvious symptom — empty Reminders list, no Telegram message, stack trace in the log. TCC denials look successful from the bash side; the only way to find the bug is to know the gotcha exists and audit-snapshot before/after the cron fires.
Pre-Commit Hook: shellcheck + bats + gitleaks
Bash bugs that show up at 10:30 the next morning are expensive: I do not get a plan that day, and may not notice for hours. A pre-commit hook gates every commit through three checks (shellcheck, bats-core, gitleaks):
| |
shellcheck has caught more than its share — quoting bugs, unset-variable bugs, the classic [ -n $foo ]-without-quotes — well before they could ship into the cron. bats catches the regressions (the deadline parser had a “by Tuesday on a Tuesday” off-by-week bug that would have shipped without the test). gitleaks is mostly insurance against an accidental commit of coach.env or a leaked bot token; it has never tripped, but the day it does, it will save a credential rotation.
Activation is one line per clone:
| |
The repository’s hooks live with the repository, so a fresh clone on a new laptop picks them up automatically.
Using It — What A Day Looks Like
A representative day, from the live system. This is the actual daily/2026-05-10.md the Sunday morning planner produced, lightly redacted:
| |
Notice the planner’s voice. The frog (the most important thing) is named “frog → …” and parked in Now before Dhuhr. The handoff between deep work and the kid’s swimming is itself a card — “stop work at 12:00 → shower → Dhuhr → leave by 12:40” — because that transition is the thing I actually need the system to remember, not the deep block itself. The deep block will happen; I have been doing deep blocks for years. What I need help with is the seam between two different modes of attention.
The Telegram message arrives at 10:30 in this shape:
| |
I read this on the train. I reply go. The interactive Claude session — running in tmux on the same Mac, listening on the Telegram channel — reads go, fires the three reminders, and replies: “Set 3 reminders. Kanban: 3 Now / 5 Next / 2 Later.”
Through the day, things happen. I finish the AiCE-Lab block early; realise I need to fix a gitlab-runner issue that wasn’t in the brain dump (so I add a card directly in Obsidian: “Fix Jervis discord service”); drop the “iCloud Drive cleanup” card because the energy is not there. I tap ⌘L (bound to a Templater snippet inserting - HH:mm · ) every time I switch contexts, dropping timestamped bullets under ## Log.
Inside any 30-minute window, sync-cron.sh fires. It runs vault_diff, finds the new Jervis card and the dropped iCloud-cleanup card, fires a Reminder for Jervis, marks the iCloud-cleanup Reminder done, advances the baseline. No Telegram message.
At 23:00 the wrap arrives:
| |
Two cards still open. The wrap is honest — “you said you’d do these, you didn’t.” It does not nag, does not ask me to commit to finishing tonight; it sweeps past-due reminders, lists open cards, suggests winding down. The next morning’s brain dump picks up whatever rolls over.
That is one day. No dashboard to check, no app to open, no metric to maintain.
Lessons
After running this for a few days in production, several things stand out — bugs that bit, design choices that paid off, and features that turned out to be wrong.
Bugs Caught
The launchd-bash PATH gap. Covered above. Easy to miss because the script “works” when run from Terminal — the bug only shows up under launchd, which I do not exercise during development.
The kanban-plugin format mismatch. Day-two bug, one almost-lost card. Always emit board-mode (frontmatter + settings block) even for empty resets. The Kanban plugin will respect what you write; it will silently rewrite what you don’t.
The TCC subject difference. Granting Reminders/Calendar permission to Terminal does not cover launchd-fired bash. Grant Full Disk Access to /bin/bash directly. Lost an evening to this one before the audit-snapshot pattern surfaced it.
Locale-dependent AppleScript dates. AppleScript’s date "..." form expects the user’s locale’s date format. Construct the date component-by-component (set year of rWhen to y) and the bug class disappears.
The two-Claudes-on-one-bot 409. Telegram’s getUpdates allows exactly one consumer per bot token; a second poller is rejected with 409 Conflict, a constraint visible in many third-party bot library issue trackers (example). The headless invocation must pass --strict-mcp-config --mcp-config '{"mcpServers":{}}' to suppress its own MCP load and leave the bot to the interactive session. A morning of debugging before finding this.
The headless prompt does not see $KAIZEN_VAULT. Variables in shell commands the planner runs are not expanded by the harness. Pre-substitute via sed before passing the prompt (current approach), or use literal absolute paths everywhere.
Design Choices That Worked
Kanban-only for tasks. Early versions had a ## Plan section in the daily note and a Kanban board, with tasks duplicated. When I moved a card on the Kanban, the daily note went stale. Collapsing the Plan section into a comment (“see the Kanban board”) removed the ambiguity. The daily note keeps narrative; the Kanban keeps tasks. One source of truth per data type.
Daily note for narrative. The Log section, with timestamped bullets dropped via a hotkey, has turned out to be unexpectedly valuable. A week of - 14:33 · Fixing gitlab group runner orchestration issue lines is a more honest history than any app’s “you had X tasks open this week” — plain markdown I own. The morning planner reads yesterday’s log and writes a 5–10 bullet summary into yesterday’s ## Notes, so the journal compounds without me having to write a journal entry deliberately.
Git ref as the morning baseline. A single refs/kaizen/morning-baseline ref, advanced by the morning planner and by the silent sync, replaces what would otherwise have been a snapshot file. git already tracks every commit; any past state is recoverable via git show. Strictly simpler than a separate mirror.
Filesystem first, Telegram last. The planner writes the Kanban, the daily note, the TSV, and any flag files before sending the Telegram message. If Telegram fails, the persisted state is still consistent. Chat as output, filesystem as truth.
Separation between user-pull and headless modes. The interactive Claude session reads CLAUDE.md (the user-pull contract for go/sync/free-form replies); the headless planner reads _system/prompts/morning.md. Two prompts, one filesystem, no shared mutable state. Either Claude runs independently.
Things To Skip If You Replicate
Templater is fiddly for one snippet. The Obsidian Templater plugin is configured to insert - HH:mm · at the cursor on a hotkey. It works, but the setup (template folder, trigger config, hotkey binding) is more friction than the result deserves. The Time Stamper community plugin provides a dedicated “insert timestamp at cursor” command with no template-file plumbing — lower-effort path for this one feature.
The first iteration’s ## Plan section in the daily note. Two surfaces for the same data is two sources of truth, which is one too many.
Auto-staged daily adhkar. An early planner unconditionally added a “morning adhkar” card every day. This violated the kaizen principle (I include what I want a card for; the system does not invent obligations on my behalf) and was removed.
A V1 Friday compound-day mode. Special-casing Fridays got built into the prompt, ran once, and was over-fitted. Reverted. Fridays are now indistinguishable from any other WFH day; I nudge mid-day with a free-form Telegram message if I need to.
Habit telemetry automation. Sleep from Apple Health, walk-distance from a tracker, weight from a smart scale. Deferred to V2 — more APIs, more permission grants, more failure modes, and the qualitative “did I sleep before midnight?” entry in the weekly review captures most of the signal at a fraction of the cost.
Honest Costs
Subscription quota. Each morning fire consumes roughly USD 0.10–0.30 of the Pro Max budget — averaging USD 0.18 across the first few days. The --max-budget-usd 2.00 cap stops a runaway prompt from eating the budget.
Permission grants. Once-per-laptop: Full Disk Access to /bin/bash, Reminders Automation to Terminal, Calendar access to Terminal. About five minutes of clicking the first time, none thereafter — until I replace the Mac.
Manual chmod when editing system files. As defence in depth, files under _system/scripts/ and _system/prompts/ are chmod 0444 after the implementation lands. Editing one means chmod +w <file>, edit, re-run the lock script. Mildly annoying. The reasoning: even if a buggy planner or a prompt-injection slips past the permission allowlist, the OS rejects the write.
TCC re-grants on macOS upgrades. Major OS upgrades occasionally reset TCC grants. Expect to re-grant Full Disk Access to /bin/bash on the next major version.
The 30-minute reconciler floor. A card moved at HH:01 may not see its Reminder marked done until HH:31. Tightening the cadence is plausible but eats CPU and quota. The 30-minute setting was the trade that felt right.
Security Posture
Precautions taken, roughly in the order they appear in the code:
- Token never in environment.
TELEGRAM_BOT_TOKENis read inside thetg_sendsubshell only. Never exported into any process the cron starts. coach.envgitignored. Chat ID and other user-specific config live in a file git refuses to commit.coach.env.exampleis committed with placeholder values.- Pre-flight prompt-injection scan. A conservative regex bank gates the brain-dump text before the headless Claude runs. Hits cause the cron to abort with a Telegram alert.
- Vault-bounded permissions. Claude CLI invoked with
--add-dir "$KAIZEN_VAULT"and--strict-mcp-config. The settings allowlist scopes Read/Edit/Write to vault paths and excludes_system/config/and_system/scripts/. - System files chmod 0444. Defence in depth against a buggy planner or a slip in the allowlist.
- gitleaks pre-commit hook. Catches an accidental commit of
coach.envor a pasted token. Has not tripped; the day it does will save a credential rotation. - Telegram input is for plan amendments only. The
CLAUDE.mdcontract for the interactive session explicitly forbids treating Telegram as a code-execution channel — soft control, but the framing matters when the model is uncertain whether a request is in-scope. - No MCP in headless mode. The morning planner runs with all MCPs suppressed; future plugin vulnerabilities would not load.
None of this is bulletproof. The system runs on a machine I own, with software I mostly trust. Defence-in-depth is calibrated to the actual threat model — accidental commits, careless variable expansion, the rare jailbreak attempt — not a state-actor adversary.
Replicating It
The vault, sans the user’s actual notes, will be packaged as a parallel system repository at:
(Note: the parallel work to extract the system files into a clean public repo is in flight as of this article’s publication date. If the URL above 404s when you read this, check back — the extraction is a half-day’s work and is on the immediate roadmap. The vault’s README.md already documents the new-laptop install in detail, and the _system/ directory in the live vault is already self-contained.)
Setup, in short:
| |
The first morning’s run will probably surface a permission you forgot to grant, or a tool that is not on PATH, or an iCloud sync lag the script’s 90-second wait does not cover. That is normal. The cron writes everything to _system/logs/coach.log; tailing it during a manual fire (KAIZEN_BYPASS_TIME_GUARD=1 _system/scripts/morning-cron.sh) is the standard debugging path.
Closing
The kaizen vault has been live for a few days. Five mornings, four good plans, one silent skip on an empty brain-dump day. Two evenings of debugging — the TCC issue and the Kanban format mismatch — that would have shown up sooner or later. Three nights asleep before midnight, the closest to a sleep streak I’ve had in a long time. No productivity app opened, once.
The point of writing this down is not that the system is novel. It is not. There are dozens of personal task systems built on Obsidian; many projects using Claude as a planner; the launchd-cron-into-Telegram-bot pattern is recognisable to anyone who has built a personal Slack bot. What is unusual, if anything, is the combination — and the choice to honour the ADHD pattern rather than fight it. Sequence over clock. Anchors over alarms. Kaizen over streaks. Filesystem-first over chat-first. A silent reconciler that picks up my edits without asking.
Two pieces of advice if you build something like this. First, build for the brain you actually have, not the brain you wish you had. It is tempting to build a strict-clock system because that is what the productivity literature describes; it is harder to admit that strict-clock has not worked for you in twenty years and design around the failure mode instead. The system gets less impressive on paper and more usable in practice.
Second, the silent reconciler is the secret sauce. The thing that distinguishes this from a thousand other “morning planner sends you tasks” systems is that I can edit the Kanban directly, all day long, and the system silently catches up. The pattern — diff against a baseline, apply, advance the baseline — is generalisable and worth stealing. It is what makes the system feel like a colleague who watches the work, not a dashboard I have to feed.
The buddy has not nagged once. It has not shamed a missed day. It has surfaced exactly two unmatched Kanban cards across three days, one of which was a typo. I have used it because it has, so far, been less work than not using it.
That is the bar. If a personal system clears it, ship it.
Stay honest, stay surgical.
References
- Ashinoff, B. K., & Abu-Akel, A. (2019). Hyperfocus: the forgotten frontier of attention. Psychological Research, 85(1), 1–19.
- Barkley, R. A. (1997). Behavioral inhibition, sustained attention, and executive functions: constructing a unifying theory of ADHD. Psychological Bulletin, 121(1), 65–94. APA record.
- Mette, C. (2023). Time perception in adult ADHD: findings from a decade — a review. International Journal of Environmental Research and Public Health, 20(4), 3098.
- Gollwitzer, P. M., & Sheeran, P. (2006). Implementation intentions and goal achievement: a meta-analysis of effects and processes. Advances in Experimental Social Psychology, 38, 69–119.
- Imai, M. (1986). Kaizen: The Key to Japan’s Competitive Success. Random House Business Division.
- Lean Enterprise Institute. “Kaizen” — Lean Lexicon entry.
- Toyota Motor Corporation. “Toyota Production System”.
- Anthropic. Claude Code CLI reference.
- Apple Developer Forums — Full disk access from a launchd daemon.
launchd.plist(5)— Apple manpage.- AlAdhan API — Prayer Times API, calculation methods.
- Obsidian Kanban plugin — obsidian-community/obsidian-kanban.
- icalBuddy — ali-rantakari/icalBuddy.
- shellcheck — shellcheck.net; bats-core — GitHub; gitleaks — GitHub.
- Telegram Bot API —
getUpdates; the single-poller-per-token constraint surfaces in many bot library trackers, e.g.node-telegram-bot-api#488.
About the Author
Syed Hasibur Rahman is a physics-turned-AI engineer specialising in geospatial AI systems and ethical technology development. He builds and breaks small tools to keep them honest.
Originally published on May 11, 2026.