Thanks for considering a contribution. oh-my-graph is a small, opinionated project — please read DESIGN.md first. It is the architecture source of truth: object responsibilities, the execution model, and the MVP scope boundary all live there. If a PR and DESIGN.md disagree, DESIGN.md wins until it is updated in the same PR.
make build # go build -o bin/oh-my-graph ./cmd/oh-my-graph
make test # go test ./... -race -count=1
make vet # go vet ./...
make fmt # gofmt -w .
make fmt-check # gofmt -l . ; fails if anything is unformatted (CI gate)make build writes the binary to ./bin/oh-my-graph; running it in place
works for a one-off check. If you want your everyday oh-my-graph command to
track local edits while you iterate, symlink it onto your PATH instead of
invoking ./bin/oh-my-graph directly: mkdir -p ~/.local/bin && ln -sf "$PWD/bin/oh-my-graph" ~/.local/bin/oh-my-graph (assumes ~/.local/bin is on
PATH). Because it's a symlink, every subsequent make build updates what's
on PATH with no separate install step — this is distinct from the README's
go install github.com/jitokim/oh-my-graph/cmd/oh-my-graph@latest, which
fetches a released version from the module proxy, not your local checkout;
the symlink is for contributors actively working on the source.
make test runs the entire suite against a scripted FakeRunner
(internal/runner/fake.go) — no test in the repo spawns a real claude
process. That is intentional: the ready-set scheduler, DAG validation,
handoff, retry, and halt-on-fail logic are all exercised through
map[nodeID]NodeOutcome fixtures, so CI never needs a claude login and
never costs money.
One file is a sanctioned exception to "spawns nothing", and it is still an
exception to nothing above: cmd/oh-my-graph/skillargv_test.go drives the real
ClaudeCLIRunner against a temporary #!/bin/sh stub it writes itself, so it
can assert the bytes of a node's argv — the one layer a real node obeys, and
the one a FakeRunner cannot reach. It never launches claude, never touches
the network, and costs nothing; what it does depend on is a POSIX /bin/sh and
mktemp, which is why the CI job runs on ubuntu-latest. A new file that
wants to spawn anything at all needs the same justification: a fact no double
can establish.
Test doubles. A double may block on its scripted sync channel or on
ctx.Done(), never on a wall-clock fallback; and an assertion must not be
satisfiable by a node or record simply being absent — state presence
explicitly. A wall-clock arm silently degrades choreography into an
unsynchronized race under CI load, and an absence-satisfiable assertion then
passes for the wrong reason: this exact combination produced the project's
only CI flake and survived four review layers before being caught.
make smoke # builds the binary, then runs graphs/haiku-smoke.yaml for realmake smoke is the one command that spawns a real claude subprocess. It
costs a few cents on your own subscription and requires you to be logged in.
It is a manual, local-only step — never add it to CI, and don't submit a
PR that wires it into a workflow.
- Branch names:
feat/...,fix/...,chore/...,docs/...— matching the change type. - Commit messages: Conventional Commits
(
feat:,fix:,docs:,chore:, optionally scoped, e.g.fix(graph): ...), matching the existing history. - Keep PRs scoped to one change. If a PR touches the execution model (the
scheduler, the
NodeRunnerinterface, handoff, or the graph schema), update DESIGN.md in the same PR — don't let the doc drift from the code. - New architectural decisions (not just implementation detail) belong in
docs/adr/, following the style ofdocs/adr/0001-subprocess-not-sdk.md.
- Graph lanes open PRs as drafts — CodeRabbit deliberately skips drafts, so marking the PR ready for review is what starts its review.
- A PR is merged only after CI is green and CodeRabbit's review has
completed with every comment triaged: either applied (mechanical fixes may
be applied by hand within the one-to-two-line threshold; anything more goes
through
graphs/apply-flags.yaml) or answered with a reason. - Admin merge is for bypassing the merge-queue mechanics, never for bypassing an unfinished review.
Commits authored by a graph lane — a claude node running one of the shipped
graphs/*.yaml pipelines against this repo — end with the trailer
Co-Authored-By: oh-my-graph <graphs@oh-my-graph.dev>
This is a transparency convention, not a GitHub account: the address receives
no mail and resolves to no user. It exists so the dogfooding claim in the
README ("It ships itself") stays verifiable —
git log --grep="Co-Authored-By: oh-my-graph" lists the commits that declare
it. Every commit-producing node in the shipped graphs/*.yaml prompts for
the trailer; like any trailer it is a convention, not cryptographic proof of
authorship.
Exactly four objects in this codebase may spawn a process, each behind its own injected interface:
| object | runs | interface |
|---|---|---|
internal/runner.ClaudeCLIRunner |
a node's claude -p subprocess |
runner.NodeRunner |
internal/verify.ShellVerifier |
a node's success_check.verify command |
verify.Verifier |
internal/worktree.GitManager |
the git worktree commands behind a node's worktree: |
worktree.Provider |
internal/browser.ExecOpener |
the open/xdg-open launch of the serve URL |
browser.Opener |
type NodeRunner interface {
Run(ctx context.Context, spec NodeInvocation) (NodeOutcome, error)
}
type Verifier interface {
Verify(ctx context.Context, req Request) (Result, error)
}
type Provider interface {
Acquire(ctx context.Context, name string) (string, error)
}
type Opener interface {
Open(ctx context.Context, url string) error
}Every other package — the scheduler, the CLI, handoff, ledger, coordinator —
depends only on those interfaces. That is what keeps the entire engine testable
via FakeRunner, FakeVerifier, worktree.FakeManager and
browser.FakeOpener with zero real spawns, and it keeps the
subscription-auth env scrub (internal/childenv) to exactly one call site
per spawner.
If your change needs to run a subprocess, it belongs behind one of the four
existing seams. A PR that spawns a process (via os/exec or any other way of
shelling out) outside runner.ClaudeCLIRunner, verify.ShellVerifier,
worktree.GitManager and browser.ExecOpener should be treated as a design
regression, not a normal review comment — a fifth spawner needs an ADR
first, the way the second, third and fourth got
ADR 0002,
ADR 0005 and
ADR 0006. (The invariant
is about objects that spawn, not os/exec imports per se: the runner and
verify seam packages also carry build-tagged process-group helpers that import
os/exec solely to kill a cancelled child's tree — they never start one.)
These are the load-bearing guarantees the whole project exists to make (see SECURITY.md for the full stance). A PR that weakens any of them without an explicit, discussed design change should not be merged:
- Subscription-auth env scrub. Every child process oh-my-graph spawns —
a claude node, a
success_check.verifycommand, the git commands behind a node'sworktree:, AND theopen/xdg-openlaunch of theserveURL — hasANTHROPIC_API_KEYandANTHROPIC_AUTH_TOKENdeleted from its environment by the sharedinternal/childenv.Scrub. Those variables silently switchclaudeto metered API billing;verify: { command: "claude -p ..." }is a legal thing to write, a repo's own git hooks may invoke claude too, and the URL handleropendispatches to is arbitrary user-configured code — so every one of the four spawners must apply it. It is asserted ininternal/childenv/childenv_test.go(the policy) and at each call site (internal/runner/claude_test.go,internal/verify/shell_test.go,internal/worktree/git_test.go,internal/browser/exec_test.go) — if you touch env construction, make sure those tests still prove the scrub. - Never the Agent SDK. The node runtime is exclusively the
claudeCLI subprocess (claude -p ... --output-format json). Don't introduce an Anthropic API/Agent SDK dependency as an alternate or default runtime path. - Never
--bare. That flag disables OAuth and would break subscription auth. Don't add it to the built argv. - Never
--no-session-persistence. Nodes run with session persistence on so every node shows up as an ordinary session in~/.claude/projects, readable by anything that reads claude transcripts. Don't add a flag or option that turns it off by default. - Every field on
graph.Nodehas an explicit auto-mode disposition. A planner reply is untrusted input, socoordinator.validatePlannedNodesmust allow, constrain, or reject every field a plan can set — this class of hole recurs each time the schema grows. It is enforced, not just requested:internal/coordinator/field_dispositions_test.gowalksgraph.Nodeby reflection and fails on any field with no recorded disposition. If you add a field toNode, that test will fail until you decide what auto mode does with it. Recording it asrejectedalso requires a probe proving the rejection actually fires. - The auto-mode tool ceiling is auto mode's alone. Hand-written graphs
(
oh-my-graph run) must keep running under the user's own settings, hooks and MCP servers — they are the user's reviewed artifact, and that path exists for precise control. Don't extend--setting-sources "",--tools,--strict-mcp-configor--disallowedToolsto it. - Don't narrow a published security claim ahead of a measurement. The
scoped-
Bashwording in README/SECURITY.md was only softened after a realclaudeinvocation demonstrated the behaviour (DESIGN.md, "Empirical verification of the tool ceiling").--helpprose has already been wrong once. If you can't measure it, say it's unverified — an honest partial mechanism beats an overclaimed complete one.
Maintainer checklist for cutting a release:
- Version bump lands with the CHANGELOG heading. Bump
cmd/oh-my-graph/version.goand add the## [x.y.z]entry toCHANGELOG.mdin the same commit — CI has guarded this pairing since v0.3.0 (TestVersionMatchesChangelogfails if they drift). - Bump the plugin manifests to the same version.
plugin/.claude-plugin/plugin.jsonand.claude-plugin/marketplace.jsonmove together with the release — CI does not guard these two, so the checklist is the only thing that does. - Sync the Korean README. A release must not ship a stale translation:
fold any
README.mdchanges since the last release intoREADME.ko.md(English is the source of truth; the ko file carries the precedence notice). Nothing in CI guards this — the checklist does. make smokebefore tagging. Run the real-claudesmoke locally as the last gate — it is the only check that exercises an actual subprocess, and it never runs in CI.- One scoped deep meta-review per release, on a rotating subject (tests → docs → security). Pick the release's subject and ask one targeted question about that area's blind spots, rather than adding another generic review pass — the v0.3.0 flake was found by asking a targeted question about test-double synchronization, after four generic review layers had missed it.
Before proposing a feature, check DESIGN.md's "MVP scope (v0.1)" section (its
IN and DEFERRED lists) and
docs/LIMITATIONS.md.
Things like a graph DSL beyond depends_on and a terminal TUI are deliberately
out of scope — that's not an oversight, it's a documented boundary. If you want
to build one of those, open an issue to discuss the design first rather than
sending a large PR.
Open an issue. This is a young project — there's no separate chat/forum yet.