Skip to content

Commit 023cdd7

Browse files
plan plans, go goes — and the first hour finally has a guide
One day's worth of making the product mean what it says: - The live view is a designed SVG dashboard (no CDN, packaged frontend): per-node status/tokens/cost, goal in the header, violet proposed nodes, approval banner, replay scrubber; goal and approve-command ride the trace. - plan/go split: `plan` proposes + admits + saves plan.json and STOPS; `go` executes the newest saved plan (re-admitted on the way in); `plan --go` is the one-shot. Registry chain: flag/config > ./registry.py > built-ins, with `--default` forcing the built-ins. - No silent scripts in real commands: a model is required; `--scripted` is the explicit, labeled rehearsal. - Local-model planning made reliable: <think>-block-safe JSON extraction, slim proposal schema for Ollama's grammar decoder, retry notes carrying the offending reply + a diamond worked example (so 8B models fan out independent work instead of chaining it), --model-arg/--workspace/ --max-planning-failures, and a registry-keyed generated-policy cache (a stale policy could silently unlock the mutating kind). - Onboarding: bare `grapharc` orients, `help` works, `start` explains every term in plain words, `init` scaffolds a registry whose first free run demonstrates refuse-then-admit and whose gather does real work. - Traces default under .grapharc/runs; serve writes a discovery marker; plan/go always print a probed `watch :` URL. Public site under docs/site with a Pages workflow; README repositioned around the admission gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d951e5e commit 023cdd7

62 files changed

Lines changed: 6331 additions & 471 deletions

Some content is hidden

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

