Skill: tristanmanchester/weekly-planner (version 1.0.0)

--- name: weekly-planner description: File-based weekly planning system (TOML) with inbox capture, time-block scheduling, weekly review, and optional calendar publishing (Google Calendar via gogcli or .ics export). Use when a user asks to plan or time-block a week, triage an inbox of tasks, roll over unfinished work, run a weekly review, or publish a weekly schedule to a calendar. Do NOT use for full project-management tools (Jira/Linear/etc.) unless explicitly requested. license: MIT compatibility: OpenClaw / AgentSkills. Scripts require Python 3.11+. Optional: gog (steipete/gogcli) for direct Google Calendar sync; otherwise export .ics. metadata: {"version":"0.2.0","tags":["planner","weekly-review","time-blocking","productivity"],"openclaw":{"emoji":"🗓️","install":[{"id":"brew-python","kind":"brew","formula":"python","bins":["python3"],"label":"Install Python 3 (brew)"},{"id":"brew-gogcli","kind":"brew","formula":"steipete/tap/gogcli","bins":["gog"],"label":"Install gogcli (brew)"}]}} --- # Weekly Planner A lightweight, **file-based weekly planner** that lives in a workspace folder as plain text (`.toml`). It supports: - **Inbox capture** (`planner/inbox.toml`) for fast, append-only task capture - **Weekly plans** (`planner/weeks/YYYY-Www.toml`) with: - scheduled **time blocks** (can be published to a calendar) - unscheduled **weekly bits** / **daily bits** - an end-of-week **review** - **A runbook** (`planner/runbook.toml`) of recurring blocks copied into each new week - Optional **mode cards** (`planner/modes/*.md`) describing how the user wants to work in different contexts ## When to use this skill Use this skill when the user asks for any of the following: - “Plan my week”, “time-block my week”, “create a weekly plan”, “make a schedule for next week” - “Capture this in my inbox”, “add this to my planner”, “triage my tasks” - “Create a new week file”, “roll over last week’s unfinished tasks” - “Publish/sync my planner to my calendar”, “export an .ics for my week” - “Do an end-of-week review” Don’t use this skill for full project-management systems (Jira/Linear/etc.) unless the user explicitly wants that. ## Quick start ### 1) Ensure the planner folder exists Look for a `planner/` folder in the current workspace that contains `planner/config.toml`. If it doesn’t exist, initialise a fresh planner skeleton (safe: refuses to overwrite existing folders): ```bash python3 {baseDir}/scripts/init_planner.py --target ./planner ``` This creates: - `planner/config.toml` - `planner/inbox.toml` - `planner/runbook.toml` - `planner/weeks/WEEK_TEMPLATE.toml` - `planner/modes/*.md` - `planner/scripts/new_week.py` - `planner/scripts/rollover_week.py` - `planner/scripts/validate.py` - `planner/scripts/sync_week.py` (Google Calendar sync via `gog`) - `planner/scripts/export_ics.py` (calendar export without Google tooling) - `planner/logs/` ### 2) Tell the user what to customise Ask the user to review **`planner/config.toml`** and customise: - `timezone` (IANA tz name, e.g. `Europe/Berlin`) - `modes.*` (their mode names + labels) - (Optional) calendar publish settings (see “Publish to calendar” below) ### 3) Create (or roll over) a week Create a new week file: ```bash python3 planner/scripts/new_week.py --week-start 2026-03-02 ``` Or roll over unfinished tasks from the most recent week: ```bash python3 planner/scripts/rollover_week.py --next ``` ### 4) Validate before “publishing” anything Run validation after edits (especially before calendar publish): ```bash python3 planner/scripts/validate.py --week planner/weeks/2026-W10.toml ``` ## Core workflows ### A) Inbox capture Goal: capture tasks with minimal friction. Rules: - Treat `planner/inbox.toml` as **append-only**. - Add one `[[items]]` block per task. - Keep each entry small; use `notes` only when it helps. When the user says “add this to my inbox”, append a new item like: ```toml [[items]] created = "YYYY-MM-DDTHH:MM:SS+01:00" text = "(task description)" mode = "ops" # any key from config.toml [modes.*] est_minutes = 30 priority = "medium" # low|medium|high status = "todo" # todo|doing|done|dropped notes = "" ``` If the user doesn’t specify `mode`, choose a reasonable default (usually their “ops/admin” mode). ### B) Triage inbox → weekly plan Goal: turn raw inbox items into (a) 2–3 outcomes, (b) a small set of scheduled blocks, and (c) a manageable list of unscheduled bits. Process: 1) Read `planner/inbox.toml`. 2) Ask the user for: - hard constraints (deadlines, appointments, travel) - 2–3 outcomes they care about this week - anything non-negotiable (exercise, family time, admin) 3) Convert inbox items into one of: - **time blocks** (must happen at a specific time) - **weekly bits** (do sometime this week) - **daily bits** (do sometime on a specific day) - or mark as **dropped** if it’s not happening this week Keep the plan intentionally small: - 2–5 focus tasks - A few high-leverage time blocks - A short “bits” list the user can realistically finish ### C) Create a new week file Use when the user wants to start planning a new week. 1) Determine `week_start` (a Monday date in `YYYY-MM-DD`). 2) Generate the file (copies runbook blocks into `[[time_blocks]]`): ```bash python3 planner/scripts/new_week.py --week-start 2026-03-02 ``` 3) Then edit `planner/weeks/2026-W10.toml`: - Fill `[goals]` outcomes (2–3 crisp outcomes) - Add `focus_tasks` (2–5 items max) - Add / adjust `[[time_blocks]]` (scheduled blocks) - Add `[[weekly_bits]]` and `[[daily_bits]]` for unscheduled tasks ### D) Roll over unfinished work (optional) Use when the user has an existing week file and wants to start the next week without retyping. Typical flow: ```bash python3 planner/scripts/rollover_week.py --next ``` This: - finds the most recent week in `planner/weeks/` - creates the next week (week_start + 7 days) - copies runbook blocks into the new week’s `[[time_blocks]]` - carries over unfinished `weekly_bits` and `daily_bits` ### E) Publish time blocks to a calendar (optional) Only `[[time_blocks]]` are published. Two safe options: #### Option 1: Google Calendar sync (direct, destructive) This workflow is **destructive by design** — it updates/deletes managed events. Safety rules (must follow): - Only ever sync to a **dedicated planner calendar** (never the user’s main calendar). - Always run **dry-run first**. - Only run with `--apply` if BOTH are true: 1) The user explicitly asked you to apply changes. 2) `calendar.write_enabled = true` in `planner/config.toml`. Requirements: - `gog` CLI installed + authenticated (`steipete/gogcli`) Dry-run: ```bash python3 planner/scripts/sync_week.py --week planner/weeks/2026-W10.toml ``` Apply: ```bash python3 planner/scripts/sync_week.py --week planner/weeks/2026-W10.toml --apply ``` #### Option 2: Export an .ics file (safe, non-destructive) Works without Google tooling. ```bash python3 planner/scripts/export_ics.py --week planner/weeks/2026-W10.toml ``` This writes `planner/weeks/2026-W10.ics`, which the user can import into most calendar apps. ### F) End-of-week review At the end of the week, help the user fill: - `review.score` (0–10) - `review.wins` (1–5 bullets) - `review.fails` (1–5 bullets) - `review.what_i_learned` (short paragraph) - `review.next_week_focus` (1–2 sentences) ## Quality gates Before publishing/syncing: 1) Run validation: ```bash python3 planner/scripts/validate.py --week planner/weeks/2026-W10.toml ``` 2) Fix all **errors**. 3) Treat **warnings** as “strong suggestions” (overlaps, out-of-bounds blocks, unknown modes). ## References - File formats and schema: `references/PLANNER_SCHEMA.md` - Calendar publish & safety model: `references/CALENDAR_SYNC.md` ## Troubleshooting ### “Python can’t import tomllib” / “No module named tomllib” You’re on Python < 3.11. Fix: install Python 3.11+ and re-run. ### “gog: command not found” Google Calendar sync requires the `gog` CLI. Fix: either install `gogcli` (see references) or use the `.ics` export instead. ### “Refusing to apply changes: calendar.write_enabled is false” Intentional safety latch. Fix: have the user set `calendar.write_enabled = true` once they’re confident in the dry-run output.

path: assets/planner_template/runbook.toml
content: # Weekly runbook blocks.
# These blocks are copied into each new week file by scripts/new_week.py.
# After generation, edit the week file freely (move days/times) — stable ids preserve updates.

# --- Planning / review ---

[[blocks]]
id = "plan-week"
mode = "ops"
title = "Weekly planning"
day = "Mon"
start = "08:30"
minutes = 45
notes = "Review inbox, pick outcomes, schedule blocks."

[[blocks]]
id = "review-week"
mode = "ops"
title = "Weekly review"
day = "Fri"
start = "16:30"
minutes = 30
notes = "Fill the review section and sketch next week."

# --- Exercise ---

[[blocks]]
id = "exercise-1"
mode = "ops"
title = "Exercise"
day = "Mon"
start = "07:00"
minutes = 60
notes = ""

[[blocks]]
id = "exercise-2"
mode = "ops"
title = "Exercise"
day = "Wed"
start = "07:00"
minutes = 60
notes = ""

[[blocks]]
id = "exercise-3"
mode = "ops"
title = "Exercise"
day = "Fri"
start = "07:00"
minutes = 60
notes = ""

# --- Shutdown (weekdays) ---

[[blocks]]
id = "shutdown-mon"
mode = "shutdown"
title = "Shutdown"
day = "Mon"
start = "21:30"
minutes = 20
notes = "Quick tidy + prep for tomorrow."

[[blocks]]
id = "shutdown-tue"
mode = "shutdown"
title = "Shutdown"
day = "Tue"
start = "21:30"
minutes = 20
notes = ""

[[blocks]]
id = "shutdown-wed"
mode = "shutdown"
title = "Shutdown"
day = "Wed"
start = "21:30"
minutes = 20
notes = ""

[[blocks]]
id = "shutdown-thu"
mode = "shutdown"
title = "Shutdown"
day = "Thu"
start = "21:30"
minutes = 20
notes = ""

[[blocks]]
id = "shutdown-fri"
mode = "shutdown"
title = "Shutdown"
day = "Fri"
start = "21:30"
minutes = 20
notes = ""


path: assets/planner_template/config.toml
content: # Weekly Planner configuration
#
# This file contains stable defaults and planning hints.
# Edit freely.

timezone = "UTC"  # IANA timezone name, e.g. "Europe/Berlin"

[day_bounds]
start = "06:00"
end = "22:00"  # "too late" cutoff

# Modes are user-defined. Use the mode key in week files (e.g. mode = "deep-work").
[modes.deep-work]
label = "Deep Work"
default_block_minutes = 90
min_block_minutes = 60

[modes.ops]
label = "Life Ops"
default_block_minutes = 45

[modes.shutdown]
label = "Shutdown"
default_block_minutes = 20

[modes.reset]
label = "Reset"
default_block_minutes = 10

[modes.relationships]
label = "Relationships"
default_block_minutes = 60

