Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -232,8 +232,9 @@ __marimo__/
# A policy generated because no `--policy`, no `grapharc.toml` and no existing
# one was found. Generated rather than authored: the design is that you *promote*
# it — read it, edit it, move it, pass `--policy` — instead of committing it from
# where a tool dropped it. `grapharc.toml` itself is deliberately NOT ignored;
# that one is yours and belongs in version control.
# where a tool dropped it. In a project of your own, the scaffolded
# `grapharc.toml` is authored config and belongs in version control; in *this*
# repo the root one is dogfooding residue, ignored with the rest of it below.
.grapharc/

# Durable claim stores from `grapharc demo --memory PATH`, and the two sidecars
Expand All @@ -247,7 +248,26 @@ __marimo__/
/workspace/
/scratch/

# Dogfooding residue at the repo root: the `grapharc init` scaffold pair, the
# registries that drive the demo films, and the `app/` directory a bench
# shakeout wrote its success condition into. None of it is project source, and
# none of it — the bench residue especially — should ride along on a push.
# Root-anchored, so `bench/fixture/app/` (a committed part of the bench) is
# untouched.
/registry.py
/grapharc.toml
/demo_registry.py
/sample_incident.py
/app/

# Claude Code's per-machine settings. Already covered by many people's global
# ignore; named here so a fresh clone does not depend on that being true.
.claude/settings.local.json
HANDOFF.md

# Generated by `grapharc init --claude-code` when this repo dogfoods its own
# supervision. Generated rather than authored, like the policy above: promote
# a copy into a project you are governing instead of committing the one a
# smoke test dropped here.
/.mcp.json
/.claude/skills/
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,5 +179,6 @@ The edges are documented, not denied — the full list with mechanisms is in the
- The HTTP API does not yet use the durable session layer.
- On the Claude CLI backend an agent node is *delegated*, not governed.
- Policy documents govern planning; the tool plane still reads CLI flags.
- The MCP gate binds the MCP surface, not the host: an agent with its own file tools in the run directory could forge the approval decision. The trust boundary is the working directory, as it is for the Slack workspace.

