PLAN v4 · agent-smith · 2026-08-17

Smith v4 — closure vocabulary + the deploy terminal

Additive to v3. v3's design is unchanged and shipped (PRs #220 / #223). v4 answers a different complaint: not "who drives the PR" but "how do I know it finished, or broke". Repo: PropFlow-Technologies/agent-smith

Founder, 2026-08-17 (#agent-smith): "right now it doesn't make it obvious that a workflow was completed or broken."

Founder authorization (v4): "Yes have fable drive it and merge all the work once it's completed and tested, and if it needs to merge to test then merge and test in a new thread."
In plain terms

Smith runs about seventeen long-lived jobs. Most of them tell you when something goes wrong and say nothing at all when things go right — so from the outside, a job that succeeded and a job that quietly died look identical. And the ones that do speak each invented their own way of saying it: some use ✅, some use 🟢, one used to post an animated gif. This plan gives every job one sentence to end with, in one shape, and fills in the one phase nobody could see: whether the merged code is actually running yet.

1 · The audit — two gap axes

Run 2026-08-17 across Smith's ~17 durable workflows and 13 Temporal schedules; every finding verified at file:line. Two independent problems turned out to be tangled together.

Axis A — durability. A few jobs still detach from an ephemeral claude -p turn via nohup & or Popen(start_new_session=True). When the turn ends, nothing owns the work; when the worker dies mid-run, nothing reconciles it. This is the same class v3 fixed for PR watching, surviving in three corners.

Axis B — closure. There was no shared terminal vocabulary at all. Eight independent verdict formatters had grown up beside each other:

FormatterVocabulary it emits
queue_table.py:63 (the closest thing to canonical)🟢 pass · 🟡 degraded · 🔴 fail · ⚪ no signal
activities/nightly_queue.py:167,185✅ / ⚠️ / 🔴 and ✅ / ❌ and ✓ / ✗ — three palettes in one function
activities/queue_banner.py:104✅ / ⚠️ / 🔴 — a parallel, incompatible copy of the table above
maintenance_eval.py:125,136✓ / ⚠️, plus the words green/warn/red
activities/deploy_freshness.py:326🟢 / 🔴
standup_recap.py:672,763✅ / ❌ / ❓ in one place, bare prose in the next
wisdom_cli.py:110no glyph — a machine sentinel line
workflows/pr_drive.py:327,465⏱ / 🔴 — and nothing at all on success

The drift is the visible half. The expensive half is silence on success, and it is worse than inconsistency because the absence of a message is indistinguishable from a job that never ran:

#SurfaceAxis AAxis Bfile:line
1PR merged → deployed → validatedmerge durable; deploy unobservedsilent — the deployed row is a permanent phase_table.py:46,217
2Terminal vocabulary (meta)ad-hoc ×8the table above
3Smith self-deploybare bash + launchctl kickstart -k, no workflownear-silentsmith-sync-from-main.sh:175
4RemediationWorkflowdurable parent, ABANDON childsilent at parent (completes when the child starts)remediation.py:66,104
5AlertRemediationWorkflowsame shapesilent at parentalert_remediation.py:72
6maintenance-eval on demandfragile nohup &rides the detached job's late postprompts.py:96-98
7Morpheus interactive triggerfragile _spawn_detachedno reconciler if the worker diessmith_slack_actions/morpheus.py:64,71
8Zoom standup pipelinePopen from the socket daemonrecap from a detached childslack_socket.py:417,246
9task_watch expirydurablelogged, never postedtask_watch.py:236
10machine_hygiene bg spawnPopencovered by the lane roll-up upstreammachine_hygiene.py:1246
Two audit corrections, recorded rather than quietly fixed

The brief's row 3 also cited a redeploy gif at deploy_announce.py:142. That module no longer exists: PR #215 (11c42c5) deleted deploy_announce.py, its tests and its worker call-site outright. Only the post-boot health/SHA probe half of row 3 is still open, and §4 is that half.

The brief also flagged a merge_gate false-pass (the earliest verdict glyph anywhere in a comment wins, so a quoted "my prior 🟢" above a 🔴 reads as green). PR #226 already fixes it and is open — v4 does not duplicate it.

2 · The closure vocabulary (spec)

One module, src/agent_smith/closure.py. Pure — stdlib only, no I/O, no clock, no config — which is what lets the Temporal workflow sandbox import it directly and lets the line shape be pinned by tests that never start a worker.

2.1 · Five outcomes, closed

OutcomeGlyphMeansSpeaks?
DONEreached the outcome it existed to reachyes
FAILED🔴ended without reaching it — a human should lookyes
PARTIAL🟡reached part of it; a named remainder is leftyes
EXPIREDran out of time / budget before any verdictyes
NOOPgenuinely nothing to do (empty ledger, no target)silent by default

Three of these distinctions are load-bearing and were argued rather than picked:

An outcome outside the five raises (UnknownOutcomeError). The tempting alternative — a neutral fallback glyph — would render a typo as a well-formed terminal line and put it in Slack where nobody can tell it from a real one. That is Constitution §4's masking-fallback class exactly, so it fails in CI instead.

2.2 · Why these glyphs, and not a fifth palette

The one thing this module must not do is become the fifth private dialect in a repo that already has four. Every glyph is borrowed from the surface that already owns it (§2 — match the named precedent):

It is not a rename of queue_table's verdict levels and does not import them. Those answer "how healthy is this job's result" about a RESULT SET; these answer "how did this run end" about ONE RUN. A nightly queue whose jobs were all green still ended DONE; a probe that could read nothing ended FAILED while its level is UNKNOWN. Collapsing them would force one of the two to lie.

2.3 · The line

<glyph> <label> · <subject> — <OUTCOME>: <detail> <url>
✅ PR-drive · agent-smith#231 — DONE: merged (9f2c1ab) https://github.com/…/pull/231
🔴 PR-drive · agent-smith#231 — FAILED: closed without merging — drive ended
⏱ Task-watch · ship the banner — EXPIRED: watched for 120m and it never reported done
🟡 Remediation · nightly failure ledger 2026-08-17 — PARTIAL: 2 code-fixable failures
   dispatched to one fix-drive: spine-health, pms-sync-canary. The drive runs on its
   own and posts its own verdict — this line means DISPATCHED, not fixed

Four properties are enforced by the renderer, not by convention:

  1. subject is mandatory. This is the failure that made the old lines useless: ✅ done in a busy channel is unattributable, and an unattributable terminal is noise however cheerful it is. For a PR that means <short-repo>#<n> — the repo is kept because Smith drives two of them and #231 alone is ambiguous; the org prefix is dropped because it is 24 characters of no information.
  2. The outcome WORD renders as well as the glyph. Two independent instruments for one fact: the glyph is what a human scans, the word is what survives a client that renders emoji badly and what makes the class greppable (grep '— FAILED:' over a channel export).
  3. All whitespace collapses. These lines are assembled from activity results and merge-gate reasons that carry newlines; a terminal that wraps over four lines stops being scannable, which was the point of having one.
  4. is_terminal_line() recognises one structurally — glyph, separator and uppercase word together, never the glyph alone. A human typing ✅ in the thread is not a workflow closing. The task-watch tests use it to separate terminals from routine traffic, so a helper that counted every ✅ would silently miscount.

3 · Where a terminal lands

closure.py has no default channel, deliberately: a closure line guessing its own channel is how one ends up somewhere nobody reads. The caller supplies both, and the standing rule is:

WorkflowChannelThread
SmithPrDriveWorkflowreq.chat_jid — the channel the work was requested inreq.thread_ts, else its durable thread anchor; channel-top only when neither exists
SmithTaskWatchWorkflowreq.chat_jidreq.key — a watch may never post top-level
RemediationWorkflowthe built trigger's chat_jid (#agent-smith)channel-top — the night's roll-up has no parent thread
AlertRemediationWorkflowthe FIRST decider trigger's chat_jidchannel-top

The alert engine's channel is taken from the trigger rather than re-resolved. build_alert_trigger already owns that decision — it honours a channel-watch's resolved channel over the #alerts default — so the roll-up lands beside the verdicts it is about by construction, and re-deriving it here would be a second copy free to drift (§1).

3.1 · The sender, and why it never raises

src/agent_smith/workflows/closure.py holds the one activity edge, terminal_post(...). It returns a bool and never raises: by the time it is called the outcome is already decided, and a Slack refusal must not turn a succeeded workflow into a Failed one that an operator will investigate and find nothing in. The swallow logs the line it could not send, so the fact is recoverable from the worker log rather than lost.

That is not the §4 masking pattern, and the distinction matters because it looks similar: nothing reports success it did not observe, the return value is the honest answer to "did the terminal land", and no state advances on a failed send. The one workflow that keeps its own sender is task_watch, whose entire job is posting — there a send refusal correctly ends the watch, so its DONE line uses closure.terminal_line for the words and its own _send for delivery. One vocabulary, two delivery semantics, both stated.

4 · The deploy terminal

Gap #1, and the only row of the phase table that has never rendered a value. phase_table._deployed_row is honest about why: "NOTHING. There is no deploy instrument on any surface Smith can read today." A merge is not a deploy — propflowai deploys on merge to main, Smith's own repo self-deploys via smith-sync-from-main.sh, and neither emitted anything back.

4.1 · The instrument

Two repos, two questions, one activity — extend deploy_freshness_activity rather than fork a second health check (§1, and the brief says so explicitly):

Repo"is commit X live?" meansExisting instrument
propflowaiproduction serves a build containing Xactivities/deploy_freshness.py:405 probe_freshness — already compares /api/health's served sha against origin/main's tip
agent-smiththe mini's worker process is running code that contains Xdoes not exist — see 4.2

4.2 · What Smith's own half needs, and the trap in it

The obvious probe — git -C ~/code/PropFlow/agent-smith rev-parse HEAD — answers the wrong question. That is the sha on DISK, i.e. what a restart would load. The deploy script syncs files first and bounces the daemons second, and the bounce can be deferred: its batch-queue guard exits 3 and leaves the daemons running the OLD code while the disk already shows the new sha. A probe reading disk would report "live" during exactly the window when it is not.

So the running worker must declare its own boot sha: one state file written at worker boot (the deleted deploy_announce already resolved this sha correctly at exactly that moment — the mechanism was right, only its purpose was cosmetic). "Is #231 live?" then becomes:

git merge-base --is-ancestor <merge_sha> <worker_boot_sha>   # the code is in the running build
AND the worker daemon is alive and serving                     # daemon_liveness, already exists

Ancestor, not equality: merges arrive in bursts, so by the time the worker boots its sha is usually a later commit that contains #231. Equality would report a live deploy as not-live whenever two PRs merged close together — which is the normal case, not the edge case.

4.3 · The chain a reader sees

Mergeable: <url>          ← the woken turn (unchanged)
[human ✅]                 ← SmithApprovalWorkflow merges (unchanged)
✅ PR-drive · agent-smith#231 — DONE: merged (9f2c1ab)        ← PR-A, shipped
✅ Deploy · agent-smith#231 — DONE: live on the worker (9f2c1ab), serving  ← PR-B

and the deployed phase row fills from that same signal — from the probe, never from merged_at. Deriving the row from the merge would be precisely the "close enough" inference phase_table exists to refuse, and it would be wrong for the whole deferred-restart window in 4.2.

Bounded, and honest when it runs out. The deploy wait is not unbounded: if the worker has not picked up the merge within the window, the terminal is ⏱ EXPIRED: merged, but the worker is still serving <older sha> — which is a true and useful sentence, and is what the deferred-restart case actually looks like.

5 · Build order

PRScopeState
Aclosure.py + workflows/closure.py; wired into pr_drive (4 terminals migrated + the ✅-on-merge that never existed), task_watch (DONE migrated, expiry now posts), remediation + alert_remediation (PARTIAL dispatch terminal)built
Bdeploy terminal (§4): worker boot-sha declaration, the probe extension, the deployed phase row. Must merge + deploy to be tested — live test in a NEW #agent-smith thread.spec'd here
Cdurable-ise the three Axis-A stragglers: maintenance-eval on demand, Morpheus interactive, zoom standup. May split into three.spec'd here
Dtiered auto-merge gate — design only until the policy file is amended (§6). Classifier ships behind a flag that is OFF.blocked on §6
EDecisionBlockWorkflow (§7) if effort ≤ Mscoped in §7

5.1 · Patch gates, because these workflows are long-lived

pr_drive's own docstring states the rule: a drive can run 36h, so any change that ADDS or REMOVES a command must be workflow.patched()-gated. v4 respects the distinction exactly — and the distinction is the interesting part:

ChangeGateWhy
pr_drive ✅-on-mergepr-drive-terminal-post-v1a NEW send at a point where none existed
task_watch expiry linetask-watch-expiry-terminal-v1same — the edge only logged before
remediation / alert dispatch line…-dispatch-terminal-v1same; both are schedule-fired, so a run can be mid-flight at deploy
the other four pr_drive terminalsnone neededa send already existed at that exact point — only its TEXT changed, so the command SEQUENCE is identical

6 · Auto-merge policy amendment

Founder, earlier in the same thread: "the standard is always merge, only pause if risky." The PR drive cannot do that today, and Smith cannot make it so on its own: the authority lives in ~/.claude/SMITH_POLICY.md, which Smith is forbidden to edit. The precedent already exists one workflow over — the alert decider self-merges a LOW-risk fix at ≥90% confidence with everything green (alert_remediation_prompt.py:254).

So PR-D builds the risk classifier behind a flag that is OFF, and the flag turns on only after a human applies this. Proposed text, for Gera to paste into ~/.claude/SMITH_POLICY.md under MERGE AUTHORIZATION:

## MERGE AUTHORIZATION — tiered (amended 2026-08-__)

SmithPrDriveWorkflow may SELF-MERGE a pull request when ALL of the
following hold at the CURRENT head sha:

  1. the merge gate passes (CI green, bot verdict 🟢 for THIS head,
     zero unresolved review threads);
  2. the diff classifies LOW-risk by the v4 risk classifier; and
  3. the classifier's confidence is >= 90%.

A diff is NOT low-risk — and therefore stays behind a human ✅ — if it
touches ANY of:

  * production data, migrations, or any irreversible write;
  * secrets, tokens, IAM, auth, or a permission boundary;
  * customer-facing communication (Clara's outward behavior, templates,
    any tenant-visible copy);
  * an autonomy arm — a gate, a scrubber, a policy file, an approval
    path, or an auto-merge rule (including this one);
  * branch protection, CI configuration, or deploy scripting.

On self-merge Smith posts a receipt in-thread naming the PR, the head
sha, the classification and the confidence. Anything short of the bar
announces `Mergeable:` and waits for a human, exactly as today.

This paragraph is the ONLY authorization. Absent it, the drive never
merges.

Until that paragraph exists, PR-D ships inert — classifier, tests, flag defaulted off, no merge call reachable. Removing a guard is the action the guard was blocking, so the flag is not flipped by the same change that builds it.

7 · DecisionBlockWorkflow — scope

v2's top operator candidate: 28 of 40 answered blocks were never read back. It does not exist. The shape it needs is the one this repo has proven three times (approval, task_watch, pr_drive): a durable per-block workflow that polls the decision store on a timer, wakes ONE turn when an answer appears, and closes with a closure terminal naming the block. Effort is S–M and it is a clean fit — but it is fifth, because a decision nobody reads back is a slower failure than a deploy nobody can see.

8 · What v4 does NOT do

Stated here so it is a decision rather than an omission:

Written by Agent Smith · agent-smith repo · plan v4 · additive to v3

PropFlow Docs