---
name: eod-digest
description: End-of-day close-out across every active session on this repo — what each session actually did against what it said it would, what is safe to close, and what tomorrow starts on.
argument-hint: "Anything to close out specifically? (optional)"
disable-model-invocation: true
---

# EOD digest

The other half of `morning-standup`. The morning asks what you will do; this asks whether you
did it, and makes it safe to close the windows.

**Attended, never scheduled.** This runs when the human is present and ends with them closing
tabs. Nothing here is a cron job.

**Read-only, and it closes nothing.** No mutation against the droplet, the broker, or the
database. No session closes itself and no session closes another. Every action stays with a
human — this is developer context, not an escalation channel. The fund's own alerting already
works and is a different thing from this.

**Not the fund's Ops EOD digest.** `specs/design.md` §3 gives the Ops seat a 16:15 digest posted
to Slack. That is the firm reporting on its trading day. This is developers closing their
laptops, and the two must not be confused.

Doctrine for how peers talk lives in `coordinating-with-peer-sessions`. The messages below inline
the rules they depend on, because a receiving session may never invoke that skill.

## Phase 0 — the baseline

Read `~/.claude/align/<repo-basename>/standups/YYYY-MM-DD.md` for today.

That file is what makes this a close-out rather than a second standup. **Without it, "did you do
what you said" degrades into "what did you do"** — which is the failure this skill exists to fix.
On 2026-08-21 thirteen sessions reported their day and nothing could be checked against anything,
because no baseline existed.

`morning-standup` Phase 6 writes that file, one block per session with **did / doing / next /
blocked / capacity / owns / stranded**. The intent to diff against is each session's `doing` and
`next`, keyed on `sessionId`. **`stranded` is the morning's per-branch disposition** — `ready` /
`dead` / `blocked-on: #<issue>`, each with the issue that branch served (`serves #<issue>` or
`serves nothing`). Phase 4 reads it rather than re-deriving it.

Lane rows in `~/.claude/align/<repo-basename>/map.md` carry the rest: which lane a session owns,
its **type** (`remediate` / `decide` / `land`), its declared region, and for a land lane its
**branch**. **A row with no type recorded is not a land row** — do not read one as a land row, and
do not fill a type in.

**The sweep row states no region, and that is correct.** `morning-standup` writes one row for the
orphan-sweep lane listing every branch it holds and "no region at all — not a region of its own,
and not its branches' regions." Do not normalise it and do not fill one in: a sweep row's
`sessionId` is a chat opened that morning and therefore live, so a region written there reads as a
live claim the next morning and holds real candidates behind code nobody is editing.

**No file for today?** Say so in one line and fall back to "what did you do" for every session.
**Never fabricate a baseline**, and never quietly treat an absent one as "everyone did what they
said." A missing baseline is a reported state, not a failure — the run continues.

**A session that started after the standup has no intent line, and that is not drift.** Diff it
against nothing and say so; a chat opened at noon never had a morning.

**A block whose intent fields were never asked is not silence either, and the rule above does not
reach it.** `morning-standup` splits sessions into a roster, polled on all seven fields, and an
**idle set**, sent one question: such a session "gets a block with `stranded` alone, marked as
having been asked that one question." It was live before the standup — being old and quiet is what
put it in the idle set — so *started after the standup* is false for exactly the population that
needs the rule. Diff it against nothing, and **drop `against intent` from that session's poll
entirely rather than sending it empty**: never asked is not the same fact as unanswered.
Interpolating the one field that is present is the fabricated baseline this phase forbids.

Where a block carries no intent and does not say why, treat it the same way — drop the field, and
report the baseline as absent without claiming which kind of absence it is.

**A baseline can exist and hold no intent for anyone.** On a quiet night or a weekend the morning's
roster is empty and the idle set is everyone, so every block in the file is `stranded`-only. The
"no file for today" escape does not fire — there is a file. Say once that the day holds no intents
to diff against and run every poll without `against intent`; the `stranded` lines are still the
baseline Phase 4 reads.

## Phase 1 — the repo's health

