aboutsummaryrefslogtreecommitdiff
path: root/.ai/workflows/sentry.org
blob: 56f0c9696a9087d5dd1af37a818f1c9c81cc4426 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
#+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 <interval>" (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 <interval>" 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-<project>=, where =<project>= 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* — 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): <pass> — <what changed>=. 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-<project>=) — 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/<today>-<host>=. 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: <branch> 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: 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/<slug>/= 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 — <date> <time> fire= — so even a read-only fire (all passes probe-skipped or no-op) leaves a clean tree. This is what lets the next fire's branch-state check see a clean, spine-excluded tree.

3. *Release the single-runner lock.*

* The digest and the approval queue

*Digest.* Each fire appends its lines to the =session-context.org= Session Log (the spine the fire already writes), so it survives a crash, rides the session archive, and is on screen in the running session. One block per fire: the timestamp, then one line per pass (ran + what, or skipped + why), plus any lock reclaim notes.

*Approval queue.* Destructive and judgment actions accumulate under one heading in the same file — =* Sentry approval queue (<date>)= — newest last. Each item carries three things: *what* (the action), *why* (what triggered it), and the *exact command or edit* that fires on approval. The morning review is Craig reading this heading top to bottom and running or discarding each item.

* Morning teardown — Craig's, documented not automated

Sentry never merges its own branch. In the morning Craig:

1. Reviews the digest and the approval queue in =session-context.org=.
2. Runs or discards each approval-queue item.
3. Reviews the branch: =git log main..sentry/<date>-<host>= and the diff.
4. Squash-merges what he wants (=git switch main && git merge --squash sentry/<date>-<host>=, then one clean commit) or cherry-picks selectively.
5. Deletes the branch: =git branch -D sentry/<date>-<host>=.
6. Reverts any Emacs buffers still showing the pre-merge on-disk state (=emacs.md= buffer-revert caveat).

A bad night is discarded by deleting one branch — nothing reached main, nothing was pushed.

* Stop Sentry

Trigger: "stop sentry" (and synonyms above). Sentry owns its own shutdown:

1. *Cancel the loop* — stop the =/loop= (=ScheduleWakeup= stop / the loop's stop path). No further fires.
2. *Release the single-runner lock* if this context holds it.
3. *Branch disposition* — offer, inline-numbered:
   1. Squash-merge the day's branch into main now (walk the morning teardown steps 3-5 interactively)
   2. Leave it named for later review (=sentry/<date>-<host>= stays; review at leisure)
4. *Approval queue* — offer to walk the queued items now, or carry them (they stay under the heading for whenever Craig reviews).

Stopping sentry is the only way to reclaim the working tree mid-night. The entry gate fronts the handoff; stop-sentry ends it.

* Wrap-up interaction

=wrap-it-up.org= refuses while sentry is live: it detects the single-runner lock (=agent-lock status sentry-<project>= → held) and stops with "sentry is active — say 'stop sentry' first." The shutdown logic lives here, not in wrap-up; wrap-up carries only the one guard.

* Common Mistakes

1. *Running without the =:COMMIT_AUTONOMY:= grant* — sentry commits unattended; the marker is the entry ticket, and its absence is a hard stop, not a degrade.
2. *Starting from a dirty or red tree* — the entry gates exist because an unattended fire can't tell Craig's in-progress work from a regression. Answer the gate; don't bypass it.
3. *Committing onto main* — every writing pass commits to the daily =sentry/*= branch. A fire that finds HEAD off the sentry branch skips rather than commits.
4. *Running a =git= write against =~/org/roam=* — roam-sync is the only committer. Sentry edits the tree under the roam-write lock and triggers the sync; it never commits or pushes roam.
5. *A per-pass suite run* — the suite runs at entry (baseline) and conditionally at fire-end (only when a pass touched non-org files). Hourly per-commit runs all night is the anti-pattern the suite policy exists to prevent.
6. *Executing a judgment or destructive action unattended* — those queue for the morning with their exact command. The pass did its detection; Craig makes the call.
7. *A silent skip* — every skip writes a digest line naming why. A missing pass with no line reads as "ran clean" when it didn't.
8. *Degrading a pass to a reduced form* — a pass runs fully or skips. No half-passes.
9. *Letting an unmerged branch stall silently* — after two consecutive unmerged-branch skips, the persistent desktop notify fires. Don't suppress it.
10. *Merging sentry's branch automatically* — the morning teardown is Craig's. Sentry creates and commits; it never merges or deletes its own branch.

* Living Document

Sentry ships with ten mechanical passes and a deferred KB pass. The pass list, the interval default, and the queue-vs-execute line for each pass are the knobs most likely to move with dogfooding. Fold in what the live trial surfaces — a pass that queues too eagerly, a probe that misfires, a digest line that wants more detail. Refine as the signal arrives.

* History

Built 2026-07-19 from the sentry spec (=docs/specs/2026-07-14-sentry-workflow-spec.org=, ID f6c51f27-d7a2-4b63-9ff9-5ba005a66dfb) — 10 decisions and 12 review findings resolved before the build. Phase 1 shipped the =agent-lock= helper (commit =a8b6cf4=); this file is Phase 2, the engine. Phase 3 reconciles the roam writers (=inbox.org=, =knowledge-base.md=) to acquire the roam-write lock and adds the =wrap-it-up.org= guard.