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
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.
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:
| Formatter | Vocabulary 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:110 | no 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:
| # | Surface | Axis A | Axis B | file:line |
|---|---|---|---|---|
| 1 | PR merged → deployed → validated | merge durable; deploy unobserved | silent — the deployed row is a permanent — | phase_table.py:46,217 |
| 2 | Terminal vocabulary (meta) | — | ad-hoc ×8 | the table above |
| 3 | Smith self-deploy | bare bash + launchctl kickstart -k, no workflow | near-silent | smith-sync-from-main.sh:175 |
| 4 | RemediationWorkflow | durable parent, ABANDON child | silent at parent (completes when the child starts) | remediation.py:66,104 |
| 5 | AlertRemediationWorkflow | same shape | silent at parent | alert_remediation.py:72 |
| 6 | maintenance-eval on demand | fragile nohup & | rides the detached job's late post | prompts.py:96-98 |
| 7 | Morpheus interactive trigger | fragile _spawn_detached | no reconciler if the worker dies | smith_slack_actions/morpheus.py:64,71 |
| 8 | Zoom standup pipeline | Popen from the socket daemon | recap from a detached child | slack_socket.py:417,246 |
| 9 | task_watch expiry | durable | logged, never posted | task_watch.py:236 |
| 10 | machine_hygiene bg spawn | Popen | covered by the lane roll-up upstream | machine_hygiene.py:1246 |
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.
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.
| Outcome | Glyph | Means | Speaks? |
|---|---|---|---|
DONE | ✅ | reached the outcome it existed to reach | yes |
FAILED | 🔴 | ended without reaching it — a human should look | yes |
PARTIAL | 🟡 | reached part of it; a named remainder is left | yes |
EXPIRED | ⏱ | ran out of time / budget before any verdict | yes |
NOOP | ⚪ | genuinely nothing to do (empty ledger, no target) | silent by default |
Three of these distinctions are load-bearing and were argued rather than picked:
EXPIRED is not FAILED. FAILED means we know it did not work; EXPIRED means we stopped looking. Collapsing them lets "nothing is watching this any more" masquerade as a decided outcome.PARTIAL requires a detail, and the renderer refuses without one. A detail-less PARTIAL is a FAILED that is embarrassed about itself. This is the outcome a dispatching parent uses (§5, PR-A), and its whole value is that the reader learns what is still outstanding.NOOP is a real terminal and a silent one. A scheduled job that announces "nothing to do" thirteen times a day trains its channel to be skimmed — and the night it says something else is the night that costs. It is still recorded and returned; it just does not take a message. force=True overrides for the human who asks "did it run?".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.
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):
phase_table._STATE_CELL's done / active / blocked verbatim — the table at the top of every Smith thread, so a reader who has learned it has already learned this line.queue_table's DOWN and DEGRADED; ⚪ is its UNKNOWN. The terminal agrees with the batch tables it sits beside.pr_drive's existing prefix for every out-of-time line.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.
<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:
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.grep '— FAILED:' over a channel export).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.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:
| Workflow | Channel | Thread |
|---|---|---|
SmithPrDriveWorkflow | req.chat_jid — the channel the work was requested in | req.thread_ts, else its durable thread anchor; channel-top only when neither exists |
SmithTaskWatchWorkflow | req.chat_jid | req.key — a watch may never post top-level |
RemediationWorkflow | the built trigger's chat_jid (#agent-smith) | channel-top — the night's roll-up has no parent thread |
AlertRemediationWorkflow | the FIRST decider trigger's chat_jid | channel-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).
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.
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.
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?" means | Existing instrument |
|---|---|---|
propflowai | production serves a build containing X | activities/deploy_freshness.py:405 probe_freshness — already compares /api/health's served sha against origin/main's tip |
agent-smith | the mini's worker process is running code that contains X | does not exist — see 4.2 |
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.
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.
| PR | Scope | State |
|---|---|---|
| A | closure.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 |
| B | deploy 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 |
| C | durable-ise the three Axis-A stragglers: maintenance-eval on demand, Morpheus interactive, zoom standup. May split into three. | spec'd here |
| D | tiered auto-merge gate — design only until the policy file is amended (§6). Classifier ships behind a flag that is OFF. | blocked on §6 |
| E | DecisionBlockWorkflow (§7) if effort ≤ M | scoped in §7 |
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:
| Change | Gate | Why |
|---|---|---|
| pr_drive ✅-on-merge | pr-drive-terminal-post-v1 | a NEW send at a point where none existed |
| task_watch expiry line | task-watch-expiry-terminal-v1 | same — the edge only logged before |
| remediation / alert dispatch line | …-dispatch-terminal-v1 | same; both are schedule-fired, so a run can be mid-flight at deploy |
| the other four pr_drive terminals | none needed | a send already existed at that exact point — only its TEXT changed, so the command SEQUENCE is identical |
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.
DecisionBlockWorkflow — scopev2'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.
Stated here so it is a decision rather than an omission:
propflow-batch-queue — awaiting would block the night's remaining jobs. That is why the dispatch terminal is PARTIAL and says "DISPATCHED, not fixed" in words. A genuine roll-up needs a reconciler that does not sit on the batch queue; that is a separate change, and pretending otherwise would be the exact §4 masking this vocabulary exists to make hard.nightly_queue, queue_banner, maintenance_eval and standup_recap emit result-set health, not run outcomes (§2.2) — swapping their palettes is a separate, and separately reviewable, change. PR-A migrates the four sites that are genuinely run terminals.Written by Agent Smith · agent-smith repo · plan v4 · additive to v3