If `.claude/health.md` exists at the repo root, run the `health_command` from its front matter
and fold the output into the digest. Interpret it with the prose in that file — that is what the
prose is for.

**File absent → skip this phase silently.** Not every repo has one, and its absence is not a
finding.

**The split is the point.** `health_command` is one tested command producing identical reads
every time; the prose beside it is how to read the output. Determinism where drift is fatal,
judgment where interpretation is needed.

**Do not re-derive any of it by hand, and do not add checks here.** Every wrong fact on
2026-08-21 came from an agent deriving production state in prose: four sessions quoted four
different droplet SHAs, none had read the broker, and the fund's own unprotected-position alert
sat eight hours. If you find yourself about to write "also check X" into the digest, X belongs in
the health command, in that repo, with a test.

**Read the descriptor's known-false-positive section before you escalate anything.** A check
flagged there as noise is noise; escalating it anyway is how a reader learns to skip the whole
report, and how the next real finding goes past.

**`health_command` missing, or it exits non-zero?** Render its stderr as a finding and carry on —
the session poll still runs. A broken health command never stops the close-out.

## Phase 2 — roster

Identical mechanics to `morning-standup` Phase 1, and they matter for the same reasons:

```bash
claude agents --json
git worktree list --porcelain
```

Call `claude agents --json` **unscoped** — `--cwd <repo-root>` matches by exact path and drops
every session sitting in a worktree, which on a busy repo is most of them. Keep rows whose `cwd`
equals `<repo-root>` or sits under a path from `git worktree list --porcelain`, matching by
**exact-path equality or containment under `path + "/"`**, never a bare string-prefix test —
`fund-probes/` and `fundablePlayground/` share a prefix with `fund` without being under it.

Your own row is the set difference between `claude agents --json` (which includes you) and the
`ListAgents` tool (which does not). **Exactly one row must survive that difference**; zero or
several means self-identification failed — stop rather than poll a set that may include you or
drop a peer.

**Re-check the roster immediately before you broadcast.** Fleets grow: one round built a roster
of 3 and found 11 live sessions nineteen minutes later. State coverage — "polled 3 of 11".

Two things that have caught people out, both still live:

- **A session that entered a worktree writes to a worktree-scoped projects directory**, so its
  old transcript path goes quiet. **Transcript mtime is not an activity signal** for any session
  that has entered a worktree — one round read a 3.5-hour-old mtime as a stall on a session that
  had merged a PR minutes earlier.
- **Key records on `sessionId`.** Names churn — six sessions renamed inside one day.

If `claude agents --json` fails, stop with the error. Do not fall back to name matching.

## Phase 3 — poll

One `SendMessage` per rostered peer, carrying `notify_when_idle: true` on the same call:

> End-of-day close-out. Answer at your next natural pause — do not abandon work in flight.
>
> - **did** — what you actually landed today, in your own words. Point at the artifact: a commit,
>   a PR, a file, an issue. "Nothing landed, spent the day reading `gate/`" is a complete answer;
>   dead ends count and are worth saying.
> - **against intent** — this morning you said you were doing `<doing>` and next `<next>`. Is
>   that what happened? **A change of plan is a fine answer** — say what changed and why. This is
>   not an audit.
> - **loss on close** — is anything lost if this window closes right now? Check, do not recall:
>
>   ```bash
>   git branch --format='%(refname:short) %(upstream)' | awk '$2==""{print $1}'
>   ```
>
>   then, for each, `git log --oneline origin/master..<branch>` — a local branch with commits **not
>   on `origin/master`** is the answer, not bare local-only. If `origin/master` does not resolve,
>   use `master` and say the baseline was local. Also name uncommitted work in any worktree you
>   have written in, and any file outside a repo you would not want reaped.
> - **releasing** — which regions are you giving up? Name them as regions, not filenames.
> - **still holding** — what stays yours overnight, and why.
> - **tomorrow** — one line: what you would pick up first.
>
> Where you name a fact — a branch, a path, a state — check it rather than recalling it. A
> session that compacted an hour ago will confidently report a branch it left.

