Files
David F GliddenandClaude Opus 5 3e828fe3f2 Session 2026-08-24: grounding gates ruled, daybook rule changed, two censuses run
PENDING-95: Amendment 2 (jurist ruling — (a) rejected, (d) discharged, (b)/(c)
deferred) plus the (b)/(c) census that discharges the deferral's condition.
60 guarded / 32 marked (record said 59/31); date 75%, sections 97%, quoting 47%.
Date broadly present => the jurist's cheaper third form is the live option.

PENDING-156 opened (kind (c): mechanisms off the path the work takes) and its
option (b) census run the same session. PENDING-109 prior confirmed by direct
read. PENDING-89: two docket entries, one same-direction miss and one
cross-direction catch, filed the same day and at the same speed.

Mechanisms: daybook-cue.py rewritten on steward ruling — the daily note must be
appended to until end of day, so the trigger is staleness, not note size, and the
matcher now includes Bash (it had never fired once). daybook-ensure.py and
/wrap-up 7.5 gain a standing Corrections slot per REVIEWED-126.
governance-mcp.py gains two read-only keys so the jurist can read the artifacts
it rules on; the doctrine that read-surface changes should arrive as rulings is
adopted, and the next key is proposed rather than added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JQKeKY9T9d95KpvHwwok8T
2026-08-24 18:36:34 +02:00

232 lines
9.9 KiB
Python
Executable File

