Files
dotfiles/scripts/daybook-ensure.py
T
David F GliddenandClaude Opus 5 7226e0d806 Daily notes link up to week and month, both in the steward's own formats
The steward asked for links between the week's daily notes, weekly and monthly
ids, and rollups that show progress. Three of those already existed in the vault
and had lapsed: 01. Weekly/2025-W15..W21 (7 notes, ISO YYYY-Www), 02. Monthly/
2025.05 (dots, not dashes), a quarterly, and templates for all of them. The old
daily template already carried week: gggg-[W]WW and month: YYYY-MM fields.

So nothing here is invented. The hook now writes his week/month frontmatter and
creates the week and month notes in his folders, in his filename formats.

LINK UPWARD ONLY, and this is the load-bearing design decision. An unresolved-
link census of the vault the same day found 4,799 unresolved targets across
1,366 distinct names, with 56% of notes carrying no link in or out. A nav line
with a "tomorrow" link manufactures ~365 more unresolved links a year, and a
weekly note hard-linking its seven days manufactures ~5 a week for days that
never happened. So: the week note always resolves because the hook creates it;
Previous is written only when yesterday's note actually exists on disk; tomorrow
is never written; and the weekly note hard-links no dailies at all — the dailies
link up, so Obsidian's backlinks pane lists exactly the days that exist.

Two controls carry that: Previous appears when yesterday exists, and is absent in
a fresh directory — proving the check reads the disk rather than the calendar.

The weekly note carries a Work heading and keeps a Personal review heading. The
2025 weekly practice was personal — intentions, patterns, task migration — and
the work rollup sits beside it under its own heading rather than displacing it,
which is the same instruction the steward gave for the daily note.

17 selftest checks, up from 7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JQKeKY9T9d95KpvHwwok8T
2026-08-23 11:07:02 +02:00