**Omit `against intent` for any session the baseline holds no intent for** — one that started
after the standup, and one from the idle set whose block carries `stranded` alone. Never send the
field with an empty `<doing>`, and never fill it from `stranded`.

**Where the morning recorded a disposition for a branch, quote it back instead of asking again** —
*the morning has `<branch>` as `<disposition>`; still true?* Re-asking a recorded fact invites a
session that compacted at lunch to contradict its own morning answer, and the morning's answer is
the one that was checked. Uncommitted work and files outside every repo are recorded nowhere, so
those stay ordinary questions.

Then **end the turn**, saying who you are waiting on. Collection ends on a full house, on a
**2-hour wall-clock cap**, or when every subscribed session has notified and taken a turn since
the poll landed — whichever comes first. A peer unanswered at the cap is named silent and still
receives the digest.

**An idle notice is not a reply.** It fires when a session next finishes a turn, and fires
immediately if it is already idle — possibly before it has read the poll.

## Phase 4 — the loss-on-close scan

Run it yourself as well, over the whole repo. Do not rely on the replies: the two real losses on
2026-08-21 both lived in worktrees belonging to sessions that had already answered "nothing in
flight."

```bash
git worktree list --porcelain
git branch --format='%(refname:short) %(upstream)' | awk '$2==""{print $1}'
```

For each local-only branch, `git log --oneline origin/master..<branch>`.

**It must be the refined form: local-only AND carrying commits not on `origin/master`.** Local-only
alone returned 18 branches on this repo, nearly all merged branches whose remote was deleted — noise a
reader learns to skip. Local-only *and* unmerged returned 2, and both were real.

**The baseline is `origin/master`, the same one the morning asked about** — `stranded` polls branches
"ahead of `origin/master` and unmerged", and a land row is struck on `git branch --merged
origin/master`. Against local `master`, which lags the remote in any checkout nobody has pulled today,
this phase reports commits for a branch the morning already called landed, and the two halves of one
day classify the same branch differently.

`origin/master` is only as fresh as this machine's last fetch, and **this skill never fetches.** Stale,
it degrades toward the behaviour it replaces: a branch that landed since that fetch still reports,
costing a human one look at a push command they do not need — never a lost commit. **If `origin/master`
does not resolve** — no remote, or one never fetched — fall back to `master` and say the baseline was
local; the command errors on an absent ref, and an errored scan read as "nothing at risk" is the one
failure this phase cannot have. Where local `master` is itself **ahead** of the remote, its unpushed
commits appear under every branch built on it: real work at risk, but `master`'s, reported once rather
than once per branch.

**Read the morning's `stranded` answers before classifying any branch**, matching by branch name.
The morning asked its author two things about each branch ahead of `origin/master`, and both
change what this phase should say:

- **`dead`** — its author declared it at 09:00. **Not a loss-on-close finding, and no push
  command**: report it as declared dead and awaiting deletion. Re-surfacing it at 18:00 as at-risk
  work is the digest arguing with a decision it was told about.
- **`blocked-on: #<issue>`** — report it with that blocker named. The morning already recorded who
  or what it waits on.
- **`ready`** — this is the branch where `git push -u origin <branch>` is the right advice.
- **no disposition** — no baseline, no answer, or a branch the morning never named. Classified by
  the scan alone, exactly as before.

**`serves #<issue>` is the only place the branch→issue mapping exists**; use it wherever this
phase asks whose a branch is. A branch recorded `serves nothing` has no issue — say so rather than
inferring one from its name. A disposition stays true about the branch after its author exits: the
morning recorded a fact about the branch, not about that session's liveness.

Report, per finding: the branch or path, whose it is if a session claimed it, and the **one
command** that would make it safe (`git push -u origin <branch>`), where a command is the right
answer at all. **Never push, commit, or prune
anything yourself** — an unpushed commit is someone's judgment call, and pruning a worktree
destroys it. On 2026-08-21 exactly one commit in the fleet existed only inside a gitignored
worktree; the fix was a human running one push.

**Uncommitted work in a shared checkout counts too**, and so does a file living outside every
repo in a temp directory — `/private/tmp` is reaped at date rollover, so a handoff left there is
gone by morning.