.github/workflows/pages.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Publishes docs/site/ — and nothing else — to GitHub Pages.
2+
# One-time repo setting: Settings → Pages → Source: "GitHub Actions".
3+
4+
name: pages
5+
6+
on:
7+
push:
8+
branches: [main]
9+
paths: ["docs/site/**", ".github/workflows/pages.yml"]
10+
workflow_dispatch:
11+
12+
permissions:
13+
contents: read
14+
pages: write
15+
id-token: write
16+
17+
concurrency:
18+
group: pages
19+
cancel-in-progress: true
20+
21+
jobs:
22+
deploy:
23+
runs-on: ubuntu-latest
24+
environment:
25+
name: github-pages
26+
url: ${{ steps.deployment.outputs.page_url }}
27+
steps:
28+
- uses: actions/checkout@v4
29+
- uses: actions/configure-pages@v5
30+
- uses: actions/upload-pages-artifact@v3
31+
with:
32+
path: docs/site
33+
- id: deployment
34+
uses: actions/deploy-pages@v4

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ Entries are newest-last within a release, matching the order they were written.
1717
- the `/live` **token was accepted in the query string on every route**, and a URL is the one place a secret cannot be taken back from: the uvicorn request line, the nginx access log, browser history, and the referrer of anything the page opens. The index made it worse by writing the token into every link it rendered, so clicking a trace filed the secret in history a second time. It is refused off `/live/api/stream` now — that route keeps it because a browser `EventSource` cannot set a header and has no other way in — with a 401 whose reason says *where* to put the token rather than that it is wrong. A browser gets a sign-in page instead of a bare 401 and trades the token for a cookie: a SHA-256 digest of it rather than the token itself, `HttpOnly`, `SameSite=Strict`, scoped to `/live`, and always ASCII, so a non-ASCII secret survives the latin-1 header encoding that a `Bearer` header cannot. Links carry no token at all. The residual exposure — the SSE request line — is now named in the cookbook next to `--live-token`, with what to scrub. Every confinement the reader already enforced is untouched: `../`, `%2e%2e%2f`, absolute paths, NUL bytes and symlinked traces are the same 404s, and a hostile token is still a 401 rather than a crash. (#41)
1818
- the live page was **blind for the whole planning phase**, which is where a governed run spends its budget and does its refusing. `plan`, `admission` and `round` events were on disk — 2,081 tokens spent before any node ran, in the report — and the page rendered none of them, because it keys the graph off the `topology` event that only lands once a round is admitted and materialised. A run refused on every round produces no topology at all, so the most governance-relevant run there is showed nothing from start to "finished". The snapshot now carries a `planning` block folded from those same events (no new trace events): per round, the proposal size, the admission status, the checks that failed and the rejection codes, the planner tokens, and whether it executed; plus the loop's stop reason and detail when it stopped without a graph. The page renders it as a panel, and a round that has begun and not closed reads as *active* rather than idle — a planner mid-inference writes nothing for a minute at a time, which is exactly the "is it thinking or is it wedged?" the report describes. A run that never planned has no `planning` field and renders exactly as before. (#47)
1919
- a finished trace **rendered as a done deal**: instantly all-green, with the amber `running` styling unreachable for every run that is already over — and for any live run whose nodes finish between two SSE polls. `?replay=1` on the stream walks the recorded events in timestamp order and emits the snapshots the run would have sent, so a node is amber for its recorded window and green after; `&speed=N` divides the wall clock and the whole replay is capped at 40 seconds, so a 40-minute incident trace is watchable. Frames are rebuilt by the same snapshot code a live stream uses, pointed at a prefix of the file, and depend on no clock: a trace replayed twice renders identically. Without the parameter nothing changed. (#48)
20+
- a qwen3-class model's **`<think>` block could beat its own answer**: JSON extraction ranked object spans longest-first, so a longer draft inside the reasoning block outranked the real reply outside it, and a fenced draft inside the block won outright (only the first fence was ever tried). The visible text — reasoning tags stripped — is scanned first now, the original text is a fallback tier (a reply that is *entirely* think-block still parses, and a `<think>` inside a JSON string is data, because a reply that already parses whole is never rewritten), every fence is tried in order, and a trailing comma is repaired only on candidates that already failed to parse — a trailing comma is never valid JSON, so no valid document can be rewritten.
21+
- the planner pushed **`Subgraph`'s own JSON schema at local grammar-constrained decoders** — recursive (`ProposedNode.subgraph → Subgraph`), every field required under strict mode including the `proposal_id`/`origin` it discards on arrival, ~3.5 KB of embedded docstrings — and small models reliably choked on it. Backends now declare `reliable_structured_output`; Ollama says no and gets the text path: a three-key slim shape (`nodes`, `edges` — pair, object and from/to forms all accepted — `rationale`) with a worked example in the prompt, re-validated through the real constructors so admission judges exactly what it always judged. A parse failure's retry note now shows the model a truncated snippet of its own reply plus the example, instead of a bare error string; `--max-planning-failures` makes the allowance operator-settable.
22+
- the generated-policy cache **was not keyed by registry**, so a `.grapharc/generated-policy.toml` written for the incident demo (`deny *->deploy`) silently governed a later stdlib run — overriding stdlib's own `deny *->apply_change` and making the mutating kind reachable with no operator decision anywhere. Generated policies are keyed by registry target now (`generated-policy.<slug>.toml`); a legacy un-keyed file is never honoured implicitly when the run can say which registry it is — the run falls through to generation or the registry default and *says so* — and the file survives untouched for an explicit `--policy`.
23+
- **the CLI never joined the runs it starts to the live view that draws them.** `plan`'s default trace went to a tempdir no server serves, and no command printed a URL. Defaults compose now: traces land under `.grapharc/runs/<stamp>/`, `serve --live-root` writes a discovery marker (`.grapharc/live-server.json` — URL, root, pid, never the token; removed on clean shutdown), and `plan`/`go` end with a `watch :` line — the exact page URL when a marker names a server that answers one loopback connect, the command that would start one otherwise. The goal now rides the loop's topology and approval events (operator-supplied text, deliberately shown — the second state field after `termination_reason`), so the page can say what a graph is *for*; a parked run shows its proposed nodes in violet with a copy-ready `grapharc approve <dir>` banner.
24+
- **`plan` planned nothing and executed everything** — the name lied. The verbs are split now: `grapharc plan` proposes, the gate admits, and the run STOPS with the admitted plan saved to `plan.json` next to its trace (exit 0, `stopped: planned`); `grapharc go` executes the newest saved plan (`go <run-dir>` for a specific one), replaying the stored proposal through the full governed loop so admission judges it again on the way in — a hand-edited plan.json is a new proposal, not a pre-approved one; `plan --go` (and `go "a goal"`) does both in one run. Looking at a plan and then typing `go` *is* the approval; `--approve` remains for parking one-shot runs mid-flight. Registry resolution is now one visible chain shared by both commands: flag/config first, else a `registry.py` in the directory (yours wins), else the built-in general-purpose kinds — with `--default` forcing the built-ins past everything.
25+
- **new commands for the first hour**: bare `grapharc` orients instead of erroring (exit 0); `grapharc start` is the guided tour; `grapharc init` scaffolds a commented `registry.py` (whose first free run reproduces refuse-then-admit), a `grapharc.toml`, and `.grapharc/runs/` — refusing to overwrite either authored file, with no `--force`; `grapharc go` is plan with doing-defaults (the stdlib tool-using registry, `--model` required); `--registry path/to/file.py:attr` loads a registry file directly; `--workspace` confines the stdlib kinds' tools to a directory (refused when a registry cannot take one — never silently un-confined); `--model-arg KEY=VALUE` reaches the backend constructor. The stdlib planner is now *told* the deterministic completion rule (end with `summarize`) instead of discovering it by burning rounds.
2026

2127
## 0.1.3
2228

README.md

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,35 @@
1313
[![CI](https://github.com/CodeGraphContext/GraphARC/actions/workflows/ci.yml/badge.svg)](https://github.com/CodeGraphContext/GraphARC/actions/workflows/ci.yml)
1414
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
1515

16-
**A governed agent runtime built on [LangGraph](https://github.com/langchain-ai/langgraph).**
16+
**The admission gate for agent graphs** — a governed agent runtime built on [LangGraph](https://github.com/langchain-ai/langgraph).
1717

18-
Build production-grade multi-agent systems with built-in safety, auditability, and control. GraphARC adds a governance layer on top of LangGraph: a planner *proposes* a subgraph, a deterministic checker *admits* it, and only then does anything execute. Every transition is permitted, every loop is bounded, and afterwards you can prove what happened and why it stopped.
18+
A planner *proposes* a subgraph, a deterministic checker *admits* it — or refuses with reasons — and only then does anything execute. Every transition is permitted, every loop is bounded, and afterwards you can prove what happened and why it stopped. Three things this package does that you will not find together anywhere else:
1919

20-
**Status:** early days (`0.1.3`) — the API is not stable yet. Known limits are listed in [Status and limits](#status-and-limits); closed ones are in [CHANGELOG.md](CHANGELOG.md).
20+
- **No step runs unless a deterministic gate admitted it — and it tells you why when it refuses.** Five checks on every proposal, every failure a structured rejection with a code and a remedy, work discovered mid-run re-entering the same gate. `grapharc run --check-only` is the gate as a linter: it can refuse without executing anything.
21+
- **The worst-case cost is known before a graph runs, and the exact per-node bill after — even when it fails.** Admission prices the worst case against what is *left* of the budget; at runtime each node's spend lands on its own trace event, error and cancellation included. `recorded_cost_usd` is never an estimate.
22+
- **One append-only JSONL trace file is the whole truth.** `replay`, `diff`, `metrics`, `viz`, cost attribution, OTel export and the live browser view all read the same file — the dashboard cannot disagree with the audit trail, because they are the same record.
23+
24+
**Website:** [codegraphcontext.github.io/GraphARC](https://codegraphcontext.github.io/GraphARC/) · **Status:** early days (`0.1.3`) — the API is not stable yet. Known limits are listed in [Status and limits](#status-and-limits); closed ones are in [CHANGELOG.md](CHANGELOG.md).
2125

2226
![One English question is decomposed by a local model into a nine-node graph — four parallel evidence pulls fanning out of START, a correlate join, a hypothesis fork, and a final report — shown live in the browser: the proposed graph waits grey for human approval, then each node turns amber while it runs and green when it is done.](docs/media/grapharc-decompose.gif)
2327

2428
*One question in, a governed graph out: a local model proposes the topology, the admission gate and a human approval decide, and the live view shows every node run — amber while executing, green when done. ([full-quality mp4](docs/media/grapharc-decompose.mp4))*
2529

2630
> *Graph engineering*: when one agent loop stops being enough, coordination becomes the engineering. Nodes do work (agent loops, model calls, deterministic functions, humans approving things), edges decide what runs next, and a typed shared state flows between them. GraphARC implements the discipline that makes such graphs production-grade rather than demos — the ideas emerging from the July 2026 loops-vs-graphs debate (Steinberger, Ng, et al.), the "Two Graphs, Two Jobs" split, and twenty years of pre-AI graph systems where every edge means something and every path can be explained.
2731
32+
## Where it sits
33+
34+
None of these is a competitor to be beaten — they do different jobs, and GraphARC's [design lineage](#design-lineage) borrows from two of them. This is the row-by-row difference:
35+
36+
| | GraphARC | Claude Code | OpenClaw | raw LangGraph |
37+
|---|---|---|---|---|
38+
| Shape | Governed multi-node graph runtime | Interactive single-agent coding loop | Personal AI assistant gateway | Graph mechanism library |
39+
| Who authorizes work | A deterministic admission gate, pre-execution, with reasons | A human, live, per action | Configuration and allowlists | Nobody — convention |
40+
| Cost control | Worst-case admission + per-node attribution, fail-closed | Usage visibility | Spend settings | None built in |
41+
| Audit | One replayable JSONL trace; replay/diff/cost read it | Session transcripts | Logs | Checkpoints (state, not *why*) |
42+
43+
Claude Code is a great agent — GraphARC's default backend drives the Claude CLI. OpenClaw is a great gateway to agents — GraphARC borrowed its policy-before-schema tool gating and put it behind enforcement. GraphARC is the layer that decides what an agent system is *allowed* to do: before it does it, with receipts after.
44+
2845
## Install
2946

3047
Python >= 3.12.
@@ -73,8 +90,14 @@ grapharc demo stage5 # verifier: fresh context + deterministic evidence a
7390
grapharc demo stage6 # memory: provenance, supersession, recall
7491
grapharc demo capstone # all of the above in one research agent
7592

76-
grapharc plan "look into the outage" # governed loop: propose -> admit -> execute -> replan
77-
grapharc plan "..." --approve # park each admitted round until a human answers
93+
grapharc start # the guided tour: concept, first run, live view
94+
grapharc init # scaffold registry.py + grapharc.toml + .grapharc/runs/
95+
96+
grapharc plan "look into the outage" --model ollama/qwen3:8b # plan ONLY: propose -> admit -> save
97+
grapharc go # execute the newest saved plan (go <run-dir> for a specific one)
98+
grapharc go "fix the flaky import" --model ollama/qwen3:8b # plan AND execute, one shot
99+
grapharc plan "..." --scripted # free rehearsal: stand-in planner, no AI
100+
grapharc plan "..." --go --approve # one-shot, parked mid-run until a human answers
78101
grapharc approve <trace> # answer a parked run (--deny to refuse)
79102
grapharc run graph.json # a topology you wrote, through the same gate
80103
grapharc run graph.json --check-only # admission as a linter; executes nothing
@@ -91,7 +114,7 @@ grapharc replay <path> <run-id> # reconstruct a run from its trace
91114
grapharc diff <path> <a> <b> # what changed between two runs
92115
```
93116

94-
Twelve commands, and every one of them takes `--json` — in JSON mode the failure is the document rather than a line on stderr. Exit codes are part of the interface: `0` did the job, `1` ran and the answer was negative (an agent stopped short, a run id had no events, two runs differed), `2` could not run at all.
117+
Fifteen commands, and every one of them takes `--json` — in JSON mode the failure is the document rather than a line on stderr. Exit codes are part of the interface: `0` did the job, `1` ran and the answer was negative (an agent stopped short, a run id had no events, two runs differed), `2` could not run at all.
95118

96119
The Slack bot puts most of these commands one `/grapharc …` away from a phone, behind an allowlisting gate that keeps the default spend at zero — setup in [docs/cookbook/07-slack.md](docs/cookbook/07-slack.md), and a command-by-command session, refusals included, in [docs/cookbook/08-slack-walkthrough.md](docs/cookbook/08-slack-walkthrough.md). A tracing command run from Slack is narrated live — one status message edited in place as nodes run, with a refreshed diagram link — and `grapharc serve --live-root` adds a browser page that redraws the orchestration graph in real time over SSE.
97120

@@ -130,12 +153,12 @@ The part with no prior art to copy, and the reason the rest exists. You cannot p
130153
Watch it happen first. This costs nothing and needs no key — the shipped planner is scripted, and its first proposal names the policy-denied `deploy` kind:
131154

132155
```bash
133-
grapharc plan "investigate the checkout outage"
156+
grapharc plan "investigate the checkout outage" --scripted --go
134157
```
135158

136159
```
137160
goal : investigate the checkout outage
138-
model : scripted
161+
model : scripted stand-in (--scripted)
139162
registry : grapharc.examples.plan_incident:build_registry
140163
kinds : deploy, patch, triage, verify
141164
policy : grapharc.examples.plan_incident:build_registry default (deny -> deploy, otherwise allow) [registry-default]
@@ -151,6 +174,8 @@ state : goal='investigate the checkout outage' notes=['triage ran', 'patch r
151174

152175
Round 1 wanted to deploy and **never executed**. Round 2 went through the *same* checker and ran.
153176

177+
The output ends with a `trace :` path — under `.grapharc/runs/` by default — and a `watch :` line. With `grapharc serve --live-root .grapharc/runs` running in another terminal, that line is the exact URL of this run's live page (proposed graph in violet awaiting approval, amber while nodes run, green when done, replay scrubber after); without one, it is the command that starts it.
178+
154179
The `policy` line ends in `[registry-default]` — that is the **provenance**, and it is on the JSON payload too as `policy_source`. It matters because a policy can now come from four places: a `--policy` flag, a `grapharc.toml`, one an LLM generated on a first run, or the registry's own default. A generated run and an authored one look identical on the command line, so the source is the only thing that tells them apart afterwards.
155180

156181
`--model SPEC` swaps in a real backend and changes none of the enforcement; `--policy policy.toml` moves the rules into a document.

0 commit comments

Comments
 (0)