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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,42 @@ oh-my-graph is **alpha software**. The graph YAML schema, the CLI, and the

## [Unreleased]

### Added

- **A planned node answers with the model you chose.** `--setting-sources ""`
withholds `~/.claude/settings.json` from a planned node, and your `model` key
lives in it — so 181 of the 187 planned nodes measured in this repository's
corpus ran a model nobody selected, while 267 hand-written nodes on the same
machine tracked the settings file
([docs/measurements/0034-planned-node-model.md](docs/measurements/0034-planned-node-model.md)).
oh-my-graph now reads **that one key** at plan time and passes it verbatim as
`--model <value>` (`internal/usermodel`,
[ADR 0034](docs/adr/0034-a-planned-node-answers-with-the-model-the-operator-chose.md)).

- **Before:** a planned node answered with **the `claude` CLI's own fallback
default** — the model the CLI picks when no settings source and no flag
names one.
- **After:** a planned node answers with **the model named by the `model` key
of your own `~/.claude/settings.json`**, passed through unmodified — no
normalisation, no case-folding, no stripping of a variant suffix such as
`[1m]`.

No allowlist: an unknown name reaches the CLI and fails the node with the
provider's own message, rather than silently becoming a different model. No
flag and no new configuration: the settings file is the single surface. No
`--model` for an agent-mapped node — its definition declares one, and 6 of
those 187 nodes rely on that. The planner and the assessor keep the CLI
default: the engine parses their replies, so their model is a compatibility
surface, not a preference. A settings file that cannot be read costs one
stderr line per run and never the run.

The capability ceiling is unchanged. A model name grants no tool, loads no
file and runs no hook; exactly one preference crosses layer 1, by name.
Claude only — under `--runtime codex` a planned node still takes the CLI's
default ([docs/LIMITATIONS.md](docs/LIMITATIONS.md); the Codex follow-up is
carried in the operator's private backlog — oh-my-graph-hq
`notes/open.md` — not in the public tracker).

### Changed