Version `0.1.5` · [changelog](CHANGELOG.md) · [roadmap](ROADMAP.md) · [website](https://codegraphcontext.github.io/GraphARC/) · MIT
11 changes: 11 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,17 @@ Everything here works and nothing calls it.
and the answer was negative, `2` could not run at all.
- [x] **9.6 — Streaming output to clients** via SSE, with a `last-event-id`
cursor so a reconnect skips what it already saw.
- [x] **9.7 — MCP supervision server** (`grapharc mcp`, behind the `mcp`
extra that had waited unimported since it was declared). Three stdio
tools — plan / show_graph / execute — and deliberately no approval
verb: a mutating plan parks on the file handshake for an out-of-band
human, a read-only one runs on the host's own prompt, and the verdict
fails closed (an undeclared registry and an old plan.json both read as
mutating). `grapharc init --claude-code` writes the `.mcp.json` and
the skill that adopt it. Distinct from §3.5, which is the MCP *client*
— GraphARC calling third-party tools — and remains open. The trust
boundary is stated in the deep dive: the gate binds the MCP surface,
not a co-located agent's own hands.
- [ ] **9.3 — Cron schedules** and **9.4 — webhook triggers.**
- [ ] **9.5 — Chat channels** (Slack / Discord).

Expand Down
65 changes: 65 additions & 0 deletions docs/cookbook/09-supervised-agents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# 09 — Supervised agents: GraphARC as an MCP server

The agents people already use — Claude Code first among them — act alone: one
loop, its own judgment, edits landing as fast as it can type them. This
chapter turns that around without replacing the agent. The agent keeps its
intelligence; GraphARC supplies the gate. Work is proposed as a graph, the
human sees the graph, and execution happens under budgets, onto the trace,
with the mutating case parked until a human answers out of band.

## The shape of it

`grapharc mcp` is a stdio MCP server exposing exactly three tools:

| tool | what it does | what it cannot do |
|---|---|---|
| `plan(goal, scripted?, max_rounds?)` | run the governed planning loop for the goal; return the admitted shape — nodes, edges, rationale, fingerprint — plus a `mutating` verdict | choose a registry, policy or model: those resolve from the operator's `grapharc.toml` in the server's root, never from the call |
| `show_graph(run_dir)` | read a run directory back: the proposal, whether a human is being asked right now, and — after execution — metrics and the Mermaid of what ran | see raw state: like the live view, it serves rendered summaries, never `state_delta` |
| `execute(run_dir, approval_timeout?)` | re-admit the saved plan through the gate and run it | approve itself: a mutating plan parks on the file handshake until a human answers `grapharc approve <run_dir>` |

There is no `approve` tool, and there never will be. The gate is between the
agent and the operator; a client that could call `approve()` would be
approving its own proposal, which is not approval. The supervised agent may
*request* (execute parks) and may *check* (`show_graph` says
`awaiting_approval`); the decision belongs to a human with a terminal or the
live view, outside the connection entirely.

The tiering is deliberate: a plan whose admitted kinds are all read-only
executes on the host agent's own permission prompt — the human already said
yes to the tool call — while any plan containing a mutating kind waits for
the out-of-band answer. The verdict is computed at plan time against the
registry module's own `MUTATING_KINDS`, is stored in `plan.json` beside the
fingerprint, and fails closed twice over: a registry that declared nothing
reads as mutating, and so does a plan file without the field.

## Adopting it in a Claude Code project

```bash
cd your-project
grapharc init # once, if there is no registry.py/grapharc.toml yet
grapharc init --claude-code # writes .mcp.json and .claude/skills/grapharc/SKILL.md
```

`.mcp.json` registers `grapharc mcp` as a project server, so Claude Code
starts it on demand. The skill is the behavioural half: it routes multi-step
and state-changing work through plan → show → execute, tells the agent to
render the proposal and the `watch_url` to the user, and states the boundary
the server cannot enforce on the host's *other* hands — never run
`grapharc approve`, never touch the request or decision files, and a timeout
means ask, not retry. Neither file is ever overwritten; an existing one is
yours, and the command refuses by name.

Watch a supervised run the same way as any other: `grapharc serve
--live-root .grapharc/runs`, and the parked proposal is drawn on the live
page with the approve command beside it.

## The trust boundary, stated plainly

The MCP gate binds the MCP surface, not the machine. A host agent holds its
own Write and Bash, and a process in the working directory can forge
`approval-decision.json` — the same posture as the Slack gate's workspace:
the trust boundary is the directory, and the skill's never-clauses are the
contract for hands the server cannot see. The park also lives inside one MCP
call: a host that times the tool out kills the wait, the plan stays
unexecuted, and the call is safe to reissue. And approval records the
decision, never the decider — the trace has no actor field.
6 changes: 6 additions & 0 deletions docs/deep-dive.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,12 @@ A stable system is not one that claims to have no edges — it is one whose edge

- **The HTTP API does not use the durable session layer.** It has its own `InProcessRuntime`, whose sessions die with the process and whose approvals are recorded without being delivered. [ROADMAP.md](../ROADMAP.md) §12.3.

**The MCP supervision surface**

- **The MCP gate binds the MCP surface, not the host.** The server exposes no approval verb, so a supervised agent cannot decide over its own connection — but the host agent holds its own Write and Bash, and a process in the run directory can forge `approval-decision.json`. The trust boundary is the working directory, the same posture the Slack gate documents for its workspace; the shipped skill states the never-clauses for the hands the server cannot see.
- **The host's tool-permission prompt is UX, not enforcement.** Allowlists and skip-permissions modes erase it, and GraphARC cannot observe it. The park on the file handshake is the gate; the prompt is a courtesy in front of it.
- **A parked `execute` lives inside one MCP call.** A host that times the tool out kills the wait; the plan stays unexecuted and the call is safe to reissue. Approval records the decision, never the decider — the trace has no actor field.

**Real limits of things that do work**

- **Admission authorises a kind, not its arguments.** A proposal carrying `args={"path": "/etc/passwd"}` is admitted on the strength of its kind alone.
Expand Down
146 changes: 146 additions & 0 deletions grapharc/cli/adopt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""`grapharc init --claude-code` — adopt GraphARC as a supervision layer.

Writes the two files that turn a Claude Code checkout into a supervised one:
`.mcp.json`, registering `grapharc mcp` as a project MCP server, and
`.claude/skills/grapharc/SKILL.md`, the contract that routes multi-step and
state-changing work through plan → show → execute instead of a stream of
unsupervised edits.

Templates are string constants, the `init` convention: nothing here reads
packaged data files, and what a test asserts about the skill is asserted
against the exact bytes a user gets. Neither file is ever overwritten —
an existing one is the operator's, and the refusal names it.

The skill's load-bearing clause is the last one: the agent must never answer
the approval itself. The MCP surface enforces that on its own connection (no
approve tool exists), but the host agent also holds Write and Bash — the
skill states the boundary for the hands the server cannot see, and the trust
boundary remains the working directory, as it is for the Slack workspace.
"""

from __future__ import annotations

from pathlib import Path

from grapharc.cli import style
from grapharc.cli.output import EXIT_OK, EXIT_UNAVAILABLE, emit

MCP_CONFIG_FILENAME = ".mcp.json"
SKILL_PATH = Path(".claude/skills/grapharc/SKILL.md")

MCP_CONFIG_TEMPLATE = """\
{
"mcpServers": {
"grapharc": {
"command": "grapharc",
"args": ["mcp"]
}
}
}
"""

SKILL_TEMPLATE = """\
---
name: grapharc
description: >-
Route multi-step or state-changing repository work through GraphARC
supervision: propose the work as a governed graph, show the user the graph,
execute only what the gate admits — and what a human approved, when the plan
can change files. Use for repo-wide fixes, refactors, migrations, or any
task that would otherwise be a long stream of unsupervised edits.
---

# GraphARC supervision

This project routes substantial work through GraphARC's admission gate. The
`grapharc` MCP server exposes three tools — `plan`, `show_graph`, `execute` —
and deliberately nothing that decides an approval.

## The flow

1. **Propose, don't act.** For multi-step or state-changing work, call
`plan` with the user's goal instead of editing files directly. The
registry, policy and model come from this project's `grapharc.toml` — if
`plan` reports no registry or model, tell the user to run `grapharc init`
and set `model` in `grapharc.toml`; do not work around it.
2. **Show the user the graph.** Render the returned `proposal` — its nodes,
edges and rationale — in your reply, and give them `watch_url` when it is
set: the live page is where the graph draws itself while it runs.
3. **Execute under the gate.** Call `execute` with the returned `run_dir`.
A plan that can change files **parks**: tell the user it is waiting and
quote `approve_command` — they answer with `grapharc approve <run_dir>`
(or `--deny`) in a terminal, or from the live view. A read-only plan runs
immediately.
4. **A timeout means ask, not retry.** If `execute` comes back
`approval_timeout`, the plan is unexecuted and the call is safe to
reissue — after the user says so. Never loop on `execute` waiting for a
yes that has not been given.
5. **Report from the record.** After execution, call `show_graph` for the
metrics and the Mermaid rendering of what actually ran, and cite those
rather than your recollection.

## What you must never do

- Never run `grapharc approve`, in any form, for any reason.
- Never create, edit or delete `approval-request.json` or
`approval-decision.json` — those files are the human's channel, not yours.
- Never edit `plan.json` to change what was admitted or whether it counts as
mutating; an edited plan is a new proposal, and the gate treats it as one.

The decision belongs to the user. Your job is to make the question easy to
answer: show the graph, quote the command, and wait.
"""


def adopt_claude_code(*, as_json: bool = False) -> int:
"""Write `.mcp.json` and the skill, or refuse naming what already exists."""
mcp_config = Path(MCP_CONFIG_FILENAME)
skill = SKILL_PATH

existing = [str(p) for p in (mcp_config, skill) if p.exists()]
if existing:
message = (
f"refusing to overwrite: {', '.join(existing)} — these are yours "
"once written; move one aside if you want it regenerated"
)
if as_json:
emit({"ok": False, "command": "init", "error": message}, [], as_json=True)
else:
import sys

print(f"error: {message}", file=sys.stderr)
return EXIT_UNAVAILABLE

skill.parent.mkdir(parents=True, exist_ok=True)
mcp_config.write_text(MCP_CONFIG_TEMPLATE, encoding="utf-8")
skill.write_text(SKILL_TEMPLATE, encoding="utf-8")

payload = {
"ok": True,
"command": "init",
"mcp_config": str(mcp_config),
"skill": str(skill),
}
width = style.LABEL_WIDTH
lines = [
style.kv("wrote", str(mcp_config), width=width, tint=style.accent),
style.kv("wrote", str(skill), width=width, tint=style.accent),
"",
style.kv(
"next",
"open Claude Code here; multi-step work now routes through "
"plan -> show -> execute, and approvals stay yours",
width=width,
),
]
emit(payload, lines, as_json=as_json)
return EXIT_OK


__all__ = [
"MCP_CONFIG_FILENAME",
"MCP_CONFIG_TEMPLATE",
"SKILL_PATH",
"SKILL_TEMPLATE",
"adopt_claude_code",
]
2 changes: 1 addition & 1 deletion grapharc/cli/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ def resolve_or_generate_policy(
workdir: Path | None = None,
write: bool = True,
catalog: dict[str, str] | None = None,
mutating: tuple[str, ...] = (),
mutating: tuple[str, ...] | None = (),
fallback: Any = None,
fallback_label: str = "",
registry_target: str = "",
Expand Down
54 changes: 54 additions & 0 deletions grapharc/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,10 @@ def _cmd_plan(args: argparse.Namespace) -> int:


def _cmd_init(args: argparse.Namespace) -> int:
if getattr(args, "claude_code", False):
from grapharc.cli.adopt import adopt_claude_code

return adopt_claude_code(as_json=args.json)
from grapharc.cli.init_cmd import init

return init(as_json=args.json)
Expand Down Expand Up @@ -399,6 +403,8 @@ def _cmd_go(args: argparse.Namespace) -> int:
run_id=args.run_id,
max_tokens=args.max_tokens,
config_path=args.config,
approve=args.approve,
approval_timeout=args.approval_timeout,
as_json=args.json,
)
candidate = Path(target)
Expand All @@ -415,6 +421,8 @@ def _cmd_go(args: argparse.Namespace) -> int:
run_id=args.run_id,
max_tokens=args.max_tokens,
config_path=args.config,
approve=args.approve,
approval_timeout=args.approval_timeout,
as_json=args.json,
)
return plan(
Expand Down Expand Up @@ -551,6 +559,23 @@ def _cmd_agent(args: argparse.Namespace) -> int:
)


def _cmd_mcp(args: argparse.Namespace) -> int:
from grapharc.cli import optional

try:
module = optional.load(
"grapharc.mcp",
needed_for="grapharc mcp",
hint="pip install 'grapharc[mcp]'",
)
except optional.Unavailable as exc:
return fail(str(exc), as_json=args.json, command="mcp")
root = Path(args.root).resolve() if args.root else None
if root is not None and not root.is_dir():
return fail(f"--root: not a directory: {root}", as_json=args.json, command="mcp")
return int(module.serve_stdio(root))


def _cmd_serve(args: argparse.Namespace) -> int:
from grapharc.cli.serve import serve

Expand Down Expand Up @@ -969,6 +994,15 @@ def build_parser() -> argparse.ArgumentParser:
parents=[common],
help="scaffold a registry, a config and a runs directory in this directory",
)
ini.add_argument(
"--claude-code",
action="store_true",
help=(
"instead of the scaffold, write .mcp.json and the Claude Code "
"skill that route this project's multi-step work through "
"grapharc mcp supervision"
),
)
ini.set_defaults(handler=_cmd_init)

st = sub.add_parser(
Expand Down Expand Up @@ -1061,6 +1095,26 @@ def build_parser() -> argparse.ArgumentParser:
agent.add_argument("--system-prompt", default=None)
agent.set_defaults(handler=_cmd_agent)

mcp = sub.add_parser(
"mcp",
parents=[common],
help=(
"run the MCP supervision server on stdio (plan / show_graph / "
"execute; approval stays out of band)"
),
)
mcp.add_argument(
"--root",
default=None,
metavar="PATH",
help=(
"directory whose grapharc.toml and registry govern every plan, and "
"which confines every run_dir a client names (default: the working "
"directory)"
),
)
mcp.set_defaults(handler=_cmd_mcp)

serve = sub.add_parser("serve", parents=[common], help="run the HTTP API")
serve.add_argument("--host", default="127.0.0.1")
serve.add_argument("--port", type=int, default=8000)
Expand Down
Loading