# Weekly non-negotiables are planning hints. You can schedule them as time_blocks.
[[weekly_nonnegotiables]]
name = "Exercise"
count = 3
duration_minutes = 60
preferred_windows = [["06:00", "09:00"]]

[[weekly_nonnegotiables]]
name = "Admin & errands"
count = 1
duration_minutes = 60
preferred_days = ["Sat", "Sun"]

[calendar]
# Dedicated calendar for *destructive* sync (create/update/delete managed events).
# Never point this at your primary calendar.
planner_calendar_name = "Planner"
planner_calendar_id = ""   # only needed for Google Calendar sync via gog
write_enabled = false      # safety latch; must be true to allow --apply

[sync]
# Prefix used to mark managed events (private extended props + description markers).
# Do not change after you start syncing, unless you intentionally want a clean slate.
marker_prefix = "planner"

[export]
# Optional settings for .ics export (export_ics.py). If omitted, defaults are used.
ics_calendar_name = "Planner"
ics_output_dir = "weeks"


path: assets/planner_template/inbox.toml
content: # Append-only task capture
#
# Add one [[items]] block per task. Keep it simple.
#
# Example:
#
# [[items]]
# created = "YYYY-MM-DDTHH:MM:SS+01:00"
# text = "Draft this week’s plan"
# mode = "ops"               # any key from config.toml [modes.*]
# est_minutes = 30
# priority = "medium"        # low|medium|high
# status = "todo"            # todo|doing|done|dropped
# notes = ""


path: assets/planner_template/scripts/rollover_week.py
content: #!/usr/bin/env python3
"""Create the next week file and roll over unfinished bits from the previous week.

This script is a convenience wrapper around the common workflow:
- Find the most recent week in planner/weeks/
- Create the next ISO week file from runbook blocks
- Carry over unfinished weekly_bits and daily_bits

Examples (from workspace root):

  # Create next week based on the most recent week file
  python3 planner/scripts/rollover_week.py --next

  # Explicitly roll over from a specific week file
  python3 planner/scripts/rollover_week.py --from planner/weeks/2026-W09.toml

  # Override output week-start date (Monday ISO date)
  python3 planner/scripts/rollover_week.py --from planner/weeks/2026-W09.toml --week-start 2026-03-02

Notes:
- Refuses to overwrite an existing week file.
- Requires Python 3.11+ (tomllib).
"""

from __future__ import annotations

import argparse
import datetime as dt
import re
import sys
from pathlib import Path

import tomllib


