#+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-=, where == is the repo-root basename: =basename "$(git rev-parse --show-toplevel)"= — the same derivation =wrap-it-up.org='s guard uses, so the two agree on the lock name). 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* — a pass that does or queues work appends its digest line to the =session-context.org= Session Log (path resolved via =.ai/scripts/session-context-path=) before its commit, so a crash between them still leaves the trail. Per-pass lines for an all-quiet fire (every pass probe-skipped or no-op) are not written one by one — the fire collapses to a single heartbeat at fire-end (below), so an idle fire doesn't spray one skip line per pass. 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. Inside a *working* fire, a pass line means the pass fully ran and a skip line names why it didn't. An *all-quiet* fire is not a silent skip either: its single =sentry at HH:MM: nothing= heartbeat is the explicit record that every pass found nothing, standing in for a wall of identical skip lines. The anti-silence rule targets a pass that hides work it should have surfaced; a quiet fire has surfaced that there was none. ** 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. Tidying the shared roam inbox is allowed from *any* project session, work included — it's housekeeping on a shared resource, not a durable KB-node write, so the work-denylist doesn't gate it (=knowledge-base.md=). Never park it as a cross-project boundary crossing. 3. *Triage intake* — run =triage-intake.org=. Probe: the project has at least one *active* triage source — a project-specific plugin (=.ai/project-workflows/triage-intake.*.org=), or a non-empty =:TRIAGE_SOURCES:= declaration naming general plugins that exist. Mere presence of the template-synced general plugins does *not* activate the pass; a project that declares no sources probe-skips (see =docs/specs/2026-07-20-triage-source-activation-spec.org=). 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. *Heartbeat or digest, then commit.* Decide quiet vs working. A *quiet* fire — every pass probe-skipped or no-op, nothing added to the approval queue — writes a single heartbeat line to the Session Log, =sentry at HH:MM: nothing= (HH:MM local, from =date=), and no per-pass digest block. A *working* fire — any pass ran, wrote, or queued — writes its full per-pass digest block. Then commit any accumulated spine writes in one sweep: =chore(sentry): digest —