#+TITLE: Sentry — Overnight Hygiene Supervisor #+AUTHOR: Craig Jennings #+DATE: 2026-07-19 * Overview Sentry is an interval loop that keeps a project's hygiene current while Craig is away. Each fire walks a fixed list of hygiene passes — roam pull, inbox zero, triage, todo cleanup, task audit, working-files hygiene, spec board, link integrity, git health, prep freshness — and commits each pass's writing to a throwaway daily branch. Nothing pushes. In the morning Craig reviews the branch, squash-merges what he wants, and deletes it. The design goal is a project that greets the morning already tidy, with every judgment call and every destructive action parked in an approval queue rather than executed unattended. Sentry does the mechanical sweeping; Craig does the deciding. This file is the engine. It owns the entry gates, the branch mechanics, the lock model, the per-fire pass runner, the digest and approval queue, the skip semantics, and the stop-sentry shutdown. The =agent-lock= helper (=.ai/scripts/agent-lock=) provides the locks. The passes reuse existing workflows (=inbox.org=, =triage-intake.org=, =clean-todo.org=, =task-audit.org=) under sentry's unattended contract. * When to Use This Workflow Craig arms sentry at the end of a session, with the machine left running, to have overnight hygiene done by morning. Triggers: - "start sentry", "run sentry", "arm sentry", "sentry mode" - "let sentry watch this overnight", "keep this tidy overnight" - "start sentry hourly", "start sentry every " (sets the loop interval) Stop trigger (see Stop Sentry below): - "stop sentry", "stand down sentry", "sentry off" Sentry is deliberately *not* auto-armed. Running it in a project is a per-project grant (the =:COMMIT_AUTONOMY:= marker) plus a deliberate launch with Craig at the terminal for the entry gates. * Prerequisite — the autonomy ticket Sentry commits unattended. =commits.md= gates commits on Craig's approval, so sentry needs standing, per-project authorization to run at all. Before anything else, read the project's =.ai/notes.org= Workflow State block for: : :COMMIT_AUTONOMY: yes If the marker is absent or not =yes=, decline to start and name the marker: : Sentry needs ":COMMIT_AUTONOMY: yes" in .ai/notes.org Workflow State to run — it commits unattended. Add it to grant, or run the hygiene passes by hand. No half-running mode: a project without the grant doesn't run sentry's read-only passes either. The grant is one line away, so this is a deliberate opt-in, not a barrier. * Entry — interactive, with Craig present Craig types the sentry trigger, so the first moves run with him at the terminal. Do them in order; each gate that fails stops entry until Craig answers. 1. *Autonomy ticket* — the prerequisite above. Absent → decline and stop. 2. *Dirty-tree gate.* =git diff --quiet HEAD= (tracked modifications only; untracked and gitignored files never block — an inbox drop or scratch file is not in-progress work). If the tracked tree is dirty, describe what's dirty and offer, inline-numbered per =interaction.md=: 1. Finish the job — commit the in-progress work first (recommended if it's a coherent unit) 2. Stash it — =git stash= and start sentry on a clean tree 3. Roll back named changes — discard specific files (names them) Wait for an answer. Sentry can't start unattended from a dirty state; that's the point. 3. *Green-suite gate.* Run the project's full suite (=make test=, or the project's equivalent — detect it). Read the output. If anything is red, describe the failures and offer to investigate before arming. The loop starts only on a green baseline, because every unattended fire measures itself against "did I break this?" and a pre-existing red poisons that check. 4. *Prior sentry branch.* =git branch --list 'sentry/*'=. An unmerged =sentry/*= branch from a previous night means the morning review didn't happen. Surface it and offer to squash-merge or delete it now (Craig is present); don't stack a second sentry branch on the first. 5. *Reconcile the project branch.* Fetch and fast-forward-only against upstream — the same reconcile =startup= runs: : git fetch --all --prune : git rev-list --left-right --count @{u}...HEAD Zero-behind → continue. Behind-only and clean → =git merge --ff-only @{u}=. Diverged → surface to Craig (he's present); don't auto-resolve. 6. *Create the daily branch.* From HEAD: : git switch -c "sentry/$(date +%F)-$(uname -n)" The host suffix (=uname -n=) stops a same-date collision between the two daily drivers. The working tree now sits on this branch overnight — the launch hands the repo to sentry until the morning merge. Reclaiming it mid-night means stopping sentry first (see Stop Sentry). Note the Emacs buffer-revert caveat to Craig if he has the repo open: files change on disk under him overnight, so buffers want reverting after the morning merge (see =emacs.md=). 7. *Arm the loop.* Start =/loop= at the interval (default hourly; Craig's "every " phrase overrides) with the per-fire body being one sentry fire (the Pass Runner below). Confirm the arming in one line: interval, branch name, project. * The lock model Two locks, both served by =.ai/scripts/agent-lock= (names only; the helper owns the paths, which live on tmpfs under =$XDG_RUNTIME_DIR/agent-locks/=, host-local and cleared on reboot). *Single-runner lock* (=sentry-=). Each fire acquires it at fire start and releases it at fire end, and refreshes it between passes (the heartbeat, so a live fire's lock never ages past one pass). If =/loop= fires again while a previous fire still holds it, the new fire's acquire fails and the fire skips with one digest line — no two fires run at once. The bounded wait is short (a few seconds); a live fire means defer, not queue. *Roam-write lock* (=roam-write=). A pass that edits a file under =~/org/roam= acquires it, runs =capture-guard --wait= (the human-capture layer stays underneath), edits the working tree, triggers =systemctl --user start roam-sync.service=, and releases. The lock spans only edit-plus-trigger. Sentry never runs =git= against =~/org/roam= — roam-sync stays the repo's only committer (the 2026-06-24 one-git-owner rule). Pass 1's =pull --ff-only= is the sole, read-only exception. Every reclaim of a stale lock surfaces in the digest — the helper prints the reclaim note, and the fire records it. A reclaim during a genuinely slow pass is possible, so it's never silent. * The Pass Runner — one contract per pass Each fire, after acquiring the single-runner lock and verifying branch state (below), walks the pass list in order. Every pass follows the same four-step contract: 1. *Probe* — a cheap existence check for the pass's target (named per pass below). Absent → the pass is one skip line in the digest and nothing more. This is what makes the pass list portable: passes self-activate where their target exists and stay silent elsewhere, with zero per-project configuration. 2. *Work* — run the pass under the unattended contract. Quick, solo, already-agreed mechanical actions execute. Anything destructive or requiring judgment does *not* execute — it appends to the morning-approval queue (what, why, the exact command or edit that fires on approval). A pass runs fully or not at all; there is no reduced-form pass. 3. *Session-context entry* — append the pass's digest line to the =session-context.org= Session Log (path resolved via =.ai/scripts/session-context-path=). This precedes the commit so a crash between them still leaves the trail. 4. *Commit* — if the pass wrote to disk, commit it: =chore(sentry): =. One commit per writing pass. A probe-skip or a no-op pass writes nothing and commits nothing. Between passes, refresh the single-runner lock (=agent-lock refresh sentry-=) — the heartbeat. ** Branch-state verification (fire start, before the passes) After acquiring the lock, confirm the fire is safe to run: - *On the right branch* — HEAD is =sentry/-=. If the loop was armed on a prior day and crossed midnight, the branch keeps the arming date; that's fine, morning teardown handles it. If HEAD is somehow *not* a sentry branch (an interrupted stop, a manual checkout), skip the whole fire with a digest line rather than committing onto main. - *Clean of foreign changes* — =git diff --quiet HEAD= excluding the spine set (=session-context.org= / =session-context.d/=, resolved via =session-context-path=). Sentry's own spine writes must not trip this; a genuinely unexpected dirty tree (something outside the spine changed and wasn't committed by a prior pass) poisons the fire — skip it with a digest line, the next fire retries. * Unattended safety — skip, never degrade With no one at the terminal, any unsafe state makes the affected scope skip with one digest line, and the next fire retries. Unsafe states and their scope: - *Unexpected dirty tree* (non-spine) → skip the whole fire. - *Lost or un-acquirable single-runner lock* → skip the fire (another fire holds it, or the helper is missing). - *A pass's own precondition unmet* (its probe fails, or a dependency is dirty) → skip that pass only. - *Red suite at fire-end* (see below) → the commits stay on the branch, flagged in the digest for morning review; the fire doesn't roll back. Skips are never silent and never partial. A pass line in the digest means the pass fully ran; a skip line names why it didn't. ** Multi-day stall notification An unmerged prior =sentry/*= branch at fire start (the morning review never happened) skips the fire. After the *second consecutive* fire skipped for this reason, send one persistent desktop notification naming the project and branch: : sentry stalled: unmerged — merge or delete to resume Then repeat at most daily. Persistent notify matches the paging convention — it stays on screen until dismissed. A multi-day stall never stays silent. * The pass list (v1) In order. Each names its detection probe. A pass whose probe fails is one skip line. 1. *Roam pull* — =git -C ~/org/roam pull --ff-only=. Probe: =~/org/roam= is a git clone. Skipped when the roam tree is dirty (roam-sync owns that case) or the clone is absent. Read-only and ff-only — the one narrow exception to "don't touch roam git," so later passes read a fresh tree. 2. *Inbox zero* — run =inbox.org= roam mode under the no-approvals contract: quick+solo+agreed items execute, shared-asset and convention proposals park (prepared diff, =VERIFY= task, sender reply) in the approval queue. Edits to =~/org/roam/inbox.org= take the roam-write lock + =capture-guard=. Probe: the roam clone or a project =inbox/= exists. 3. *Triage intake* — run =triage-intake.org=. Probe: triage source plugins present for this project. Destructive actions (deleting, archiving, sending) queue; they never fire unattended. 4. *Todo cleanup* — the =clean-todo.org= mechanics (hygiene pass + =--archive-done= + =--convert-subtasks=). Probe: a root =todo.org=. 5. *Task audit* — =task-audit.org=. Probe: a root =todo.org=. Priority regrades and consolidations queue for review; factual staleness fixes that are unambiguous execute. 6. *Working-files hygiene* — flag =working//= directories whose backing task is closed (a filing candidate per =working-files.md=). Probe: a =working/= directory exists. The filing itself queues (it's a judgment move). 7. *Spec status board* — the =docs-lifecycle= grep for spec keywords, surfacing any =DOING= spec whose bound build parent is closed. Probe: =docs/specs/= exists. 8. *Link integrity* — broken =file:= links in the project's org files, via =lint-org.el=. Probe: =lint-org.el= present. Report-only into the digest; no unattended rewrites. 9. *Git health* — uncommitted drift, unpushed commits on other branches, stale branches, main-behind-origin. Probe: =.git=. Report into the digest. 10. *Prep + symlink freshness* — stale daily-prep docs, broken symlinks. Probe: the prep dir / symlinks exist (work and home only, in practice). (KB lesson promotion — the proposal's eleventh pass — is deferred to vNext. An unattended judgment pass writing to the shared knowledge base waits until sentry has quiet weeks behind it and a designed detection heuristic. See the filed lesson-detection-heuristic task.) * Fire-end — conditional suite, then the digest commit After the passes: 1. *Conditional suite run.* If any pass this fire modified files *outside* the org/spine set (a code-touching pass, rare but possible via fixtures), run the full suite once. A green run confirms the fire's commits are safe; a red run flags the digest for morning review — the commits stay on the branch (nothing is pushed, so the morning gate catches it). No per-pass suite runs: the entry run is the green baseline, and hourly per-commit runs would turn a seconds-long fire into minutes all night. Fires that only touched org/spine files skip this. 2. *Digest commit.* Commit any accumulated spine writes in one sweep — =chore(sentry): digest —