## Phase 5 — digest

1. **One block per session** — did / against intent / loss on close / releasing / still holding /
   tomorrow. Silent sessions named as silent.

   **Report drift as a fact, not a verdict.** A session that worked off its stated intent is
   usually right and the morning's plan is usually what was stale. The job is to make the
   divergence visible, not to rule on it.

2. **Close-out state**, one line each:
   - sessions carrying zero bearing — nothing unpushed, nothing held, nothing in flight
   - sessions still holding something, and what
   - anything at risk of loss, with the command that saves it

3. **Consolidation** — overlaps named, the survivor named, and **releases recorded as explicitly
   as claims**. The measured failure in fleets like this is not two agents racing for one task; it
   is an agent finishing and never letting go of the slot. A region nobody records as released
   stays claimed until someone re-derives that it is not.

4. **Flags** — surfaced, never resolved. Each ends with where it goes: `→ /get-aligned` for an
   ownership collision, `→ ask <peer> directly` for a one-to-one unblock, `→ the human` for a
   ruling.

   The `→ the human` ones are what Phase 6 writes under `## Decisions for the human`, and harm that
   is occurring is what it marks `⚠️ LIVE` there. Both are read verbatim by the next morning.

   **Mark each flag reported or verified — and when it is unverified, say what you ran.** A
   session can report an absence, never verify one: nothing found is a fact about your search,
   not about the world.

**For each session carrying zero bearing, print a large banner** so the human can close windows
by sight:

```
════════════════════════════════════════
  CLOSE ME — fund-4a [3a66027d]
  nothing unpushed · nothing held · no work in flight
════════════════════════════════════════
```

**No session closes itself, and no session closes another.** The banner is a recommendation to a
human with a mouse.

## Phase 6 — tomorrow, and record

Append to today's `~/.claude/align/<repo-basename>/standups/YYYY-MM-DD.md` — outcomes and
tomorrow's todos, under the morning's intents rather than replacing them. **The morning file is
tomorrow's baseline**, so what is written here is what the next `morning-standup` opens on.

Keep the morning's intent lines intact beside the outcomes. Overwriting them destroys the only
record of what was expected, and next week the diff is the interesting part.

**Append; never prepend and never replace.** Two things in that file are load-bearing and survive
only because this phase appends — noted so a future edit does not quietly break them, not as a
mechanism, since nothing checks either. The **ledger line is the file's first line**:
`morning-standup` writes it there so the next run reads the trend instead of re-deriving it, and
anything put above it buries it. And **each rider on the decision list carries a day count that
carries forward** — riders get no `map.md` row and no first-seen stamp of their own, so that list
is the only place the count exists.

**Two markers the next morning reads, written in exactly these forms.** `morning-standup` matches
on the strings themselves, not on the prose around them, so an approximation is a silent failure:

- **`## Decisions for the human`** — the fixed heading. A lane named under it in the previous
  digest and still open is a decide lane the next morning, labelled or not. Write tonight's ruling
  flags — the ones this digest sends `→ the human` — under that heading, spelled exactly. Without
  it, a decision surfaced at 18:00 never becomes tomorrow's decide lane. **No such flags, no
  heading**: there is nothing to carry forward. The morning's own occurrence of the heading stays
  where it is; this adds a second one rather than editing the first.
- **`⚠️ LIVE`** — mark any item recording harm that is *occurring*, not harm that is possible. The
  distinction is tense, not severity. The next morning's precedence order pre-empts everything with
  that marker, and marking it is the only thing that produces one, so live harm found tonight and
  left unmarked is invisible at nine. Name the issue whose closing retires it: the item is
  re-dispatched every morning until that issue closes, so one with no issue behind it recurs
  forever.

Update `~/.claude/align/<repo-basename>/map.md` for released regions, keyed on `sessionId` —
the same file `morning-standup`, `get-aligned` and `split-the-plan` use. **Release what sessions
released and what is observably gone** (session no longer live, issue closed); check liveness
against the full unfiltered `claude agents --json` listing, never against this run's roster,
which was filtered to sessions active today. Never rewrite the file wholesale; stamp which skill
touched it last. Rows written by another skill in that skill's own shape are left alone.