VALID_DAYS = {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
WEEK_FILE_RE = re.compile(r"^\d{4}-W\d{2}\.toml$", re.IGNORECASE)


def load_toml(path: Path) -> dict:
    with path.open("rb") as f:
        return tomllib.load(f)


def parse_iso_date(s: str) -> dt.date:
    try:
        return dt.date.fromisoformat(s)
    except Exception as e:
        raise SystemExit(f"Invalid ISO date: {s!r}") from e


def week_id_from_week_start(week_start: dt.date) -> str:
    iso_year, iso_week, _ = week_start.isocalendar()
    return f"{iso_year}-W{iso_week:02d}"


def week_id_from_path(p: Path) -> str:
    base = p.name
    if base.lower().endswith(".toml"):
        base = base[:-5]
    return base


def toml_str(s: str) -> str:
    return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'


def discover_week_files(weeks_dir: Path) -> list[Path]:
    files: list[Path] = []
    for p in sorted(weeks_dir.glob("*.toml")):
        if p.name == "WEEK_TEMPLATE.toml":
            continue
        if p.name.endswith(".scratch.toml"):
            continue
        if WEEK_FILE_RE.match(p.name):
            files.append(p)
    return files


def latest_week_file(weeks_dir: Path) -> Path | None:
    files = discover_week_files(weeks_dir)
    if not files:
        return None

    def key(p: Path):
        try:
            data = load_toml(p)
            ws = parse_iso_date(str(data.get("week_start") or ""))
            return ws
        except Exception:
            return dt.date.min

    files_sorted = sorted(files, key=key)
    return files_sorted[-1] if files_sorted else None


def carry_note(notes: str, prev_week_id: str) -> str:
    marker = f"Carried over from {prev_week_id}."
    notes = (notes or "").strip()
    if not notes:
        return marker
    if marker.lower() in notes.lower():
        return notes
    return f"{marker} {notes}"


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--planner", default="./planner", help="Planner directory (default: ./planner)")
    ap.add_argument("--from", dest="from_week", default=None, help="Previous week TOML path to roll over from")
    ap.add_argument("--next", action="store_true", help="Use the most recent week in planner/weeks/ as the source")
    ap.add_argument("--week-start", default=None, help="Override new week_start (Monday ISO date)")
    ap.add_argument("--week-id", default=None, help="Override output week id (default: computed from week_start ISO week)")
    ap.add_argument("--out", default=None, help="Override output path (default: planner/weeks/<week-id>.toml)")
    args = ap.parse_args()

    planner_dir = Path(args.planner).expanduser().resolve()
    weeks_dir = planner_dir / "weeks"
    runbook_path = planner_dir / "runbook.toml"
    config_path = planner_dir / "config.toml"

    if not weeks_dir.exists():
        raise SystemExit(f"weeks/ directory not found: {weeks_dir}")
    if not runbook_path.exists():
        raise SystemExit(f"runbook.toml not found: {runbook_path}")

    source_week_path: Path | None = None
    if args.from_week:
        source_week_path = Path(args.from_week).expanduser().resolve()
    elif args.next:
        source_week_path = latest_week_file(weeks_dir)

    if not source_week_path or not source_week_path.exists():
        raise SystemExit("Could not determine source week file (use --from or --next).")

    prev_week_id = week_id_from_path(source_week_path)

    prev_week = load_toml(source_week_path)
    prev_week_start = parse_iso_date(str(prev_week.get("week_start") or "").strip())

    new_week_start = parse_iso_date(args.week_start) if args.week_start else (prev_week_start + dt.timedelta(days=7))
    if new_week_start.weekday() != 0:
        print(
            f"Warning: new week_start {new_week_start.isoformat()} is not a Monday (weekday={new_week_start.weekday()}).",
            file=sys.stderr,
        )

    computed_week_id = week_id_from_week_start(new_week_start)
    new_week_id = (args.week_id or computed_week_id).strip()

    out_path = Path(args.out).expanduser().resolve() if args.out else (weeks_dir / f"{new_week_id}.toml")
    if out_path.exists():
        raise SystemExit(f"Refusing to overwrite existing file: {out_path}")

    # Load runbook blocks (same shape as new_week.py)
    runbook = load_toml(runbook_path)
    blocks = runbook.get("blocks", [])
    if not isinstance(blocks, list):
        raise SystemExit("runbook.toml: expected [[blocks]] array")

    # Carry over unfinished bits
    carry_weekly: list[dict] = []
    for b in (prev_week.get("weekly_bits") or []):
        if not isinstance(b, dict):
            continue
        status = str(b.get("status") or "todo").strip().lower()
        if status in {"done", "skip"}:
            continue
        carry_weekly.append(b)

    carry_daily: list[dict] = []
    for b in (prev_week.get("daily_bits") or []):
        if not isinstance(b, dict):
            continue
        status = str(b.get("status") or "todo").strip().lower()
        if status in {"done", "skip"}:
            continue
        carry_daily.append(b)

    out_path.parent.mkdir(parents=True, exist_ok=True)

    lines: list[str] = []
    lines.append(f"# Rolled over from {prev_week_id} ({source_week_path.name})")
    lines.append(f"week_start = {toml_str(new_week_start.isoformat())}\n")

    lines.append("[goals]")
    lines.append('primary_outcome = ""')
    lines.append('maintenance_outcome = ""\n')
    lines.append("# Optional: 2–5 focus tasks you keep coming back to all week.")
    lines.append("focus_tasks = []\n")

    lines.append("# Time blocks (published to calendar)")
    lines.append("# id: required, unique within week")
    lines.append("# day: Mon|Tue|Wed|Thu|Fri|Sat|Sun")
    lines.append("# mode: any key defined in config.toml under [modes.*]\n")

    for b in blocks:
        title = str(b.get("title") or "")
        notes = str(b.get("notes") or "")
        lines.append("[[time_blocks]]")
        lines.append(f"id = {toml_str(str(b['id']))}")
        lines.append(f"day = {toml_str(str(b['day']))}")
        lines.append(f"mode = {toml_str(str(b['mode']))}")
        lines.append(f"start = {toml_str(str(b['start']))}")
        lines.append(f"minutes = {int(b['minutes'])}")
        lines.append(f"title = {toml_str(title)}")
        lines.append(f"notes = {toml_str(notes)}\n")

    if carry_weekly:
        lines.append(f"# --- Carried weekly bits from {prev_week_id} ---")
        for b in carry_weekly:
            bid = str(b.get("id") or "").strip()
            text = str(b.get("text") or "").strip()
            est = b.get("est_minutes")
            mode = str(b.get("mode") or "").strip()
            notes = carry_note(str(b.get("notes") or ""), prev_week_id)
            lines.append("[[weekly_bits]]")
            lines.append(f"id = {toml_str(bid)}")
            lines.append(f"text = {toml_str(text)}")
            if isinstance(est, int) and est > 0:
                lines.append(f"est_minutes = {est}")
            if mode:
                lines.append(f"mode = {toml_str(mode)}")
            lines.append('status = "todo"')
            lines.append(f"notes = {toml_str(notes)}\n")

    if carry_daily:
        lines.append(f"# --- Carried daily bits from {prev_week_id} ---")
        for b in carry_daily:
            day = str(b.get("day") or "").strip()
            bid = str(b.get("id") or "").strip()
            text = str(b.get("text") or "").strip()
            est = b.get("est_minutes")
            mode = str(b.get("mode") or "").strip()
            notes = carry_note(str(b.get("notes") or ""), prev_week_id)
            lines.append("[[daily_bits]]")
            lines.append(f"day = {toml_str(day)}")
            lines.append(f"id = {toml_str(bid)}")
            lines.append(f"text = {toml_str(text)}")
            if isinstance(est, int) and est > 0:
                lines.append(f"est_minutes = {est}")
            if mode:
                lines.append(f"mode = {toml_str(mode)}")
            lines.append('status = "todo"')
            lines.append(f"notes = {toml_str(notes)}\n")

    lines.append("# --- End-of-week review ---")
    lines.append("[review]")
    lines.append("score = 0")
    lines.append("wins = []")
    lines.append("fails = []")
    lines.append('what_i_learned = ""')
    lines.append('next_week_focus = ""\n')

    out_path.write_text("\n".join(lines), encoding="utf-8")

    print(f"Wrote: {out_path}")
    print(f"Carried over: {len(carry_weekly)} weekly bit(s), {len(carry_daily)} daily bit(s)")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: assets/planner_template/scripts/validate.py
content: #!/usr/bin/env python3
"""Validate Weekly Planner files (config, inbox, runbook, week plans).

This is a deterministic safety net for agents and humans.

Examples (from workspace root):

  # Validate a specific week
  python3 planner/scripts/validate.py --week planner/weeks/2026-W10.toml

  # Validate config + inbox + runbook + latest week (default behaviour)
  python3 planner/scripts/validate.py

  # Validate all week files
  python3 planner/scripts/validate.py --all-weeks

Exit codes:
  0  OK (no errors; warnings allowed)
  1  Errors found
  2  No errors, but warnings found and --strict was set
"""

from __future__ import annotations

import argparse
import datetime as dt
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from zoneinfo import ZoneInfo

import tomllib


VALID_DAYS = {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
DAY_OFFSETS = {"Mon": 0, "Tue": 1, "Wed": 2, "Thu": 3, "Fri": 4, "Sat": 5, "Sun": 6}
WEEK_FILE_RE = re.compile(r"^\d{4}-W\d{2}\.toml$", re.IGNORECASE)


@dataclass
class Issue:
    level: str  # "ERROR" | "WARN"
    where: str
    message: str


def load_toml(path: Path) -> dict:
    with path.open("rb") as f:
        return tomllib.load(f)


def parse_iso_date(s: str) -> dt.date:
    return dt.date.fromisoformat(s)


def parse_hhmm_to_minutes(s: str) -> int:
    hh, mm = s.split(":")
    hh_i = int(hh)
    mm_i = int(mm)
    if not (0 <= hh_i <= 23 and 0 <= mm_i <= 59):
        raise ValueError
    return hh_i * 60 + mm_i


def discover_week_files(weeks_dir: Path) -> list[Path]:
    files = []
    for p in sorted(weeks_dir.glob("*.toml")):
        if p.name == "WEEK_TEMPLATE.toml":
            continue
        if p.name.endswith(".scratch.toml"):
            continue
        if WEEK_FILE_RE.match(p.name):
            files.append(p)
    return files


def latest_week_file(weeks_dir: Path) -> Path | None:
    week_files = discover_week_files(weeks_dir)
    if not week_files:
        return None

    def key(p: Path):
        try:
            data = load_toml(p)
            ws = parse_iso_date(str(data.get("week_start") or ""))
            return ws
        except Exception:
            return dt.date.min

    week_files_sorted = sorted(week_files, key=key)
    return week_files_sorted[-1] if week_files_sorted else None


def validate_config(config: dict, path: Path, issues: list[Issue]) -> None:
    where = str(path)
    tz_name = str(config.get("timezone") or "").strip()
    if not tz_name:
        issues.append(Issue("ERROR", where, "Missing timezone (expected IANA name like 'Europe/Berlin')."))
    else:
        try:
            ZoneInfo(tz_name)
        except Exception:
            issues.append(Issue("ERROR", where, f"Invalid timezone: {tz_name!r} (must be an IANA tz name)."))

    day_bounds = config.get("day_bounds") or {}
    if isinstance(day_bounds, dict) and day_bounds:
        for key in ("start", "end"):
            if key in day_bounds:
                try:
                    parse_hhmm_to_minutes(str(day_bounds[key]))
                except Exception:
                    issues.append(Issue("ERROR", where, f"day_bounds.{key} must be HH:MM (got {day_bounds[key]!r})."))

    modes = config.get("modes") or {}
    if modes and not isinstance(modes, dict):
        issues.append(Issue("ERROR", where, "modes must be a table (e.g. [modes.ops])."))
        return

    for mode_key, m in (modes or {}).items():
        if not isinstance(m, dict):
            issues.append(Issue("ERROR", where, f"modes.{mode_key} must be a table."))
            continue
        label = m.get("label")
        if not isinstance(label, str) or not label.strip():
            issues.append(Issue("WARN", where, f"modes.{mode_key}.label is missing/empty (used for calendar titles)."))
        for fld in ("default_block_minutes", "min_block_minutes"):
            if fld in m:
                val = m.get(fld)
                if not isinstance(val, int) or val <= 0:
                    issues.append(Issue("ERROR", where, f"modes.{mode_key}.{fld} must be a positive int."))


def validate_inbox(inbox: dict, config: dict, path: Path, issues: list[Issue]) -> None:
    where = str(path)
    items = inbox.get("items", [])
    if items is None:
        return
    if not isinstance(items, list):
        issues.append(Issue("ERROR", where, "inbox.toml: expected [[items]] array."))
        return

    valid_modes = set((config.get("modes") or {}).keys()) if isinstance(config.get("modes"), dict) else set()
    for i, item in enumerate(items):
        if not isinstance(item, dict):
            issues.append(Issue("ERROR", where, f"items[{i}] must be a table."))
            continue

        created = str(item.get("created") or "").strip()
        if created:
            try:
                dt.datetime.fromisoformat(created)
            except Exception:
                issues.append(Issue("WARN", where, f"items[{i}].created is not ISO/RFC3339: {created!r}"))
        else:
            issues.append(Issue("WARN", where, f"items[{i}].created is missing (recommended)."))

        text = str(item.get("text") or "").strip()
        if not text:
            issues.append(Issue("ERROR", where, f"items[{i}].text is required."))

        mode = str(item.get("mode") or "").strip()
        if mode and valid_modes and mode not in valid_modes:
            issues.append(Issue("WARN", where, f"items[{i}].mode {mode!r} not found in config modes."))

        est = item.get("est_minutes")
        if est is not None and (not isinstance(est, int) or est <= 0):
            issues.append(Issue("WARN", where, f"items[{i}].est_minutes should be a positive int (got {est!r})."))

        priority = str(item.get("priority") or "").strip().lower()
        if priority and priority not in {"low", "medium", "high"}:
            issues.append(Issue("WARN", where, f"items[{i}].priority should be low|medium|high (got {priority!r})."))

        status = str(item.get("status") or "").strip().lower()
        if status and status not in {"todo", "doing", "done", "dropped"}:
            issues.append(Issue("WARN", where, f"items[{i}].status should be todo|doing|done|dropped (got {status!r})."))


def validate_runbook(runbook: dict, config: dict, path: Path, issues: list[Issue]) -> None:
    where = str(path)
    blocks = runbook.get("blocks", [])
    if blocks is None:
        issues.append(Issue("WARN", where, "runbook.toml has no [[blocks]] (is that intentional?)."))
        return
    if not isinstance(blocks, list):
        issues.append(Issue("ERROR", where, "runbook.toml: expected [[blocks]] array."))
        return

    valid_modes = set((config.get("modes") or {}).keys()) if isinstance(config.get("modes"), dict) else set()

    seen: set[str] = set()
    for i, b in enumerate(blocks):
        if not isinstance(b, dict):
            issues.append(Issue("ERROR", where, f"blocks[{i}] must be a table."))
            continue

        bid = str(b.get("id") or "").strip()
        if not bid:
            issues.append(Issue("ERROR", where, f"blocks[{i}].id is required."))
        elif bid in seen:
            issues.append(Issue("ERROR", where, f"Duplicate blocks[].id: {bid!r}"))
        else:
            seen.add(bid)

        day = b.get("day")
        if day not in VALID_DAYS:
            issues.append(Issue("ERROR", where, f"blocks[{i}].day must be Mon..Sun (got {day!r})."))

        mode = str(b.get("mode") or "").strip()
        if not mode:
            issues.append(Issue("ERROR", where, f"blocks[{i}].mode is required."))
        elif valid_modes and mode not in valid_modes:
            issues.append(Issue("WARN", where, f"blocks[{i}].mode {mode!r} not found in config modes."))

        start = str(b.get("start") or "").strip()
        try:
            parse_hhmm_to_minutes(start)
        except Exception:
            issues.append(Issue("ERROR", where, f"blocks[{i}].start must be HH:MM (got {start!r})."))

        minutes = b.get("minutes")
        if not isinstance(minutes, int) or minutes <= 0:
            issues.append(Issue("ERROR", where, f"blocks[{i}].minutes must be a positive int (got {minutes!r})."))


def _validate_bits_common(
    bits: list[dict],
    where: str,
    config: dict,
    issues: list[Issue],
    *,
    kind: str,
    require_day: bool,
) -> None:
    valid_modes = set((config.get("modes") or {}).keys()) if isinstance(config.get("modes"), dict) else set()
    valid_status_weekly = {"todo", "done", "skip"}
    for i, b in enumerate(bits):
        if not isinstance(b, dict):
            issues.append(Issue("ERROR", where, f"{kind}[{i}] must be a table."))
            continue
        if require_day:
            day = b.get("day")
            if day not in VALID_DAYS:
                issues.append(Issue("ERROR", where, f"{kind}[{i}].day must be Mon..Sun (got {day!r})."))

        bid = str(b.get("id") or "").strip()
        if not bid:
            issues.append(Issue("ERROR", where, f"{kind}[{i}].id is required."))

        text = str(b.get("text") or "").strip()
        if not text:
            issues.append(Issue("ERROR", where, f"{kind}[{i}].text is required."))

        est = b.get("est_minutes")
        if est is not None and (not isinstance(est, int) or est <= 0):
            issues.append(Issue("WARN", where, f"{kind}[{i}].est_minutes should be a positive int (got {est!r})."))

        mode = str(b.get("mode") or "").strip()
        if mode and valid_modes and mode not in valid_modes:
            issues.append(Issue("WARN", where, f"{kind}[{i}].mode {mode!r} not found in config modes."))

        status = str(b.get("status") or "").strip().lower()
        if status and status not in valid_status_weekly:
            issues.append(Issue("WARN", where, f"{kind}[{i}].status should be todo|done|skip (got {status!r})."))


def validate_week(week: dict, config: dict, path: Path, issues: list[Issue]) -> None:
    where = str(path)

    week_start_raw = week.get("week_start")
    if not isinstance(week_start_raw, str) or not week_start_raw.strip():
        issues.append(Issue("ERROR", where, "Missing week_start (expected ISO date string)."))
        return
    try:
        week_start = parse_iso_date(week_start_raw.strip())
    except Exception:
        issues.append(Issue("ERROR", where, f"Invalid week_start: {week_start_raw!r} (expected YYYY-MM-DD)."))
        return

    if week_start.weekday() != 0:
        issues.append(Issue("WARN", where, f"week_start {week_start.isoformat()} is not a Monday."))

    # Time blocks
    blocks = week.get("time_blocks", [])
    if blocks is None:
        blocks = []
    if not isinstance(blocks, list):
        issues.append(Issue("ERROR", where, "time_blocks must be an array of tables ([[time_blocks]])."))
        blocks = []

    day_bounds = config.get("day_bounds") if isinstance(config.get("day_bounds"), dict) else {}
    bound_start = None
    bound_end = None
    if isinstance(day_bounds, dict):
        try:
            if "start" in day_bounds:
                bound_start = parse_hhmm_to_minutes(str(day_bounds["start"]))
            if "end" in day_bounds:
                bound_end = parse_hhmm_to_minutes(str(day_bounds["end"]))
        except Exception:
            # config validator will flag this; ignore here
            pass

    # Collect intervals per day for overlap checks
    intervals_by_day: dict[str, list[tuple[int, int, str]]] = {d: [] for d in VALID_DAYS}

    seen_ids: set[str] = set()
    for i, b in enumerate(blocks):
        if not isinstance(b, dict):
            issues.append(Issue("ERROR", where, f"time_blocks[{i}] must be a table."))
            continue
        bid = str(b.get("id") or "").strip()
        if not bid:
            issues.append(Issue("ERROR", where, f"time_blocks[{i}].id is required."))
        elif bid in seen_ids:
            issues.append(Issue("ERROR", where, f"Duplicate time_blocks[].id: {bid!r}"))
        else:
            seen_ids.add(bid)

        day = b.get("day")
        if day not in VALID_DAYS:
            issues.append(Issue("ERROR", where, f"time_blocks[{i}].day must be Mon..Sun (got {day!r})."))
            continue

        start_raw = str(b.get("start") or "").strip()
        try:
            start_min = parse_hhmm_to_minutes(start_raw)
        except Exception:
            issues.append(Issue("ERROR", where, f"time_blocks[{i}].start must be HH:MM (got {start_raw!r})."))
            continue

        minutes = b.get("minutes")
        if not isinstance(minutes, int) or minutes <= 0:
            issues.append(Issue("ERROR", where, f"time_blocks[{i}].minutes must be a positive int (got {minutes!r})."))
            continue

        end_min = start_min + int(minutes)

        if bound_start is not None and start_min < bound_start:
            issues.append(Issue("WARN", where, f"time_blocks[{i}] starts before day_bounds.start ({start_raw})."))
        if bound_end is not None and end_min > bound_end:
            issues.append(Issue("WARN", where, f"time_blocks[{i}] ends after day_bounds.end ({start_raw}+{minutes}m)."))

        intervals_by_day[str(day)].append((start_min, end_min, bid or f"idx-{i}"))

    # Overlap checks
    for day, intervals in intervals_by_day.items():
        if not intervals:
            continue
        intervals_sorted = sorted(intervals, key=lambda t: t[0])
        prev_s, prev_e, prev_id = intervals_sorted[0]
        for s, e, bid in intervals_sorted[1:]:
            if s < prev_e:
                issues.append(
                    Issue(
                        "ERROR",
                        where,
                        f"Overlapping time blocks on {day}: {prev_id!r} ({prev_s:04d}-{prev_e:04d}) overlaps {bid!r} ({s:04d}-{e:04d}).",
                    )
                )
            if e > prev_e:
                prev_s, prev_e, prev_id = s, e, bid

    # Bits
    weekly_bits = week.get("weekly_bits", [])
    if weekly_bits and not isinstance(weekly_bits, list):
        issues.append(Issue("ERROR", where, "weekly_bits must be an array of tables ([[weekly_bits]])."))
        weekly_bits = []
    daily_bits = week.get("daily_bits", [])
    if daily_bits and not isinstance(daily_bits, list):
        issues.append(Issue("ERROR", where, "daily_bits must be an array of tables ([[daily_bits]])."))
        daily_bits = []

    if isinstance(weekly_bits, list) and weekly_bits:
        _validate_bits_common(weekly_bits, where, config, issues, kind="weekly_bits", require_day=False)
    if isinstance(daily_bits, list) and daily_bits:
        _validate_bits_common(daily_bits, where, config, issues, kind="daily_bits", require_day=True)

    # Review sanity
    review = week.get("review") or {}
    if isinstance(review, dict) and "score" in review:
        score = review.get("score")
        if isinstance(score, int):
            if not (0 <= score <= 10):
                issues.append(Issue("WARN", where, f"review.score should be 0–10 (got {score})."))
        else:
            issues.append(Issue("WARN", where, f"review.score should be an int (got {score!r})."))


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--planner", default="./planner", help="Planner directory (default: ./planner)")
    ap.add_argument("--week", action="append", default=[], help="Week file to validate (repeatable).")
    ap.add_argument("--all-weeks", action="store_true", help="Validate all week files in planner/weeks/")
    ap.add_argument("--inbox", action="store_true", help="Validate inbox.toml")
    ap.add_argument("--runbook", action="store_true", help="Validate runbook.toml")
    ap.add_argument("--config", action="store_true", help="Validate config.toml")
    ap.add_argument("--strict", action="store_true", help="Treat warnings as non-zero exit (code 2).")
    args = ap.parse_args()

    planner_dir = Path(args.planner).expanduser().resolve()
    config_path = planner_dir / "config.toml"
    inbox_path = planner_dir / "inbox.toml"
    runbook_path = planner_dir / "runbook.toml"
    weeks_dir = planner_dir / "weeks"

    issues: list[Issue] = []

    config: dict = {}
    if config_path.exists():
        try:
            config = load_toml(config_path)
        except Exception as e:
            issues.append(Issue("ERROR", str(config_path), f"Failed to parse TOML: {e}"))
            config = {}
    else:
        issues.append(Issue("WARN", str(config_path), "config.toml not found (validation will be limited)."))

    # Decide what to validate if no flags are given.
    any_explicit = bool(args.week) or args.all_weeks or args.inbox or args.runbook or args.config
    do_config = args.config or (not any_explicit)
    do_inbox = args.inbox or (not any_explicit)
    do_runbook = args.runbook or (not any_explicit)

    if do_config and config_path.exists():
        validate_config(config, config_path, issues)

    if do_inbox and inbox_path.exists():
        try:
            inbox = load_toml(inbox_path)
            validate_inbox(inbox, config, inbox_path, issues)
        except Exception as e:
            issues.append(Issue("ERROR", str(inbox_path), f"Failed to parse TOML: {e}"))
    elif do_inbox:
        issues.append(Issue("WARN", str(inbox_path), "inbox.toml not found."))

    if do_runbook and runbook_path.exists():
        try:
            runbook = load_toml(runbook_path)
            validate_runbook(runbook, config, runbook_path, issues)
        except Exception as e:
            issues.append(Issue("ERROR", str(runbook_path), f"Failed to parse TOML: {e}"))
    elif do_runbook:
        issues.append(Issue("WARN", str(runbook_path), "runbook.toml not found."))

    week_paths: list[Path] = []
    for w in args.week:
        week_paths.append(Path(w).expanduser().resolve())

    if args.all_weeks:
        if weeks_dir.exists():
            week_paths.extend(discover_week_files(weeks_dir))
        else:
            issues.append(Issue("WARN", str(weeks_dir), "weeks/ directory not found."))

    if not any_explicit and not week_paths:
        # Default: validate latest week if present.
        if weeks_dir.exists():
            latest = latest_week_file(weeks_dir)
            if latest:
                week_paths.append(latest)

    for wp in week_paths:
        if not wp.exists():
            issues.append(Issue("ERROR", str(wp), "Week file not found."))
            continue
        try:
            week = load_toml(wp)
            validate_week(week, config, wp, issues)
        except Exception as e:
            issues.append(Issue("ERROR", str(wp), f"Failed to parse TOML: {e}"))

    # Print
    errs = [x for x in issues if x.level == "ERROR"]
    warns = [x for x in issues if x.level == "WARN"]

    for x in issues:
        print(f"{x.level}: {x.where}: {x.message}")

    print()
    print(f"Summary: {len(errs)} error(s), {len(warns)} warning(s).")

    if errs:
        return 1
    if args.strict and warns:
        return 2
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: assets/planner_template/scripts/new_week.py
content: #!/usr/bin/env python3
"""Generate a new week file from runbook blocks.

This script:
- Creates planner/weeks/YYYY-Www.toml
- Copies runbook [[blocks]] into week [[time_blocks]]

Usage (from workspace root):
  python3 planner/scripts/new_week.py --week-start 2026-03-02
  python3 planner/scripts/new_week.py --week-id 2026-W10 --week-start 2026-03-02

Notes:
- Refuses to overwrite existing week files.
- Requires Python 3.11+ (tomllib).
"""

from __future__ import annotations

import argparse
import datetime as dt
import re
import sys
from pathlib import Path

import tomllib


VALID_DAYS = {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
WEEK_ID_RE = re.compile(r"^\d{4}-W\d{2}$")


def load_toml(path: Path) -> dict:
    with path.open("rb") as f:
        return tomllib.load(f)


def parse_iso_date(s: str) -> dt.date:
    try:
        return dt.date.fromisoformat(s)
    except Exception as e:
        raise SystemExit(f"Invalid ISO date: {s!r}") from e


def parse_hhmm(s: str) -> None:
    try:
        hh, mm = s.split(":")
        hh_i = int(hh)
        mm_i = int(mm)
        if not (0 <= hh_i <= 23 and 0 <= mm_i <= 59):
            raise ValueError
    except Exception as e:
        raise SystemExit(f"Invalid HH:MM time: {s!r}") from e


def week_id_from_week_start(week_start: dt.date) -> str:
    iso_year, iso_week, _iso_weekday = week_start.isocalendar()
    return f"{iso_year}-W{iso_week:02d}"


def toml_str(s: str) -> str:
    # Minimal TOML string escaping for our use case.
    return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument(
        "--week-start",
        required=True,
        help="Monday date (ISO), e.g. 2026-03-02",
    )
    ap.add_argument(
        "--week-id",
        default=None,
        help="Optional, e.g. 2026-W10 (default: computed from --week-start ISO week)",
    )
    ap.add_argument(
        "--planner",
        default=None,
        help="Path to planner root (default: parent of this script)",
    )
    ap.add_argument("--runbook", default=None, help="Override path to runbook.toml")
    ap.add_argument("--config", default=None, help="Override path to config.toml (used for mode validation)")
    ap.add_argument("--out", default=None, help="Override output path (default: <planner>/weeks/<week-id>.toml)")
    args = ap.parse_args()

    week_start = parse_iso_date(args.week_start.strip())
    if week_start.weekday() != 0:
        print(
            f"Warning: week_start {week_start.isoformat()} is not a Monday (weekday={week_start.weekday()}).",
            file=sys.stderr,
        )

    computed_week_id = week_id_from_week_start(week_start)
    week_id = (args.week_id or computed_week_id).strip()

    if args.week_id and args.week_id.strip() != computed_week_id:
        print(
            f"Warning: provided week-id {args.week_id.strip()!r} does not match ISO week for week_start "
            f"({computed_week_id}).",
            file=sys.stderr,
        )
    if not WEEK_ID_RE.match(week_id):
        print(f"Warning: week-id {week_id!r} does not match the usual pattern YYYY-Www", file=sys.stderr)

    script_dir = Path(__file__).resolve().parent
    planner_dir = Path(args.planner).expanduser().resolve() if args.planner else script_dir.parent

    runbook_path = Path(args.runbook).expanduser().resolve() if args.runbook else (planner_dir / "runbook.toml")
    config_path = Path(args.config).expanduser().resolve() if args.config else (planner_dir / "config.toml")

    out_path = Path(args.out).expanduser().resolve() if args.out else (planner_dir / "weeks" / f"{week_id}.toml")

    if out_path.exists():
        raise SystemExit(f"Refusing to overwrite existing file: {out_path}")

    if not runbook_path.exists():
        raise SystemExit(f"Runbook not found: {runbook_path}")

    runbook = load_toml(runbook_path)
    blocks = runbook.get("blocks", [])
    if not isinstance(blocks, list):
        raise SystemExit("runbook.toml: expected [[blocks]] array")

    valid_modes: set[str] | None = None
    if config_path.exists():
        cfg = load_toml(config_path)
        modes = cfg.get("modes") or {}
        if isinstance(modes, dict) and modes:
            valid_modes = set(modes.keys())

    seen_ids: set[str] = set()
    for b in blocks:
        bid = str(b.get("id") or "").strip()
        if not bid:
            raise SystemExit("runbook.toml: missing blocks[].id")
        if bid in seen_ids:
            raise SystemExit(f"runbook.toml: duplicate blocks[].id: {bid!r}")
        seen_ids.add(bid)

        day = b.get("day")
        if day not in VALID_DAYS:
            raise SystemExit(f"runbook.toml: invalid day for {bid!r}: {day!r}")

        mode = str(b.get("mode") or "").strip()
        if not mode:
            raise SystemExit(f"runbook.toml: missing mode for {bid!r}")
        if valid_modes is not None and mode not in valid_modes:
            raise SystemExit(
                f"runbook.toml: invalid mode for {bid!r}: {mode!r}. Expected one of: {sorted(valid_modes)}"
            )

        start = str(b.get("start") or "").strip()
        parse_hhmm(start)

        minutes = b.get("minutes")
        if not isinstance(minutes, int) or minutes <= 0:
            raise SystemExit(f"runbook.toml: invalid minutes for {bid!r}: {minutes!r}")

    out_path.parent.mkdir(parents=True, exist_ok=True)

    # Write explicit TOML (hand-friendly).
    lines: list[str] = []
    lines.append(f"week_start = {toml_str(week_start.isoformat())}\n")

    lines.append("[goals]")
    lines.append('primary_outcome = ""')
    lines.append('maintenance_outcome = ""\n')
    lines.append("# Optional: 2–5 focus tasks you keep coming back to all week.")
    lines.append("focus_tasks = []\n")

    lines.append("# Time blocks (published to calendar)")
    lines.append("# id: required, unique within week")
    lines.append("# day: Mon|Tue|Wed|Thu|Fri|Sat|Sun")
    lines.append("# mode: any key defined in config.toml under [modes.*]\n")

    for b in blocks:
        title = str(b.get("title") or "")
        notes = str(b.get("notes") or "")
        lines.append("[[time_blocks]]")
        lines.append(f"id = {toml_str(str(b['id']))}")
        lines.append(f"day = {toml_str(str(b['day']))}")
        lines.append(f"mode = {toml_str(str(b['mode']))}")
        lines.append(f"start = {toml_str(str(b['start']))}")
        lines.append(f"minutes = {int(b['minutes'])}")
        lines.append(f"title = {toml_str(title)}")
        lines.append(f"notes = {toml_str(notes)}\n")

    # Unscheduled bits (commented examples)
    lines.append("# --- Unscheduled bits (no fixed time) ---\n")

    lines.append("# Weekly bits: do sometime this week")
    lines.append("# [[weekly_bits]]")
    lines.append('# id = "pay-bills"')
    lines.append('# text = "Pay bills"')
    lines.append("# est_minutes = 20")
    lines.append('# mode = "ops"')
    lines.append('# status = "todo"   # todo|done|skip')
    lines.append('# notes = ""\n')

    lines.append("# Daily bits: do sometime on a given day")
    lines.append("# [[daily_bits]]")
    lines.append('# day = "Tue"')
    lines.append('# id = "groceries"')
    lines.append('# text = "Buy groceries"')
    lines.append("# est_minutes = 45")
    lines.append('# mode = "ops"')
    lines.append('# status = "todo"')
    lines.append('# notes = ""\n')

    # End-of-week review
    lines.append("# --- End-of-week review ---")
    lines.append("[review]")
    lines.append("score = 0")
    lines.append("wins = []")
    lines.append("fails = []")
    lines.append('what_i_learned = ""')
    lines.append('next_week_focus = ""\n')

    out_path.write_text("\n".join(lines), encoding="utf-8")

    print(f"Wrote: {out_path}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: assets/planner_template/scripts/sync_week.py
content: #!/usr/bin/env python3
"""Sync planner week TOML -> Google Calendar (via gog).

Safety:
- Only touches events on the configured planner calendar.
- Only manages events that contain our private props (marker_prefix + week/key).
- Default is dry-run; use --apply to make changes.
- Even with --apply, requires calendar.write_enabled = true in config.toml.

Usage:
  python3 planner/scripts/sync_week.py --week planner/weeks/2026-W10.toml           # dry-run
  python3 planner/scripts/sync_week.py --week planner/weeks/2026-W10.toml --apply   # write
"""

from __future__ import annotations

import argparse
import datetime as dt
import json
import shutil
import subprocess
import sys
import tomllib
from dataclasses import dataclass
from pathlib import Path
from zoneinfo import ZoneInfo


DAY_OFFSETS = {"Mon": 0, "Tue": 1, "Wed": 2, "Thu": 3, "Fri": 4, "Sat": 5, "Sun": 6}
DAY_ORDER = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]


@dataclass(frozen=True)
class DesiredEvent:
    week_id: str
    key: str
    summary: str
    start: dt.datetime
    end: dt.datetime
    description: str


def load_toml(path: Path) -> dict:
    with path.open("rb") as f:
        return tomllib.load(f)


def sh(cmd: list[str]) -> str:
    try:
        return subprocess.check_output(cmd, text=True)
    except subprocess.CalledProcessError as e:
        # Preserve any partial output for debugging.
        if e.output:
            print(e.output, file=sys.stderr)
        raise


def gog_json(args: list[str]) -> object:
    out = sh(["gog", "calendar", *args, "--json", "--results-only"])
    if not out.strip():
        return None
    return json.loads(out)


def week_id_from_path(week_path: Path) -> str:
    base = week_path.name
    if base.lower().endswith(".toml"):
        base = base[:-5]
    return base


def parse_date_iso(s: str) -> dt.date:
    return dt.date.fromisoformat(s)


def parse_hhmm(s: str) -> tuple[int, int]:
    hh, mm = s.split(":")
    return int(hh), int(mm)


def rfc3339(d: dt.datetime) -> str:
    # gog expects RFC3339; datetime.isoformat() is OK when tz-aware.
    return d.isoformat(timespec="seconds")


def mode_label(config: dict, mode: str) -> str:
    return (((config.get("modes") or {}).get(mode) or {}).get("label") or mode).strip()


def safe_int(value: object, default: int = 0) -> int:
    try:
        return int(value)
    except (TypeError, ValueError):
        return default


def format_minutes(total_minutes: int) -> str:
    total = max(0, int(total_minutes))
    hh, mm = divmod(total, 60)
    if hh and mm:
        return f"{hh}h {mm}m"
    if hh:
        return f"{hh}h"
    return f"{mm}m"


def discover_week_files(weeks_dir: Path) -> list[Path]:
    files: list[Path] = []
    for p in sorted(weeks_dir.glob("*.toml")):
        low = p.name.lower()
        if low == "week_template.toml" or low.endswith(".scratch.toml"):
            continue
        files.append(p)
    return files


def render_week_markdown(config: dict, week_id: str, week: dict) -> str:
    lines: list[str] = []

    lines.append(f"## {week_id}")
    lines.append("")

    week_start = (week.get("week_start") or "").strip()
    if week_start:
        lines.append(f"- week_start: {week_start}")

    goals = week.get("goals") or {}
    primary = (goals.get("primary_outcome") or "").strip()
    maintenance = (goals.get("maintenance_outcome") or "").strip()
    focus_tasks = goals.get("focus_tasks") or []

    if primary or maintenance or focus_tasks:
        lines.append("")
        lines.append("### Goals")
        if primary:
            lines.append(f"- primary_outcome: {primary}")
        if maintenance:
            lines.append(f"- maintenance_outcome: {maintenance}")
        if focus_tasks:
            lines.append("- focus_tasks:")
            for task in focus_tasks:
                lines.append(f"  - {task}")

    time_blocks = week.get("time_blocks") or []
    if time_blocks:
        lines.append("")
        lines.append("### Time blocks")

        def block_sort_key(block: dict) -> tuple[int, int, int, str]:
            day_idx = DAY_OFFSETS.get(block.get("day", ""), 99)
            start_s = str(block.get("start") or "99:99")
            try:
                hh, mm = parse_hhmm(start_s)
            except Exception:
                hh, mm = 99, 99
            return day_idx, hh, mm, str(block.get("id") or "")

        sorted_blocks = sorted(time_blocks, key=block_sort_key)
        for day in DAY_ORDER:
            day_blocks = [b for b in sorted_blocks if b.get("day") == day]
            if not day_blocks:
                continue
            lines.append(f"#### {day}")
            for block in day_blocks:
                mode = str(block.get("mode") or "")
                title = (block.get("title") or "").strip()
                mode_lbl = mode_label(config, mode)
                summary = title if title else mode_lbl
                start_s = str(block.get("start") or "??:??")
                dur = format_minutes(safe_int(block.get("minutes"), 0))
                lines.append(f"- {start_s} ({dur}) — {summary} [{mode}]")
                notes = (block.get("notes") or "").strip()
                if notes:
                    lines.append(f"  - notes: {notes}")

    weekly_bits = week.get("weekly_bits") or []
    if weekly_bits:
        lines.append("")
        lines.append("### Weekly bits")
        for bit in weekly_bits:
            status = str(bit.get("status") or "todo").lower()
            marker = "[x]" if status == "done" else ("[-]" if status == "skip" else "[ ]")
            text = (bit.get("text") or "").strip() or str(bit.get("id") or "(untitled)")
            mode = str(bit.get("mode") or "")
            est = format_minutes(safe_int(bit.get("est_minutes"), 0))
            lines.append(f"- {marker} {text} ({est}, {mode})")
            notes = (bit.get("notes") or "").strip()
            if notes:
                lines.append(f"  - notes: {notes}")

    daily_bits = week.get("daily_bits") or []
    if daily_bits:
        lines.append("")
        lines.append("### Daily bits")
        for day in DAY_ORDER:
            day_bits = [b for b in daily_bits if b.get("day") == day]
            if not day_bits:
                continue
            lines.append(f"#### {day}")
            for bit in day_bits:
                status = str(bit.get("status") or "todo").lower()
                marker = "[x]" if status == "done" else ("[-]" if status == "skip" else "[ ]")
                text = (bit.get("text") or "").strip() or str(bit.get("id") or "(untitled)")
                mode = str(bit.get("mode") or "")
                est = format_minutes(safe_int(bit.get("est_minutes"), 0))
                lines.append(f"- {marker} {text} ({est}, {mode})")
                notes = (bit.get("notes") or "").strip()
                if notes:
                    lines.append(f"  - notes: {notes}")

    review = week.get("review") or {}
    review_score = review.get("score")
    review_wins = review.get("wins") or []
    review_fails = review.get("fails") or []
    review_learned = (review.get("what_i_learned") or "").strip()
    review_next = (review.get("next_week_focus") or "").strip()
    has_review = any(
        [
            review_score not in (None, 0, "0"),
            bool(review_wins),
            bool(review_fails),
            bool(review_learned),
            bool(review_next),
        ]
    )

    if has_review:
        lines.append("")
        lines.append("### Review")
        if review_score is not None:
            lines.append(f"- score: {review_score}/10")
        if review_wins:
            lines.append("- wins:")
            for win in review_wins:
                lines.append(f"  - {win}")
        if review_fails:
            lines.append("- fails:")
            for fail in review_fails:
                lines.append(f"  - {fail}")
        if review_learned:
            lines.append(f"- what_i_learned: {review_learned}")
        if review_next:
            lines.append(f"- next_week_focus: {review_next}")

    lines.append("")
    return "\n".join(lines)


def write_weeks_markdown_index(config: dict, weeks_dir: Path, out_path: Path) -> int:
    week_files = discover_week_files(weeks_dir)

    lines: list[str] = []
    lines.append("# Planner Weeks Index")
    lines.append("")
    lines.append("Auto-generated from planner/weeks/*.toml by sync_week.py. Do not edit manually.")
    lines.append("")

    for week_file in week_files:
        week = load_toml(week_file)
        week_id = week_id_from_path(week_file)
        lines.append(render_week_markdown(config, week_id, week))

    out_path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
    return len(week_files)


def build_desired_events(config: dict, week: dict, week_id: str, marker_prefix: str) -> list[DesiredEvent]:
    tz = ZoneInfo(str(config.get("timezone") or "UTC"))

    week_start = parse_date_iso(week["week_start"])
    mode_cfg = (config.get("modes") or {})

    desired: list[DesiredEvent] = []
    seen_block_ids: set[str] = set()

    for block in week.get("time_blocks", []):
        block_id = (block.get("id") or "").strip()
        if not block_id:
            raise ValueError("Missing time_blocks[].id (required). Use a short mnemonic like 'sage-otter'.")
        if block_id in seen_block_ids:
            raise ValueError(f"Duplicate time_blocks[].id: {block_id!r}")
        seen_block_ids.add(block_id)

        day = block["day"]
        if day not in DAY_OFFSETS:
            raise ValueError(f"Invalid day: {day!r}. Use Mon..Sun")

        mode = block["mode"]
        start_s = block["start"]
        minutes = int(block["minutes"])
        title = (block.get("title") or "").strip()
        notes = (block.get("notes") or "").strip()

        mode_lbl = ((mode_cfg.get(mode) or {}).get("label") or mode).strip()

        d = week_start + dt.timedelta(days=DAY_OFFSETS[day])
        hh, mm = parse_hhmm(start_s)
        start = dt.datetime(d.year, d.month, d.day, hh, mm, tzinfo=tz)
        end = start + dt.timedelta(minutes=minutes)

        # Event title format: put the thing first, mode second.
        # If the title is missing (or matches the mode label), just use the label.
        if (not title) or (title.casefold() == mode_lbl.casefold()):
            summary = mode_lbl
        else:
            summary = f"{title} ({mode})"

        # Stable identifier: moving a block updates the same event.
        key = f"{week_id}|{block_id}"

        prefix = marker_prefix
        desc_lines = [
            f"{prefix}_week={week_id}",
            f"{prefix}_key={key}",
            f"{prefix}_block_id={block_id}",
        ]
        if notes:
            desc_lines += ["", notes]
        description = "\n".join(desc_lines)

        desired.append(DesiredEvent(week_id=week_id, key=key, summary=summary, start=start, end=end, description=description))

    return desired


def fetch_existing(calendar_id: str, week_id: str, start: dt.datetime, end: dt.datetime, marker_prefix: str) -> list[dict]:
    # Filter to only our events for this week.
    res = gog_json(
        [
            "events",
            calendar_id,
            "--from",
            rfc3339(start),
            "--to",
            rfc3339(end),
            "--all-pages",
            "--private-prop-filter",
            f"{marker_prefix}_week={week_id}",
        ]
    )
    if res is None:
        return []
    if isinstance(res, dict) and "events" in res:
        return res["events"]
    if isinstance(res, list):
        return res
    return []


def get_private_props(ev: dict) -> dict:
    return (((ev.get("extendedProperties") or {}).get("private") or {}))


def plan_actions(
    desired: list[DesiredEvent],
    existing: list[dict],
    marker_prefix: str,
) -> tuple[list[DesiredEvent], list[tuple[DesiredEvent, dict]], list[dict]]:
    desired_by_key = {d.key: d for d in desired}

    existing_by_key: dict[str, dict] = {}
    for ev in existing:
        props = get_private_props(ev)
        key = props.get(f"{marker_prefix}_key")
        if key:
            existing_by_key[str(key)] = ev

    to_create: list[DesiredEvent] = []
    to_update: list[tuple[DesiredEvent, dict]] = []
    to_delete: list[dict] = []

    # Create/update
    for key, d in desired_by_key.items():
        ev = existing_by_key.get(key)
        if not ev:
            to_create.append(d)
            continue

        ev_summary = ev.get("summary") or ""
        ev_desc = ev.get("description") or ""
        ev_start = (ev.get("start") or {}).get("dateTime")
        ev_end = (ev.get("end") or {}).get("dateTime")

        changed = False
        if ev_summary != d.summary:
            changed = True
        if (ev_start or "") != rfc3339(d.start):
            changed = True
        if (ev_end or "") != rfc3339(d.end):
            changed = True
        if ev_desc != d.description:
            changed = True

        if changed:
            to_update.append((d, ev))

    # Delete events that exist but are no longer desired
    for key, ev in existing_by_key.items():
        if key not in desired_by_key:
            to_delete.append(ev)

    return to_create, to_update, to_delete


def apply_create(calendar_id: str, d: DesiredEvent, marker_prefix: str) -> dict:
    block_id = d.key.split("|", 1)[1] if "|" in d.key else ""
    return gog_json(
        [
            "create",
            calendar_id,
            "--summary",
            d.summary,
            "--from",
            rfc3339(d.start),
            "--to",
            rfc3339(d.end),
            "--description",
            d.description,
            "--private-prop",
            f"{marker_prefix}_week={d.week_id}",
            "--private-prop",
            f"{marker_prefix}_key={d.key}",
            "--private-prop",
            f"{marker_prefix}_block_id={block_id}",
            "--send-updates",
            "none",
        ]
    )


def apply_update(calendar_id: str, d: DesiredEvent, ev: dict, marker_prefix: str) -> dict:
    ev_id = ev["id"]
    block_id = d.key.split("|", 1)[1] if "|" in d.key else ""
    return gog_json(
        [
            "update",
            calendar_id,
            ev_id,
            "--summary",
            d.summary,
            "--from",
            rfc3339(d.start),
            "--to",
            rfc3339(d.end),
            "--description",
            d.description,
            "--private-prop",
            f"{marker_prefix}_week={d.week_id}",
            "--private-prop",
            f"{marker_prefix}_key={d.key}",
            "--private-prop",
            f"{marker_prefix}_block_id={block_id}",
            "--force",
        ]
    )


def apply_delete(calendar_id: str, ev: dict) -> object:
    return gog_json(["delete", calendar_id, ev["id"], "--force"])


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--week", required=True, help="Path to weeks/YYYY-Www.toml")
    ap.add_argument("--planner", default=None, help="Path to planner root (default: parent of this script)")
    ap.add_argument("--config", default=None, help="Path to config.toml (default: <planner>/config.toml)")
    ap.add_argument("--apply", action="store_true", help="Apply changes (default is dry-run)")
    ap.add_argument("--no-index", action="store_true", help="Skip writing planner/weeks/INDEX.md")
    args = ap.parse_args()

    if shutil.which("gog") is None:
        raise SystemExit("gog: command not found. Install/enable the gog CLI (required for calendar sync).")

    week_path = Path(args.week).expanduser().resolve()
    if not week_path.exists():
        raise SystemExit(f"Week file not found: {week_path}")

    script_dir = Path(__file__).resolve().parent
    planner_dir = Path(args.planner).expanduser().resolve() if args.planner else script_dir.parent

    config_path = Path(args.config).expanduser().resolve() if args.config else (planner_dir / "config.toml")
    if not config_path.exists():
        raise SystemExit(f"Config not found: {config_path}")

    config = load_toml(config_path)
    week = load_toml(week_path)

    weeks_dir = week_path.parent
    weeks_index_path = weeks_dir / "INDEX.md"
    if not args.no_index:
        count = write_weeks_markdown_index(config, weeks_dir, weeks_index_path)
    else:
        count = 0

    cal = config.get("calendar") or {}
    calendar_id = cal.get("planner_calendar_id")
    if not calendar_id:
        raise SystemExit("Missing calendar.planner_calendar_id in config.toml")

    write_enabled = bool(cal.get("write_enabled", False))

    sync_cfg = config.get("sync") or {}
    marker_prefix = str(sync_cfg.get("marker_prefix") or "planner").strip()
    if not marker_prefix:
        raise SystemExit("sync.marker_prefix must be a non-empty string")

    week_id = week_id_from_path(week_path)

    desired = build_desired_events(config, week, week_id, marker_prefix)

    tz = ZoneInfo(str(config.get("timezone") or "UTC"))
    week_start = parse_date_iso(week["week_start"])
    range_start = dt.datetime(week_start.year, week_start.month, week_start.day, 0, 0, tzinfo=tz)
    range_end = range_start + dt.timedelta(days=7)

    existing = fetch_existing(calendar_id, week_id, range_start, range_end, marker_prefix)
    to_create, to_update, to_delete = plan_actions(desired, existing, marker_prefix)

    def fmt(d: DesiredEvent) -> str:
        return f"{d.key} | {d.summary} | {d.start.strftime('%a %H:%M')}–{d.end.strftime('%H:%M')}"

    print(f"Week: {week_id}")
    print(f"Calendar: {calendar_id}")
    print(f"Marker prefix: {marker_prefix}")
    print(f"Desired blocks: {len(desired)}")
    print(f"Existing managed events: {len(existing)}")
    if not args.no_index:
        print(f"Weeks index: {weeks_index_path} ({count} week files)")

    print("\nPlan:")
    for d in to_create:
        print("  CREATE", fmt(d))
    for d, ev in to_update:
        print("  UPDATE", fmt(d), f"(id={ev.get('id')})")
    for ev in to_delete:
        props = get_private_props(ev)
        print("  DELETE", props.get(f"{marker_prefix}_key"), f"(id={ev.get('id')})")

    if not args.apply:
        print("\nDry-run only. Re-run with --apply to write changes.")
        return 0

    if not write_enabled:
        raise SystemExit("Refusing to apply changes: calendar.write_enabled is false in config.toml")

    print("\nApplying...")
    for d in to_create:
        apply_create(calendar_id, d, marker_prefix)
    for d, ev in to_update:
        apply_update(calendar_id, d, ev, marker_prefix)
    for ev in to_delete:
        apply_delete(calendar_id, ev)

    print("Done.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: assets/planner_template/scripts/export_ics.py
content: #!/usr/bin/env python3
"""Export planner week TOML -> .ics (iCalendar) file.

This is a safe, non-destructive alternative to direct Google Calendar sync.

Only week [[time_blocks]] are exported.

Examples (from workspace root):

  python3 planner/scripts/export_ics.py --week planner/weeks/2026-W10.toml
  python3 planner/scripts/export_ics.py --week planner/weeks/2026-W10.toml --out /tmp/week.ics

Notes:
- Uses timezone from planner/config.toml (default: UTC).
- Writes events in UTC (Z) so imports are timezone-correct.
- Produces stable UIDs so re-importing is less likely to duplicate events (calendar-app dependent).
"""

from __future__ import annotations

import argparse
import datetime as dt
import hashlib
import re
from pathlib import Path
from zoneinfo import ZoneInfo

import tomllib


VALID_DAYS = {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
DAY_OFFSETS = {"Mon": 0, "Tue": 1, "Wed": 2, "Thu": 3, "Fri": 4, "Sat": 5, "Sun": 6}
WEEK_ID_RE = re.compile(r"^\d{4}-W\d{2}$", re.IGNORECASE)


def load_toml(path: Path) -> dict:
    with path.open("rb") as f:
        return tomllib.load(f)


def week_id_from_path(week_path: Path) -> str:
    base = week_path.name
    if base.lower().endswith(".toml"):
        base = base[:-5]
    return base


def parse_iso_date(s: str) -> dt.date:
    return dt.date.fromisoformat(s)


def parse_hhmm(s: str) -> tuple[int, int]:
    hh, mm = s.split(":")
    return int(hh), int(mm)


def to_utc_z(d: dt.datetime) -> str:
    # RFC5545 UTC format: YYYYMMDDTHHMMSSZ
    return d.astimezone(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")


def ics_escape(text: str) -> str:
    # RFC5545 value escaping for TEXT
    return (
        text.replace("\\", "\\\\")
        .replace("\r\n", "\n")
        .replace("\r", "\n")
        .replace("\n", "\\n")
        .replace(";", "\\;")
        .replace(",", "\\,")
    )


def fold_ics_line(line: str, limit: int = 75) -> str:
    # Simple line folding at ~75 characters (not octets). Good enough for ASCII-heavy content.
    if len(line) <= limit:
        return line
    parts = [line[:limit]]
    rest = line[limit:]
    while rest:
        parts.append(" " + rest[: limit - 1])
        rest = rest[limit - 1 :]
    return "\r\n".join(parts)


def stable_uid(marker_prefix: str, week_id: str, block_id: str) -> str:
    raw = f"{marker_prefix}|{week_id}|{block_id}".encode("utf-8")
    h = hashlib.sha1(raw).hexdigest()[:16]
    return f"{marker_prefix}-{h}@weekly-planner.local"


def build_events(config: dict, week: dict, week_id: str) -> list[dict]:
    tz_name = str(config.get("timezone") or "UTC")
    tz = ZoneInfo(tz_name)

    marker_prefix = "planner"
    sync_cfg = config.get("sync")
    if isinstance(sync_cfg, dict):
        marker_prefix = str(sync_cfg.get("marker_prefix") or marker_prefix)

    modes = config.get("modes") if isinstance(config.get("modes"), dict) else {}

    week_start = parse_iso_date(str(week.get("week_start")))
    blocks = week.get("time_blocks", []) or []
    if not isinstance(blocks, list):
        raise SystemExit("Week file: expected [[time_blocks]] array")

    events: list[dict] = []
    for b in blocks:
        if not isinstance(b, dict):
            continue
        block_id = str(b.get("id") or "").strip()
        day = str(b.get("day") or "").strip()
        mode = str(b.get("mode") or "").strip()
        start_str = str(b.get("start") or "").strip()
        minutes = b.get("minutes")

        if not block_id or day not in VALID_DAYS or not start_str or not isinstance(minutes, int):
            continue

        hh, mm = parse_hhmm(start_str)
        start_local = dt.datetime.combine(
            week_start + dt.timedelta(days=DAY_OFFSETS[day]),
            dt.time(hour=hh, minute=mm),
            tzinfo=tz,
        )
        end_local = start_local + dt.timedelta(minutes=int(minutes))

        title = str(b.get("title") or "").strip()
        mode_label = str((modes.get(mode) or {}).get("label") or mode)
        summary = title if title else mode_label
        if title and mode:
            summary = f"{title} ({mode_label})"

        notes = str(b.get("notes") or "").strip()

        desc_lines = [
            f"{marker_prefix}_week={week_id}",
            f"{marker_prefix}_block_id={block_id}",
        ]
        if notes:
            desc_lines.append("")
            desc_lines.append(notes)

        events.append(
            {
                "uid": stable_uid(marker_prefix, week_id, block_id),
                "summary": summary,
                "start_utc": to_utc_z(start_local),
                "end_utc": to_utc_z(end_local),
                "description": "\n".join(desc_lines),
            }
        )

    return events


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--planner", default="./planner", help="Planner directory (default: ./planner)")
    ap.add_argument("--week", required=True, help="Week TOML file (e.g. planner/weeks/2026-W10.toml)")
    ap.add_argument("--out", default=None, help="Output .ics path (default: alongside week file)")
    args = ap.parse_args()

    planner_dir = Path(args.planner).expanduser().resolve()
    config_path = planner_dir / "config.toml"

    week_path = Path(args.week).expanduser().resolve()
    if not week_path.exists():
        raise SystemExit(f"Week file not found: {week_path}")

    week_id = week_id_from_path(week_path)
    if not WEEK_ID_RE.match(week_id):
        # still allow export, but warn
        print(f"Warning: week id {week_id!r} does not match YYYY-Www", flush=True)

    config = {}
    if config_path.exists():
        config = load_toml(config_path)

    week = load_toml(week_path)

    events = build_events(config, week, week_id)

    out_path = Path(args.out).expanduser().resolve() if args.out else week_path.with_suffix(".ics")
    out_path.parent.mkdir(parents=True, exist_ok=True)

    now_utc = dt.datetime.now(dt.timezone.utc)
    lines: list[str] = []
    lines.append("BEGIN:VCALENDAR")
    lines.append("VERSION:2.0")
    lines.append("CALSCALE:GREGORIAN")
    lines.append("PRODID:-//weekly-planner//EN")
    # Optional cosmetic name
    export_cfg = config.get("export") if isinstance(config.get("export"), dict) else {}
    calname = str((export_cfg or {}).get("ics_calendar_name") or "Weekly Planner")
    lines.append(f"X-WR-CALNAME:{ics_escape(calname)}")

    for ev in events:
        lines.append("BEGIN:VEVENT")
        lines.append(f"UID:{ev['uid']}")
        lines.append(f"DTSTAMP:{to_utc_z(now_utc)}")
        lines.append(f"SUMMARY:{ics_escape(ev['summary'])}")
        lines.append(f"DTSTART:{ev['start_utc']}")
        lines.append(f"DTEND:{ev['end_utc']}")
        lines.append("STATUS:CONFIRMED")
        lines.append("TRANSP:OPAQUE")
        lines.append(f"DESCRIPTION:{ics_escape(ev['description'])}")
        lines.append("END:VEVENT")

    lines.append("END:VCALENDAR")

    # Fold + CRLF
    out_text = "\r\n".join(fold_ics_line(x) for x in lines) + "\r\n"
    out_path.write_text(out_text, encoding="utf-8")

    print(f"Wrote: {out_path} ({len(events)} event(s))")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: assets/planner_template/modes/MODE_Shutdown.md
content: # MODE: Shutdown

## TRIGGER
- When: end of workday OR late evening
- Read time: 30–60s

## IDENTITY (1–2 lines)
- I am: winding down.
- Priority: protect sleep and end the day cleanly.

## RULES
- Small clean finish beats late-night heroics.
- Don’t start new work.
- Anything still on your mind gets written down.

## STEPS
1) Write down lingering thoughts / tomorrow’s first step.
2) Close the laptop.
3) Quick tidy (2–5 minutes).
4) Hygiene routine.
5) Bedtime.

## IF/THEN
- If wired or frustrated: it’s not getting fixed tonight. Park it and stop.

## DONE WHEN
- In bed, mind clear.


path: assets/planner_template/modes/MODE_Reset.md
content: # MODE: Reset (5–10 minutes)

## TRIGGER
- When: dysregulated, spiralling, doomscrolling, stuck
- Read time: 30–60s

## IDENTITY (1–2 lines)
- I am: getting back on track.
- Priority: reduce intensity and regain traction.

## RULES
- Gentle, minimal, non-judgmental.
- Tiny still counts.

## STEPS
1) Drink water.
2) 1–2 minute micro-meditation (eyes closed, slow breathing).
3) Identify *one* small action (2 minutes).
4) Start it immediately.

