Skip to content

Releases: jitokim/oh-my-graph

v0.10.0

Choose a tag to compare

@github-actions github-actions released this 19 Aug 00:40
56e64fb

Minor because two things you may now type were errors before. Compared by
name: the eleven subcommands are unchanged, flags.go's seventeen long flags
are unchanged, and the fragment file's five keys are unchanged — so nothing new
was invented. What changed is where existing spellings are accepted:
resume now takes --verify-cmd and --verify-timeout, which it previously
rejected with flag provided but not defined; and a use: inside a fragment
file, previously a load error, now resolves.

The headline is closure: a fragment may cite a fragment, so a loop can
contain a loop, bounded by a citation chain and a depth of three files. ADR 0027
opened the unit; this closes it — a graph of loops is a loop.

Two of this release's entries exist because someone was stranded by them. #198
was found by the maintainer's own auto --verify-cmd run hitting a session
limit, and #200 by the same person reaching for resume --help at the moment it
mattered. Both are in the release that also changed how this project dogfoods:
291 runs, 284 of them run and 7 auto — the path the README leads with was
the path we exercised least, and that is where both bugs lived.

Added

  • A node inside a fragment file may carry use:/with: — a fragment may
    cite a fragment
    (ADR 0029). A loop can now hold a loop, and the shape a
    graph reuses no longer has to be the shape whose halves happen to be inline.
    Resolution became recursive descent through one code path used at every
    depth, so a nested use: is judged by exactly the rules a top-level one is:

    # graphs/fragments/gated-lane.yaml — the shipped instance
    fragment: gated-lane
    exit: pr
    nodes:
      - { id: dev, prompt: "{{ with.task }}", … }     # inline: no shape to cite
      - { id: e2e,    use: e2e-verify,  depends_on: [dev] }
      - { id: review, use: review-style, depends_on: [e2e], feedback: { rerun: dev, max: 1 } }
      - { id: pr,     use: pr-publish,  depends_on: [review] }
    $ oh-my-graph lint graphs/backlog-batch.yaml
    fragment: node "lane-a" spliced from "gated-lane" (…) — nodes: lane-a/dev, lane-a/e2e, lane-a/review, lane-a/pr — allowed_tools resolved from with: lane-a/dev: Read, Edit, Write, Bash(git *), Bash(go *), Bash(make *)
    fragment: node "lane-a/e2e" spliced from "e2e-verify" (…)
    fragment: node "lane-a/review" spliced from "review-style" (…) — node overrides: success_check
    fragment: node "lane-a/pr" spliced from "pr-publish" (…)

    The rules, each a decision the ADR argues rather than an implementation
    detail:

    • A cycle is a repeat on the CURRENT citation chain — a load error naming
      the cycle in order (top → mid → back → top), charged to the file whose
      use: line closes it, because that is the one a reader can delete. Chain
      membership, not a global visited set: a diamond — two loops citing one
      leaf, or one loop citing a leaf twice — is exactly what fragments are for
      and stays legal.
    • The chain is bounded at 3 citation hops, checked before the cited file
      is read, so a runaway arrives as a message rather than as a hang or a stack
      overflow. Hops are fragment FILES, not id segments: three multi-node hops
      mint a four-segment id, three single-node hops mint none, and an alias hop
      spends the budget anyway because what is bounded is how far the loader
      walks. The number is projected need (2) plus one level of headroom, and it
      is deliberately small enough to be reachable — a bound of 16 could never
      be shown to be wrong.
    • Namespacing composes left-to-right by the same jointop + core
      top/core, then + maketop/core/make — and decomposition stays
      unique because an atom cannot contain the delimiter. nodeIDPattern's
      (?:/segment)? became *; it is a widening of the backstop only, and it
      needs no new authorship refusal, because each of the three existing ones
      tests for the PRESENCE of a / and never for how many.
    • exit: is transitive, so a loop whose exit is itself a loop still
      exposes exactly one value from outside: its transitive exit's artifact.
      depends_on inheritance chains the same way, one level at a time, and
      cwd/worktree propagate by value through every level.
    • Parameter pass-through works, and falls out of the resolution order
      rather than being added to it.
      Each level namespaces its own file's text,
      then substitutes its own bindings, then descends — so a value bound at
      any using site is never id-rewritten by any level, at any depth. That is
      ADR 0027's guarantee extended, not a hole in it.
    • A nested use: name must be a literal. use: "{{ with.which }}" is a
      load error: the chain, the cycle check and the bound are all decided before
      the cited file is read, so a citation whose target came from a binding would
      make which files a graph pulls behavior from depend on data.
    • A single-node fragment may not cite a multi-node one — its body splices
      onto the citing node and declares no id, so there is no namespace to mint
      <id>/<internal> in. Citing another single-node fragment is an alias and is
      fine — except that an alias may not write its own prompt:: it relays the
      cited fragment's behavior, and one that rewrites the prompt is claiming that
      fragment's name while replacing what it does. A single-node body cited from
      inside a fragment has its tokens namespaced against the citing fragment's
      declared ids, and a token naming an id that fragment does not declare is a
      load error charged to the citing site.
    • A fragment file's own use: is judged against the file. The literal-name
      rule, the prompt:-alongside-use: refusal above, and a dead with: (a
      binding with no use: to bind) are facts about the file, so all three are
      reported once, against it, naming the file the text is written in — never at
      splice time charged to whichever node happened to cite it.
    • Lookup stays a pure function of the ENTRY file's path at every depth, so
      a fragment that cites a fragment depends on a file its own author cannot
      ship with it. No manifest, no pre-flight check — what is owed instead is in
      the message: an error below depth 1 names the chain, so a reader who
      never wrote e2e-verify is told which fragment did, and that the citing
      use: is in a fragment file rather than in their graph.
    • One disclosure line per resolution, parent line first. A nested line's
      node id is the already-namespaced id of the node that cited the fragment, so
      the ids alone say the shape of the tree without opening a file. Spliced
      names only ids that exist in the resolved graph, so a parent line
      deliberately undercounts a subtree containing a nested loop — the lines
      below it are what answer "how big did this get". A resolution also carries
      its Depth (the chain length), because the id's slash count is a different
      quantity: a single-node hop mints no segment, so an alias chain two files
      deep is a nested resolution with no slash in its id at all.

    No snapshot, feed or ledger change, which was the ADR's load-bearing
    claim and its own falsification condition. internal/runstate,
    internal/runfeed and internal/ledger hold a node id as an opaque string,
    and handoff.SanitizeNodeID is injective at any depth for exactly the reason
    it was at one: / and ~ are both outside the atom charset, so a/b/c → a~b~c.out collides with nothing. The only edit outside the loader's own
    resolution path is one regex character. What moves outside is output: ids
    grow segments and one use: now yields several disclosure lines.

    What it does not claim: adoption. Nesting is a precondition for the
    conversion below, and nothing measured says it is what unblocks the operator
    lanes — see the retraction in the ADR entry further down. The falsification
    condition is now a test rather than a paragraph
    (TestNestingHasAShippedAdopter): if no shipped graph carries a chain of
    length 2, the build fails and the ADR says to reopen. It asks the resolution's
    Depth rather than counting slashes in its node id, so it measures the
    quantity it names.

  • ADR 0029 — a fragment may cite a fragment, bounded by a chain and a depth.
    Opens the nesting non-goal ADR 0027 deferred, and settles what the deferral
    named as its price: cycle detection over resolution (a repeat on the
    current chain, not a global visit, so a diamond stays legal), a depth bound
    of 3 citation hops with the argument for the number (projected need 2,
    one level of headroom, deliberately falsifiable), and the composition rule
    for namespacing an already-namespaced id. It also settles what the first
    draft left open: the top-down resolution order and parameter pass-through,
    the lookup rule at depth (entry-file-relative, and the file dependency a
    citing fragment cannot declare), what the run's disclosure says about
    Grants and in what order, and what a single-node fragment's tokens mean
    when a multi-node fragment cites it. Implemented in the same release
    see the two entries above.

    The ADR records that ADR 0027's registered falsification condition fired, and
    — after review — retracts its own first explanation of why. The 28
    operator lanes cite no repair-round because repair-round.yaml is not in
    the fragments/ directory they resolve against, so use: repair-round is a
    load error for every one of them; those same lanes carry 28 use:
    citations across 17 files
    , naming every fragment that is there. Neither
    "nesting is the blocker" nor "the tool grants are the blocker" is measured;
    the grant mismatch is real but untested, and the experiment that would settle
    it is one file copy. Numbers, definitions and an asserting script:
    `docs/measurements/0029-repair-round-was-never-in-reac...

Read more

v0.9.0

Choose a tag to compare

@github-actions github-actions released this 16 Aug 16:07
5812604

Minor because the graph schema grew keys you may type. Compared by name
rather than by diff line: the seventeen long flags in flags.go are unchanged
and so are the eleven subcommands — the CLI surface is byte-identical to
v0.8.0. What grew is the FRAGMENT file schema, which gained exit: and made
nodes: usable where it was previously refused (ADR 0027). No node or graph
key was added, renamed or removed.

The headline is that the reusable unit is a loop, not a node: a fragment may
now carry several nodes and the edges among them, so a QA loop or a
review/repair round is citable the way a single node has been since ADR 0013.

Eight PRs: #176,
#177,
#181,
#182,
#183,
#184,
#185,
#186.

Added

  • A fragment may declare a LOOP, not only a node
    (ADR 0027,
    #186). A
    fragment file may now declare nodes: (several, with the edges among them)
    plus a required exit:, and be cited with the same use:/with: a
    single-node fragment is. Spliced ids are <using-id>/<internal-id>, which no
    author and no planner may write, so a spliced node can never collide with an
    authored one; entry nodes inherit the citing node's depends_on, cwd: and
    worktree: propagate from it, and depends_on: [<loop>] /
    {{ artifacts.<loop> }} from downstream both resolve to the loop's exit.
    exit: is never inferred from the unique sink — inference is right only
    while there is exactly one, and when it is wrong it is wrong silently.
    ADR 0013's rule is generalized, not weakened: a fragment may never name an
    id it does not itself declare
    , of which "a single-node fragment may declare
    no wiring at all" is now the special case, with every one of its tests kept.
    Measured on the shipped corpus: adr-driven-dev's two hand-unrolled
    review/apply rounds became two use: of one fragment, 119 lines removed
    for 53
    , and the one-direction discipline, both verdict contracts, the
    apply's tool grant, its evidence gate and its retry stopped being written out
    four times. Scheduler, snapshot, event feed and ledger are untouched — a
    spliced node is an ordinary node, and a consumer that wants the loop view
    groups by the <using-id>/ prefix.

Changed

  • A node's budget_usd no longer refuses a Codex graph
    (ADR 0026,
    #185).
    Preflight had one sentence for two different facts: agent: names a subagent
    whose system prompt the node would otherwise lose (a different node — still
    refused), while budget_usd is a USD ceiling a runtime that reports no USD
    has nothing to bound. Inapplicable is not unsafe, so the graph now loads and
    warns per node, naming the guard still in force — that node's timeout:, or
    the runner's 20m default. Measured on graphs/*.yaml: five refused under
    --runtime codex before, one after
    (adr-driven-dev, for its agent:).
    auto --max-goal-budget-usd stays refused and that is not an inconsistency:
    it is checked only at a cycle boundary, so an unmeasurable ceiling would buy a
    whole cycle before stopping to say it cannot be checked, where an inapplicable
    node cap costs nothing extra. The loop stays bounded either way —
    --max-cycles is what bounds iterations.
    internal/runner/shipped_graphs_runtime_test.go now lints every shipped
    graph under both runtimes and asserts the verdict by name, so a graph that
    becomes unloadable under Codex fails make test instead of a user's run.
    The Claude path is unchanged: ValidateGraphForRuntime still returns on
    its first line for RuntimeClaude, warning nothing and refusing nothing.

Fixed

  • The changelog guard no longer turns main red on its own maintenance
    (#189,
    #191). The guard added in
    #188 counted two kinds of commit against themselves. A release cut does not
    describe itself, so its own number is always absent from the section it just
    wrote — green on the release PR, red on main the instant it landed. And a
    changelog-only commit has no change to describe, so demanding an entry made
    the check eat its own tail: the PR adding a missing entry is itself missing
    one, and so is the PR adding that. Both are exempt now, recognised by what the
    commit touched rather than by how its subject is worded — a cut changes
    CHANGELOG.md and version.go together, which nothing else does; changelog-only
    means exactly one file. Each exemption is mutation-checked: disabling either
    turns the test red.

    Recorded because the trade is not free: the guard caught five genuinely
    missing entries in this release, two of them user-visible fixes that would
    have shipped a release page never mentioning them — and its edges then cost
    four round trips, every one of them on a commit whose subject was the
    changelog itself.

  • A Codex run's live view says there is no tail, instead of showing nothing
    (#182). The view polled
    /api/transcript every three seconds per running node, and the endpoint looks
    for <session-id>.jsonl under ~/.claude/projects and nowhere else — so on a
    Codex run every poll answered 204, for the whole run. The pointless polling
    was the smaller half: an empty tail is indistinguishable from "the node
    hasn't printed anything yet"
    , so the view looked broken with no way to learn
    it was working as designed. /api/graph now carries a note when the run's
    runtime keeps no per-node transcript, and the page renders it in place of the
    tail and stops asking. The endpoint gains no runtime branch.

  • Snapshot.Runtime is a property of the format, not of one writer
    (#181). The field was
    omitempty and docs/RUN-FEED.md tells consumers an absent value means
    claude — safe only because executeGraph happened to canonicalize before
    writing. A future caller of runstate.Write could leave a schema-3 snapshot
    with no runtime
    , which every consumer then reads as claude when it was not,
    reopening the hole the schema-3 bump was taken to close. Snapshot.MarshalJSON
    now canonicalizes, so the key is always present whichever writer produced it.
    Reading is unchanged: an absent runtime in an existing file still means
    claude.

Documented

  • ADR 0009's session-limit pause is the Claude runtime's promise, not the
    engine's
    (#184, closing
    #171). Detection matches
    Claude's own prose, so there is nothing for another runtime's message to
    match, and the RuntimeClaude gate in CLIRunner is the second layer rather
    than the cause — deleting it would not add the pause under Codex, it would
    add a pause that can never fire.
    So a new runtime does not owe a
    session-limit signal; what it owes is the honest degradation ADR 0009 already
    specifies. No behaviour changed.

  • The user-facing documents now know about the second runtime
    (#177) — docs/EXAMPLES.md
    gains a section on what --runtime codex changes, docs/RUN-FEED.md tells
    consumers the live-output supplement is Claude-only and that a Codex
    cost_usd is 0 beside cost_unknown: true in the snapshot (present but not
    authoritative), and plugin/README.md says which of the three plugin entry
    points can reach the flag at all.

Repository

  • The release body is now CHANGELOG.md's own section plus a Contributors
    line computed from git log, never goreleaser's commit-subject list
    (#176). A missing section
    fails the release.
  • main enforces a gate that can actually be met
    (#183): test and
    stress required, administrators included, and no required-approval count —
    which was unsatisfiable for a solo maintainer and so was being bypassed with
    --admin, which bypasses the tests too. Strictly tighter than before.

v0.8.0

Choose a tag to compare

@github-actions github-actions released this 14 Aug 17:06
072acd1

This release's headline is not ours. --runtime codex was contributed by
@minkichoe-lbox in
#170 — the first outside
contribution this project has received, 117 files, and it kept every
load-bearing invariant intact.

A second node runtime (#170)

oh-my-graph --runtime codex run graph.yaml

Claude remains the default; one runtime per run. A single CLIRunner owns both
protocols, runtime identity is persisted so resume and browser gate actions
know what they are resuming, Codex sandbox modes map from graph permission
modes, and Codex thread ids carry handoff: session. USD cost is explicitly
unknown rather than zero, with provider token usage preserved through the
ledger, the snapshot, the feed contract and the web UI. state.json and
events.jsonl move to schema 3, so an older binary refuses to misread
unknown cost or runtime identity instead of guessing.

The thing most likely to have broken did not. Adding a second provider had
exactly one quiet failure mode: a runtime branch inside the env scrub, where the
half that fell behind would bill silently with nothing failing to say so.
childenv.Scrub still takes no runtime, still has no branch, and all four exec
seams still call it unconditionally — so a Claude node drops the OpenAI switches
and a Codex node drops the Anthropic ones. Three reviewers checked that
independently; none found a seam.

The Codex disclosure names what you would otherwise meet after paying (#174)

Before a Codex run spends on a node, it now prints four differences alongside
the sandbox mapping:

  • No network. gh, git push and git ls-remote fail, so a graph halts at
    the first node that publishes — and the disclosure says where that node
    sits
    , because it is not always the last one. Last in adr-driven-dev and
    every user of graphs/fragments/pr-publish.yaml; first in apply-flags,
    which pushes from dev and ends on a read-only verify; every node in
    merge-shepherd, which is gh end to end and so fails at node 1 having done
    nothing. Two per-node ways out are named with what each costs.
  • USD cost is unknown for every Codex node, not merely unbudgetable.
  • approval_policy="never" is passed unconditionally.
  • ADR 0009's session-limit pause does not exist on Codex
    (#171).

Writing that down truthfully cost us two corrections to claims we had made
about our own graphs. If you have been reading LIMITATIONS, it was wrong about
merge-shepherd and silent about apply-flags; it no longer is.

A budget is never measured against a cost the loop does not have (#173)

--max-goal-budget-usd compared known spend alone, so unknown spend counted as
$0 and a capped goal loop could iterate under a ceiling it could no longer
measure. ADR 0025 states that as a property of the system, and the CLI refusing
the runtime/budget combination up front is not the same thing — a cost can go
unknown at runtime too
, from a node killed before it reported or a garbled
envelope.

The ordering keeps the honest half: known spend is a floor on true spend, so
"the known part already reaches the ceiling" stays sound however much went
unreported and still stops with StopBudgetExceeded. Only when the known part
is under the ceiling does the unknown decide, and there the loop stops with the
new StopBudgetUnmeasurable — a stop reason, not an error, so it prints
the remaining: line every clean stop prints and no summary bills a cycle that
never began.

Known blast radius, stated rather than discovered: one ordinary 20-minute
node timeout also sets CostUnknown, so a single timed-out node can end a
budgeted loop that ADR 0011 §2 would otherwise keep iterating.

A node's stderr is bounded again (#173)

Collecting stderr by hand had replaced cmd.Output()'s stdlib cap with an
unbounded buffer — paid by the Claude path too — while the only consumer
reads 500 bytes of it. Restored as a 32 KiB tail: the tail, because every
consumer reads through tailOf and a CLI's fatal line is its last one; 32 KiB,
because that is the ceiling prefixSuffixSaver kept rather than a tighter
number invented here.

make local passes on macOS again (#172)

The new timing tests failed on every mac while CI stayed green. macOS charges a
security scan on the first exec of a newly written file, per file, so a stub
written into a fresh t.TempDir() paid it every run: 384–1607 ms, against
6–15 ms to re-run the same file. Both tests raced that against a wall clock and
lost before the code under test was involved.

Fixed by warming the stub outside the timed window rather than by widening a
deadline. With a cost spanning 4x, no constant is both tight enough to test the
property and loose enough to stay green — a bigger number would have turned a
red suite into a flaky one, which is worse.

Also

  • The README is a front page again — 904 lines to 223
    (#169).
  • lint and run --dry-run warn when a success_check.verify.command splices
    a model's own reply into the shell command line the engine runs
    (#168). Measured before
    shipping: zero hits across 34 verify blocks in this repo's graphs plus a
    20-graph operator corpus.

Version rule

Minor, checked by name rather than by diff line: the seventeen long flags in
flags.go are unchanged, the eleven subcommands are the same eleven, and the
global flags gained exactly one — --runtime. Nothing renamed, nothing removed.

Full detail in CHANGELOG.md.

v0.7.0

Choose a tag to compare

@jitokim jitokim released this 13 Aug 14:51
0f1546b

Three changes, one story: a user reported that the longest wait in the tool
shows nothing, and chasing it surfaced two defects underneath.

A run has ONE status, and it has six values (#163, ADR 0023)

auto prints Planning a graph for goal "…" and then, for the entire planner
call, runs list says nothing and the dashboard is empty — indistinguishable
from a command that silently failed. The cause was ordering: the run id was
minted after the planner returned, so during planning there was nothing for
any reader to find.

A run id now identifies an oh-my-graph execution, so planning is inside it,
and one enumeration replaces a liveness value plus a verdict string that six
surfaces each combined for themselves:

PLANNING → RUNNING → { PASS | FAIL | PAUSED | ABANDONED }

Two of those six are fixes, not additions. ABANDONED because ADR 0015
refuses to call it FAIL — "a FAIL is a verdict about the work, and the work
never got one"
. And PAUSED because a run stopped at a human gate was
already being rendered FAIL: exit code 2, resumable, working exactly as
designed, and listed as a failure. If you have been ignoring FAIL rows on
gated runs, that is why.

ADR 0015's liveness rule is unchanged and un-duplicated — a planning phase
opens a leg and holds the run lock, so a killed planner reads ABANDONED rather
than hanging in flight forever.

A node timeout is its own retry cause

It used to be classified as run_error, the same token a failed spawn gets, so
retry.on could not ask for one without the other. It now has its own:

retry: { max: 1, on: [timeout] }

Nothing retries automatically because of this — the cause makes a timeout
askable, and the graph author decides. Auto-retry was considered and
rejected: a timeout is the one failure that always burns its full budget before
dying, so a retry costs another whole timeout, and it cannot tell a slow
machine from an instruction that cannot finish at any timeout.

Existing meanings are unchanged — the closed set grew, no token changed. But
a graph that retried on run_error expecting timeouts to be covered now needs
on: [run_error, timeout].

The boundary is whose clock it was: a deadline this engine minted is timeout;
one inherited from the caller's context stays run_error, because retrying
inside an already-expired context burns every remaining attempt on a deadline
that has passed.

adr-driven-dev's localrun asked for stress it could not finish

Found by running ADR 0023's own work through that shipped template. Its prompt
asked for go test … -race -count=300 under a 20-minute node timeout; measured
on this repository, cmd/oh-my-graph alone would need about 72 minutes, so the
pipeline halted with the implementation already committed.

Underneath it, a second trap: the prompt never mentioned -timeout, and
go test's default is 10 minutes per package — so a stress run that did fit
the node's budget would still have reported an artificial FAIL, and
localrun's FAIL halts the pipeline. The instruction now derives a budget from
the node's own timeout, reports what it actually exercised, and passes
-timeout.


Install: go install github.com/jitokim/oh-my-graph/cmd/oh-my-graph@v0.7.0

Full changelog: https://github.com/jitokim/oh-my-graph/blob/v0.7.0/CHANGELOG.md

v0.6.1

Choose a tag to compare

@jitokim jitokim released this 12 Aug 14:18
6f9136d

Security fix. If you run auto on v0.6.0 or earlier, upgrade. Measurement
L-REF re-ran v0.6.0's own argv on the same machine while validating this
release: it still breached, 1 of 1.

What was wrong

auto maps a planned node onto one of your Claude Code agents. To let
--agent resolve at all, applyAgentMapping handed that node your settings
back — one line, policy.SettingSources = nil. Three consequences, all
measured on shipped code in v0.6.0 (ADR 0017 measurement (j), 18 spawns):

  • The scope ceiling did not hold. An out-of-scope command ran with
    permission_denials: [], while the in-scope positive control passed — so a
    node declaring Bash(git *) could run a non-git command if your own
    settings allowed one.
  • The repository being worked on could supply invocable procedure text. A
    SKILL.md committed to a fixture repo fired 3 of 3 with the prompt naming
    no skill.
  • The staged corpus lost its own bare name to a settings-scoped
    definition, so the corpus the engine hashes and seals was not the one read.

v0.6.0 disclosed all three per node. This release closes them.

What changed

The matched agent's definition is copied into the run directory and supplied
through --plugin-dir, so layer 1 stays --setting-sources "" for every
planned node
, mapped or not. ADR 0022. The scope ceiling is measured closed
(K-CEIL / L-CEIL: 0 breaches of 3 and of 3, with positive controls
passing), and a repository's own .claude/ no longer reaches a mapped node.

A side effect worth naming: toolsBeyondCeiling is now stronger. The
scanned bytes are pinned by hash, so the file the CLI reads is the file that
was checked — previously the agent file was re-read at spawn time, and that
window was open.

And a hole this change opened before it shipped

The first version of the staging scanned <cwd>/.claude/agents as well as
~/.claude/agents, and project shadows user. A repository could therefore
have supplied a mapped node's system prompt — and now through
oh-my-graph's own --plugin-dir, which --setting-sources "" structurally
cannot shut, unlike the CLI discovery path it replaced.

Review caught it, measurement (l) confirmed it (L-PRE: the marker carried
the repository's token, 2 of 2), and the fix is that only ~/.claude/agents
is scanned (L-FIX: user 3 of 3, repository 0 of 3). This is recorded here
rather than quietly repaired, because a reader who later finds (l) in the tree
and no mention of it in this changelog would learn the wrong thing about how
this project reports on itself.

It is injection, not escalation. Layer 1 was "" in both arms and the
declared tool list still bound — a mapped node could be given a different
system prompt, not wider permissions.


All 40 spawns across (j), (k) and (l) — pre-registrations, raw tool-use
records, argv captures and ceiling artifacts — are committed under
docs/measurements/.

Install: go install github.com/jitokim/oh-my-graph/cmd/oh-my-graph@v0.6.1

Full changelog: https://github.com/jitokim/oh-my-graph/blob/v0.6.1/CHANGELOG.md

v0.6.0

Choose a tag to compare

@jitokim jitokim released this 12 Aug 01:37
25df003

One change, and it is a measurement that refused a feature — plus two facts
about code that is already installed. Read the second section before upgrading.

The agent-mapping exclusion stays, and not because the composite failed

auto maps a planned node onto one of your own Claude Code agents. Such a node
holds no Skill tool at all, so it can invoke nothing — not the staged
corpus, not your own skills, whose definitions its settings do load. That
breaks the promise that auto uses the skills you already have, and it lands
by construction on design, review and doc nodes.

ADR 0017 measurement (j), 18 spawns, pre-registered, judged only by planted
marker files and raw Skill tool-use records:

  • The composite works — 3 of 3, attributable to the staged corpus.
  • Lifting the exclusion would cut no ceiling (see below).
  • And it was refused anyway, because for those nodes Skill resolves against a
    corpus the target repository can write. Arm R-D: a SKILL.md committed
    to the fixture repo, the node's prompt mentioning no skill and no corpus,
    byte-identical to a naming prompt minus one sentence — 3 of 3 invoked it,
    and step one of the planted procedure executed. A repository can also load a
    plugin from an arbitrary path through its own committed
    .claude/settings.json.

git clone putting invocable procedure text into an unattended node is a
supply-chain surface, and it is the class ADR 0012 spent 750 lines on before
cutting the feature that opened it.

Two things about the build you are already running

The scope ceiling does not hold on agent-mapped nodes, and has not since
agent mapping shipped.
Arm G-T used the argv that ships today — no staged
plugin, no Skill, nothing edited — and an out-of-scope command ran with
permission_denials: []. Arm G-POS is the positive control: the same argv
runs an in-scope command fine, so this is a scope escape and not a missing
tool. A mapped node loads your settings so --agent can resolve, and your
standing grants load with them — so a node declaring Bash(git *) can run a
non-git command if your own settings allow one.

This release does not fix that. It stops claiming otherwise: the plan
printout's (their declared tool list still binds) is gone, replaced per node
by "it holds NO Skill tool, and its declared scope is enforced only as far as
YOUR settings enforce it"
, with the measurement cited inline. The MCP half of
the guarantee does still hold — mapped nodes keep --strict-mcp-config with no
--mcp-config. Tracked in #161, which is where a fix has to start:
--agent and layer 1 are mutually exclusive, so this is a redesign, not a
patch.

--no-agent <name> — an escape, and what it costs

New flag on auto and chat, repeatable, naming an agent by its frontmatter
name. It declines that one mapping and leaves every other in place. The node
loses its agent
— that is the price. In exchange it keeps layer 1 at
--setting-sources "", so its declared scope is enforced again and skill
activation reaches it like any other planned node.

The agent name is the identifier because it exists before you pay the planner;
a node id does not. The decline is applied after candidate selection, so
opting out can never promote a second candidate into a mapping you did not
have — that is pinned by a test.

--no-agent-mapping still turns every mapping off run-wide. It is simply no
longer the only door.


All 18 spawns' raw tool-use records, argv captures and pre-registration are
committed under docs/measurements/.

Install: go install github.com/jitokim/oh-my-graph/cmd/oh-my-graph@v0.6.0

Full changelog: https://github.com/jitokim/oh-my-graph/blob/v0.6.0/CHANGELOG.md

v0.5.5

Choose a tag to compare

@jitokim jitokim released this 11 Aug 23:02
15e8a7b

Three fixes. The first one had been merging past a reviewer nobody noticed.

merge-shepherd reads the PR's decision, not one bot's opinion (#156)

recheck filtered reviews to coderabbitai, so a human reviewer's
CHANGES_REQUESTED was invisible: the graph answered RECHECKED, the gate
comment told the operator that review status was no longer theirs to confirm,
and merge was authorised to use --admin on a rationale — "verify passed,
CI concluded, comments triaged"
— that contained no human. It did not hang. It
merged, and left no failed row to notice.

Both waits now read reviewDecision (every reviewer) and mergeStateStatus
from the same call, and judge the whole check rollup rather than one entry.

A second thing came out of the same diagnosis: some conditions a wait observes
cannot resolve on their own. A rate-limited bot that never reviewed, a
rebutted CHANGES_REQUESTED, a branch that is BEHIND — those are latches, and
polling them burns money and ends in a timeout that names nothing. They now
answer LATCHED <what>; unblock: <act> on the first read, with zero polls, and
the remedy is the first line of the failed node's artifact. Chasing that took
four operator interventions in two days; the graph now says what each of them
was.

(The unified story — "every wait assumes time resolves the condition" — was
tested and only partly held. What survived: the graph modelled PR readiness as
one bot's opinion plus a check rollup and never read the two fields GitHub
computes.)

A node that can observe no tool denial is told so (#154, #157)

A fourth advisory sweep: a node declaring neither allowed_tools nor
success_check.verify has no way to notice a denial. dontAsk denies
without asking, and a hand-written node inherits your settings — so on a machine
without a blanket grant, that node does nothing, explains the denial in prose,
and a result_matches: '^DONE' check passes on the explanation. Reported by a
second machine, from a transcript.

The measurement is the uncomfortable part: 62 of 150 nodes hit, and 61 of
those 62 are this maintainer's own lanes
pr nodes that push branches and
open pull requests while declaring no tools at all. They had never failed only
because this machine grants Bash(*). The single noise hit was fixed in the
graph rather than silenced. Advisory, never a load error: a hand-written graph
is the user's own reviewed artifact.

init tops the tree up, and where you save a graph decides whether it can reuse one (#158)

use: resolves only against the entry file's own fragments/ sibling — a
boundary ADR 0013 argues for and this release keeps. The consequence was never
written down, and it was the whole story: of 80 lanes with a pr node, 77
sat directly in /tmp
, where use: cannot load at all. Reuse was not
refused; it was unreachable. The code change needed for reach was zero
lines
— what was missing was a storage convention, now documented with the
prescription in the resolution error itself.

Separately, init refused the whole command if any target existed, so a tree
created before a fragment shipped could never receive it — pr-publish landed
in v0.5.3 and could not reach anyone who ran init earlier. A re-run now tops
up: existing files are kept and reported, missing ones written. The
no-overwrite promise is unchanged; skip-existing cannot produce the
half-replaced tree the all-or-nothing refusal existed to prevent.


Install: go install github.com/jitokim/oh-my-graph/cmd/oh-my-graph@v0.5.5

Full changelog: https://github.com/jitokim/oh-my-graph/blob/v0.5.5/CHANGELOG.md

v0.5.4

Choose a tag to compare

@jitokim jitokim released this 10 Aug 15:33
b801960

A small release. One change alters what an installed graph does; the other is
a recorded negative result.

backlog-batch lane A stops on findings and repairs them (#151, #152)

review-style and review-security pass on either verdict, by design —
a review's job is to judge, not to be clean. What that left behind: three
shipped graphs put a PR-opening node downstream of those reviews, so a review
that found a real defect still opened the pull request, and the ledger printed
PASS.

The fix uses no new mechanism. A feedback arc (ADR 0010) fires only on the
declaring node's judgment failure, so while the fragment passed FINDINGS:
nothing in the failure grammar could reach it. backlog-batch lane A now
narrows review-a's check to CLEAN and carries feedback: { rerun: dev-a, max: 1 }, so findings buy one bounded repair round instead of a PR.

For a user with these graphs installed, lane A is the only thing that runs
differently: a FINDINGS: review now shows node_retried and feedback round 1/1 rather than a failure, and the defect ends up fixed rather than quoted
in a PR body
. Worst case is 8 executions, not 6 — e2e-a inherits
retry: { max: 1 } from the e2e-verify fragment while dev-a and review-a
declare none.

Three graphs deliberately did not change. dev-review-pr and self-dev
cannot carry the arc: their two reviews fan out from e2e, so any loop body
takes a side exit — verified against the real binary, not reasoned about. Lane
B stays advisory by choice, and the review fragments now say what a caller
must do to gate, and why a fragment cannot do it for them.

Two suggestions were declined with the code behind them: a companion
review-style-strict fragment would need a full prompt copy (a fragment cannot
use: a fragment), and a with: { on_findings: ... } substitution point would
break all five existing callers, since substitution defaults are deferred in v1.

The operator's lane corpus had nothing to extract (#150)

75 hand-written lanes, measured: the repeated part is worktree, cwd,
depends_on and id — exactly the fields ADR 0013 makes a load error inside a
fragment. The one prose candidate declared neither half of the verdict
convention nor a grant, so extracting it would have been designing a new node
and calling it an extraction.

The finding that mattered points the other way: across those 75 lanes,
result_matches appears 0 times on any pr node and use: 0 times.
That corpus was never a source of shapes for graphs/ — it was a consumer that
had never adopted the four fragments already shipped.

Also recorded: ADR 0013's own similarity metric (longest common suffix)
understates convergence for prompts that diverge mid-body.


Install: go install github.com/jitokim/oh-my-graph/cmd/oh-my-graph@v0.5.4

Full changelog: https://github.com/jitokim/oh-my-graph/blob/v0.5.4/CHANGELOG.md

v0.5.3

Choose a tag to compare

@jitokim jitokim released this 09 Aug 16:13
622a99c

Four of these entries correct something this project had already published.
That is the release.

The subscription guarantee now holds on every platform it runs on (#142)

internal/childenv.Scrub compared environment keys exactly. Windows treats
env names as case-insensitive, so a lowercase anthropic_api_key survived
the scrub and reached the child
— a metered API bill on a run the user was
told is inside their plan. It was documented as a native-Windows caveat in
LIMITATIONS.md while README.md and SECURITY.md stated the deletion
unconditionally.

Matching is now case-insensitive on the whole key, on every platform — not
behind a GOOS=windows build tag, deliberately: a guarantee guarded by a path
CI never runs is how this hole survived in the first place. Prefix behaviour
is unchanged (ANTHROPIC_API_KEY_BACKUP still survives) and the scrubbed set
did not grow. Reverting the fix fails a Linux test.

An agent-mapped planned node cannot invoke a skill — retraction (#144)

ADR 0017 said an excluded node "already sees your real skills, so the
exclusion costs it little", and the plan printout told users so. Measured
false
, 8 spawns: arm T (the recorded argv, unchanged) fired 0 of 3; arm C1
(--tools WriteWrite,Skill, six characters, nothing else) fired 3 of 3.
The node has no Skill tool, so it invokes nothing — not the staged corpus
and not the user's own, whose definitions its settings do load. The loss is
total and lands by construction on design/review/doc nodes, where a procedure
fits best.

The raw tool_use records of all ten spawns are committed, so the verdict
does not rest on a directory outside this repository. The guard is kept
lifting it is a policy change, and ADR 0017 now carries the measurement that
would retire it.

ADR 0018's compliance baseline: 0 of 6 (#145)

Taken before the §6 planner clause exists, because afterwards the status quo's
number can never be taken. Over 6 real auto runs and 20 node transcripts:
zero git worktree add, zero git clone. Every non-compliant command was one
the planner wrote into the node's prompt, so node disobedience was never
observed.

The <50% trigger does not fire, and that is pre-registered rather than
post-hoc: the threshold judges the §6 advisory, and the baseline is by
construction advisory-free. The decision stands, resting now on §3's cleanup
costs alone — and is provisional from this date.

merge-shepherd re-waits for the checks its own fix restarted (#147)

The CI wait sat before triage, so when triage pushed a fix the checks
restarted and the merge node always met pending ones — five times in one day,
each ending in a promise reply the anchored verdict correctly rejected. A new
recheck node polls the final SHA in the foreground and answers one of
three: RECHECKED <sha>, BLOCKED <sha> (not in the pattern — the run halts
before the gate), UNSETTLED <sha> (hands off to WITHHELD). It judges the
whole check rollup, not one entry, and compares each review's commit.oid
against the head rather than assuming.

Also

  • The PR node is one shape, so it is a fragment (#143). Measured before
    extracting: the five PR <url> nodes share 83 words, 75% of the shortest
    prompt. The other repeated verdicts do not group by node — six DONE
    nodes share zero words — which is why a verdict: schema key would
    deduplicate the cheap half and leave the expensive one.
  • serve names the build answering the page (#148). The reported symptom
    — "dies silently, a stale binary holds the port" — was wrong on both halves.
    What was true: a serve started two days earlier kept serving the code it
    was compiled from, and nothing said so.
  • Company repository names removed from test fixtures (#140); a six-item drift
    sweep including a duplicate ADR number and three stale version stamps (#148).

Install: go install github.com/jitokim/oh-my-graph/cmd/oh-my-graph@v0.5.3

Full changelog: https://github.com/jitokim/oh-my-graph/blob/v0.5.3/CHANGELOG.md

v0.5.2

Choose a tag to compare

@jitokim jitokim released this 08 Aug 01:21
7815bf3

Nothing new to type. No flag, no command, no schema key — every change here
corrects something v0.5.1 already shipped.

Skill activation's 1-in-7 has a cause now (#130, #137)

v0.5.1 shipped skill activation wired, disclosed, and recording 1 Skill
invocation across 7 activated planned nodes
— with the cause unknown.
44 real claude -p spawns later it is known:

arm prompt corpus sentence fired
A planner register 35 real skills no 0 of 9
B the same bytes 35 real yes 8 of 9
H the same bytes 35 real + 1 planted trigger description no 3 of 3
J no fitting skill exists 35 real yes 0 of 3

So the staged descriptions do reach a node running under --setting-sources ""
— arm H moves 0→3 without changing one byte of the prompt. An activated node's
prompt now carries one fixed sentence saying a corpus exists. Arm J is the
bound: the sentence does not manufacture fit it does not have.

The "fit" reading an earlier draft attached to this data is retracted inside
the entry itself
: the confirming arm was pre-registered at 3 of 3 and came
back 0 of 3. Whether the nodes that fired produced better work is still
unmeasured, and says so next to the feature.

Judged only by a Skill tool-use record in the node's own transcript, or a
planted skill's marker file on disk — never by asking a model what it can see.
Every arm, both sealed pre-registrations and the runner scripts are in
docs/measurements/.

lint and run --dry-run stopped losing advisories over a pipe (#134)

Both read the graph path twice. On a non-seekable path the second read came
back empty, so an empty graph passed every check and every advisory vanished
while valid and exit 0 printed
. It fooled two probes of the review that
found it.

Every verdict prompt now names where a caveat goes (#138, ADR 0019)

A merge node reported FAIL over a merge that had landed — it put a
housekeeping line before its verdict. Across 187 runs / 218 verdict-bearing
node executions
, 22 result_matches failures: 16 were the check working
(4 of them literally the promise reply the anchor exists to reject), 6 were
misjudgements, all in the safe direction. Replaying all 22 without the anchor
passes 9 that should fail. The anchor stays; the prompts now say caveats go
after the verdict, and merge's re-run is made cheap instead.

Also: merge was confirming against a stale origin/main.

Isolation stays scoped to the invocation repository (#103, ADR 0018)

Multi-repo managed worktrees: decided against, with the costs a future
"yes" would owe — resume is not free, cleanup is unwired, paths collide — and
a pre-registered measurement that would flip the decision.


Install: go install github.com/jitokim/oh-my-graph/cmd/oh-my-graph@v0.5.2

Full changelog: https://github.com/jitokim/oh-my-graph/blob/v0.5.2/CHANGELOG.md