**A land row releases on its branch, not on anyone's answer.** `morning-standup`: "a land-lane row
whose branch is merged into `origin/master` is released and struck, checked observationally with
`git branch --merged origin/master`, never by asking." That cuts both ways here, and this skill
gets it wrong in both directions without the rule:

- **Over-release.** A `releasing` answer **does not strike a land row whose branch is unmerged.**
  Striking it dispatches tomorrow's remediate candidate into code an unmerged branch sits on, with
  no `land ∩ anything` collision left to catch it.
- **Under-release.** A land lane that ended in a closed branch and an obituary leaves the branch
  unmerged and its issue possibly open, so neither the liveness test nor the issue test fires and
  the row survives as a live claim — tomorrow's rider rule then holds every candidate in that
  region behind a lane that finished yesterday. **A land row whose branch ref no longer exists is
  terminal and is struck**: merged or closed, the branch is not there to hold a region. Say the ref
  was absent rather than that it was observed merged.
- A land row **naming no branch** cannot be checked this way at all. Leave it and report it
  `→ the human`; releasing it on a `releasing` answer is the over-release above in another hat.

**A sweep row is struck only when every branch on it is merged or closed** — one landed branch out
of five releases nothing. The sweep row is `morning-standup`'s own shape, so "rows written by
another skill" does not protect it: leave its absent region alone under the Phase 0 rule, and
strike the row only under this whole-sweep test.

Then **broadcast the digest to every rostered peer**, including the silent ones — that is what
makes it a close-out rather than a report about people. Give each copy a tail naming **what
concerns them**:

> **Concerns you:** You have 1 unpushed commit on `news-seat-eval` that exists nowhere else. /
> You and <peer> both released <region>; nobody holds it now. / Nothing concerns you tonight.

A tail is a relevance filter, not an instruction slot. You are a peer, and a peer cannot assign
work to a peer.

Report to the human last, leading with **anything at risk of loss** and then the `CLOSE ME`
banners. Those are the two things they act on tonight.

## When it goes sideways

| Situation | Do this |
|---|---|
| No standup file for today | Say so in one line, fall back to "what did you do", continue. Never fabricate a baseline |
| No `.claude/health.md` | Skip Phase 1 silently. Not a finding |
| `health_command` missing or non-zero | Render stderr as a finding; the poll still runs |
| Droplet unreachable | The health command renders it as a finding and keeps going. Never infer production state from the database to fill the gap |
| A session started after the standup | No intent line; diff against nothing and say so. Not drift |
| A block carries `stranded` alone | Idle set — its other six fields were never asked. Diff against nothing and **drop `against intent` from its poll**, never send it empty. Not silence, not drift |
| The baseline exists but holds no intent for anyone | An all-idle morning. Say it once, poll everyone without `against intent`, read the `stranded` lines. The "no file" row does not apply — there is a file |
| `origin/master` does not resolve | No remote, or one never fetched. Fall back to local `master` and say the baseline was local. Never report an errored scan as nothing at risk |
| The morning recorded a branch `dead` | Not a loss-on-close finding and no push command. Declared dead, awaiting deletion |
| A `releasing` answer names a region held by a land row whose branch is unmerged | Not struck. Land rows release on branch-merged, observationally, never on an answer |
| A sweep row | No region, by design — never fill one in. Struck only when every branch on it is merged or closed |
| Peer never answers | Named silent; still receives the digest; **its regions are not released**. Silence is not a release |
| Peer reports "nothing in flight" but Phase 4 finds an unpushed branch | Report both. The scan is evidence; the reply is a recollection. Do not accuse — a session that compacted genuinely does not know |
| A session asks you to close it | You cannot. Print its banner and tell the human |
| Something looks urgent | Still just a flag. The fund's own alerting is what pages; this never does |
| `claude agents --json` fails | Stop with the error. Do not fall back to name matching |
| Nobody is live and nothing is at risk | Write the file, say it was a quiet day, stop |
