Agent Smith · Implementation Plan
2026-08-14: Smith opened propflowai #5749, said "I'll drive it to mergeable and let you know", and ended the turn without running smith-pr-watch add 5749 — the one manual CLI step that binds the GitHub webhook to the thread. The bot's 🟡 CHANGES SUGGESTED verdict landed into a dead session; nothing woke Smith; five hours of silence until the human asked "You never gave an update, why." One manual smith-pr-watch add later, the rest of the drive worked flawlessly (webhook → wake → fix → re-review → 🟢 → merge). The machinery works; the arming is manual, the durable primitive can't act, and there is no backstop when the webhook path is dark.
| Gap | Fix | Where |
|---|---|---|
| A — arming is manual | Auto-arm the existing pr_watch registry from the runner activity, off observed gh pr create evidence in the turn's own stream-json log | activities/claude_runner.py + new pure parser |
| B — durable primitive can't drive | New SmithPrDriveWorkflow — a per-PR durable reconciler modeled on approval.py, whose only action is spawning a reasoning turn through the same synthetic-inbound path pr_event.py uses | workflows/pr_drive.py + one wake activity |
| C — no backstop | The same workflow's timer-based reconcile tick reads merge_gate_activity (the existing PR-state instrument) and wakes a turn when an actionable state sits un-acted — webhook or no webhook | same workflow |
Invariant preserved: the merge stays human-gated through SmithApprovalWorkflow's ✅ flow, untouched. The drive gets a PR to mergeable; it never merges.
SmithTaskWatchWorkflow vs. new SmithPrDriveWorkflowDecision: (b) — a new per-PR SmithPrDriveWorkflow, modeled on approval.py. Do not extend SmithTaskWatchWorkflow.
This is not a §1 (DRY) violation, because the two candidate parents are different precedents for different jobs, and §2 says match the named precedent:
workflows/approval.py is the named long-lived, per-PR, gh-polling precedent. It already holds a durable timer for up to 24h keyed smith-approval-<repo__slug>-<pr>, already polls merge_gate_activity on a cadence (_GATE_POLL / _READY_POLL), already handles WorkflowAlreadyStartedError idempotent re-arm, already runs as an ABANDON child started by a reply-workflow end-of-turn hook (start_approval_if_mergeable, approval.py:631). The drive is its structural sibling on the pre-mergeable half of a PR's life; the approval owns the post-mergeable half.workflows/task_watch.py cannot serve without forking its own identity, for four concrete reasons:
task_watch.py:561): its id is {WATCH_WF_ID_PREFIX}{chat_jid}-{key} — one watch per thread. The requirement is per-PR ("a thread can spawn several PRs"); two PRs from one thread would collide on one workflow id.watch_intent.py:42-47): MAX_LIFETIME_SECONDS = 2h, re-clamped inside run() (task_watch.py:161-171) so the caps are a property of the watch. A PR drive routinely spans longer (the approval alone waits 24h). Raising the caps for one probe forks the cap semantics.task_watch.py:36-43): "It sends no email, opens no PR, merges nothing, runs no shell. Disarmed of real-send risk by construction, not by a flag." A "nudge" that spawns a claude -p turn — which pushes commits and re-triggers reviews — is exactly the action surface that docstring exists to exclude. Adding it deletes the watch's safety property rather than extending it.thread_phase.is_terminal). A per-PR drive terminates on that PR's merge/close — a different object.SmithTaskWatchWorkflow stays exactly what it is — the thread narrator. Its docstring's gap sentence ("a drive with no webhook traffic never wakes anything") gets updated to point at the new workflow.
| Concern | Reused mechanism (not forked) |
|---|---|
| PR state probe | merge_gate_activity — already registered, already returns ok / recoverable / reason / head_sha / pr_state / unresolved_threads / verdict_is_stale: precisely a reconciler's input. No new gh poller. |
| Acting on state | the synthetic-inbound path — pr_event.py's compose_text → build_trigger → dispatcher.dispatch(). The core is extracted into one callable used by both the CLI and the new wake activity. One wake path. |
| Webhook routing | pr_watch.py JSON files stay the listener's cheap "is Smith driving this PR?" filter (listener.js:384). Kept, not retired — §6. |
| Status surface | none new. The drive posts almost nothing itself; the turns it wakes ride the existing pr_live_status heartbeat collapse. Its own posts (escalation, expiry) are one-liners threaded in the PR thread. |
| Arming idempotency | WorkflowAlreadyStartedError → no-op, verbatim the start_approval_if_mergeable / start_task_watch_if_requested pattern. |
Why (a)'s webhook-down story is also worse: the task-watch probe reads the thread_phase evidence store — which is written by pr_event.py (pr_event.py:119-145), i.e. by the webhook path. With the listener down, the store freezes and the probe sees nothing wrong forever. A backstop must read GitHub truth (merge_gate_activity shells gh), not a mirror the dead path was supposed to refresh. That alone disqualifies a PROBE_PR_STATE inside task-watch unless the probe also grew a gh activity — at which point it is approval.py's poll loop wearing task-watch's clothes, with the wrong key, the wrong caps and the wrong safety contract.
Double-wake risk: both paths converge on dispatcher.dispatch(), which start-or-signals one reply workflow per (chat_jid, thread_root) (dispatcher.py:180). A race produces two turns in one conversation, never two competing conversations — the same behavior two clustered CI webhooks already produce today. The drive additionally suppresses its wake whenever the gate fingerprint moved since the last tick, and webhook events signal note_event into the drive, resetting its stall clock. Residual worst case: one redundant heartbeat turn.
Worker deploys / restarts: the drive is a Temporal workflow on propflow-smith — a smith-sync-from-main.sh restart replays it exactly like SmithApprovalWorkflow today. New workflow class → v1 needs no workflow.patched() gates; the docstring carries the standard "future command-sequence changes must be patch-gated" rule.
Decision: layer (i) — the runner activity — as the primary, evidence-based arm; the reply-workflow end-of-turn hook arms the durable drive off the same evidence; pr_event.py signal-with-starts the drive as the third belt. The brain's smith-pr-watch add prompt instruction stays as a fallback for create paths the detector can't see.
gh pr create observed in the turn's own logNew src/agent_smith/pr_open_detect.py (pure, no I/O — the watch_intent.py posture):
PR_URL_RE # reuse phase_table.PR_URL_RE — one definition (§1)
def opened_prs(stream_json_lines: Iterable[str]) -> list[tuple[str, int]]
Parses the claude stream-json activity log for a Bash tool_use whose command contains gh pr create, pairs it (by tool_use_id) with its tool_result containing a GitHub PR URL (gh pr create prints the URL on success), and returns deduped (repo, pr_number) pairs. Keying on the create-command + its result is what makes this fire on PRs opened this turn and not on PRs merely mentioned — the false-positive the prompt-text candidates could not exclude. Precedent: audit_session_activity already post-scans this same log for git-commit evidence.
claude_runner_activity (Gap A, zero workflow-history impact)activities/claude_runner.py already reads the log post-run (_extract_error_detail) and already holds the conversation binding — it injects SMITH_CONV_CHAT_JID / SMITH_CONV_THREAD_TS / SMITH_CONV_ANCHOR_KEY into the child env (lines 393–407). After the subprocess completes, on the non-error path:
opened = pr_open_detect.opened_prs(log_lines) # pure
for repo, pr in opened: # fail-soft, try/except OSError
pr_watch.add_watch(repo, pr, input.chat_jid, input.thread_ts,
anchor_key=input.anchor_key)
reply.opened_prs = [f"{repo}#{pr}" for repo, pr in opened]
pr_watch.add_watch is a documented idempotent overwrite (pr_watch.py:57-88), and the brain's own CLI reads the same env vars the activity injected — identical payload, same file.chat_jid is empty (adhoc runs; same guard the CLI enforces). ClaudeReply gains opened_prs: list[str] = field(default_factory=list) (additive-defaulted, types.py rule 3).reply.py (twin of the existing two hooks)New start_pr_drive_for_opened(chat_jid, opened_prs, *, thread_ts, anchor_key) in workflows/pr_drive.py, called in SmithReplyWorkflow._run_turn immediately after the task-watch hook (reply.py:1358-1373). Starts one drive per opened PR as an ABANDON child, id smith-pr-drive-<repo__slug>-<pr> (mirrors the approval keying, approval.py:669); WorkflowAlreadyStartedError → leave the running drive alone.
Replay safety without a patch gate — by the abstain precedent (reply.py:1263-1276): the hook's sole trigger is ClaudeReply.opened_prs, an additive-defaulted activity-result field. Replay never re-executes activities; every pre-deploy history's recorded ClaudeReply deserializes with opened_prs=[], so the hook is a zero-command no-op on every old history. (Contrast _TASK_WATCH_PATCH_ID, which did need a gate because its trigger — a human sentence — could pre-exist in recorded inbound text.) The code comment must state this reasoning explicitly.
pr_event.py signal-with-startAfter the existing dispatch (pr_event.py:222-232), best-effort:
await client.start_workflow(
SmithPrDriveWorkflow.run, drive_request_from_watch(args.repo, args.pr, watch),
id=pr_drive_workflow_id(args.repo, args.pr), task_queue=TASK_QUEUE,
start_signal="note_event", start_signal_args=[args.kind],
)
Effect: any PR with a watch file — however armed (auto-arm, brain CLI, a human by hand) — gets its durable drive on the first webhook event, and every event thereafter resets the drive's stall clock via note_event.
SmithPrDriveWorkflow — the durable reconcilerNew src/agent_smith/workflows/pr_drive.py. Determinism rules as everywhere: no config import, workflow-local timedelta constants, all I/O in activities, workflow.unsafe.imports_passed_through() for activity/type imports.
types.py, additive)@dataclass
class PrDriveRequest:
repo: str
pr_number: int
chat_jid: str # Slack channel of the driving conversation
thread_ts: str = "" # PR thread root when known ("" → anchor resolution at wake time)
anchor_key: str = "" # synthetic-drive anchor identity (decider case)
url: str = ""
title: str = ""
@dataclass
class PrDriveWakeInput:
repo: str
pr_number: int
kind: str # "reconcile" — joins pr_event.KINDS' vocabulary
summary: str # the gate's reason, verbatim (incl. dead-review playbook hint)
chat_jid: str = "" # fallback binding when the watch file is missing/corrupt
thread_ts: str = ""
@dataclass
class PrWatchRemoveInput:
repo: str
pr_number: int
| Name | Payload | Effect |
|---|---|---|
note_event (signal) | kind: str (review/check/merged) | records webhook liveness: resets the stall clock; merged sets the terminal flag so the drive completes without waiting for the next tick |
stop (signal) | — | operator off-switch; drive completes "stopped" |
status (query) | — | f"{phase} ticks={n} wakes={m} fp={fingerprint}" — mirrors task_watch.progress |
_RECONCILE_INTERVAL = timedelta(minutes=10) # tick cadence; ~2 gh reads/tick, trivial vs. 5000/hr
_STALL_AFTER = timedelta(minutes=15) # unchanged actionable fingerprint must persist this long
_ONE_WAKE_PER_FP = True # bounded by construction (§3.4)
_MAX_WAKES = 12 # absolute ceiling per drive
_DRIVE_LIFETIME = timedelta(hours=36) # > approval's 24h window; then one honest expiry line
_DEAD_REVIEW_HINT = "If no bot verdict exists for the current head, re-trigger it: "
"`gh workflow run claude-code-review.yml -f pr_number=<N>` "
"(the #alerts dead-review playbook), then end the turn."
deadline = now + _DRIVE_LIFETIME
last_fp = None; fp_since = now; woken_fps = []; escalated_fps = []; wakes = 0
while workflow.now() < deadline and not (self._merged or self._stopped):
await wait_condition(lambda: self._merged or self._stopped or self._event_seen,
timeout=_RECONCILE_INTERVAL) # durable timer, early-wake on signal
if self._event_seen: self._event_seen = False; fp_since = now # webhook alive → stall resets
if self._merged or self._stopped: break
gate = merge_gate_activity(repo, pr) # the ONE probe (reused)
if gate.pr_state in ("MERGED", "CLOSED"): break # terminal from GitHub truth
fp = f"{gate.head_sha}|{gate.ok}|{gate.recoverable}|{gate.reason}"
if fp != last_fp: last_fp, fp_since = fp, now; continue # movement = someone is acting
if not _actionable(gate): continue # CI running / bot mid-review → wait
if now - fp_since < _STALL_AFTER: continue
if fp in woken_fps:
if fp not in escalated_fps and wakes > 0:
post "⏱ backstop: woke a turn and #N is still stuck on <reason> — needs a human"
escalated_fps.append(fp)
continue # one wake + one escalation per fp, then silent
if wakes >= _MAX_WAKES: post budget line; return "wake_budget_exhausted"
await pr_drive_wake_activity(repo, pr, "reconcile", summary_for(gate))
wakes += 1; woken_fps.append(fp)
# terminal
pr_watch_remove_activity(repo, pr) # best-effort cleanup of the webhook router file
return "merged" | "closed" | "stopped" | "expired"
_actionable(gate) (pure module-level function, unit-tested):
gate.ok → actionable (green + clean, and nothing announced/merged it for the whole stall window, means the announce/approval step is the thing that's stuck).not ok and not recoverable → actionable (bot 🟡/🔴, merge conflict, stale 🟢, unresolved threads — reason says which).not ok and recoverable → given the stall window to resolve on its own, then treated as stalled. This one rule covers both dead review (recoverable "awaiting bot verdict" that never resolves — summary_for appends the playbook hint so the woken brain fires gh workflow run claude-code-review.yml -f pr_number=<N> itself) and hung CI, with no special cases. The workflow never runs shell; the claude turn is the only actor.Posting discipline: the drive posts at most 2 + len(escalated_fps) one-liners over its whole life, threaded under thread_ts via the existing slack_send_activity (retry policy verbatim from task_watch.py:77). When thread_ts is empty it first tries thread_anchor_get_activity with anchor_key. Everything louder comes from the turns it wakes, which ride the existing pr_live_status collapse — no new status surface.
src/agent_smith/activities/pr_drive.py)| Activity | Body |
|---|---|
pr_drive_wake_activityname smith_pr_drive_wake | Calls the extracted pr_event.wake(repo, pr, kind, summary, fallback_chat_jid, fallback_thread_ts) — load watch → resolve anchor → build_trigger → record_phase → build_client() → dispatch(). When the watch file is missing/corrupt, builds the trigger from the fallback binding the drive carries. Timeout 60s, RetryPolicy(maximum_attempts=2) — a duplicate turn beats a lost one (the at-least-once posture Slack sends already take). |
pr_watch_remove_activityname smith_pr_watch_remove | pr_watch.remove_watch(repo, pr) — best-effort, mirrors pr_event's merged path. |
Refactor inside pr_event.py (§1 — one wake path): extract the body of main() between watch-load and dispatch into async def wake(...); main() becomes parsing + wake() + the merged-cleanup block. Byte-equivalent CLI behavior, pinned by the existing tests/test_pr_event.py.
| # | File | Change |
|---|---|---|
| 1 | src/agent_smith/pr_open_detect.py new | Pure stream-json parser: opened_prs(lines) → list[(repo, pr)]. Reuses phase_table.PR_URL_RE. |
| 2 | src/agent_smith/activities/claude_runner.py | Post-run: detect opened PRs from the activity log; pr_watch.add_watch(...) per PR (fail-soft, skip on empty chat_jid); populate ClaudeReply.opened_prs. |
| 3 | src/agent_smith/types.py | ClaudeReply.opened_prs: list[str] (additive default); new PrDriveRequest, PrDriveWakeInput, PrWatchRemoveInput. |
| 4 | src/agent_smith/workflows/pr_drive.py new | SmithPrDriveWorkflow (signals note_event/stop, query status, loop §3.4); pure _actionable + _fingerprint; hook start_pr_drive_for_opened(...) + pr_drive_workflow_id(repo, pr); docstring carries determinism + future-patch-gate + merge-stays-human notes. |
| 5 | src/agent_smith/activities/pr_drive.py new | pr_drive_wake_activity, pr_watch_remove_activity. |
| 6 | src/agent_smith/pr_event.py | Extract wake(...) from main(); after dispatch, best-effort signal-with-start of the drive (start_signal="note_event"). |
| 7 | src/agent_smith/agent_ident.py | PR_DRIVE_WF_ID_PREFIX (per-agent slug, mirrors APPROVAL_WF_ID_PREFIX). |
| 8 | src/agent_smith/worker.py | Register SmithPrDriveWorkflow in SMITH_WORKFLOWS (~197-224) + the two activities (~251-267). |
| 9 | src/agent_smith/workflows/reply.py | Call start_pr_drive_for_opened in _run_turn after the task-watch hook (~1373), guarded on reply.opened_prs; comment states the abstain-precedent no-patch reasoning. |
| 10 | src/agent_smith/workflows/task_watch.py | Docstring only: the gap sentence now points at SmithPrDriveWorkflow; watch = narrator, drive = actor. |
| 11 | src/agent_smith/prompts.py | "# Opening a PR" → note the watch auto-arms off the observed gh pr create and a durable backstop re-wakes the drive if webhooks go quiet; keep smith-pr-watch add <N> as the explicit fallback for scripted create paths. |
| 12 | docs/planning/smith-pr-drive-durable.md new | This document. |
| 13 | Tests | See §7. |
No changes to: listener.js (webhook path untouched), pr_watch.py, approval.py, pr_live_status.py, thread_phase.py, watch_intent.py, dispatcher.py.
| Failure | Detection | Behavior | Recovery |
|---|---|---|---|
| Webhook listener down | Tick sees the gate fingerprint stuck on an actionable state past _STALL_AFTER, no note_event | One wake turn fires ≤ ~25 min after the state became actionable; the turn reads GitHub truth and acts | Drive keeps reconciling until merge/close; listener restart resumes the fast path |
| Single webhook delivery lost | Same — the drive never depended on the event | Same | Nothing to do |
| Worker down / deploy-restart mid-drive | Temporal task-timeout + replay on worker return | Timers and signals are durable; the drive replays and resumes exactly (v1: new class, no patch-gate hazard) | None needed; future command-sequence changes must be patch-gated (docstring rule) |
| Duplicate wakes (webhook + backstop race) | Both converge on dispatcher.dispatch() → one reply workflow per thread; drive suppresses on fingerprint movement and note_event | Worst case: one redundant heartbeat turn (edits the live line, no thread burial) | Self-limiting; one wake per fingerprint bounds repeats |
| Armed on an already-merged PR | First tick's gate → pr_state == "MERGED" | Completes "merged" silently (never announce a completion predating the request — the task-watch already_done posture); removes the watch file | — |
| PR closed without merge | Tick sees pr_state == "CLOSED" | Completes "closed", removes the watch file (fixes today's 3-day _MAX_WATCH_AGE leak window) | — |
| Bot review never posts (dead review) | Fingerprint recoverable / awaiting bot verdict unchanged past the stall window | Wake turn's summary carries the playbook (gh workflow run claude-code-review.yml -f pr_number=<N>); the brain, not the workflow, runs it | Still stuck on the same fingerprint after the wake → one escalation line, then silence on that fp |
| Claude turn crashes mid-fix | Reply workflow posts its inline error note (existing); fingerprint doesn't move | Next actionable tick: fp already woken → escalation line ("still stuck — needs a human") | Human intervenes; any new push moves the fingerprint and re-opens the cycle |
| Watch file missing/corrupt at wake time | pr_watch.load_watch → None inside the wake activity | Wake activity falls back to the binding the drive carries — the wake still lands in the right thread | Brain can re-run smith-pr-watch add; the drive works either way |
Wake budget exhaustion (_MAX_WAKES) | Counter in the loop | One honest budget line in-thread; returns "wake_budget_exhausted" | Human takes over; re-arm deliberately if wanted |
| Drive lifetime expiry (36h, PR still open) | Deadline check | One honest expiry line with the PR URL; watch file left in place (webhook path still live) | Human decides; re-arm restarts a fresh drive under the same id |
| Slack send refused (escalation/expiry line) | SendRefusedError non-retryable | Logged, drive continues — its job is waking turns; posting is best-effort | — |
pr_watch.py — kept as the webhook router (not retired)listener.js:384 checks for the watch file before shelling Python at all — non-Smith PRs cost a stat(), nothing more. Replacing that with a Temporal visibility query from Node adds a client, credentials, and a network dependency to a fail-soft-local hot path.Two free improvements: the drive removes the file on close-without-merge (today only lazy-expired after 3 days), and a corrupt/missing file no longer kills a wake (activity fallback binding).
Patterns: time-skipping WorkflowEnvironment + name-matched mock activities (tests/test_task_watch_workflow.py), pure-function tables (tests/test_watch_intent.py), CLI/env round-trips (tests/test_pr_watch.py), drift guards (tests/test_canonical_paths.py, tests/test_worker_registration.py). Gate for every PR: uv run ruff check src tests scripts && uv run mypy && uv run pytest -q.
| File | Pins |
|---|---|
tests/test_pr_open_detect.py new | gh pr create tool_use + URL-bearing tool_result → detected; PR URL in plain text or gh pr view output → not detected (the mentioned-vs-opened line); multi-PR turn → both; sibling-repo create → right repo; malformed/truncated stream-json → [], never raises; result paired by tool_use_id, not adjacency. |
tests/test_claude_runner.py (extend) | Auto-arm writes the watch file with the input's binding (tmp PR_WATCH_DIR); opened_prs populated; empty chat_jid → no file, no crash; unwritable dir → fail-soft (reply still returned); is_error run → no arm. |
tests/test_pr_drive_workflow.py new | Time-skipping env, mocked smith_merge_gate/smith_pr_drive_wake/smith_pr_watch_remove/smith_slack_send: note_event("merged") → terminal + watch-remove + zero wakes; tick sees MERGED → same; stalled actionable fp → exactly one wake carrying the gate's reason; fp changes between ticks → zero wakes; recoverable inside stall → zero wakes; recoverable-unchanged past stall → wake with dead-review hint; same fp post-wake → one escalation line then silence; _MAX_WAKES → budget line + terminal; lifetime expiry → expiry line; note_event resets the stall clock; stop → "stopped"; send-refused on escalation → drive still completes. |
tests/test_reply_workflow.py (extend) | opened_prs=["…#7"] → drive child started with the right id/request; opened_prs=[] → zero drive commands (replay-safety asserted as behavior); already-started child → no crash, no duplicate; hook fires on webhook-sender turns too. |
tests/test_pr_event.py (extend) | main() still dispatches + removes watch on merged (extraction behavior-preserving); signal-with-start issued with the event kind; Temporal signal failure → wake still dispatched (fail-soft ordering). |
tests/test_worker_registration.py (extend) | SmithPrDriveWorkflow + both new activities registered on the home worker. |
tests/test_canonical_paths.py (extend) | Drift guard: any workflow running claude_runner_activity must call start_approval_if_mergeable and start_pr_drive_for_opened — the incident class this plan kills. |
tests/test_prompts.py (extend) | "# Opening a PR" mentions auto-arm + backstop; still names smith-pr-watch add as fallback; still forbids polling loops. |
tests/test_types.py (extend) | New dataclasses deserialize with defaults; ClaudeReply without opened_prs deserializes to []. |
smith-sync-from-main.sh| PR | Contents | Why this order |
|---|---|---|
| PR-1 ships first | Auto-arm the existing shim: pr_open_detect.py + claude_runner.py arm + ClaudeReply.opened_prs + tests | Near-zero risk (activity-internal, fail-soft, no workflow-history impact, no listener change) and it kills the incident class — a forgotten arm can no longer dead-end the webhook path — the day it deploys. |
| PR-2 | The durable drive: workflows/pr_drive.py + activities/pr_drive.py + pr_event.wake() extraction + types + agent_ident prefix + worker.py registration + the reply.py hook + tests | The backstop; works standalone (gh-state-driven, no dependency on PR-3). |
| PR-3 | Wiring polish + docs: pr_event signal-with-start + prompts.py + task_watch.py docstring + this doc committed + tests | Makes the drive quieter/faster and closes the CLI-armed-PR arm path. |
Deploy per PR: merge to main → ~/.claude/scripts/smith-sync-from-main.sh (worker restart). No listener restart at any step (listener.js untouched). No Temporal schedule — drives are child-started, not scheduled.
smith-pr-watch add in the transcript; temporal workflow list shows smith-pr-drive-… Running; verdict → wake → fix → 🟢 → Mergeable: → ✅ → merged → drive Completed + watch file gone.launchctl stop gui/$(id -u)/com.propflow.review-webhook — kill the listener.github-sourced reconcile turn appears in the thread with the gate's reason.launchctl kickstart -k …), drive the PR closed, confirm the drive completes and removes the watch.temporal workflow query --type status on a live drive; worker-log lines at each wake/escalation.gh workflow run claude-code-review.yml -f pr_number=<N> itself (bounded by one-wake-per-fingerprint). Alternative: post the playbook to #alerts for a human. Proposed default: the brain does it — it's the existing playbook and already something Smith does when asked.