390 lines
15 KiB
Python
Executable File
390 lines
15 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Wake digest — cheap, computed session-start context.
|
|
|
|
Emits ~600 tokens of CURRENT state at SessionStart, replacing the ~1,000-token
|
|
session-handoff.md snapshot that had gone five months stale.
|
|
|
|
The governing property: this is DERIVED, never CACHED. A stored digest drifts
|
|
silently from the substrate — that is precisely how MemPalace's boot context
|
|
failed. A computation over the substrate cannot drift; it can only have bugs,
|
|
and bugs are found by positive controls. Every section here degrades honestly:
|
|
if it cannot be computed it says so rather than emitting nothing.
|
|
|
|
wake-digest.py emit the digest
|
|
wake-digest.py --selftest prove each extractor detects presence AND absence
|
|
|
|
Provenance: 2026-07-28, steward-authorized alongside the PENDING split and the
|
|
CLAUDE.md doctrine annotation. Sibling of governance-drift-check.py.
|
|
"""
|
|
import os, re, subprocess, sys, time
|
|
|
|
HOME = os.path.expanduser("~")
|
|
D = os.path.join(HOME, "dotfiles")
|
|
MEM = os.path.join(HOME, ".claude", "projects", "-Users-davidglidden", "memory")
|
|
PENDING = os.path.join(D, "PENDING.md")
|
|
REVIEWED = os.path.join(D, "REVIEWED.md")
|
|
DRIFT = os.path.join(D, "scripts", "governance-drift-check.py")
|
|
REPOS = ["CapableMind-AI", "BetterMemories.io", "chamber-library",
|
|
"animal-davidglidden-eu", "studium-engine"]
|
|
|
|
warn = []
|
|
|
|
|
|
def sh(args, cwd=None, timeout=8):
|
|
try:
|
|
r = subprocess.run(args, cwd=cwd, capture_output=True, text=True, timeout=timeout)
|
|
return r.stdout.strip() if r.returncode == 0 else None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def read(p):
|
|
try:
|
|
return open(p, encoding="utf-8").read()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
# ---------- extractors (pure functions of text, so --selftest can exercise them) ----------
|
|
|
|
def open_items(text):
|
|
"""-> [(header, line_no)] for every '## ' item lacking closure evidence.
|
|
Closure evidence = a REVIEWED-N (resolved by the caller) or CLOSED/COMPLETED
|
|
in the header. Any '## ' line is an item — assuming one naming family missed
|
|
twenty items on 2026-07-28."""
|
|
out = []
|
|
for i, l in enumerate(text.split("\n"), 1):
|
|
if l.startswith("## ") and "CLOSED" not in l and not l.startswith("## COMPLETED"):
|
|
out.append((l[3:].strip(), i))
|
|
return out
|
|
|
|
|
|
def tag_of(text, header):
|
|
m = re.search(re.escape("## " + header) + r".*?\n\*\*Tag:\*\*\s*(\[[A-Z]+\])",
|
|
text, re.S)
|
|
return m.group(1) if m else ""
|
|
|
|
|
|
def strip_frontmatter(text):
|
|
"""YAML frontmatter restates the thread inside `description:`, and matching
|
|
there runs the extraction on into `metadata:`. The body is the record."""
|
|
m = re.match(r"^---\n.*?\n---\n", text, re.S)
|
|
return text[m.end():] if m else text
|
|
|
|
|
|
def extract_anchor(text, anchor, limit=900):
|
|
"""Verbatim text following an anchor. Never summarised — a digest that
|
|
paraphrases the steward's own words is the lossy extraction this whole
|
|
architecture is designed to avoid.
|
|
|
|
Handles both wrap shapes: `**ANCHOR: content**` (label and content share the
|
|
bold run) and `**ANCHOR for next-Claude:** content` (label closes first)."""
|
|
text = strip_frontmatter(text)
|
|
i = text.find(anchor)
|
|
if i < 0:
|
|
return None
|
|
s = text[i + len(anchor): i + len(anchor) + limit + 120]
|
|
c = s.find(":")
|
|
if 0 <= c <= 40: # remainder of the label, not content
|
|
s = s[c + 1:]
|
|
cut = re.search(r"\n\s*\n", s)
|
|
if cut:
|
|
s = s[: cut.start()]
|
|
s = re.sub(r"\s+", " ", s.replace("**", "")).strip(" *—-")
|
|
return (s[:limit].rstrip() + " …[truncated]") if len(s) > limit else s
|
|
|
|
|
|
# ---------- sections ----------
|
|
|
|
def sec_pause():
|
|
files = [f for f in os.listdir(MEM)
|
|
if f.startswith("session-") and not f.startswith("session-ledger-")]
|
|
if not files:
|
|
warn.append("no session memory files found")
|
|
return None, None
|
|
newest = max(files, key=lambda f: os.path.getmtime(os.path.join(MEM, f)))
|
|
age = time.time() - os.path.getmtime(os.path.join(MEM, newest))
|
|
h = age / 3600
|
|
span = f"{age/60:.0f} min" if h < 1 else (f"{h:.1f} h" if h < 48 else f"{h/24:.1f} days")
|
|
return newest, span
|
|
|
|
|
|
def sec_pending():
|
|
t = read(PENDING)
|
|
if t is None:
|
|
warn.append("PENDING.md unreadable")
|
|
return []
|
|
rt = read(REVIEWED) or ""
|
|
rev = {m for m in re.findall(r"^## REVIEWED-(\S+)", rt, re.M)}
|
|
items = []
|
|
for h, ln in open_items(t):
|
|
m = re.match(r"PENDING-(\S+?)\s*—", h)
|
|
if m and m.group(1) in rev:
|
|
continue
|
|
items.append((h, ln, tag_of(t, h)))
|
|
if not items:
|
|
warn.append("PENDING.md parsed but yielded no open items — check the parser")
|
|
return items
|
|
|
|
|
|
def sec_reviewed(n=3):
|
|
t = read(REVIEWED)
|
|
if t is None:
|
|
warn.append("REVIEWED.md unreadable")
|
|
return []
|
|
hs = re.findall(r"^## (REVIEWED-\S+ —.*)$", t, re.M)
|
|
out = []
|
|
for h in hs[-n:]:
|
|
m = re.search(re.escape("## " + h) + r".*?\n\*\*Decision:\*\*\s*(\w+)", t, re.S)
|
|
out.append((h[:88], m.group(1) if m else "?"))
|
|
return out
|
|
|
|
|
|
def sec_drift():
|
|
o = sh([sys.executable, DRIFT])
|
|
if o is None:
|
|
warn.append("drift check did not run")
|
|
return None
|
|
if "INSTRUMENT NOT VERIFIED" in o:
|
|
return "INSTRUMENT NOT VERIFIED — treat as unestablished"
|
|
m = re.search(r"(\d+) claim", o)
|
|
return m.group(1) if m else "0"
|
|
|
|
|
|
def sec_repos():
|
|
rows = []
|
|
for r in REPOS:
|
|
p = os.path.join(HOME, "_Dev", r)
|
|
if not os.path.isdir(os.path.join(p, ".git")):
|
|
continue
|
|
sb = sh(["git", "-C", p, "status", "-sb", "--porcelain"]) or ""
|
|
lines = sb.split("\n")
|
|
head = lines[0][3:] if lines and lines[0].startswith("##") else "?"
|
|
dirty = sum(1 for l in lines[1:] if l.strip())
|
|
subj = sh(["git", "-C", p, "log", "-1", "--format=%s"]) or "?"
|
|
rows.append((r, head, dirty, subj[:60]))
|
|
if not rows:
|
|
warn.append("no repos resolved")
|
|
return rows
|
|
|
|
|
|
def sec_thread(newest):
|
|
t = read(os.path.join(MEM, newest)) if newest else None
|
|
if t is None:
|
|
warn.append("session file unreadable — no thread or question")
|
|
return None, None
|
|
th = extract_anchor(t, "PULLING THREAD", 400)
|
|
q = extract_anchor(t, "LITERAL QUESTION", 900)
|
|
if th is None:
|
|
warn.append("no PULLING THREAD anchor in the last wrap")
|
|
if q is None:
|
|
warn.append("no LITERAL QUESTION anchor in the last wrap")
|
|
return th, q
|
|
|
|
|
|
# ---------- the .app brief ----------
|
|
# The jurist reads Claude.app's personal preferences and has NO filesystem access, so
|
|
# unlike CLAUDE.md that document cannot compute its state — it can only cache it. A cache
|
|
# drifts; the mitigations are to keep it small, generate rather than hand-write it, and
|
|
# date it so staleness is visible rather than misleading (Constitutional Constraint #4
|
|
# across a boundary we cannot check). This emits a paste-ready block and records when it
|
|
# was generated, so the wake can report the age.
|
|
BRIEF_PATH = os.path.join(D, "claude", "app-brief.md")
|
|
|
|
|
|
def trackers():
|
|
t = read(os.path.join(MEM, "MEMORY.md"))
|
|
if t is None:
|
|
warn.append("MEMORY.md unreadable — no workstreams for the brief")
|
|
return []
|
|
m = re.search(r"^## Canonical Workstream Trackers\s*$(.*?)^## ", t, re.S | re.M)
|
|
if not m:
|
|
warn.append("MEMORY.md has no 'Canonical Workstream Trackers' heading")
|
|
return []
|
|
def clip(s, n):
|
|
s = re.sub(r"\s+", " ", s).strip()
|
|
if len(s) <= n:
|
|
return s
|
|
return s[:n].rsplit(" ", 1)[0] + "…"
|
|
|
|
out = []
|
|
for line in m.group(1).split("\n"):
|
|
if not line.startswith("- "):
|
|
continue
|
|
body = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", line[2:]) # unlink
|
|
body = re.sub(r"[*`]", "", body)
|
|
name, _, gist = body.partition(" — ")
|
|
out.append((clip(name, 38), clip(gist, 72)))
|
|
return out
|
|
|
|
|
|
def commits_30d(path):
|
|
o = sh(["git", "-C", path, "log", "--since=30.days", "--oneline"])
|
|
return len(o.split("\n")) if o else 0
|
|
|
|
|
|
def brief(stamp):
|
|
"""Replaces ONLY the projects half of the .app preferences' §Standing Context.
|
|
The personal entries there (the orchestra conflict, the divorce, fraternal practice)
|
|
are steward-held, unverifiable from any substrate, and must never be generated."""
|
|
items = sec_pending()
|
|
o = [f"### Standing Context — Projects *(generated {stamp}; do not hand-edit)*",
|
|
"",
|
|
"Regenerate: `python3 ~/dotfiles/scripts/wake-digest.py --brief`. This is a snapshot,",
|
|
"not a live view — the reader of this document has no filesystem access, so it cannot",
|
|
"be computed here. If today is more than ~30 days after the date above, treat every",
|
|
"line as unverified rather than current. Personal standing context is kept separately",
|
|
"and by hand.", "",
|
|
"TRACKER INDEX (from MEMORY.md — what exists, not what is hot; see repo activity"
|
|
" below for that)"]
|
|
for n, tail in trackers():
|
|
o.append(f" {n:<38} {tail}")
|
|
o.append(f"\nOPEN AUTHORIZATION ITEMS ({len(items)}) — full text in ~/PENDING.md; "
|
|
"closed items in ~/PENDING-archive.md")
|
|
for h, _ln, tag in items:
|
|
o.append(f" {tag:<12} {h[:92]}")
|
|
o.append("\nLAST RULINGS")
|
|
for h, d in sec_reviewed(4):
|
|
o.append(f" {d:<10} {h}")
|
|
o.append("\nREPO ACTIVITY (commits, last 30 days)")
|
|
for r in REPOS:
|
|
p = os.path.join(HOME, "_Dev", r)
|
|
if os.path.isdir(os.path.join(p, ".git")):
|
|
o.append(f" {r:<24} {commits_30d(p):>4}")
|
|
o.append(f"\nGOVERNANCE DRIFT — ~/CLAUDE.md: {sec_drift()} substrate-contradicted claim(s)")
|
|
o.append("\n*(end generated block)*")
|
|
return "\n".join(o) + "\n"
|
|
|
|
|
|
def brief_age_days():
|
|
"""Age from the date written INSIDE the brief, never from mtime: git does not
|
|
preserve mtime, so a fresh clone would make a stale brief read as newly
|
|
generated — false freshness inside the freshness instrument."""
|
|
t = read(BRIEF_PATH)
|
|
if t is None:
|
|
return None
|
|
m = re.search(r"generated (\d{4})-(\d{2})-(\d{2})", t)
|
|
if not m:
|
|
warn.append("app-brief.md carries no 'generated <date>' stamp — age unknown")
|
|
return None
|
|
y, mo, d = (int(x) for x in m.groups())
|
|
return (time.time() - time.mktime((y, mo, d, 12, 0, 0, 0, 0, -1))) / 86400
|
|
|
|
|
|
# ---------- self-test ----------
|
|
|
|
def selftest():
|
|
ok = True
|
|
|
|
def chk(name, cond):
|
|
nonlocal ok
|
|
ok = ok and cond
|
|
print(f" [{'ok ' if cond else 'FAIL'}] {name}")
|
|
|
|
print("extractor controls (presence AND absence):")
|
|
chk("open_items finds a plain item",
|
|
open_items("## PENDING-1 — x") == [("PENDING-1 — x", 1)])
|
|
chk("open_items finds a non-numeric family [v1 splitter's blind spot]",
|
|
open_items("## PENDING-S2 — x\n## PENDING — ICP-19") and
|
|
len(open_items("## PENDING-S2 — x\n## PENDING — ICP-19")) == 2)
|
|
chk("open_items excludes CLOSED / COMPLETED",
|
|
open_items("## PENDING-1 — x (CLOSED)\n## COMPLETED — y") == [])
|
|
chk("open_items returns empty on empty input", open_items("") == [])
|
|
chk("tag_of reads a tag",
|
|
tag_of("## PENDING-9 — t\n**Date:** d\n**Tag:** [ESCALATE]\n", "PENDING-9 — t") == "[ESCALATE]")
|
|
chk("tag_of returns '' when absent", tag_of("## PENDING-9 — t\n", "PENDING-9 — t") == "")
|
|
chk("extract_anchor pulls verbatim",
|
|
extract_anchor("noise\n**PULLING THREAD:** do the thing.\n\nmore", "PULLING THREAD")
|
|
== "do the thing.")
|
|
chk("extract_anchor handles label-inside-bold (**A: content**)",
|
|
extract_anchor("**PULLING THREAD: do the thing.**\n\nmore", "PULLING THREAD")
|
|
== "do the thing.")
|
|
chk("extract_anchor drops a label remnant (**A for next-Claude:** content)",
|
|
extract_anchor("**LITERAL QUESTION for next-Claude:** Is it?\n\nx", "LITERAL QUESTION")
|
|
== "Is it?")
|
|
chk("extract_anchor skips YAML frontmatter",
|
|
extract_anchor('---\ndescription: "PULLING THREAD: the wrong one."\ntype: x\n---\n'
|
|
"**PULLING THREAD: the right one.**\n\nz", "PULLING THREAD")
|
|
== "the right one.")
|
|
chk("extract_anchor marks truncation",
|
|
extract_anchor("**A:** " + "w " * 200, "A", limit=50).endswith("…[truncated]"))
|
|
chk("extract_anchor returns None when the anchor is absent",
|
|
extract_anchor("no anchor here", "PULLING THREAD") is None)
|
|
print("\nlive substrate:")
|
|
chk("PENDING.md readable", read(PENDING) is not None)
|
|
chk("REVIEWED.md readable", read(REVIEWED) is not None)
|
|
chk("drift check runs", sec_drift() is not None)
|
|
chk("memory dir resolves", os.path.isdir(MEM))
|
|
print("\nSELFTEST", "PASS" if ok else "FAIL")
|
|
return 0 if ok else 2
|
|
|
|
|
|
# ---------- render ----------
|
|
|
|
def main():
|
|
newest, span = sec_pause()
|
|
th, q = sec_thread(newest)
|
|
items, revs, drift, repos = sec_pending(), sec_reviewed(), sec_drift(), sec_repos()
|
|
|
|
o = ["=== WAKE DIGEST (computed now — not a stored snapshot) ==="]
|
|
o.append(f"Last wrap: {span} ago ({newest})" if span else "Last wrap: UNKNOWN")
|
|
if th:
|
|
o.append(f"\nPULLING THREAD — {th}")
|
|
if q:
|
|
o.append(f"\nOPEN QUESTION — {q}")
|
|
|
|
o.append(f"\nOPEN AUTHORIZATION ITEMS ({len(items)}) — bodies in ~/PENDING.md at the line given;"
|
|
" closed items are in ~/PENDING-archive.md")
|
|
for h, ln, tag in items:
|
|
o.append(f" L{ln:<5} {tag:<12} {h[:78]}")
|
|
|
|
if revs:
|
|
o.append("\nLAST RULINGS")
|
|
for h, d in revs:
|
|
o.append(f" {d:<10} {h}")
|
|
|
|
o.append(f"\nGOVERNANCE DRIFT — ~/CLAUDE.md: {drift} substrate-contradicted claim(s)"
|
|
" (detection only; correction needs [ESCALATE])")
|
|
|
|
age = brief_age_days()
|
|
if age is None:
|
|
o.append("\n.APP BRIEF — never generated. The jurist's §Standing Context cannot be"
|
|
" checked from here; run `wake-digest.py --brief` and paste it.")
|
|
elif age > 30:
|
|
o.append(f"\n.APP BRIEF — last GENERATED {age:.0f} days ago. This tracks generation,"
|
|
" not pasting: it is a lower bound on the jurist's staleness, never a"
|
|
" guarantee of freshness.")
|
|
|
|
if repos:
|
|
o.append("\nREPOS")
|
|
for r, head, dirty, subj in repos:
|
|
flag = f" ~{dirty} dirty" if dirty else ""
|
|
o.append(f" {r:<24} {head:<34}{flag} {subj}")
|
|
|
|
if warn:
|
|
o.append("\n⚠ DIGEST DEGRADED — these sections could not be computed:")
|
|
for w in warn:
|
|
o.append(f" - {w}")
|
|
o.append("=== END WAKE DIGEST ===")
|
|
print("\n".join(o))
|
|
|
|
|
|
def emit_brief():
|
|
stamp = sh(["date", "+%Y-%m-%d"]) or "date-unavailable"
|
|
text = brief(stamp)
|
|
os.makedirs(os.path.dirname(BRIEF_PATH), exist_ok=True)
|
|
open(BRIEF_PATH, "w", encoding="utf-8").write(text)
|
|
print(text)
|
|
print(f"[written to {BRIEF_PATH} — the wake reports this file's age]", file=sys.stderr)
|
|
if warn:
|
|
print("⚠ brief degraded: " + "; ".join(warn), file=sys.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
if "--brief" in sys.argv:
|
|
sys.exit(emit_brief())
|
|
sys.exit(selftest() if "--selftest" in sys.argv else main())
|
|
except Exception as e: # never break session start
|
|
print(f"=== WAKE DIGEST UNAVAILABLE — {type(e).__name__}: {e} ===")
|