Skip to content

Commit c9b83d1

Browse files
Supervised Claude Code from Slack: the graph is the answer, and a human presses the button
The loop the README promises had no exit. A Slack-launched `plan --approve` parked, a human approved, and the run returned "awaiting `grapharc go`" — into a subcommand the gate does not carry. `--go` is admitted now, and from Slack it *forces* `--approve`: anyone in the workspace can type into that bot, and the difference between proposing a graph and running one on the host always goes through a person. What comes back is the proposal itself, in the message. Not a link — the person deciding is on a phone, and "the graph is over there" asks them to approve something they have not seen. Kinds ride alongside names because only the kind is governed: `fix_it` is a label a planner chose, `apply_change` is the thing the gate rules on, and a node that can change files is marked. A registry that declares no MUTATING_KINDS marks every node and says so. Approve/Deny buttons answer the file handshake by fingerprint, so a button on a message scrolled back to — drawn for a plan the run has since replaced — is refused rather than honoured. Who clicked is posted in the thread and is deliberately *not* claimed by the trace, which has no actor field. Two budgets, because 120 seconds is generous for `metrics` and a SIGKILL through the middle of an approved run: readers keep the short timeout, and anything that executes gets `GRAPHARC_SLACK_WORK_TIMEOUT` (30 min). A parked `--go` gives the human a third of it, capped, and leaves the rest for the work they authorised. Bugs found by building this, each on the path it broke: - the stdlib registry could not delegate to Claude Code *at all*. Its harness used LocalExecutor, `Harness(workspace=...)` was silently discarded whenever an explicit executor was passed, and `_run_delegated` asked only the executor — so every agent phase of the one registry+backend combination whose documented behaviour is delegation failed with "does not expose one". - `mcp.driver.graph_status` read a running trace with the strict reader and raised on the half-written last line — the supervised agent's own status tool, crashing at exactly the moment it is meant to be useful. Every other live-trace reader already used TailRecorder; this was the holdout. - a curtailed phase folded `result.output` into state, which is empty by contract for every stop reason but TARGET_MET, so its work was dropped; and `goal_met` counted notes, so a run whose entire output was `['[error] ']` reported success (#96). - a delegated run's cost was dropped by `attribute()` (orphan cost was read only from `model` events) while `ReplayedRun` counted it — two readers of one trace disagreeing about the bill. - `mutating or stdlib.MUTATING_KINDS` collapsed "declared nothing mutates" with "declared nothing", so a custom registry's real mutating kind went unnamed in a generated — and then cached — policy. - the live view called a multi-phase run done at the first agent phase's stop. - an exhausted deadline spawned Claude Code only to kill it on the first wait. `approve` grows `--show` (print the parked plan, decide nothing) and `--fingerprint` (decide only if this is still the plan you read, exit 2 naming both otherwise), and a parked run now announces the fingerprint, the node list, the deadline and both commands that end the wait (#46, #52). Four recordings under docs/demo/, with what is and is not staged written down next to each: the gate refusing, GraphARC fixing that graph_status bug in a copy of itself under its own policy, one trace answering every question, and the Slack thread end to end. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d59d50d commit c9b83d1

43 files changed

Lines changed: 3577 additions & 106 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,3 +271,7 @@ HANDOFF.md
271271
# smoke test dropped here.
272272
/.mcp.json
273273
/.claude/skills/
274+
275+
# The demo recording is regenerated by docs/demo/capture_supervised_slack.py;
276+
# it embeds absolute paths from whichever machine made it.
277+
docs/demo/session.json

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ and "used to be true" — the two things a reader most needs kept apart.
77

88
Entries are newest-last within a release, matching the order they were written.
99

10+
## Unreleased
11+
12+
- **the supervised loop had no exit from Slack.** A Slack-launched `plan --approve` parked, showed a human the run, was approved — and then returned `stopped: planned (awaiting `grapharc go`)`, into a subcommand the bot's gate does not carry and cannot be talked into carrying. So the one exchange the bot exists for (ask for work, see the graph, let it run) stopped one step short of running anything, and the approval it collected authorised nothing. `plan --go` is admitted from Slack now, and admitting it is only safe because of the rule that came with it: **from Slack, `--go` forces `--approve`**, on every registry, whether or not the requester typed it. Anyone in a workspace can type into this bot; without that rule one message would take a model's proposal straight to execution on the host with the graph visible only afterwards. It is forced rather than refused so the useful command stays one message — propose it, show me, run it if I say yes.
13+
- **the graph a human was asked to approve was not in the message they were asked to approve it from.** The parked status message said "planned graph is in the live view link" and left the reader to open a URL — on a phone, from a chat client, to see the thing they were being asked about. The proposal is rendered into the message now: the planner's rationale, every node, every edge, and the worst-case token estimate the gate computed. Two things ride with it. First, **kinds, not just names**: `ProposedNode.kind` is what admission governs and the name is a label the planner picked freely, so a node named `fix_it` of kind `apply_change` used to read as harmless — the kind is shown, and a kind the registry declares mutating is marked `✎`. Second, a registry that declares no `MUTATING_KINDS` marks *every* node and says so, matching the fail-closed reading `plan` already takes when it writes `mutating: true` into a plan file it cannot vouch for; the bot resolves the registry from the flag or the working directory's `grapharc.toml` and refuses to import anything outside the shipped set to find out.
14+
- **the app manifest enabled interactivity and nothing used it.** Approving meant typing `/grapharc approve slack-runs/20260808-130940-1b43a32a/trace.jsonl` — a generated path, from a phone. A parked run's message now carries Approve and Deny buttons, and a click writes the same decision file the typed command writes. The click is **bound to the plan's fingerprint**: a parked run rewrites its request every round, so a button on a message scrolled back to names a proposal that is no longer the one waiting, and approving it would be approving a graph nobody read. That is refused at the handler and would be discarded by `file_approval` besides. The button's directory is re-confined inside the working directory exactly as a typed path is — a signed payload says the click is genuine, not that the button was drawn by a version of the bot that meant the same thing by it. The buttons leave on the same edit that reports the run started, because a finished run still showing a live Approve button is a lie a later click would act on. Who clicked is posted into the thread and **not** into the trace, which has no actor field; the bot will not imply an audit trail it does not have.
15+
- **two minutes was the whole budget for work that takes minutes.** One timeout bounded every Slack command, defaulted to 120s so a runaway `metrics` could not hold a worker thread — and a delegated Claude Code phase reads files, runs things and writes a report. That is not a safety limit for such a run, it is a SIGKILL through the middle of one a human just approved. Commands that *execute* (`agent`, `plan --go`) draw on a separate `GRAPHARC_SLACK_WORK_TIMEOUT` (default 1800s); readers keep the short one. The human's share is carved out of whichever applies — a third for a parked `--go`, capped at 15 minutes, so saying yes leaves the run its budget; half for a plan-only park, which has nothing to do afterwards. A requester-supplied `--approval-timeout` larger than the budget is now refused rather than silently outliving the runner: a park that outlives its kill does not report `approval_timeout`, it dies mid-wait.
16+
- **the stdlib registry could not use Claude Code at all** — the one combination its own module docstring describes. Given a backend with no tool-calling wire format, every agent-backed kind delegates its whole loop to Claude Code; delegation needs a directory to run in, and it looked for one on `harness.executor`, where only the sandboxing executors have it. The stdlib harness uses `LocalExecutor`, which has none, so every `investigate`/`verify`/`summarize` phase of every such run failed with *"the delegated executor needs a workspace directory"* before spawning anything. Underneath it was a quieter defect: `Harness(..., executor=…, workspace=…)` **silently discarded `workspace`**, which existed only to construct the default executor — three call sites in this repo pass both and got nothing for it. The harness records its workspace now, whichever executor is in play, delegation asks the harness before the executor, and the stdlib harness names the directory its tools are already confined to.
17+
- a phase the budget curtailed **reported nothing at all**. `AgentResult.output` is empty by contract for every termination reason but `TARGET_MET` — the mid-work text lives in `partial_output` — and the stdlib phase body formatted `output` regardless, so a curtailed phase wrote `[budget_exhausted] ` with nothing after it. The work it did manage was dropped, and a downstream goal check read an empty note as a phase with nothing to report rather than one cut off. It reads `partial_output`, falling back to the reason line.
18+
- a **delegated run was billed at zero**. `grapharc agent --executor claude-cli` drives an `AgentNode` with no enclosing graph, so every event it writes is an orphan, and it reports its spend on the `stop` event; `observe.cost` attributed orphan cost only from `model` events, so the recorded cost came out `$0.00` with `unpriced_tokens` at zero too — nothing said the figure was incomplete. Meanwhile `ReplayedRun.recorded_cost_usd`, which counts orphans by cost rather than by phase, reported the real number: two readers, one trace, two answers. Cost is attributed from any orphan carrying one now; only a `model` event still becomes a row in the model-call breakdown, because only that is a model call.
19+
- the live page **closed the stream while the graph was still running**. "Finished" was any `stop` event in the run — but every `AgentNode` phase writes its own `stop` when its agent loop ends, so a three-phase graph was declared done the moment the first phase finished, and the SSE stream closed on a page with two nodes left to run. Only the *driver's* terminal stop ends a run, and `orphan_sub_events` is how that is read: a phase's stop falls inside its node span and is attributed there, while the driver's is an orphan — and a run with no graph at all (`grapharc agent`) has only orphans, so its stop still ends the stream exactly as before.
20+
- policy generation **substituted another registry's dangerous kinds for a custom registry's silence**. `mutating or stdlib.MUTATING_KINDS` collapsed three states into two: a registry declaring `()` ("none of mine mutate") and a registry declaring nothing at all both became stdlib's `("apply_change",)`. So a registry whose real mutating kind is `deploy` or `publish` had that kind go unnamed while the model was told to guard a name the registry does not contain — and the resulting permissive policy is cached and governs every later run. A declared tuple is now used verbatim, empty included; `None` means nobody said, and every kind in the catalog is named dangerous.
21+
- a delegated run whose deadline had already passed **spawned Claude Code in order to kill it**. `remaining_seconds()` is `max_seconds - elapsed` and goes negative once the budget is spent; `subprocess.run` accepts a negative timeout, starts the child, and kills it on the first wait — so an over-budget run launched the CLI, tore it down mid-startup, and reported `max_seconds (-3.2) reached`, a number no caller set. An exhausted deadline is refused before anything is spawned.
22+
- the supervised agent's own status tool **crashed on the runs it was watching**. `mcp.driver.graph_status` read the trace with `TraceRecorder`, the strict reader, which raises `TraceReadError` on a half-written last line — and a half-written last line is the normal state of a file something is still appending to. An agent polls `show_graph` precisely *while* its run is in progress, so the one moment this had to answer was the one moment it raised, out of the MCP server, as a crash rather than a status. The rest of the codebase already had the answer: `TailRecorder` skips the torn line, which is how the live view and the Slack tailer read a running run. `graph_status` uses it now. Found and fixed on camera — `docs/demo/scenarios/fix_bug.py` is the recording, and the fix was then read and landed here deliberately with `tests/test_torn_trace_read.py`.
23+
- **three CLI recordings, on the same terms as the Slack one.** `docs/demo/capture_cli.py` runs a scenario's commands in a pseudo-terminal — so the CLI takes its tty branch and emits the colour a person actually sees, rather than the byte-stable colourless form a pipe would give — and records each command's bytes, exit code and wall clock. `render_cli.py` draws them, parsing exactly `\x1b[…m` (including the `38;5;N` form the CLI emits under a 256-colour terminal) and dropping every other escape rather than half-interpreting it. The three: the admission gate refusing and then admitting, free and deterministic; GraphARC fixing the `graph_status` bug above in a copy of itself, where the interesting frame is the planner's own rationale under a deny — *"Since apply_change cannot be reached by an edge, this round investigates … for a human to act on"*, `mutating: false` — against the five-node `mutating: true` graph the same goal and model produce once a human amends the rule; and one trace file answering `trace`, `replay` and `diff`, including a run priced and refused during admission so that `diff` reports `path 3 -> 0 nodes`.
24+
- **a demo you can re-make and check.** `docs/demo/capture_supervised_slack.py` drives the real Slack path — the real gate, a real planner, the real file handshake, a real fingerprint-checked click, a real delegated Claude Code phase — against a recording sink instead of a socket, and `render_demo.py` turns the recording into the film in the README. What is real and what is mocked is written down rather than implied, and the closing frame is computed from the trace file rather than from any message: `approval_request → approval_response → start`, with `start` last. The same property is asserted against a real CLI subprocess in `tests/test_slack_supervision.py`, so it does not depend on anyone re-recording a video.
25+
1026
## 0.1.5
1127

1228
- a documentation and demo release; no runtime code changed between `0.1.4` and this wheel. The demo film was re-cut to open on the graph itself — frame one is the nine-node incident graph with its first node already running, then the question that built it, then the finished audited run — and the README now leads with it. The README and website stopped describing the project as early and unstable: the status line states the version and the testing discipline, and *Status and limits* became *Limits*, framed as edges that are documented and tested rather than confessed. A PyPI downloads badge joined the badge row. This release exists mostly so the PyPI project page, which renders the README frozen at publish time, catches up with all of it.

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ grapharc serve --live-root .grapharc/runs # live browser view of every run
4343
grapharc replay <trace> <run-id> # reconstruct a run from its trace
4444
```
4545

46+
![A terminal running grapharc plan: round 1 is rejected with edge_denied and never executes, round 2 replans and runs, then viz draws the graph and metrics prints the per-node bill — all from the one trace file.](docs/media/grapharc-cli-gate.gif)
47+
48+
*Free and deterministic — `--scripted` runs the registry's own stand-in planner, so this reproduces exactly on any checkout. Three more recordings, and what is and is not staged in each, in [docs/demo/](docs/demo/).*
49+
4650
Building a graph directly:
4751

4852
```python
@@ -159,6 +163,31 @@ goal_met
159163

160164
Both blocks are executed by `tests/test_readme.py` against every commit, so this page cannot drift from the code.
161165

166+
### What a policy actually does to an agent
167+
168+
Not "blocks the bad call at the last moment" — it decides what the planner is *able to propose*. The planner is told the policy before it plans, so a denied edge changes the shape of the graph rather than producing a refusal to retry.
169+
170+
![A terminal: a failing test, then a policy denying any edge into apply_change, then a plan under that policy proposing three read-only nodes whose rationale says it cannot reach apply_change, then the amended policy, then the same goal producing a five-node mutating graph, then the execution, the diff, and the test passing.](docs/media/grapharc-cli-fix-bug.gif)
171+
172+
That is GraphARC fixing a bug in GraphARC — a real one, from this project's backlog, in a copy of this repository. Under `deny *->apply_change` the planner proposes three read-only nodes and says so itself:
173+
174+
> Since `apply_change` cannot be reached by an edge, this round investigates the torn trace bug and writes findings to notes **for a human to act on**.
175+
176+
`mutating: false`. A person then amends the rule, and the same goal on the same model returns a five-node graph containing `apply_change`, `mutating: true`. Claude Code executes it and the red test goes green. The recording, and exactly what in it is staged (the pacing) and what is not (everything else), is in [docs/demo/](docs/demo/).
177+
178+
## Supervised Claude Code, from Slack
179+
180+
One message asks for work; the answer is the *graph* it intends to run — nodes, their governed kinds, edges, worst-case cost — and two buttons. Nothing executes until a human presses one.
181+
182+
![A Slack thread: the bot replies with the proposed three-node graph and Approve / Deny buttons, the plan is approved, the nodes then run one by one, and the closing frame reads the trace back — approval_request, approval_response, then the first node's start.](docs/media/grapharc-slack-supervised.gif)
183+
184+
```
185+
/grapharc plan "explain what flaky.py does and why it is not reproducible" --go \
186+
--model claude-cli --registry grapharc.stdlib:build_registry
187+
```
188+
189+
`--go` means plan *and* execute. From Slack the gate appends `--approve` to it unconditionally, so the run parks before its first node — a message from anyone in the workspace can propose work, and only a person can start it. The click is bound to the plan's fingerprint, so a button on a superseded proposal is refused rather than honoured. Setup and the full rule list are in the [Slack cookbook](docs/cookbook/07-slack.md); what is and is not mocked in that recording is in [docs/demo/](docs/demo/).
190+
162191
## Where it sits
163192

164193
| | GraphARC | Claude Code | OpenClaw | raw LangGraph |

0 commit comments

Comments
 (0)