## IF/THEN
- If still stuck: take a short walk.
- If resisting: make the next action even smaller.

## DONE WHEN
- Breathing calmer + one action started.


path: assets/planner_template/modes/MODE_Relationships.md
content: # MODE: Relationships

## TRIGGER
- When: spending intentional time with partner/family/friends
- Read time: 30–60s

## IDENTITY (1–2 lines)
- I am: present.
- Priority: connection and care.

## RULES
- No multitasking.
- Listen first; don’t problem-solve unless asked.
- Phones/laptops stay away unless explicitly agreed.

## STEPS
1) Wrap up work: write down where you left off.
2) Set the space (tidy, tea/food, calm).
3) Be curious: ask questions and listen.
4) End with a clean handover: decide what happens next (rest, bedtime, tomorrow plan).

## IF/THEN
- If low energy: say so kindly and take a short break.

## DONE WHEN
- The other person feels heard and you feel connected.


path: assets/planner_template/modes/MODE_Ops.md
content: # MODE: Life Ops

## TRIGGER
- When: scattered, doing admin/errands, start-of-day organisation, weekend reset
- Read time: 60–90s

## IDENTITY (1–2 lines)
- I am: calm, practical, methodical.
- Priority: close loops and reduce friction.

## RULES
- One list. One next action.
- Prefer small wins that unlock the rest of the day.
- Finish loops: messages, bookings, payments, deliveries.

