Skip to content
Merged
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
28 changes: 26 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,30 @@ __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/

# The demo recording is regenerated by docs/demo/capture_supervised_slack.py;
# it embeds absolute paths from whichever machine made it.
docs/demo/session.json
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ and "used to be true" — the two things a reader most needs kept apart.

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

## Unreleased

- **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.
- **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.
- **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.
- **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.
- **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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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`.
- **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`.
- **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.

## 0.1.5

- 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.
Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ grapharc serve --live-root .grapharc/runs # live browser view of every run
grapharc replay <trace> <run-id> # reconstruct a run from its trace
```

![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)

*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/).*

Building a graph directly:

```python
Expand Down Expand Up @@ -159,6 +163,31 @@ goal_met

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

### What a policy actually does to an agent

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.

![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)

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:

> 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**.

`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/).

## Supervised Claude Code, from Slack

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.

![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)

```
/grapharc plan "explain what flaky.py does and why it is not reproducible" --go \
--model claude-cli --registry grapharc.stdlib:build_registry
```

`--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/).

## Where it sits

| | GraphARC | Claude Code | OpenClaw | raw LangGraph |
Expand All @@ -179,5 +208,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
Loading
Loading