- **An unresolvable `{{ inputs.x }}` or `{{ artifacts.id }}` now says that a
Expand Down
40 changes: 37 additions & 3 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ node's own `timeout:` or the runner's default.
A Claude node is one subprocess:
```
claude -p "<rendered prompt>" --output-format json --permission-mode <mode> \
[ --model <value> ] \
[ --max-budget-usd <amount> ] \
[ --setting-sources "" ] [ --plugin-dir <dir> ]… [ --agent <name> ] \
[ --allowedTools "<comma,joined>" ] \
Expand All @@ -56,8 +57,12 @@ claude -p "<rendered prompt>" --output-format json --permission-mode <mode> \
This is emission order, not just a flag inventory: `runner.buildArgs` appends
in exactly this sequence and `claude_test.go`'s `want` argv pins it
element-by-element, so a reordering is a test failure, not a style choice.
Note where `--max-budget-usd` sits — immediately after `--permission-mode`,
*before* the ceiling flags, because it is not one of them. Note too where
Note where `--model` and `--max-budget-usd` sit — immediately after
`--permission-mode`, *before* the ceiling flags, because neither is one of
them. `--model` carries the operator's own model choice, read from ONE key of
their settings file at plan time (`internal/usermodel`, ADR 0034) and passed
verbatim; it is omitted when they expressed none, and omitted for an
agent-mapped node, whose definition declares its own model. Note too where
`--plugin-dir` sits — after isolation and before the grant, one flag per
`ToolPolicy.PluginDirs` entry in order, because it restores instruction material
layer 1 withheld before any layer decides what may be done with it. It is what
Expand Down Expand Up @@ -146,7 +151,24 @@ assessor add `--ignore-user-config`, `--ignore-rules`,
nodes unless the run typed `--accept-loaded-user-config`, which omits all four
(ADR 0032; `--sandbox` and `approval_policy="never"` are appended outside that
branch and are unaffected). Hand-written nodes and the
planner keep normal Codex config. A `turn.completed` event supplies the final
planner keep normal Codex config.

**No `--model` appears here, and that absence is a decision, not an omission**
(ADR 0034 §6b). The defect is identical — `--ignore-user-config` withholds
`$CODEX_HOME/config.toml`, which is where the operator's `model` key lives — and
the mechanism to fix it exists (`codex exec` takes a model flag, and the
`-c model="…"` override this protocol already uses for `approval_policy`). It is
not used because **no codex node's model is observable in this repository's
corpus**: a codex thread writes no `~/.claude/projects` transcript, so shipping
it would be a fix for a population nobody has measured. So under `--runtime
codex` a planned node answers with whatever model `codex` itself defaults to;
`codexProtocol.buildArgs` ignores `NodeInvocation.Model`, says so in place, and a
test pins that silence. `docs/LIMITATIONS.md` states it where a user meets it and
ADR 0034 §2.6 carries the research; the follow-up itself is carried in the
operator's private backlog (oh-my-graph-hq `notes/open.md`), not in the public
tracker.

A `turn.completed` event supplies the final
token usage; the last completed `agent_message` is the node result. A
`turn.failed` event is a failed node even if the CLI process itself exits zero.
`--skip-git-repo-check` preserves the graph contract that a node `cwd` may be a
Expand Down Expand Up @@ -2436,6 +2458,18 @@ The table is the default — the run that types nothing. Layers 1 and 4 are the
two an operator may decline together, at launch, and nothing else in it moves;
"The operator's opt-in" below is the whole of that difference.

`--model` is **not** on this table and is not a sixth layer either — it is not
even on `ToolPolicy`, it rides on `runner.NodeInvocation` beside the prompt.
Every layer here bounds capability: which grants bind, which tools exist, whose
settings, hooks and `CLAUDE.md` load. A model name binds no grant, adds no tool
and loads no file, so the operator's own choice — ONE key of their settings
file, read at plan time by `internal/usermodel` and carried on `Plan.Model`
(ADR 0034) — crosses layer 1 by name without moving a row of it. That the
ceiling really is untouched is a test, not a claim:
`TestPlan_ModelLeavesTheCeilingUntouched` diffs every layer of a plan that read
a model against one that did not. An agent-mapped node gets no `--model`,
because its staged definition declares one and that is the more specific choice.

`PluginDirs` is the sixth field and **not** a sixth layer: it ADDS definitions —
a staged skill corpus (ADR 0017) or the one agent a node was mapped onto
(ADR 0022) — and grants no capability, since whatever it supplies still runs
Expand Down
12 changes: 12 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,18 @@ rules/AGENTS 파일, hook, MCP server를 그대로 유지하며,
`--accept-loaded-user-config`는 `auto`에서도 그것들을 원한다고 소리 내어
선언하는 플래그입니다.

그 선을 이름을 밝히고 넘는 선호(preference)가 딱 하나 있습니다. 당신
`~/.claude/settings.json`의 `model` 키 하나만 따로 읽어 planned Claude node에
`--model <value>`로 전달하므로, 그 node는 설정이 차단됐을 때 CLI가 기본값으로
되돌아가는 모델이 아니라 **당신이** 고른 모델로 답합니다 (측정: planned node
187개 중 181개가 아무도 고르지 않은 모델로 실행됨 —
[ADR 0034](docs/adr/0034-a-planned-node-answers-with-the-model-the-operator-chose.md)).
이것으로 node의 capability ceiling은 달라지지 않습니다. 모델 이름은 tool을 주지
않고, 파일을 로드하지 않으며, hook을 실행하지 않습니다. 그 파일에서 다른 것은
아무것도 읽지 않고, `--runtime codex`에서는 아무것도 읽지 않습니다 — codex는
`--ignore-user-config`가 `~/.codex/config.toml`을 막으므로 planned node가 `codex`
자체의 기본 모델로 답합니다 ([docs/LIMITATIONS.md](docs/LIMITATIONS.md)).

계층별 입장과 그 뒤의 모든 측정은 [SECURITY.md](SECURITY.md)에, 나머지 정직한
빈틈들과 플랫폼 지원 매트릭스(macOS·Linux 지원, WSL first-class, 네이티브
Windows는 best-effort), 그리고 의도적으로 보류한 목록은
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,16 @@ a hand-written graph you launch with `run` keeps your user config, project
rules/AGENTS files, hooks and MCP servers, and `--accept-loaded-user-config`
states out loud that you want them under `auto` too.

One preference crosses that line by name, and only that one: the `model` key of
your `~/.claude/settings.json` is read on its own and passed to a planned Claude
node as `--model <value>`, so it answers with the model **you** chose instead of
whatever the CLI falls back to when its settings are withheld (measured: 181 of
187 planned nodes ran a model nobody selected —
[ADR 0034](docs/adr/0034-a-planned-node-answers-with-the-model-the-operator-chose.md)).
The node's capability ceiling is unchanged by it: a model name grants no tool,
loads no file and runs no hook. Nothing else in that file is read, and under
`--runtime codex` nothing is.

The layer-by-layer stance and every measurement behind it are in
[SECURITY.md](SECURITY.md); the rest of the honest gaps, the platform support
matrix (macOS and Linux supported, WSL first-class, native Windows best-effort)
Expand Down
27 changes: 26 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,19 @@ is said, everything in this section describes what runs; where it is said, the
differences are set out under "The operator's opt-in" below, and layers 0, 2, 3
and 5 are unchanged either way.

**One preference crosses layer 1 by name, and grants nothing.** Every layer in
the table above bounds **capability** — which grants bind, which tools exist,
whose settings, hooks and `CLAUDE.md` load. Since
[ADR 0034](docs/adr/0034-a-planned-node-answers-with-the-model-the-operator-chose.md)
oh-my-graph reads exactly one key of your `~/.claude/settings.json` — `model` —
and hands it to a planned Claude node as `--model <value>`, so the node answers
with the model **you** chose rather than the CLI's own fallback default. A model
name adds no tool, loads no file, runs no hook and widens no grant: not one row
of the table moves, and the value reaches argv rather than a prompt, so no
planner output can select it (the graph schema has no `model` key). Nothing else
in that file is read — its `permissions` block holds the very standing grants
layer 1 exists to withhold, and a second key would need its own ADR.

Layer 1 is what makes the rest bind. Permission rules are matched from every
loaded source, so a standing `Bash(*)` in your own `~/.claude/settings.json` was
previously matching before a planned node's narrower `Bash(git *)` ever
Expand Down Expand Up @@ -314,7 +327,10 @@ measured** in both directions, and nothing here should be read as covering them.
your standing grants — that half is measured — and no longer gets your
`CLAUDE.md` or your hooks, which arrive by the same source list `--setting-sources ""`
empties and are, as above, **implied rather than measured**. It was the one
planned node that did. **MCP is not on that list in either direction**: layer 4 is a
planned node that did. **Its model comes from neither of those places**: an
agent-mapped node is the one planned node that gets no `--model`, because the
agent definition you wrote declares one and that is the more specific choice —
the route 6 of the 187 planned nodes measured for ADR 0034 already took. **MCP is not on that list in either direction**: layer 4 is a
flag rather than a settings scope, so `--strict-mcp-config` was already on a
mapped node's argv before this change and still is, and whether that flag
actually closes MCP is the one thing here nobody has observed (E5) — read it as
Expand Down Expand Up @@ -372,6 +388,15 @@ MCP servers for those nodes. That is the intended direction, but it is a real
behaviour change: if your `auto` runs depended on an MCP server, they will stop
— and `--accept-loaded-user-config` is the narrow door out of that, per run,
typed at launch, at the price set out below.
**The one thing they do not lose is your model choice**: that key is read on its
own and passed as `--model`, per the paragraph above, so "less capable" is about
tools, files and hooks and never about which model does the thinking. On a
`--runtime codex` run they lose that too — `--ignore-user-config` withholds
`~/.codex/config.toml` and oh-my-graph does not read it, so a planned Codex node
runs the model `codex` itself defaults to
([docs/LIMITATIONS.md](docs/LIMITATIONS.md); the Codex follow-up is carried in
the operator's private backlog — oh-my-graph-hq `notes/open.md` — not in the
public tracker).
**Through v0.6.0 agent-mapped nodes were the exception in both directions** — no
*settings* were dropped for them, so your CLAUDE.md and hooks, and the
repository's, did load, and they were correspondingly less isolated, not more.
Expand Down
5 changes: 5 additions & 0 deletions cmd/oh-my-graph/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

"github.com/jitokim/oh-my-graph/internal/coordinator"
"github.com/jitokim/oh-my-graph/internal/runner"
"github.com/jitokim/oh-my-graph/internal/usermodel"
)

// chatFlags holds the parsed `chat` subcommand options. Like every other
Expand Down Expand Up @@ -96,6 +97,10 @@ func runChatWith(runtime runner.Runtime, args []string, in io.Reader, out io.Wri
// typed at a launch, and chat's one [y/N] already answers two
// questions; every chat-planned node stays isolated.
options = mappingOptions(out, flags.noAgentMapping, flags.noAgents, flags.noSkillActivation, false)
// A chat-planned node is a planned node: it is isolated exactly as
// `auto`'s is, so it loses the settings file the operator's model choice
// lives in and gets that one key read back out of it (ADR 0034).
options = append(options, coordinator.WithUserSettingsPath(usermodel.DefaultPath()))
} else {
fmt.Fprintln(out, "Codex runtime: Claude agent mapping and skill activation are unavailable; each generated plan will show its filesystem sandbox policy before confirmation.")
}
Expand Down
8 changes: 8 additions & 0 deletions cmd/oh-my-graph/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ type commonRunFlags struct {
noWeb bool
planningCostUnknown bool
planningUsage runner.TokenUsage
// plannedModel is the model this run's planned nodes answer with — the
// operator's own choice, read from one key of their settings file at plan
// time (coordinator.Plan.Model, ADR 0034). Not a flag, and deliberately not
// registered as one: there is exactly one surface for the choice, the
// settings file, so a run cannot disagree with it (§6c). Empty for `run`,
// which executes a hand-written graph whose nodes load those settings
// themselves.
plannedModel string
// buildEvidence is the launch-time build-evidence question and its answer
// (ADR 0030 §2.5a), for the snapshot to record and the plan screen to state.
// Not a flag: it is what the gate concluded from the flags plus the
Expand Down
19 changes: 19 additions & 0 deletions cmd/oh-my-graph/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import (
"github.com/jitokim/oh-my-graph/internal/runner"
"github.com/jitokim/oh-my-graph/internal/runstate"
"github.com/jitokim/oh-my-graph/internal/schedule"
"github.com/jitokim/oh-my-graph/internal/usermodel"
"github.com/jitokim/oh-my-graph/internal/verify"
"github.com/jitokim/oh-my-graph/internal/worktree"
)
Expand Down Expand Up @@ -453,6 +454,13 @@ func runAutoWithRuntime(runtime runner.Runtime, args []string, nodeRunner runner
}
if runtime == runner.RuntimeClaude {
options = append(mappingOptions(os.Stdout, flags.noAgentMapping, flags.noAgents, flags.noSkillActivation, flags.acceptLoadedUserConfig), options...)
// Claude only, and one of the two places the real settings path enters
// the coordinator: a planned node's `--setting-sources ""` withholds the
// file the operator's model choice lives in, so the choice is read back
// out of it by name (ADR 0034). A codex run reads nothing here — its
// model lives in ~/.codex/config.toml, which oh-my-graph does not read
// (docs/LIMITATIONS.md).
options = append(options, coordinator.WithUserSettingsPath(usermodel.DefaultPath()))
} else {
fmt.Fprintln(os.Stdout, "Codex runtime: Claude agent mapping and skill activation are unavailable; the generated plan will show the filesystem sandbox policy used for each node.")
}
Expand Down Expand Up @@ -783,6 +791,16 @@ func executePlan(ctx context.Context, runID string, plan coordinator.Plan, nodeR
if plan.AgentStaging != nil {
nodeRunner = coordinator.GuardAgentStaging(nodeRunner, plan.AgentStaging)
}
// The operator's model choice travels with the plan that read it, so no
// path can execute a planned graph while forgetting which model its nodes
// were meant to answer with (ADR 0034). A settings file that could not be
// read says so once, here — one line per run, never per node — and the run
// proceeds on the CLI's default: a broken settings file is the operator's,
// and killing the run over it is the worse outcome.
flags.plannedModel = plan.Model
if plan.ModelWarning != "" {
fmt.Fprintln(os.Stderr, plan.ModelWarning)
}
// false: a planned graph never resolved a fragment — the coordinator
// refuses planner-emitted use:/with: (ADR 0013), so plan.Spec is
// fragment-free by construction and stays reusable verbatim.
Expand Down Expand Up @@ -948,6 +966,7 @@ func executeGraph(ctx context.Context, runID string, g *graph.Graph, nodeRunner
Verifier: verify.NewShellVerifier(),
Worktrees: worktrees,
ToolPolicies: toolPolicies,
Model: flags.plannedModel,
SerializedVerifyNodes: serializedVerify,
Recorder: recorder,
EventSink: leg.feed,
Expand Down
24 changes: 24 additions & 0 deletions cmd/oh-my-graph/resume.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"github.com/jitokim/oh-my-graph/internal/runstate"
"github.com/jitokim/oh-my-graph/internal/runstatus"
"github.com/jitokim/oh-my-graph/internal/schedule"
"github.com/jitokim/oh-my-graph/internal/usermodel"
"github.com/jitokim/oh-my-graph/internal/verify"
)

Expand Down Expand Up @@ -550,6 +551,28 @@ func continueRun(flags *resumeFlags, snap runstate.Snapshot, records map[string]
noteLoadedUserConfig(os.Stdout, runtime)
}

// A resumed PLANNED leg reads the operator's model choice again (ADR 0034).
// Its nodes are isolated exactly as the first leg's were, so without this
// they would answer with the CLI's default — the same defect, arriving one
// leg late. The discriminator is the one `resume` already uses to tell a
// planned graph from a hand-written one: a non-empty tool ceiling.
//
// Re-READ rather than persisted, and that is the point: the settings file is
// the single surface for this choice (there is no flag — §6c), so a leg
// running now honours the answer the operator would give now, exactly as a
// fresh run started now would. Nothing here can widen anything: the value
// reaches argv as `--model`, and every ceiling layer this leg runs under was
// rehydrated above from the snapshot.
var plannedModel string
if runtime == runner.RuntimeClaude && len(policies) > 0 {
model, err := usermodel.Read(usermodel.DefaultPath())
if err != nil {
fmt.Fprintf(os.Stderr, "could not read your model preference (%v).\n"+
"this leg's nodes will run whatever model your CLI defaults to.\n", err)
}
plannedModel = model
}

recorder := runstate.NewSnapshotRecorder(filepath.Join(runDir, stateFileName), runstate.Snapshot{
RunID: runID,
Runtime: snap.Runtime,
Expand Down Expand Up @@ -626,6 +649,7 @@ func continueRun(flags *resumeFlags, snap runstate.Snapshot, records map[string]
Verifier: verify.NewShellVerifier(),
Worktrees: worktrees,
ToolPolicies: policies,
Model: plannedModel,
// An injected evidence command runs one at a time on a resumed leg for
// the same load-bearing reason it does on a fresh one (ADR 0016 §2): two
// concurrent builds of one directory can each fail on the other's
Expand Down
Loading