## STEPS
1) Capture: dump tasks into the inbox.
2) Choose 1–3 ops to complete today.
3) Schedule essentials (errands, chores, admin).
4) Do the smallest next action now (2–5 minutes).
5) Close loops.

## IF/THEN
- If overwhelmed: pick the smallest task that removes friction.
- If time is short: do one loop-close + one baseline task.

## DONE WHEN
- Baseline stable + next actions queued.


path: assets/planner_template/modes/MODE_DeepWork.md
content: # MODE: Deep Work

## TRIGGER
- When: building, writing, coding, analysis, or any task that benefits from uninterrupted focus
- Read time: 30–60s

## IDENTITY (1–2 lines)
- I am: focused and deliberate.
- Priority: produce one meaningful output.

## RULES
- One task at a time.
- Break the work into 1–3 concrete deliverables.
- Prefer a 60–90 minute focus block, then a short break.

## STEPS
1) Clarify the deliverable (what is "done"?).
2) Identify the smallest next action.
3) Work until the deliverable is shipped or the next step is clearly queued.
4) Write a short note of what changed + what to do next.

## IF/THEN
- If stuck: reduce scope to a 5–10 minute step.
- If tempted to switch tasks: park the idea in the inbox, then return.

## DONE WHEN
- Output delivered OR next step is unambiguous.