#!/usr/bin/env python3
"""daybook-cue.py — PostToolUse cue (Write|Edit): the daily note has no trigger.
Diagnosed 2026-08-24 from the record, not from impression. Daily notes for 19-22
August do not exist; 2026-08-23 exists at 33,242 bytes across eight commits — written
incrementally, as the convention specifies. So the practice has produced exactly one
note, on the day the convention was authored and live in working memory.
The failure is structural. `daybook-ensure.py` fires at SessionStart and ONLY EVER
CREATES. The next cue is /wrap-up §7.5, hours later. Between them — the entire working
session, which is exactly when the convention says to write — there is no cue at all.
The vault's own notes diagnosed the two dead predecessors correctly ("neither had a
trigger. The hook is the trigger") and then built the trigger for the half that never
needed one. Whether the file exists was never the failure mode.
The convention's own trigger word is "as it lands", and work lands when a file is
written. So this fires there.
REMINDER, NOT BLOCK — unlike verify-before-compose, whose failure is unrecoverable.
This one's is recoverable, and a gate that interrupts every write would be removed
within a day. Fail-open everywhere: any unexpected condition exits 0.
REVISED 2026-08-24, steward ruling, verbatim: "the daily note needs to be appended to
until the end of the day. Period. There is no design choice to make."
Two defects were fixed together, both found by censusing the hooks rather than by a
selftest -- the suite was 16/16 green throughout:
1. WRONG CONDITION. v1 cued only while the note was under SKELETON_CEILING (1200 B),
i.e. it watched "was the note ever started", not "is it current". A session that
appends nothing to an already-filled note was invisible to it: on the day it was
built, the note stood at 15,688 B and the cue would have stayed silent all
afternoon while work landed elsewhere. Size is not the question and the ceiling
is gone. The question is STALENESS: has the note been appended to lately?
2. UNREACHABLE. v1 was wired PostToolUse | Write|Edit -- the same matcher as
verify-before-compose, copied from the hook whose blindness was already recorded
in PENDING-95 since 2026-08-19. Work routes through Bash heredocs, so it never
fired once: no stamp, ~/.claude/state/ did not exist. Bash is now matched, and
any tool use counts as the session being live.
It self-silences on compliance: appending resets the note's mtime, which clears the
staleness condition. It insists only while the note is actually behind.
"""
import datetime, json, os, sys, time
HOME = os.path.expanduser("~")
VAULT = os.path.join(HOME, "Library/Mobile Documents/iCloud~md~obsidian/Documents",
"David, root-and-branch")
DAILY_DIR = os.path.join(VAULT, "01. Daily")
STAMP = os.path.join(HOME, ".claude/state/daybook-cue.stamp")
STALE_SECONDS = 1800 # the note may lag the work by 30 min, no longer
QUIET_SECONDS = 900 # cue at most once per 15 minutes; a nag gets disabled
# Writes that are not "work landing".
IGNORED_FRAGMENTS = ("/scratchpad/", "/private/tmp/", "/tmp/", "/.git/",
"/node_modules/", "/.obsidian/")
IGNORED_SUFFIXES = (".bak", ".stamp", ".lock", ".log", ".jsonl", ".pyc")
def is_substantive(path):
if not path:
return False
if any(f in path for f in IGNORED_FRAGMENTS):
return False
if path.endswith(IGNORED_SUFFIXES):
return False
# writing the daily note itself is the thing being asked for, not a trigger for it
if os.path.normpath(DAILY_DIR) in os.path.normpath(path):
return False
return True
def note_path(today=None):
d = today or datetime.date.today()
return os.path.join(DAILY_DIR, f"{d.isoformat()}.md")
def should_cue(activity, note_exists, note_age, seconds_since_last):
"""Pure decision, so it can be tested without a filesystem or a clock.
activity -- did substantive work just land? (bool)
note_age -- seconds since the daily note was last modified
"""
if not activity:
return False
if not note_exists: # daybook-ensure owns creation, not us
return False
if note_age < STALE_SECONDS: # appended to recently; it is current
return False
if seconds_since_last < QUIET_SECONDS:
return False
return True
def activity_from(tool_input):
"""Substantive work, from EITHER a file_path (Write/Edit) or a command (Bash).
For Bash the written path is not recoverable without parsing arbitrary shell, so
any Bash call counts as the session being live -- which is the right reading of
the rule: the note must be current while work is happening, whatever the tool.
A command that touches the daily note itself is not a trigger for writing it.
"""
path = tool_input.get("file_path") or ""
if path:
return is_substantive(path)
command = tool_input.get("command") or ""
if not command:
return False
if os.path.basename(DAILY_DIR) in command or "01. Daily" in command:
return False
return True
MESSAGE = """DAILY NOTE — behind the work. Append to it now.
The convention is FILL IT AS THE WORK HAPPENS, not at the wrap:
01. Daily/{date}.md (last touched {mins} min ago; {size} bytes)
Give what just landed its own `##` heading now — plain language, for the steward on a
day he wants to know what happened without reading git log. /wrap-up §7.5 finalises;
it never begins the file, and a skeleton at wrap is a failure that step exists to catch.
(Cue at most once per 15 min. Reminder, not a block — the write already succeeded.)"""
def main():
try:
raw = sys.stdin.read()
data = json.loads(raw) if raw.strip() else {}
except Exception:
sys.exit(0)
activity = activity_from(data.get("tool_input") or {})
np = note_path()
try:
exists = os.path.exists(np)
size = os.path.getsize(np) if exists else 0
note_age = (time.time() - os.path.getmtime(np)) if exists else 0
except OSError:
sys.exit(0)
try:
last = os.path.getmtime(STAMP) if os.path.exists(STAMP) else 0
except OSError:
last = 0
since = time.time() - last
if not should_cue(activity, exists, note_age, since):
sys.exit(0)
try:
os.makedirs(os.path.dirname(STAMP), exist_ok=True)
open(STAMP, "w").write(str(time.time()))
except OSError:
pass
print(MESSAGE.format(date=datetime.date.today().isoformat(), size=size,
mins=int(note_age // 60)), file=sys.stderr)
sys.exit(2) # PostToolUse: tool already ran; stderr is surfaced as feedback
def selftest():
ok = fail = 0
def check(name, cond):
nonlocal ok, fail
if cond:
ok += 1; print(f" PASS {name}")
else:
fail += 1; print(f" FAIL {name}")
vault_md = os.path.join(VAULT, "08. Notes/Something.md")
daily_md = os.path.join(DAILY_DIR, "2026-08-24.md")
STALE = STALE_SECONDS + 1
# positive controls — it fires when it should
check("cues when the note is stale and work lands",
should_cue(True, True, STALE, 9999) is True)
check("cues on a LARGE but stale note (the v1 defect: size is not the question)",
should_cue(True, True, STALE, 9999) is True)
# negative controls — each suppression works in isolation
check("silent when the note was just appended to",
should_cue(True, True, 60, 9999) is False)
check("silent when the note does not exist", should_cue(True, False, STALE, 9999) is False)
check("silent inside the quiet window", should_cue(True, True, STALE, 10) is False)
check("silent when no substantive activity", should_cue(False, True, STALE, 9999) is False)
# boundary on staleness
check("threshold is exclusive at STALE_SECONDS",
should_cue(True, True, STALE_SECONDS, 9999) is True)
check("one second under the threshold is silent",
should_cue(True, True, STALE_SECONDS - 1, 9999) is False)
# activity_from — the reachability half (the v1 defect: Bash was invisible)
check("Write/Edit: a real vault write is activity",
activity_from({"file_path": vault_md}) is True)
check("Write/Edit: a repo write is activity",
activity_from({"file_path": os.path.join(HOME, "_Dev/x/y.py")}) is True)
check("BASH counts as activity (v1 never fired because it did not)",
activity_from({"command": "python3 - <<'EOF'\nopen('x','w')\nEOF"}) is True)
check("bash touching the daily note is NOT a trigger for writing it",
activity_from({"command": "cat '01. Daily/2026-08-24.md'"}) is False)
check("empty tool_input is not activity", activity_from({}) is False)
check("silent on the daily note itself", activity_from({"file_path": daily_md}) is False)
check("silent on scratchpad",
activity_from({"file_path": "/private/tmp/claude-501/x/scratchpad/a.md"}) is False)
check("silent on /tmp", activity_from({"file_path": "/tmp/a.md"}) is False)
check("silent on .obsidian config",
activity_from({"file_path": os.path.join(VAULT, ".obsidian/x.json")}) is False)
check("silent on backups", activity_from({"file_path": vault_md + ".bak"}) is False)
check("silent on jsonl logs",
activity_from({"file_path": os.path.join(HOME, "a/b.jsonl")}) is False)
# fail-open on malformed input
import subprocess
r = subprocess.run([sys.executable, __file__], input="not json",
capture_output=True, text=True)
check("fail-open on malformed stdin", r.returncode == 0)
r = subprocess.run([sys.executable, __file__], input="", capture_output=True, text=True)
check("fail-open on empty stdin", r.returncode == 0)
print(f"\nSELFTEST {'PASS' if not fail else 'FAIL'} — {ok} ok, {fail} failed")
return 0 if not fail else 1
if __name__ == "__main__":
if "--selftest" in sys.argv:
sys.exit(selftest())
main()