Commit 8c5dab7
Supervised Claude Code from Slack: propose a graph, show it, run it only if a human approves (#99)
* plan --approve never parked and go --approve was dropped; both gates are real now
Two flags that promised a gate and delivered none. GovernedLoop returned
PLANNED before the approval block could fire, so `plan --approve` — the
form the Slack gate injects — was inert by ordering; and `_cmd_go` accepted
--approve/--approval-timeout for the saved-plan path and passed neither on,
so `go <dir> --approve` was accepted and silently ignored. The approval
block now precedes the plan_only return (an approved plan is still PLANNED,
a denial saves nothing for a later go to pick up), execute_plan wires the
same file handshake `plan` uses — one shared gate builder, so the two
commands cannot drift on how a parked run asks — and a denied or timed-out
go leaves plan.json without an executed_run_id.
For the external drivers this exists to serve, `plan --json` now carries
the admitted shape as data: run_dir, fingerprint, the proposal's nodes,
edges and rationale, and a `mutating` verdict computed against the registry
module's own MUTATING_KINDS — with a module that declared nothing read as
"assume mutating", never as "declared safe" (RegistryBundle.mutating now
distinguishes silence from an empty declaration).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* grapharc mcp: an agent may request and check, and can never decide
The supervision surface external agents plug into, behind the mcp extra
that has waited in pyproject since it was declared. Three stdio tools and
deliberately not a fourth: plan proposes through the governed loop and
returns the admitted shape as data; show_graph reads a run directory back
(rendered summaries only, never raw state); execute re-admits and runs the
saved plan — parking on the file handshake exactly when the plan's own
record says it can change files, so an all-read-only plan runs on the
host's prompt while anything mutating waits for a human at a terminal.
There is no approve, deny, or decide tool, and a gate test holds the tool
list to that sentence: a client that could call approve() would be
approving its own proposal, which is not approval.
The server drives the CLI as subprocesses — stdout belongs to the
protocol, the --json payloads and exit codes are the tested interface, and
a parked execute cannot wedge show_graph. Argv is built, never parsed: no
tool accepts a registry, policy or model, because the operator's
grapharc.toml in the server's root decides those, and a run_dir a client
names is confined to that root. plan.json now records the mutating verdict
beside the fingerprint, and a record without the field reads as mutating —
an old plan is never assumed safe.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* One command turns Claude Code supervised: the config, the skill, the boundary
grapharc init --claude-code writes the two files that adopt the MCP
supervision surface: .mcp.json registering `grapharc mcp` as a project
server, and .claude/skills/grapharc/SKILL.md — the contract that routes
multi-step and state-changing work through plan -> show the user the graph
-> execute, quotes the approve command when a mutating plan parks, and
states the never-clauses for the hands the server cannot see: never run
grapharc approve, never touch the request or decision files, a timeout
means ask rather than retry. The templates are string constants in init's
convention, asserted byte-for-byte by the gate tests, and neither file is
ever overwritten — a refusal names what exists and half-writes nothing.
Cookbook 09 walks the adoption; the deep dive and README state the trust
boundary out loud rather than implying it: the MCP gate binds the MCP
surface, not a co-located agent's own file tools — the boundary is the
working directory, as it is for the Slack workspace — the host's permission
prompt is UX in front of the gate, not the gate, and a parked execute lives
inside one tool call. ROADMAP gains 9.7, distinct from the still-open MCP
*client* in 3.5.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* An error event carries the node's spend, so cost and the live view must count it
Three defects that all followed from the same stale assumption — that a
failed node leaves no terminal event worth reading.
`observe.cost` skipped every failed execution, so a run stopped for
overspending reported a cost report that disagreed with its own audit trail
by precisely the spend the stop was about. The kernel stamps `error`
terminals with the node's tokens exactly as it stamps `end`, and
`metrics.summarize` already counted them; cost now does too, prices them,
and keeps `tokens_before_error` for the narrower case it was really about —
a trace whose `error` event carries no count of its own, where the
AgentNode sub-steps are the only record.
The live view double-counted: sub-step tokens accumulated into `live_tokens`
were never cleared at a terminal, so a node that ran, finished and ran again
showed its closed execution's spend twice.
And `go` never resolved `policy` from `grapharc.toml` the way `plan` does,
so a saved plan re-admitted through the gate was judged by the built-in
default rather than the operator's document.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* The lockfile catches up with the mcp dev dependency, and adoption output is ignored
uv.lock records the `mcp` dev dependency added with `grapharc mcp`, so its
gate tests run in CI rather than silently skipping. The `.mcp.json` and
skill that `grapharc init --claude-code` writes when this repo dogfoods its
own supervision join the generated-not-authored list, alongside the runtime
residue ignored above.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 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>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>1 parent 1d289e3 commit 8c5dab7
58 files changed
Lines changed: 4886 additions & 181 deletions
File tree
- docs
- cookbook
- demo
- scenarios
- media
- grapharc
- cli
- harness
- mcp
- observe
- planner
- server
- slack
- tests
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
232 | 232 | | |
233 | 233 | | |
234 | 234 | | |
235 | | - | |
236 | | - | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
237 | 238 | | |
238 | 239 | | |
239 | 240 | | |
| |||
247 | 248 | | |
248 | 249 | | |
249 | 250 | | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
250 | 263 | | |
251 | 264 | | |
252 | 265 | | |
253 | 266 | | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
10 | 26 | | |
11 | 27 | | |
12 | 28 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
43 | 43 | | |
44 | 44 | | |
45 | 45 | | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
46 | 50 | | |
47 | 51 | | |
48 | 52 | | |
| |||
159 | 163 | | |
160 | 164 | | |
161 | 165 | | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
162 | 191 | | |
163 | 192 | | |
164 | 193 | | |
| |||
179 | 208 | | |
180 | 209 | | |
181 | 210 | | |
| 211 | + | |
182 | 212 | | |
183 | 213 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
407 | 407 | | |
408 | 408 | | |
409 | 409 | | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
410 | 421 | | |
411 | 422 | | |
412 | 423 | | |
| |||
0 commit comments