path: assets/planner_template/weeks/WEEK_TEMPLATE.toml
content: # Copy this to weeks/YYYY-Www.toml (or use scripts/new_week.py)

week_start = "2026-01-05"  # Monday date (ISO)

[goals]
primary_outcome = ""
maintenance_outcome = ""

# Optional: 2–5 focus tasks you keep coming back to all week.
focus_tasks = []

# Time blocks are what gets synced to the planner calendar.
# id: required, unique within the week file (short mnemonic like "sage-otter").
# day: Mon|Tue|Wed|Thu|Fri|Sat|Sun
# mode: any key defined in config.toml under [modes.*]

[[time_blocks]]
id = "example-block"
day = "Mon"
mode = "deep-work"
start = "09:00"
minutes = 90
title = ""
notes = ""

# --- Unscheduled bits (no fixed time) ---

# Weekly bits: do sometime this week
# [[weekly_bits]]
# id = "pay-bills"
# text = "Pay bills"
# est_minutes = 20
# mode = "ops"
# status = "todo"   # todo|done|skip
# notes = ""

# Daily bits: do sometime on a given day
# [[daily_bits]]
# day = "Tue"
# id = "groceries"
# text = "Buy groceries"
# est_minutes = 45
# mode = "ops"
# status = "todo"
# notes = ""

