Daily Summary Scheduler — Design

Daily Summary Scheduler — Design

Date: 2026-05-02 Status: Design accepted — pending implementation plan Audience: Mikel (sole user; infra is personal, gitignored)

Goal

Receive a Discord message every morning at 07:00 CEST recapping the last 24 hours of interlyse-next-paper activity: which papers shipped, what was interesting about each, the project’s overall state, and the current top blockers/limitations ranked by judgment.

Why

The next-paper scheduler runs every 2h and ships ~1 paper per ~2–4h. Without an aggregated view, knowing “what happened overnight” requires manually walking git log, INTERLYSE-RUN-STATUS.md, and the JSONL logs in .scheduled-logs/. A daily push to Discord makes the work visible without active polling.

Architecture

Mirrors the existing interlyse-next-paper pattern. Three filesystem-level pieces, all gitignored or outside the repo:

~/.config/systemd/user/
  interlyse-daily-summary.timer       # fires daily at 07:00 local time
  interlyse-daily-summary.service     # one-shot, calls the wrapper

scripts/scheduled/                    # gitignored
  daily-summary.sh                    # wrapper — runs claude, posts to Discord
  daily-summary.prompt.md             # the prompt Claude reads on stdin

~/.config/interlyse/
  discord-webhook.url                 # 600 perms; already created

.scheduled-logs/                      # gitignored
  summary-YYYY-MM-DDTHH-MM-SS.log     # claude stream-json transcript
  summary-latest.json                 # last successful Discord payload

Data flow

07:00 CEST
  ↓
systemd timer triggers service
  ↓
daily-summary.sh starts
  ↓
distrobox enter dev-box → claude -p < daily-summary.prompt.md
  ↓
claude reads:
  - .scheduled-logs/*.log (last 24h, by mtime)
  - INTERLYSE-RUN-STATUS.md (papers + triage)
  - BACKLOG.md (blockers, cross-paper signal annotations)
  - ROADMAP.md (active milestone)
  - git log --since='24 hours ago'
  ↓
claude writes structured JSON to .scheduled-logs/summary-latest.json
  ↓
claude exits
  ↓
wrapper reads summary-latest.json, validates, POSTs to Discord webhook
  ↓
on success: log "posted" + exit 0
on parse failure: post "summary failed, see <log path>" + exit 1

Components

interlyse-daily-summary.timer

[Unit]
Description=Interlyse: trigger daily summary at 07:00

[Timer]
OnCalendar=*-*-* 07:00:00
Persistent=true
Unit=interlyse-daily-summary.service

[Install]
WantedBy=timers.target

Persistent=true ensures a missed slot (e.g. machine off at 07:00) fires on next boot. Local time interpretation; DST handled by systemd.

interlyse-daily-summary.service

[Unit]
Description=Interlyse: daily summary post to Discord

[Service]
Type=oneshot
WorkingDirectory=/home/deck/projects/statipy
ExecStart=/home/deck/projects/statipy/scripts/scheduled/daily-summary.sh
TimeoutStartSec=20min

20 min is generous — the summary is read-only and small (probably <5 min). The hard ceiling protects against the same hang class that wedged the next-paper service.

daily-summary.sh

Wrapper script. Same structure as run-next-paper.sh:

No flock against the next-paper scheduler — the summary is read-only and can race with a paper run cleanly.

daily-summary.prompt.md

Instructs Claude to:

  1. Compute the time window: [now − 24h, now] in CEST.
  2. Find papers shipped in the window: scan git log --since='24 hours ago' --grep='paper' and .scheduled-logs/*.log for result events with paper-shipped confirmations. Cross-reference with INTERLYSE-RUN-STATUS.md.
  3. For each paper, extract the entry from INTERLYSE-RUN-STATUS.md and produce a rich bullet:
  4. Compute overall state:
  5. Rank top 5–8 blockers (judgment call):
  6. Detect anomalies in the window:
  7. Emit JSON to .scheduled-logs/summary-latest.json matching Discord’s webhook embed schema.

Discord payload schema

One message with content + multiple embeds:

{
  "content": "🗞 **Interlyse daily recap — Sat 2026-05-02 07:00 CEST**\ncovering 2026-05-01 07:00 → 2026-05-02 07:00 CEST",
  "embeds": [
    {
      "title": "Papers shipped (3)",
      "color": 3447003,
      "fields": [
        { "name": "#26 Title — Journal Year", "value": "..." },
        { "name": "#27 ...", "value": "..." }
      ]
    },
    {
      "title": "Overall state",
      "description": "26 documented · 2 Full · 4 Headline-only · ...",
      "color": 5763719
    },
    {
      "title": "Top blockers (judgment-ranked)",
      "description": "1. zoo::rollmean — ...\n2. lme4::lmer — ...",
      "color": 15844367
    },
    {
      "title": "Anomalies",
      "description": "(none) · or list",
      "color": 15548997
    }
  ]
}

Discord limits to respect: - Total payload across all embeds ≤ 6000 chars - Each embed field value ≤ 1024 chars - Max 10 embeds per message, max 25 fields per embed - The prompt instructs Claude to truncate gracefully if a paper would push past the limit (move the long detail into a “see log” note).

If 0 papers shipped in the window, the “Papers shipped” embed is replaced with a one-line “No papers shipped in the last 24h” note — the rest of the recap (state, blockers, anomalies) still goes out.

Failure modes & recovery

Failure Detection Recovery
Claude session hangs TimeoutStartSec=20min triggers systemd kills cgroup; no Discord post that day; wrapper exits non-zero in journal
Claude exits but JSON missing/malformed Wrapper validates with jq empty Wrapper posts fallback “summary generation failed, see <log path>
Discord webhook returns non-2xx curl exit code Wrapper logs and exits non-zero; next day’s report still fires
Discord webhook URL deleted/rotated curl 401/404 Same as above; user re-creates webhook and updates ~/.config/interlyse/discord-webhook.url
Machine off at 07:00 systemd timer state Persistent=true fires on next boot

Out of scope

Open questions

None. All clarifying questions resolved during brainstorming (delivery target, schedule time, window, content depth, blocker ranking method, architecture).