Kaizen Coach: A Personal Day-Orchestration System for the ADHD Brain

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.

  1. 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 dump section.
  2. Plan. A headless claude --print invocation, 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.
  3. 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.
  4. 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.
  5. 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:

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

LayerChoiceWhy
Vault formatObsidian + plain markdownFiles I can edit with any tool; git-trackable; survives the system being uninstalled
SchedulermacOS launchdAlready on the box, runs without an app open, missed-fire handling on wake
PlannerClaude CLI (claude --print), Pro Max subscriptionReuses my already-paid quota; OAuth via keychain; no API key in env
ChatTelegram bot + Claude Code’s --channels pluginAlready on my phone; the chat plugin pushes inbound messages as turns; replies via MCP tool calls
NotificationsReminders.app via osascriptNative macOS surface — same alerts I already get for everything else; no new app to install
Calendaricalbuddy reading iCloud CalendarPlain-text output, scriptable, no API
Prayer timesaladhan.com APIFree, no key, MWL calculation method (18°/17°)
Persistencegit push to gitlab on every check-inFree off-box backup; “diff yourself over time” comes for free
Lintingshellcheck + bats + gitleaks via pre-commit hookCatches 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
kaizen/
├── daily/                    YYYY-MM-DD.md per day
├── kanban/                   today.md (rolling, archived weekly)
├── kaizen-log/               YYYY-Www.md per ISO week
├── _system/
│   ├── config/coach.env      gitignored, contains chat-id
│   ├── config/coach.env.example
│   ├── git-hooks/pre-commit
│   ├── launchd/*.plist       6 schedules
│   ├── logs/coach.log        cron output
│   ├── logs/pending-reminders.tsv  the morning-planner's TSV
│   ├── prompts/morning.md    headless Claude system prompt
│   ├── scripts/lib/*.sh      9 helper libraries
│   ├── scripts/*-cron.sh     6 cron-fired entry points
│   ├── state/*.flag          per-day flag files (tahajjud, wrap-fired)
│   ├── templates/            Obsidian Templater templates
│   └── tests/*.bats          bats test suite
├── CLAUDE.md                 contract for the interactive Claude session
└── README.md                 user-facing setup doc

The .gitignore is small but load-bearing:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Obsidian per-vault state is per-machine and noisy in git.
.obsidian/*
!.obsidian/community-plugins.json
!.obsidian/templates.json

# macOS
.DS_Store

# Local config containing the chat id and other secrets
_system/config/coach.env

# Log files
_system/logs/*.log
_system/logs/pending-reminders.tsv

# Daily flag files (per-day state, ephemeral)
_system/state/*.flag

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:

1
2
3
4
5
6
7
8
# Cheap existence check on TELEGRAM_BOT_TOKEN — verify the variable is in the
# token file, but do NOT export it into the calling shell. Helpers that need
# the token re-read it inside their own subshell scope (see tg_send), which
# limits exposure to other helpers, child processes, and any future debug
# `printenv` / env-dump that lands in the log.
if ! grep -q '^TELEGRAM_BOT_TOKEN=' "$KAIZEN_TG_TOKEN_FILE"; then
  _kaizen_die 66 "TELEGRAM_BOT_TOKEN missing inside $KAIZEN_TG_TOKEN_FILE" || return $?
fi

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:

1
2
3
4
5
_kaizen_die() {
  local code="$1" msg="$2"
  echo "kaizen: $msg" >&2
  if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then exit "$code"; else return "$code"; fi
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
tg_send() {
  local text="${1:-}"
  if [[ -z "$text" ]]; then
    echo "tg_send: empty text" >&2
    return 64
  fi
  local reply_to="${2:-}"
  # Run the send inside a subshell so TELEGRAM_BOT_TOKEN never escapes into
  # the caller's env. The subshell is the only place the token exists.
  (
    set -a
    . "$KAIZEN_TG_TOKEN_FILE"
    set +a
    if [[ -z "${TELEGRAM_BOT_TOKEN:-}" ]]; then
      echo "tg_send: TELEGRAM_BOT_TOKEN missing" >&2
      return 66
    fi

    local args=( --data-urlencode "chat_id=${KAIZEN_TG_CHAT_ID}" \
                 --data-urlencode "text=${text}" )
    if [[ -n "$reply_to" ]]; then
      args+=( --data-urlencode "reply_to_message_id=${reply_to}" )
    fi
    local attempt delay=2 result
    for attempt in 1 2 3; do
      if result=$(curl -sS --max-time 10 \
          "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
          "${args[@]}" 2>&1); then
        if echo "$result" | grep -q '"ok":true'; then
          echo "$result"; return 0
        fi
      fi
      if [[ "$attempt" -lt 3 ]]; then
        sleep "$delay"; delay=$((delay * 2))
      fi
    done
    echo "tg_send: failed after 3 attempts. Last response: $result" >&2
    return 1
  )
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
set_reminder() {
  local name="${1:-}"
  local when="${2:-}"
  local body="${3:-}"
  if [[ -z "$name" || -z "$when" ]]; then
    echo "set_reminder: name and when (ISO datetime) required" >&2
    return 64
  fi
  # Tight regex rejects out-of-range fields (e.g. month 13, day 32, hour 25).
  # AppleScript silently rolls those over otherwise.
  local re='^([0-9]{4})-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])[ T]([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])$'
  if [[ ! "$when" =~ $re ]]; then
    echo "set_reminder: when must match YYYY-MM-DD HH:MM:SS, got: $when" >&2
    return 64
  fi
  local y="${BASH_REMATCH[1]}" mo="${BASH_REMATCH[2]}" d="${BASH_REMATCH[3]}"
  local h="${BASH_REMATCH[4]}" mi="${BASH_REMATCH[5]}" s="${BASH_REMATCH[6]}"
  _kaizen_ensure_list || return $?
  osascript -e 'on run argv
    set listName to item 1 of argv
    set rName to item 2 of argv
    set y to (item 3 of argv) as integer
    set mo to (item 4 of argv) as integer
    set d to (item 5 of argv) as integer
    set h to (item 6 of argv) as integer
    set mi to (item 7 of argv) as integer
    set s to (item 8 of argv) as integer
    set rBody to item 9 of argv
    set rWhen to current date
    set day of rWhen to 1
    set year of rWhen to y
    set month of rWhen to mo
    set day of rWhen to d
    set hours of rWhen to h
    set minutes of rWhen to mi
    set seconds of rWhen to s
    tell application "Reminders"
      tell list listName
        make new reminder with properties {name:rName, remind me date:rWhen, body:rBody}
      end tell
    end tell
  end run' "$KAIZEN_REMINDER_LIST" "$name" "$y" "$mo" "$d" "$h" "$mi" "$s" "$body"
}

A few details worth pointing out:

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
fuzzy_match_active_reminder() {
  # Token-overlap match: given a target text (typically a Kanban card body),
  # find the active uncompleted reminder whose name shares the most
  # significant (4+ chars) words with the target. Echoes the matched
  # reminder name if overlap >= 50% of the reminder's significant words.
  local target="${1:-}"
  [[ -z "$target" ]] && return 0
  local target_tokens
  target_tokens=$(echo "$target" | tr '[:upper:]' '[:lower:]' \
                  | tr -cs '[:alnum:]' '\n' | awk 'length($0) >= 4' | sort -u)
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
reset_board() {
  local f="${1:-}"
  if [[ -z "$f" ]]; then
    echo "reset_board: kanban file required" >&2
    return 64
  fi
  cat > "$f" <<'EOF'
---

kanban-plugin: board

---

## Now


## Next


## Later (after Isha)


## Done




%% kanban:settings

{“kanban-plugin”:“board”}

1
2
3
%%
EOF
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
roll_over() {
  local board="${1:-}" log="${2:-}"
  if [[ -z "$board" || -z "$log" ]]; then
    echo "roll_over: board and log file required" >&2
    return 64
  fi
  read_undone_cards "$board" || return $?
  archive_done_to "$board" "$log" || return $?
  reset_board "$board" || return $?
}

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:

1
2
3
read_calendar_today() {
  icalbuddy -nc -nrd -iep "title,datetime,location" -b "" eventsToday 2>/dev/null || true
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ deadline.sh "ship migration by 2026-05-12"
2026-05-12

$ deadline.sh "respond to legal email today"
today

$ deadline.sh "review the tutorial"            # no deadline
                                                # (empty output, exit 0)

$ deadline.sh "PR review by Tuesday" "2026-05-08"   # Friday
2026-05-12                                          # next Tuesday

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:

1
2
3
4
5
@test "by weekday on same day → next week" {
  # Today Fri 2026-05-08, "by Friday" → next Friday 2026-05-15
  run detect_deadline "wrap the doc by Friday" "2026-05-08"
  [ "$output" = "2026-05-15" ]
}

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):

1
2
3
4
5
6
7
8
prayer_times() {
  local iso="${1:-$(date '+%Y-%m-%d')}"
  local dmy
  dmy=$(date -j -f '%Y-%m-%d' "$iso" '+%d-%m-%Y') || return 64
  curl -sS --max-time 10 \
    "https://api.aladhan.com/v1/timings/${dmy}?latitude=${KAIZEN_PRAYER_LAT}&longitude=${KAIZEN_PRAYER_LON}&method=${KAIZEN_PRAYER_METHOD}&school=0" \
    | jq -r '.data.timings | "Fajr\t\(.Fajr)\nSunrise\t\(.Sunrise)\nDhuhr\t\(.Dhuhr)\nAsr\t\(.Asr)\nMaghrib\t\(.Maghrib)\nIsha\t\(.Isha)\nMidnight\t\(.Midnight)\nLastthird\t\(.Lastthird)"'
}

A typical run:

1
2
3
4
5
6
7
8
9
$ _system/scripts/lib/prayer-times.sh
Fajr	03:04
Sunrise	04:42
Dhuhr	11:39
Asr	15:25
Maghrib	18:36
Isha	20:08
Midnight	23:39
Lastthird	01:39

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:

  1. Prepend $HOME/.local/bin and /opt/homebrew/bin to 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 tries claude --print — and I would not notice for hours.

  2. 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=1 for the smoke runner.

  3. 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.

  4. Pre-flight prompt-injection scan on the brain dump. Defence in depth is cheap:

    1
    2
    3
    
    if 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
    fi
    

    The pattern is conservative. A tripped scan sends a Telegram heads-up so the user can inspect.

  5. Substitute $KAIZEN_VAULT in the prompt with the literal path. The Claude CLI’s Bash tool sandbox does not expand shell variables. Before invoking claude:

    1
    
    PROMPT_CONTENT=$(sed 's|$KAIZEN_VAULT|'"$KAIZEN_VAULT"'|g' "$PROMPT_FILE")
    

    The intermixed quoting is load-bearing — single-quoted pattern keeps $KAIZEN_VAULT literal in the regex; double-quoted concatenation expands it in the replacement. Get this backwards and the planner sees a literal it cannot resolve.

  6. 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_count unchanged — turned out the AppleScript date construction was failing silently for that locale, and the fix went into the locale-independent rewrite of set_reminder above.

  7. Invoke claude. The actual command:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    claude --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-config plus --mcp-config '{"mcpServers":{}}' is MCP isolation. The headless session must not inherit my interactive ~/.claude/settings.json MCP set — that contains the Telegram channel plugin, and Telegram’s bot API allows exactly one getUpdates consumer per token: a second poller gets a 409 Conflict: terminated by other getUpdates request until 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’s getUpdates doc, 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-dir adds the vault to the file-tool’s scope. Combined with a broad allowlist in ~/.claude/settings.json for kaizen scripts and vault paths, there is no need for --dangerously-skip-permissions.
    • --max-budget-usd 2.00 and --no-session-persistence cap cost and prevent this fire’s transcript from polluting my interactive session list.
  8. 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.

  9. Update the morning baseline ref. After git_sync:

    1
    2
    3
    
    if (( rc == 0 )); then
      update_morning_baseline   # git update-ref refs/kaizen/morning-baseline HEAD
    fi
    

    The 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:

  1. Run vault_diff — compare the current Kanban + daily note against refs/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.

  2. Choose a smart fire time for any new tasks:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    NOW_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"
    fi
    

    A 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).

  3. Apply each change:

    • +TASK — call set_reminder with the smart fire time.
    • ✓DONE / ✗DROP — fuzzy-match to an active reminder and call mark_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.
  4. git_sync any vault changes I made during the past 30 minutes (timestamped log bullets, edits to the brain dump, etc.).

  5. Advance the baselinegit 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>org.kaizen.morning</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/bash</string>
    <string>-c</string>
    <string>"$HOME/Library/Mobile Documents/iCloud~md~obsidian/Documents/kaizen/_system/scripts/morning-cron.sh"</string>
  </array>
  <key>StartCalendarInterval</key>
  <dict>
    <key>Hour</key><integer>10</integer>
    <key>Minute</key><integer>30</integer>
  </dict>
  <key>RunAtLoad</key>
  <false/>
  <key>StandardOutPath</key>
  <string>/tmp/kaizen-morning.out</string>
  <key>StandardErrorPath</key>
  <string>/tmp/kaizen-morning.err</string>
</dict>
</plist>

The sync plist uses StartInterval instead of StartCalendarInterval:

1
2
  <key>StartInterval</key>
  <integer>1800</integer>

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:

1
2
3
4
for f in _system/launchd/*.plist; do
  cp "$f" ~/Library/LaunchAgents/
  launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/$(basename "$f")
done

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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 1. shellcheck on staged .sh files
staged_sh=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.sh$' || true)
if [[ -n "$staged_sh" ]]; then
  while IFS= read -r f; do
    if ! shellcheck -x "$f"; then
      echo "pre-commit FAIL: shellcheck rejected $f" >&2
      fail=1
    fi
  done <<< "$staged_sh"
fi

# 2. bats — run if any helper or test changed
staged_test_or_lib=$(echo "$staged_sh" | grep -E '_system/(scripts/lib|tests)/' || true)
if [[ -n "$staged_test_or_lib" ]]; then
  if ! bats _system/tests/*.bats >/dev/null; then
    echo "pre-commit FAIL: bats suite failed" >&2
    fail=1
  fi
fi

# 3. gitleaks — scan staged content for committed secrets
if command -v gitleaks >/dev/null 2>&1; then
  if ! gitleaks protect --staged --no-banner >/dev/null 2>&1; then
    echo "pre-commit FAIL: gitleaks detected potential secret" >&2
    fail=1
  fi
fi

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:

1
git config core.hooksPath _system/git-hooks

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# 2026-05-10 — Sunday

## Brain dump
→ last night again I worked until fajr time. I was working on the
proposal for Mati project. I was able to finish the proposal but had to
go to bed at around 3:30 am after fajr. Today, I will focus on fewer
work and early finishing and go to bed early.

I need to do so many things...:
- Continue the `aice-lab-foundation` work
- work on heal-mobile app
- In chores:
  - I need to clean the toilet.
  - Clean the balcony (dump all the cardboard boxes)
- Today is Adrito's swimming day, I'll take him there today.
- Walk for 1hr
- Check the aramco job listing
- Communicate with roufun
- Organize the JIRA board
- House keeping my iCloud directory
- House keeping my `project` dir

## Plan (auto-filled)

**Prayer times** (Sagamihara, MWL — pray near beginning of waqt where possible)
- Fajr 03:04
- Sunrise 04:42 *(end of Fajr waqt)*
- Dhuhr 11:39
- Asr 15:25
- Maghrib 18:36
- Isha 20:08
- Midnight 23:39 *(end of Isha waqt)*

**Now**
- frog → AiCE-Lab foundation work (deep block, before Dhuhr)
- **stop work at 12:00** → shower → Dhuhr (~20 min) → leave by 12:40 for swimming
- heal-mobile app — continue (after swimming + walk, before Asr)

**Next**
- Adrito's swimming class (calendar anchor, post-Dhuhr)
- 1hr walk (post-swimming, before Asr)
- Communicate with roufun (after Asr)
- Clean balcony — dump cardboard boxes (before Maghrib)
- Check aramco job listing (before Maghrib)

**Later (after Isha)**
- Clean toilet (low-energy chore)
- Organize Jira board (prep for Monday office)
- Housekeeping sweep — iCloud Drive + ~/project (low-energy declutter)

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
🌅 Plan for Sunday (WFH)

▸ frog → AiCE-Lab foundation work (deep block, before Dhuhr)
▸ stop work at 12:00 → shower → Dhuhr → leave by 12:40 for swimming
▸ heal-mobile app — continue (after swimming + walk, before Asr)
▸ Adrito's swimming class (calendar anchor, post-Dhuhr)
▸ 1hr walk (post-swimming, before Asr)
▸ Communicate with roufun (after Asr)
▸ Clean balcony — dump cardboard boxes (before Maghrib)
▸ Clean toilet (low-energy chore, after Isha)
▸ Organize Jira board (prep for Monday office, after Isha)

📅 Calendar today: 14:00 Adrito swimming class

⏰ Reminders to set (3):
  1. AiCE-Lab deep block — start
  2. Stop work, leave for swimming
  3. After-Isha chores — toilet + Jira

Reply: 'go' / 'go skip N' / 'push N to <anchor>' / combined

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
🌙 Wrap window — anything still open?

▸ heal-mobile app — continue (Now)
▸ Communicate with roufun (Next)

Isha → adhkar → wind down → sleep before midnight 🌙

(Optional kaizen one-liner: what worked / didn't?)

🧹 Swept 2 past-due reminders to done.

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:

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:

https://gitlab.com/ananno/kaizen-system

(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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 1. Clone
git clone git@gitlab.com:ananno/kaizen-system.git ~/Documents/kaizen
cd ~/Documents/kaizen

# 2. Homebrew dependencies
brew install ical-buddy bats-core jq shellcheck gitleaks

# 3. macOS permissions
#    - System Settings → Privacy & Security → Full Disk Access → + → /bin/bash
#    - System Settings → Privacy & Security → Automation → Terminal → Reminders ON
#    - System Settings → Privacy & Security → Automation → Terminal → Calendar ON

# 4. Config
cp _system/config/coach.env.example _system/config/coach.env
# Edit coach.env: set KAIZEN_TG_CHAT_ID to your Telegram chat id.

# 5. Telegram bot token at $HOME/.claude/channels/telegram/.env (the standard
#    location used by the Claude Code Telegram channel plugin).

# 6. launchd
for f in _system/launchd/*.plist; do
  cp "$f" ~/Library/LaunchAgents/
  launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/$(basename "$f")
done

# 7. Pre-commit hook
git config core.hooksPath _system/git-hooks

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


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.