# --- End-of-week review ---
[review]
score = 0               # 0–10
wins = []
fails = []
what_i_learned = ""
next_week_focus = ""


path: scripts/init_planner.py
content: #!/usr/bin/env python3
"""Initialise a fresh Weekly Planner folder in a workspace.

This script copies the template planner directory bundled with this skill into a target path.

Usage:
  python3 {baseDir}/scripts/init_planner.py --target ./planner

Safety:
  - Refuses to overwrite an existing non-empty target unless --force is provided.
"""

from __future__ import annotations

import argparse
import shutil
import sys
from pathlib import Path
from typing import NoReturn


def die(msg: str, code: int = 1) -> NoReturn:
    print(msg, file=sys.stderr)
    raise SystemExit(code)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument(
        "--target",
        default="./planner",
        help="Target directory to create (default: ./planner)",
    )
    ap.add_argument(
        "--force",
        action="store_true",
        help="Overwrite the target directory if it already exists (DANGEROUS).",
    )
    args = ap.parse_args()

    skill_root = Path(__file__).resolve().parents[1]
    template_dir = skill_root / "assets" / "planner_template"

    if not template_dir.exists():
        die(f"Template directory not found: {template_dir}")

    target_dir = Path(args.target).expanduser().resolve()

    if target_dir.exists():
        if any(target_dir.iterdir()):
            if not args.force:
                die(
                    "Refusing to overwrite existing non-empty directory:\n"
                    f"  {target_dir}\n\n"
                    "If you really want to replace it, re-run with --force (this will delete it first)."
                )
            shutil.rmtree(target_dir)
        else:
            # copytree() requires the destination to not exist
            shutil.rmtree(target_dir)

    shutil.copytree(template_dir, target_dir)

    print(f"Initialised planner at: {target_dir}")
    print("Next steps:")
    print(f"  1) Edit {target_dir / 'config.toml'} (timezone, modes, optional calendar settings)")
    print("  2) Create a week: python3 planner/scripts/new_week.py --week-start YYYY-MM-DD")
    print("  3) Validate:     python3 planner/scripts/validate.py --week planner/weeks/YYYY-Www.toml")
    print("  4) Publish (optional):")
    print("     - Google Calendar sync (dry-run): python3 planner/scripts/sync_week.py --week planner/weeks/YYYY-Www.toml")
    print("     - Export .ics:                   python3 planner/scripts/export_ics.py --week planner/weeks/YYYY-Www.toml")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: references/PLANNER_SCHEMA.md
