Date: 2026-09-22
Tracking: A-4984. This SoW replaces the thread-turns SoW under the same ticket. It follows the review lane, A-4979, and the PR questions work, A-4993.
Objective. DREW is a Claude Code harness whose
terminal is a Slack thread. A chat is one persistent agent session with
one checkout and a list of watched PRs. Each @DREW message
in the thread is one turn. Each event on a watched PR is one turn. The
review flow, the question flow, and the no-PR flow are default behaviors
of the same chat, set by its system prompt. A chat can hold any number
of PRs, including none.
Slack thread ──@DREW reply──▶ listener ──────────┐
▼
GitHub PR ──phase change──▶ tick ──watch event──▶ turn(chat, message)
│
▼
docker run --rm drew-worker: claude -p --resume <session>
mounts: the chat's checkout and config dir
token: read, or write to the chat's write set
│
final message ────────────┼──▶ post in the thread
new PRs on the write set ─┴──▶ new watches
Chat. The unit of work. A chat has a DREW-minted id, a profile, an optional Slack binding, a session id, a directory, a capability, a write set, a list of watches, a spend cap, and a state. The ledger keys chats by id.
Binding. The Slack channel and thread that carries the chat. A chat has at most one binding. A thread has at most one chat.
Turn. One container run that resumes the chat's session, handles one message, and exits. The message is a human reply or a watch event.
Watch. One PR the chat tracks. A watch holds the PR number, its mode, the last fingerprint, the phase, the arm time, and the last dispatch time. The phase machine runs per watch.
Capability. read or write.
read gives the turn a token that cannot write and a git
guard that refuses every push. write gives the turn a token
with contents and pull-request write and a git guard that allows pushes
to the write set only.
Write set. The branches a write chat
may push to. A live PR's own branch is in the set. The
drew/ namespace is in the set. Nothing else is.
Profile. The fixed part of a chat's setup: the
system prompt frame, the default capability, the egress allowlist, and
the mounts. This SoW builds the review profile. The
incident responder (A-4750) and the
dependency lanes are profiles of the same chat type. They are out of
scope here.
At 34b02fe80.
| Area | State |
|---|---|
| Ledger | py/drew/drew/ledger.py. One Job per
thread, keyed channel:thread_ts. A job holds exactly one
PR. by_pr allows one active review job per PR. Unknown keys
are preserved in extra. |
| Lanes | review and ask on Job.lane.
drew.router picks the lane from the first message with a
Sonnet 5 call. A bare link is review. |
| First touch | py/drew/drew/intake.py. The listener and the tick both
call first_touch. Only top-level messages that
@-mention DREW and carry a PR link start a job. |
| Thread replies | The tick reads them every two minutes. In a review thread the text
is appended to instructions and a worker runs with a fresh
clone. In an ask thread the router runs again. |
| Phase machine | py/drew/drew/tick.py. fingerprint,
phase, decide, and describe are
pure functions of one PR. A phase change posts one line. Red CI, a
conflict, new comments, and changes requested dispatch a worker.
ready arms auto-merge. |
| Worker | One docker run --rm per run. The session file is inside
the container and is lost at exit. The entrypoint clones unless the
workspace has .git. |
| Git guard | py/drew/worker/git-guard. Refuses force pushes and
remote deletes. With DREW_READ_ONLY=1 refuses every push.
No branch allowlist. |
| Question turn | py/drew/drew/ask.py. A read-only turn with a fixed
frame. The answer is the worker's final message, posted without a
footer. |
| Status line | A review worker ends with DREW_STATUS: <word>.
drew.dispatch moves the job on it. |
| Blocked | A job state. A blocked job is not re-dispatched until a reply or a push. |
The ledger file is <audit_dir>/chats.json. Its
lock and transaction rules are the ones Ledger has now.
Chat replaces Job.
| Field | Meaning |
|---|---|
id |
a ULID minted by the supervisor at creation |
profile |
review |
binding |
{channel, thread_ts} or null |
requester |
Slack user id of the person who started the chat |
session_id |
the Claude Code session id; replaced by a cold turn |
chat_dir |
host path of the checkout, the config dir, and the turn dirs |
capability |
read or write |
write_set |
branch names and prefixes the git guard allows |
watches |
list of Watch |
run |
{owner, container, started_at} while a turn runs, else
null |
pending |
replies received while a turn runs:
[{ts, user, text}] |
last_reply_ts |
the newest thread reply a turn has consumed |
turns |
count of turns run |
cost_usd |
realized spend across turns |
spend_cap_usd |
the chat's current cap |
state |
active or done |
outcome |
why the chat is done: cancelled, idle,
spend cap |
created_at, updated_at |
unix timestamps |
extra |
unknown keys preserved across writers |
Watch:
| Field | Meaning |
|---|---|
pr |
the PR number |
mode |
live or post_merge |
source_pr |
for a post-merge fix PR, the merged PR whose comments it addresses |
fingerprint |
the last fingerprint, as tick.fingerprint computes
it |
phase |
the last phase |
armed_at |
when the chat armed auto-merge, else null |
last_dispatch_at |
when the last watch event ran a turn |
The ledger has two indexes, both derived at read time: binding to
chat, and PR number to the write chat that watches it.
review_max_dispatches, Job.lane,
Job.instructions, Job.phase,
Job.fingerprint, and the blocked state do not
exist in the new ledger.
Conversion. The first tick that finds
reviews.json and no chats.json converts every
active job into a chat. A review job becomes a write chat
with one watch. An ask job becomes a read chat with no
watches. The converted chat has no session, so its first turn is a cold
turn.
A top-level message in #ax-drew that @-mentions DREW
creates a chat. The listener creates it. The tick's history backstop
creates the ones the listener missed. The binding is the message's
thread.
The router reads the message text with links and the mention removed. It returns the capability.
| Message | Capability | Watches | First turn's message |
|---|---|---|---|
| A PR link and no text | write |
the PR | the review prompt |
| A PR link and a request | write |
the PR | the review prompt plus the request |
| A PR link and a question | read |
none | the question |
| No PR link | read |
none | the text |
A write chat on an open PR has the PR's branch in its
write set. A write chat on a merged PR has
drew/review/ in its write set and no watch until the fix PR
exists. A PR from a fork gets a read chat and a reply that
says why.
One write chat per PR. A second message that links a PR
another write chat watches gets the reply "handled in
read chats may name
the same PR.
Promotion. A reply in a read chat that
the router classifies as a request raises the chat to
write. The supervisor sets the write set from the PRs the
reply names, and adds those PRs as watches. A chat never lowers its
capability.
One container per turn. A turn is one
docker run --rm that mounts the chat's checkout and config
dir, sets CLAUDE_CONFIG_DIR to the config dir, and runs
claude -p --resume <session_id>. The first turn runs
claude -p --session-id <session_id> instead. The
entrypoint skips the clone when the workspace has .git. The
egress jail, the wall-clock kill, the container name in the ledger, and
the reap step do not change.
The input is the message. The user message is the
reply text, or the watch event's reason. The rules live in the profile's
frame, passed with --append-system-prompt. The frame
carries a per-turn header: the chat id, the thread, the requester's
Slack id, the capability, the write set, and each watch with its phase.
The agent knows its PRs without querying.
The output is the final assistant message. The
supervisor posts it to the thread, converted to Slack mrkdwn. Every post
in a write chat carries the footer
drew: chat=<id> prs=<n,n>, so the ledger can be
rebuilt from the thread. Posts in a read chat carry no
footer.
No status line. The supervisor observes what a turn
did. After a write turn it lists open PRs authored by DREW
created since the turn started whose head branch is in the write set.
Each becomes a watch. A push to a watched PR's branch appears in that
watch's next fingerprint. When the agent needs a decision from a person,
it mentions the requester in its message. The frame gives it the id.
The listener owns turns. The tick is the backstop.
The listener accepts a human reply that @-mentions DREW in
a bound thread. It reacts with 👀, takes the ledger lock, and records
the reply's ts as last_reply_ts. If no turn is
running, it starts one on its thread pool. If a turn is running, it
appends the reply to pending. When a turn ends, the
supervisor starts the next turn with the pending replies joined as one
message. The tick's steer step calls the same turn function for replies
newer than last_reply_ts. The lock and
last_reply_ts make a double pickup a no-op.
Commands. The supervisor handles three replies without a turn.
| Reply starts with | Effect |
|---|---|
stop |
removes the running container by name; the chat stays active and the session resumable |
stop watching #<n> |
drops that watch; the chat stays active |
cancel, drop it,
never mind |
retires the chat |
All three work while a turn is running, because the listener handles them immediately.
Timeouts. A read turn has
chat_read_timeout_s (default ten minutes). A
write turn has worker_timeout_s (forty
minutes). Capability is known before the turn starts, so the supervisor
picks the timeout without reading the message.
Spend. Each chat gets
chat_spend_cap_usd. When a turn's cost crosses it, the chat
posts a line that mentions the requester and stops running turns. A
reply from the requester raises the cap by
chat_spend_step_usd and runs the turn. Watch events on a
capped chat post their phase line and run no turn.
Reap. A chat whose run.container is
gone after the grace period loses its run. The next reply
or watch event resumes the session. The reap posts one line in the
thread.
The tick evaluates every watch of every active chat.
fingerprint, phase, and describe
do not change. decide becomes a per-watch function that
returns one of the events below. A phase change posts
describe's line.
| Event | Condition | Turn message |
|---|---|---|
merged |
PR state is merged | "PR #n merged." The watch is dropped after the turn. |
closed |
PR state is closed | "PR #n was closed without merging." The watch is dropped after the turn. |
conflict |
mergeable is conflicting | "PR #n conflicts with main." |
red |
a check failed | "PR #n: check x failed." |
comments |
newest comment is newer than the last fingerprint's | "New review comments on PR #n." |
changes_requested |
review decision entered changes requested | "A reviewer requested changes on PR #n." |
ready |
approved, green, not armed | none; the supervisor arms auto-merge and posts |
| none | any other phase change | none; the phase line is posted |
A post-merge watch's fingerprint includes the source PR's unresolved
threads and newest comment, as _snapshot computes
today.
red, conflict, comments, and
changes_requested respect review_cooldown_s
per watch. The merge queue owns a PR in the queue phase; no
event fires there.
A merged turn on a chat with other watches is how a
stack advances. The agent sees "PR #1 merged" with #2 and #3 still in
its header, retargets #2 to main, and pushes. The push appears in #2's
fingerprint.
A read chat has no watches. It cannot push, so it has
nothing to shepherd.
The frame is short. The behaviors live in the checkout as skills, so a PR changes them without an image rebuild.
The frame says:
<link> for chat
<id>. <requester> started it. Any
channel member may reply.<branch>. Your capability is
<read|write>. You may push to
<write set> only. Never force-push.#4079 (ci),
#4080 (review: waiting on alee)./drew-review on it./drew-answer on it.<requester> when you need a decision./drew-review is today's review prompt as a skill: the
work list, the fix flow, replying on threads, resolving them, and the
push. /drew-answer is today's ask frame as a skill.
/shepherd exists and the review skill calls it. The skills
live under .claude/skills/ in the repository.
A chat is active from creation. It becomes
done on cancel, or when it has no watches and
no reply for chat_idle_s (default one day). A watch keeps
its chat active.
A done chat's checkout and config dir are deleted after
chat_ttl_s (default seven days). Turn dirs stay, as run
dirs do today.
chat_max_workspaces (default eight) bounds disk. When a
new chat needs a directory and the count is at the cap, the checkout of
the active chat with the oldest updated_at is deleted. Its
next turn is a cold turn.
Cold turn. When the session file or the checkout is missing, the turn clones the branch, starts a new session with a new id, and seeds the first message with the thread's messages so far. The reply text follows. The ledger records the new session id. The chat id does not change.
The entrypoint takes DREW_SESSION_ID,
DREW_SESSION_RESUME (1 after the first turn),
DREW_SYSTEM_PROMPT_FILE, DREW_CAPABILITY, and
DREW_WRITE_SET (a comma-separated list of branch names and
prefixes ending in /). It sets
CLAUDE_CONFIG_DIR to the mounted config dir. The git guard
reads DREW_CAPABILITY and DREW_WRITE_SET. With
read it refuses every push. With write it
refuses a push whose refspec is outside the write set. The force and
delete rules stay.
The supervisor keeps the transcript as today: stream-json on stdout,
written by the host. The result event's text is the
reply.
Chat and Watch, chats.json,
the conversion from reviews.json, the chat directory, the
session id, the resume env contract, the CLAUDE_CONFIG_DIR
mount, and worker.run_turn. First touch creates chats with
watches. The shepherd step evaluates watches and runs turns for events.
A drew chat --id <ulid> --turn "<text>" command
runs a turn from a checkout for local testing. Thread replies still take
the tick's steer path. No Slack changes.
The frame with the per-turn header, /drew-review and
/drew-answer as skills, the observation step that turns new
PRs into watches, the removal of the status line, the spend cap, the
mrkdwn post with the chat footer, and capability-based timeouts.
The listener accepts thread replies, the pending queue,
stop, stop watching, and cancel,
promotion from read to write, and the tick's
steer step as backstop only.
A message with no PR link creates a read chat on
main. The git guard's write-set allowlist. The idle retire,
the TTL, the workspace cap, and the cold turn. Mini rollout: the audit
dir is already mirror-mounted, so compose does not change. The
drew-slack image is pulled and restarted after each PR. The
worker image is rebuilt after PR B and PR D.
stop during a turn removes
the container within seconds. The next reply resumes the same
session.write chat on a merged PR
opens a fix PR. The fix PR is a watch before the next tick posts
anything.read turn's push is
refused by the guard. A write turn's push outside the write
set is refused by the guard. Both refusals appear in the
transcript.@DREW hello creates a chat and
gets a greeting. A follow-up question about the repository gets an
answer from the same session.run,
and the next reply resumes the session. Mini restart: the ledger and the
chat directories are intact.reviews.json with one
review job and one ask job becomes a chats.json with one
write chat and one read chat. The next reply
in each thread is a cold turn.chat_spend_cap_usd $25, chat_spend_step_usd
$10.read chat on main that is asked to push to
alee/foo has no branch in its write set. Proposed: the chat
opens a PR from drew/ instead. Adding a named branch on
request is a later addition.drew work keeps its own shepherd loop and
DREW_STATUS protocol. A deps profile with a
chat per bump, bound to a post in #ax-drew, replaces it later.Chat with a read capability, its own frame,
and its own egress allowlist.claude --resume <id>.