284 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""daybook-ensure — the day's work-log note exists before anyone remembers to want it.
Two previous attempts at this log died inside four weeks (`daily-log/` 4 entries, `sessions/`
3, both Mar-Apr 2026). Neither had a trigger; both depended on someone remembering. This hook
removes the remembering: at every session start the day's note exists, skeleton in place, so
writing into it is an append rather than a decision to begin.
Deliberately narrow:
- CREATES only. An existing note is never opened for writing, never rewritten, never
reordered. The prose is the executor's and the hook must not be able to damage it.
- Says so when it fails. The job this replaces died silently for five months behind a
`> /dev/null 2>&1` (see the 2026-08-23 daily note). A hook that cannot write must
announce that, not shrug. Constitutional Constraint #4, at hook scale.
- Always exits 0. Honest reporting must never cost the steward a session.
"""
import datetime, os, sys
VAULT = os.path.expanduser(
"~/Library/Mobile Documents/iCloud~md~obsidian/Documents/David, root-and-branch")
DAILY = os.path.join(VAULT, "01. Daily")
# The steward's own folders and formats, in use since 2025 — 01. Weekly/YYYY-Www.md and
# 02. Monthly/YYYY.MM.md (dots, not dashes). Reused rather than reinvented: the vault already
# holds 2025-W15..W21 and 2025.05, and [[2025-W15]]-style links already resolve against them.
WEEKLY = os.path.join(VAULT, "02. Reviews & Planning", "01. Weekly")
MONTHLY = os.path.join(VAULT, "02. Reviews & Planning", "02. Monthly")
def week_id(day):
y, w, _ = day.isocalendar()
return f"{y}-W{w:02d}"
def month_id(day):
return day.strftime("%Y.%m")
SKELETON = """---
title: "{date} — {dayname}"
type: daily
date: {date}
week: {week}
month: {month}
status: active
tags:
- daily
- worklog
---
# {date} — {dayname}
**{nav}**
*A plain-language record of the day's work, so the history is readable without reading `git log`.
Project work sits under its own heading; decisions, insights and open threads are collected at the end.*
## The day in short
## Decisions taken
## Insights and exchanges worth keeping
## Open / next
"""
def note_path(day, daily_dir=DAILY):
return os.path.join(daily_dir, f"{day.isoformat()}.md")
def ensure(day=None, daily_dir=DAILY):
"""-> (status, path). status in {'exists','created','no-dir','error:<msg>'}.
'exists' is not a failure and 'created' is not a success worth announcing loudly —
the caller decides what is worth a line of the steward's attention.
"""
day = day or datetime.date.today()
p = note_path(day, daily_dir)
try:
if not os.path.isdir(daily_dir):
return "no-dir", p
if os.path.exists(p):
return "exists", p
# Upward links only. A "tomorrow" link on every note manufactures ~365 unresolved
# links a year, and an unresolved-link census on 2026-08-23 found 4,799 already —
# 56% of notes isolated. The week note is created below, so [[week]] always resolves;
# yesterday is linked only if it actually exists.
nav = [f"[[{week_id(day)}|Week]]", f"[[{month_id(day)}|Month]]", "[[Daily index]]"]
prev = day - datetime.timedelta(days=1)
if os.path.exists(note_path(prev, daily_dir)):
nav.insert(0, f"[[{prev.isoformat()}|Previous]]")
body = SKELETON.format(date=day.isoformat(), dayname=day.strftime("%A"),
week=week_id(day), month=month_id(day), nav=" · ".join(nav))
# x-mode: refuse to clobber if something raced us between the check and the write.
with open(p, "x", encoding="utf-8") as fh:
fh.write(body)
return "created", p
except FileExistsError:
return "exists", p
except OSError as e:
return f"error:{e.__class__.__name__}", p
WEEK_SKELETON = """---
type: weekly
week: {week}
start_date: {start}
status: active
tags:
- weekly
- review
---
# Weekly Review — {week}
*Week of {start} to {end}.*
> The daily notes for this week link up to here, so Obsidian's **backlinks** pane below lists
> exactly the days that have one. Nothing is hard-linked downward on purpose: a link to a day that
> never happened is an unresolved link, and the vault already carries thousands of those.
## Work — the week in review
*Written at the end of the week, from the dailies. What moved, what was decided, what is still open.
Plain language, and shorter than the sum of its days — a week's worth of perspective, not a digest.*
## Threads that carried across days
*The point of the weekly view: what appeared on more than one day. A problem that kept returning, a
question that stayed open, an idea that grew. These are invisible from inside a single day.*
## Personal review
*The 2025 weekly practice lived here — intentions, patterns, task migration. Kept as its own
heading; the work rollup above does not displace it.*
"""
MONTH_SKELETON = """---
type: monthly
month: {month}
status: active
tags:
- monthly
- review
---
# Monthly Review — {label}
## Work — the month in perspective
*Written from the weeks, not from the days. The question this answers is the one no daily can:
**did the month go anywhere?** Name the two or three things that actually advanced, and what
consumed time without advancing.*
## Threads that carried across weeks
## Personal review
"""
def rollup(day, kind, weekly_dir=None, monthly_dir=None):
"""-> (status, path) for the week or month note. Same create-only contract as the daily."""
try:
if kind == "week":
d, name = (weekly_dir or WEEKLY), week_id(day)
start = day - datetime.timedelta(days=day.isoweekday() - 1)
body = WEEK_SKELETON.format(week=name, start=start.isoformat(),
end=(start + datetime.timedelta(days=6)).isoformat())
else:
d, name = (monthly_dir or MONTHLY), month_id(day)
body = MONTH_SKELETON.format(month=name, label=day.strftime("%B %Y"))
if not os.path.isdir(d):
return "no-dir", os.path.join(d, name + ".md")
p = os.path.join(d, name + ".md")
if os.path.exists(p):
return "exists", p
with open(p, "x", encoding="utf-8") as fh:
fh.write(body)
return "created", p
except FileExistsError:
return "exists", p
except OSError as e:
return f"error:{e.__class__.__name__}", os.path.join(d, name + ".md")
def selftest():
import tempfile, shutil
ok = fail = 0
def chk(label, cond):
nonlocal ok, fail
print(f" [{'ok ' if cond else 'FAIL'}] {label}")
ok, fail = (ok + (1 if cond else 0), fail + (0 if cond else 1))
d = tempfile.mkdtemp()
try:
day = datetime.date(2026, 8, 23)
st, p = ensure(day, d)
chk("creates the note when absent", st == "created" and os.path.exists(p))
txt = open(p, encoding="utf-8").read()
chk("skeleton carries the ISO date and weekday", "2026-08-23" in txt and "Sunday" in txt)
chk("skeleton is spec-conformant (type/status/tags present)",
"type: daily" in txt and "status: active" in txt and "worklog" in txt)
# THE control that matters: an existing note must survive untouched.
open(p, "w", encoding="utf-8").write("PROSE THE EXECUTOR WROTE\n")
st2, _ = ensure(day, d)
chk("does NOT overwrite an existing note [the whole point]",
st2 == "exists" and open(p, encoding="utf-8").read() == "PROSE THE EXECUTOR WROTE\n")
chk("reports 'exists' rather than 'created' on the second run", st2 == "exists")
# --- upward-link controls. The census that motivated them: 4,799 unresolved link
# targets, 56% of notes isolated. A nav line that manufactures links is the disease.
day2 = datetime.date(2026, 8, 24) # the day AFTER one that now exists
st5, p5 = ensure(day2, d)
txt2 = open(p5, encoding="utf-8").read()
chk("links Previous when yesterday's note EXISTS", "[[2026-08-23|Previous]]" in txt2)
chk("never links tomorrow [no manufactured unresolved link]", "2026-08-25" not in txt2)
chk("links up to the week and month, which the hook creates",
"[[2026-W35|Week]]" in txt2 and "[[2026.08|Month]]" in txt2)
chk("carries the steward's own week/month frontmatter vocabulary",
"week: 2026-W35" in txt2 and "month: 2026.08" in txt2)
d3 = tempfile.mkdtemp()
st6, p6 = ensure(datetime.date(2026, 9, 1), d3) # no previous note in a fresh dir
chk("OMITS Previous when yesterday's note does NOT exist [negative control — proves"
" the Previous check reads the disk, not the calendar]",
"Previous" not in open(p6, encoding="utf-8").read())
shutil.rmtree(d3, ignore_errors=True)
# --- rollups: same create-only contract, the steward's own folders and formats
wd, md = tempfile.mkdtemp(), tempfile.mkdtemp()
try:
stw, pw = rollup(day, "week", wd, md)
chk("creates the weekly note as YYYY-Www.md [his 2025 format]",
stw == "created" and os.path.basename(pw) == "2026-W34.md")
stm, pm = rollup(day, "month", wd, md)
chk("creates the monthly note as YYYY.MM.md [dots, as his 2025.05]",
stm == "created" and os.path.basename(pm) == "2026.08.md")
wtxt = open(pw, encoding="utf-8").read()
chk("weekly carries a Work heading AND keeps his personal-review heading",
"## Work — the week in review" in wtxt and "## Personal review" in wtxt)
chk("weekly hard-links NO daily notes [backlinks instead; zero manufactured links]",
"2026-08-2" not in wtxt.split("start_date")[1].split("## Personal")[0]
.replace("2026-08-23", "").replace("2026-08-17", ""))
open(pw, "w", encoding="utf-8").write("STEWARD PROSE\n")
stw2, _ = rollup(day, "week", wd, md)
chk("does NOT overwrite an existing weekly note",
stw2 == "exists" and open(pw, encoding="utf-8").read() == "STEWARD PROSE\n")
finally:
shutil.rmtree(wd, ignore_errors=True); shutil.rmtree(md, ignore_errors=True)
missing = os.path.join(d, "nope")
st3, _ = ensure(day, missing)
chk("reports a missing directory instead of creating one [no silent mkdir]",
st3 == "no-dir" and not os.path.exists(missing))
# positive control: the failure path must be reachable, or 'no-dir' proves nothing.
st4, _ = ensure(day, d)
chk("the same call succeeds where the directory exists [positive control — proves"
" 'no-dir' reports the directory, not a broken function]", st4 == "exists")
finally:
shutil.rmtree(d, ignore_errors=True)
print(f"\n{'SELFTEST PASS' if not fail else 'SELFTEST FAIL'} — {ok} ok, {fail} failed")
return 0 if not fail else 1
if __name__ == "__main__":
if "--selftest" in sys.argv:
sys.exit(selftest())
made, failed = [], []
for st, pth in (ensure(), rollup(datetime.date.today(), "week"),
rollup(datetime.date.today(), "month")):
if st == "created":
made.append(os.path.basename(pth))
elif st != "exists":
failed.append(f"{os.path.basename(pth)} ({st})")
if made:
print("DAYBOOK — created " + ", ".join(made))
if failed:
# Loud on purpose. The predecessor's silence cost five months.
print("DAYBOOK — COULD NOT WRITE: " + "; ".join(failed))
sys.exit(0)