content: # Weekly Planner schema

This skill uses a small set of plain-text files (TOML + Markdown) stored in a `planner/` folder.

## Directory layout

A typical workspace layout:

```
planner/
  config.toml
  inbox.toml
  runbook.toml
  modes/
    MODE_DeepWork.md
    MODE_Ops.md
    MODE_Reset.md
    MODE_Relationships.md
    MODE_Shutdown.md
  weeks/
    WEEK_TEMPLATE.toml
    2026-W10.toml
    INDEX.md            # optional, auto-generated by sync scripts
  logs/                 # optional freeform notes
  scripts/
    new_week.py         # create a new week file from runbook
    rollover_week.py    # create next week + carry over unfinished bits
    validate.py         # deterministic validation
    sync_week.py        # Google Calendar sync via gog (optional)
    export_ics.py       # calendar export without Google tooling
```

## `config.toml`

`planner/config.toml` contains stable defaults.

Recommended fields:

- `timezone` (string): IANA timezone name used for calendar sync.
  - Example: `Europe/Berlin`, `Europe/London`, `America/New_York`

- `[day_bounds]` (table): optional planning hints.
  - `start` (HH:MM)
  - `end` (HH:MM)

- `[modes.<mode_key>]` (tables): user-defined mode keys.
  - `label` (string): human label shown in calendar titles when no explicit block title exists.
  - `default_block_minutes` (int): planning hint.
  - Optional: `min_block_minutes` (int)

- `[[weekly_nonnegotiables]]` (array of tables): planning hints.
  - `name` (string)
  - `count` (int)
  - `duration_minutes` (int)
  - Optional: `preferred_windows` (array of `["HH:MM","HH:MM"]`)
  - Optional: `preferred_days` (array of day strings: `Mon`..`Sun`)

- `[calendar]` (table): only needed for Google Calendar sync.
  - `planner_calendar_name` (string, optional): for humans only.
  - `planner_calendar_id` (string, required for sync): the Google Calendar ID.
  - `write_enabled` (bool): extra safety latch; must be `true` before `--apply` works.

- `[sync]` (table): optional sync behaviour.
  - `marker_prefix` (string, default `planner`): prefix used for private props and description markers.

## `inbox.toml`

`planner/inbox.toml` is **append-only**. It contains `[[items]]` entries.

Each `[[items]]` supports:

- `created` (string): RFC3339 timestamp (include offset).
- `text` (string): task description.
- `mode` (string): a key that exists under `[modes.*]`.
- `est_minutes` (int): estimate.
- `priority` (string): `low` | `medium` | `high`.
- `status` (string): `todo` | `doing` | `done` | `dropped`.
- `notes` (string): optional.

## `runbook.toml`

`planner/runbook.toml` defines recurring weekly blocks copied into each new week file.

It contains `[[blocks]]` entries with:

- `id` (string, required): stable identifier (must be unique across blocks).
- `mode` (string, required): mode key.
- `title` (string, required): displayed title.
- `day` (string, required): `Mon`..`Sun`.
- `start` (string, required): `HH:MM`.
- `minutes` (int, required): duration.
- `notes` (string, optional).

## Week file: `weeks/YYYY-Www.toml`

Week files are the main planning artefact.

### Top-level

- `week_start` (string): Monday date in ISO format: `YYYY-MM-DD`.

### `[goals]`

Keep goals short.

Recommended fields:

- `primary_outcome` (string)
- `maintenance_outcome` (string)
- `focus_tasks` (array of strings): 2–5 items.

### `[[time_blocks]]` (scheduled)

Time blocks are what gets synced to calendar.

Fields:

- `id` (string, required): unique within the week.
- `day` (string, required): `Mon`..`Sun`.
- `mode` (string, required): mode key.
- `start` (string, required): `HH:MM`.
- `minutes` (int, required)
- `title` (string, optional)
- `notes` (string, optional)

### `[[weekly_bits]]` (unscheduled, any day)

Fields:

- `id` (string, required)
- `text` (string)
- `est_minutes` (int)
- `mode` (string)
- `status` (string): `todo` | `done` | `skip`
- `notes` (string)

### `[[daily_bits]]` (unscheduled, on a specific day)

Fields:

- `day` (string, required): `Mon`..`Sun`.
- `id` (string, required)
- `text` (string)
- `est_minutes` (int)
- `mode` (string)
- `status` (string): `todo` | `done` | `skip`
- `notes` (string)

### `[review]`

Fields:

- `score` (int): 0–10.
- `wins` (array of strings)
- `fails` (array of strings)
- `what_i_learned` (string)
- `next_week_focus` (string)


path: references/CALENDAR_SYNC.md
content: # Calendar publishing

Publishing is optional. Only `[[time_blocks]]` are published.

There are two supported publishing paths:

1) **Google Calendar sync (direct, destructive)** via `planner/scripts/sync_week.py` + `gog`
2) **.ics export (safe, non-destructive)** via `planner/scripts/export_ics.py`

---

## Option 1: Google Calendar sync (direct, destructive)

If enabled, `planner/scripts/sync_week.py` syncs a week’s `[[time_blocks]]` into a **dedicated Google Calendar**.

### Requirements

- Python 3.11+
- The `gog` CLI (`steipete/gogcli`) available on `PATH`
- A dedicated Google Calendar ID configured in `planner/config.toml`

Install (macOS/Homebrew):

```bash
brew install steipete/tap/gogcli
gog --version
```

Configure calendar target:

```toml
[calendar]
# Dedicated calendar for destructive sync (never use your primary calendar)
planner_calendar_id = "...@group.calendar.google.com"
write_enabled = false
```

Tip: to find calendar IDs (requires gog auth):

```bash
gog calendar calendars --max 50 --json
```

### Safety model

The sync is intentionally conservative:

- It only queries the configured calendar (`calendar.planner_calendar_id`).
- It only manages events that it can identify as **managed**.
- Default mode is **dry-run**.
- Even with `--apply`, it refuses to write unless `calendar.write_enabled = true`.

### How managed events are identified

The script uses a configurable `marker_prefix` (default: `planner`) from:

```toml
[sync]
marker_prefix = "planner"
```

It then sets and filters on Google Calendar **private extended properties**:

- `<prefix>_week = YYYY-Www`
- `<prefix>_key = YYYY-Www|<time_block_id>`
- `<prefix>_block_id = <time_block_id>`

The same markers are also written into the event description for transparency.

### Recommended workflow

1) Start with dry-run:

```bash
python3 planner/scripts/sync_week.py --week planner/weeks/2026-W10.toml
```

2) Review the printed plan (CREATE / UPDATE / DELETE).

3) If it looks correct, opt in to writes:

- edit `planner/config.toml`
- set `calendar.write_enabled = true`

4) Apply:

```bash
python3 planner/scripts/sync_week.py --week planner/weeks/2026-W10.toml --apply
```

### Troubleshooting

#### The script wants to DELETE events I care about

That means those events are considered “managed” for that week, but no longer exist in the week file.

Fix:

- Either restore the corresponding `[[time_blocks]]` entry (same `id`), or
- Accept the delete and apply.

#### Events are being created instead of updated

Common causes:

- You changed the time block `id` (which is the stable key).
- You changed `sync.marker_prefix` between runs.

Fix:

- Keep time block `id` stable.
- Don’t change marker prefix once you’ve begun syncing (unless you intentionally want a clean slate).

---

## Option 2: Export an .ics file (safe, non-destructive)

Use this if you don’t want to configure `gog`, or you want a non-destructive workflow.

Export:

```bash
python3 planner/scripts/export_ics.py --week planner/weeks/2026-W10.toml
```

This writes:

- `planner/weeks/2026-W10.ics`

The user can then import the `.ics` file into most calendar applications.


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: suspicious | SkillSpector: clean (LOW)