npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@nutteen/conductor

v0.1.0

Published

Conductor: a graph-engineering orchestrator for issue-tracker workflows

Readme

Conductor

A graph-engineering orchestrator for issue-tracker workflows.

Conductor dispatches tracker tickets to CLI coding agents, the same as Symphony does. The difference is what happens inside one dispatch. Symphony runs one agent session against one 400-line prose prompt and can only observe the tracker's status field afterwards. Conductor runs a declared graph: named nodes with named agents, edges with machine-evaluated predicates, an explicitly write-scoped shared state object, and a cursor that survives a restart.

That buys four things Symphony cannot express:

  • Different agents for different steps. The builder and the checker are different models with different tool scopes — and can be different providers — because a node binds to a named profile rather than the flow having one global agent.provider.
  • Deterministic steps. Validation is a kind: shell node over scripts/check.sh, not a prompt asking the agent to run tests and report honestly.
  • Bounded maker–checker loops. A rejection routes back to the builder with the reasons in shared state; each loop edge carries its own budget, and an exhausted one escalates to a human instead of spinning. A run that ends that way — or that exits through an on_error edge — reports escalated, not completed, so it is counted as a failure and the ticket is given up on after agent.max_attempts of them.
  • The runtime moves the ticket. tracker_writes: conductor makes each edge's on_enter.tracker_status an actual write, so a finished run leaves the ticket where the graph says it should be rather than hoping a prompt did it.
  • Replayable runs. Every routing decision is an edge_taken line in the JSONL, with the predicate that fired. You can read what the run decided instead of inferring it.

apps/symphony is frozen and untouched. The two run side by side behind disjoint label gates until Conductor has proven itself.


Commands

# Validate a graph without touching a ticket. The errors are the schema documentation.
conductor graph validate conductor-runner/workflows/IST.md

# Run one flow in the foreground.
conductor run conductor-runner/workflows/IST.md --format human

# Managed background: one detached process per flow.
conductor --config conductor-runner/flows.yaml start
conductor status
conductor logs IST -f
conductor control-tower
conductor graph IST --issue IST-42    # this ticket's cursor, history and state
conductor stop --all

Runtime directory precedence: --runtime-dir > config runtime_dir > CONDUCTOR_RUNTIME_DIR > ~/.conductor. Ticket-scope precedence: CLI --assignee/--label > flows.yaml > workflow front matter

default assignee me.

Log volume

CONDUCTOR_LOG_LEVEL is debug | info | warn | error, defaulting to info.

Everything the control tower reconstructs a run from is emitted at info, so the default costs no observability. debug adds a poll tick every interval and a per-turn line naming the binary, model and flags:

CONDUCTOR_LOG_LEVEL=debug conductor run conductor-runner/workflows/IST.md --format human

The prompt body is never logged, at any level. It is ~7 KB per turn on the IST graph — 165 KB per attempt, more with OKF context enabled — and it is reproducible from the workflow files, so the log records the argument's size instead:

codex … '--model' 'gpt-5.5' '--sandbox' 'danger-full-access' 'exec' '--json' <7000 chars elided>

Subprocess stderr is capped at 500 characters before it becomes an error message, because that message is propagated into node_failed, the run event and the retry log — three copies of whatever a crashing CLI decided to print.


What stays config, and what needs a rebuild

A primary design goal, not a side effect. Workflow files live outside the npm package and the loader hot-reloads them.

| Change | Config only? | Where | |---|---|---| | Add/remove a node, rewire an edge, change the entry point | Yes — hot-reloaded | graph: front matter | | Change a node's agent, model, tools, max_turns, timeouts | Yes — hot-reloaded | graph.nodes[] / agents: | | Edit any node's prompt | Yes — hot-reloaded | nodes/<name>.md | | Add tracker statuses (refinement, spec_review, …) | Yes | tracker.*_states + edge predicates | | Add a deterministic, non-LLM step (tests, lint, a script) | Yes | a kind: shell node | | Add a new CLI agent (gemini, aider, cursor-agent …) | Yes, if it's command + argv/stdin + stdout | a provider: command profile | | Add a whole new kind of work (e.g. a BA story-writing flow) | Yes — a new flow | flows.yaml + a new workflows/<NAME>/ dir | | A provider with genuinely odd wire behaviour | No — one new file | src/agents/<name>.ts + one registry line | | A privileged builtin needing tracker/forge access | No — one new file | src/graph/builtins/ | | Change the predicate grammar or the state tier model | No | src/graph/* |

Hot-reload semantics, stated because they are easy to assume wrong: a reloaded graph applies to newly dispatched issues. A ticket already mid-graph finishes on the config it started with. Restarting the flow picks up the new graph and resumes from the checkpoint.


Layout

conductor-runner/
  flows.yaml
  TEMPLATE.md                 # annotated full-schema reference
  workflows/
    IST.md                    # front matter + graph: block, short body
    IST/
      _shared.md              # includable partial — files prefixed _ are not nodes
      plan.md implement.md review.md land.md

Only kind: agent nodes have a prompt file; check_plan, checks, handoff and merge_pr are builtin or shell nodes and have none.

workflows/<FLOW>.md carries the config and the graph in YAML front matter, plus a short Markdown body that is prepended to every node prompt as the flow-wide contract. nodes_dir defaults to a sibling directory named for the file stem.


The graph

graph:
  version: 1
  entry: __start__              # the entry sentinel, or a declared node
  nodes_dir: ./IST              # default: sibling dir named for the file stem
  max_parallel_nodes: 1         # frontier width. Fan-out is correct at 1; width buys wall-clock
  max_node_visits: 30           # global fuse per attempt
  max_loop_iterations: 3        # default budget for a back edge
  tracker_writes: conductor     # agent | conductor — the shipped IST flow uses conductor
  state: { ... }
  nodes: [ ... ]
  edges: [ ... ]

state: is the write-authority table

Mandatory. Every key declares its type, its writers, and its initial value.

  state:
    review_verdict: { type: enum, values: [pass, reject, unknown], writers: [review], initial: unknown }
    reject_reasons: { type: list,    writers: [review],       initial: [] }
    pr:             { type: object,  writers: [handoff, merge_pr], initial: null }
    branch:         { type: string,  writers: [__runtime__],  initial: null }
    revisions:      { type: counter, writers: [__runtime__],  initial: 0 }

Types: string, number, boolean, object, list, enum (needs values), counter.

Three tiers, and the tier boundary is the write boundary.

  1. Runtime-owned (writers: [__runtime__]) — agents can never write these. branch is read from git rev-parse --abbrev-ref HEAD rather than trusted from the agent; revisions is maintained for prompts ("revision 2") and is deliberately not load-bearing for routing. It counts back edges only — the edges that actually repeat work — so one maker-checker cycle is one revision. It is not "of max_loop_iterations": two loops budgeted at 3 each can legitimately take this to 6.
  2. Node-declared — written only by the nodes named in writers, only via that node's declared result.
  3. Deliberately not in state — the workpad, the diff, file contents. Those live in the tracker and the workspace. Hard cap: serialized state < 8 KB.

This is what gives the reviewer independent authority. review writes review_verdict and reject_reasons and nothing else, so it has no mechanism to advance the work itself — it cannot set branch or pr. That is enforced by write-scoping, not by asking a prompt nicely. (The reviewer's read-only allowed_tools is a second, softer layer; Bash is in that list because it needs git diff, so the state scoping is the real guarantee.)

How a node writes: a fenced result block

```conductor-result
{"review_verdict": "reject", "reject_reasons": ["no test covers the 404 path"]}
```

The last such block in the agent's final message is parsed, validated against the node's writes: allowlist, each key's writers, the declared type, and the 8 KB cap — and only then committed. A node that fails, times out, or emits a malformed block commits nothing. Failure isolation is enforced rather than hoped for.

A file (.conductor/out/<node>.json) was considered and rejected for a decisive reason: the reviewer node has read-only tools and cannot write files at all. The fenced block also lands in the transcript, so the control tower can surface it.

A node that declares writes: must emit a block, or it fails. A review node that never says pass or reject must not be read as a silent pass. A node with no writes: emits {} to signal it is done.

Tracker observation is the fallback, and the robustness floor. Conductor re-reads ist issue view and git rev-parse after every node. A graph whose edges reference only tracker.* depends on zero agent compliance and is exactly as robust as Symphony — that is the recommended starting point, with result-block state as an upgrade.

nodes: — three kinds

  nodes:
    - { name: check_plan, kind: builtin, builtin: has_plan, marker: "## Conductor Workpad", writes: [has_plan] }
    - { name: implement, agent: builder,  max_turns: 12 }

    - name: checks                        # deterministic: no model, no tokens, no budget
      kind: shell
      run: bash scripts/check.sh api && bash scripts/check.sh architecture
      writes: [checks_passed]
      result: { from: exit_code, key: checks_passed }
      timeout_ms: 900000

    - { name: handoff, kind: builtin, builtin: publish_handoff, writes: [pr], on_error: continue }

kind: agent (the default) needs nodes/<name>.md. Node config may live in that file's front matter as well as in the graph block; the graph block wins on conflict, so topology and the fields you want visible next to the edges stay there.

kind: shell runs through bash -lc in the repository directory. Three result forms:

| result: | Writes | |---|---| | { from: exit_code, key: k } | k = true when the command exited 0. Always a boolean — a shell node's exit status is a verdict. | | { from: stdout, format: text, key: k } | k = trimmed stdout. Non-zero exit fails the node. | | { from: stdout, format: json } | The last JSON object on stdout, merged as writes and validated exactly like an agent's block. |

Add output_key: k2 to either of the first two forms to also capture the run's output into k2 — the tail of stdout+stderr, capped by output_max_chars (default 3000) so a chatty failure cannot approach the 8 KB shared-state cap. The tail rather than the head, because build tools print the failure last. k2 must be listed in the node's writes: exactly as key must; capturing output is a state write, not a side channel around the write-authority table.

Without it a red gate records only checks_passed: false, and the next builder turn pays a full model turn to rediscover a failure the runtime already had in hand. This is the "deterministic gather, LLM judge" split applied to the one place it was missing.

Shell nodes hold the issue's dispatch slot but consume no session, tokens or budget, and still respect the run's AbortController and their own timeout_ms.

kind: builtin is for the steps that need tracker or forge access: publish_handoff, merge_pr, has_plan and record_validation. Anything that needs neither is a shell node, and therefore config.

noop is the exception that proves it — the one builtin with no privileges at all, Airflow's EmptyOperator. It exists because a fan-out region needs a declared join for its branches to converge on and that join is often a pure structural point. The obvious alternative, kind: shell, run: "true", spawns a login shell to do nothing and — because a join usually has no on_error edge — turns an EMFILE under concurrency into a whole-run error. Having no subprocess is the entire feature.

has_plan is the small one, and the argument for it is the argument against agent nodes generally. It answers one boolean from one ist issue view call — does any comment on the issue start with marker? — and writes it to the key named by writes_key (default has_plan). Both are node options, so the engine never learns what a workpad is, the same way merge_pr knows only method and delete_branch.

It replaced an agent at that position, which spent a model turn ($0.42 across IST-135's three dispatches, 10% of the ticket) producing a six-field route_decision of which exactly one field was read by exactly one edge — and that field, pr_attached, answered the wrong question. A PR is evidence that work happened, not that planning did, so a human who opened a PR before labelling the ticket made the graph skip plan entirely and the builder plan its own work.

Note what is not part of the argument any more: "something has to sit at entry". That was true when entry had to name a declared node, and it made has_plan look partly load-bearing for structural reasons. __start__ removed the structural need (see "The entry sentinel" below), so check_plan now exists purely because the graph wants its answer — and because a deterministic lookup beats a model turn for a question with a factual answer. It is also no longer on the free path: a ticket the tracker status alone can route never reaches it.

A shell node was the alternative and is worse here: it would mean bash-in-YAML with a Liquid-interpolated, tracker-controlled {{ issue.identifier }}, plus a jq dependency and hand-rolled error suppression to guarantee exit 0.

Every runtime failure degrades to false — unreachable tracker, malformed JSON, unknown issue. false routes to whichever edge handles "no plan yet", which is the safe direction: re-planning a planned ticket costs one reconcile pass, while skipping plan when no plan exists is the defect this replaced. builtinFailed would strand the ticket on the on_error edge instead of either. Only a missing marker — a misconfiguration the loader cannot catch, since it cannot check option semantics — fails the node.

Known trade-off: a ticket reopened after its PR merged reads as planned and resumes at implement against a plan describing finished work. It needs a human reversing a completed ticket, and such a ticket still walks implement → checks → review, where the reviewer can reject it into the maker-checker loop. If it becomes real, the fix is additive — a second builtin answering "is the branch's PR merged", and an edge when: "state.has_plan && !state.pr_merged" — rather than growing has_plan back into the classification grab-bag it replaced.

record_validation is the other small one, and it exists because of a gap the first live run made obvious. IST's proof gate needs a PASSED validation run before a ticket may enter a proof_gate_enabled status — and Conductor already knew the answer deterministically, because checks is a shell node whose exit code cannot be argued with, and then threw it away. Every domain concept is a node option (validation_type, passed_key, summary_key, command), so the engine learns nothing about IST.

The two rejected alternatives both hide a tracker side effect where the workflow file cannot see it: extending shell-node result: teaches the one tracker-free node executor the ist CLI, and folding it into publish_handoff runs it only on a pass — so a red run records nothing at exactly the moment a human is being asked to look.

Same failure rule as has_plan: every runtime failure degrades to validation_recorded: false, which an edge can guard on. Only a misconfigured passed_key fails the node, because a silent false there is indistinguishable from a red gate.

on_error: continue makes a node failure route along the normal edges — the declared form of Symphony's swallowed catch around handoff publishing. It is also the reason the one edge that asks for a proof-gated status has to carry a when: a failed handoff still takes the normal edge, so an unguarded on_enter: { tracker_status: review } would ask for a status the gate must refuse, and the ticket would sit in in_progress with nothing for a human to see. The shipped IST flow guards it on state.validation_recorded && state.pr.proof_recorded and falls back to rework, which is unrefusable.

edges: — first match wins, in declaration order

  edges:
    - { from: __start__,  to: land,      when: "tracker.state == 'merging'" }
    - { from: __start__,  to: __end__,   when: "tracker.state in ['review','backlog']" }
    - { from: __start__,  to: check_plan }                          # default: MUST be last

    - { from: check_plan, to: implement, when: "state.has_plan" }
    - { from: check_plan, to: plan }

    - { from: checks, to: review,    when: "state.checks_passed" }
    - { from: checks, to: implement, when: "!state.checks_passed", max_traversals: 3 }
    - { from: checks, to: __end__,   on_enter: { tracker_status: rework } }

    - { from: implement, to: __end__, on_error: true, on_enter: { tracker_status: rework } }

on_error: true edges are a separate list, considered only when the node failed. That is why a merge_pr graph can declare its on_error edge after its default without the error edge shadowing it.

__end__ deliberately borrows LangGraph's END naming — see "Why hand-rolled" below.

The entry sentinel

  entry: __start__

__start__ is the other half of that borrowing, and it exists for a measurable reason. Edges are declared from: <node>, so something has to sit at entry. When that something was a real node, its body ran before its edges were evaluated — so a merging ticket paid for an entry node's work and then took the very first edge, which never looked at the answer.

With the sentinel the entry resolves by routing alone: no node body, no visit count, no node_started, and no charge against max_node_visits. It still emits edge_taken, so every routing decision stays greppable in the JSONL. The rules are narrow on purpose:

  • edges may be declared from: __start__
  • nothing may target it (to: __start__ is a load error), which is what keeps it outside every cycle
  • no node may be named it
  • node.* and upstream.* are load errors on an edge leaving it — nothing has run, so such a predicate is null-safely false forever rather than an error you would notice. state, tracker, issue, config and graph are all fully available.

The practical effect on the reference graph: the two status-first edges are now free, and check_plan is reached only when the tracker status did not already decide.

Predicates

A tiny total, side-effect-free evaluator (~350 LOC with the parser). ||, &&, !, == != > >= < <=, in, matches, parenthesisation, string/number/boolean/null/array literals, and dotted paths resolved against seven roots:

state, tracker, issue, node, upstream, config, graph.

Not eval, not a JS sandbox, and deliberately not Liquid despite Liquid already being a dependency: Liquid renders to a string (the truthiness of "" vs "false" is a footgun) and reports errors at render time, once per tick inside a live run, rather than at load.

Two rules worth stating outright:

  • An unknown path is a load-time error. Every legal prefix is derivable from graph.state plus a fixed schema, so a typo'd edge condition cannot ship.
  • Null-safety: state.x.y where x is null evaluates false rather than throwing, because a node that never ran must route to its default edge, not crash the attempt.
  • Truthiness is explicit, not inherited from JavaScript: null, undefined, false, 0, "" and [] are false. An empty array being false is what makes state.reject_reasons read naturally as "were there any".

LLM judgment is a node; edges stay deterministic. An edge predicate that called a model would be an unobservable, unbillable, non-reproducible decision in the one place that must be debuggable at 3am from a log. Where routing genuinely needs judgment, that judgment is a node writing a state key and the edge is a plain predicate over it: review decides pass/reject and review_verdict routes. The decision lands in the log with a cost attached, and no new runtime concept is needed.

The corollary bites harder than it looks: a node that is not exercising judgment should not be a model at all. IST's entry node was an agent for three months because it sat where a decision was made, not because deciding needed a model — see has_plan above.

Maker–checker loops

The shape the graph is built around. The reference IST graph contains two:

  • Judgment checker — implement → review → implement. A different model, read-only tools, write-scoped to a verdict. reject_reasons is shared state and implement.md renders {{ state.reject_reasons }}, so the maker sees exactly what was flagged.
  • Deterministic checker — implement → checks → implement. The checker is scripts/check.sh: no model, no tokens, and it cannot be argued out of a failure.

Bounding is the part that matters:

  • Cycles are found statically at load (Tarjan SCC), and back edges — the edges that actually repeat work, found by a DFS from entry — are the ones that carry a budget. Forward legs of the same cycle stay unbounded on purpose: bounding both would make checks → implement's third rejection also exhaust implement → checks, killing the run one round before the loop it was meant to bound.
  • Each loop edge has its own max_traversals, counted per edge. Two loops never share a budget.
  • Load-time validation rejects a cycle with no exit edge, and a cycle with no bounded back edge. You cannot ship a workflow that spins forever.
  • When a loop edge's budget is spent the runtime skips it and routing falls through to the next match — so an unresolvable disagreement escalates to a human instead of burning budget. The attempt then ends in the escalated outcome, naming the edges that gave up, and the ticket is re-dispatched at most agent.max_attempts times before Conductor gives up on it durably. An on_error edge into __end__ ends the same way: the walk reached the exit because a node failed and the graph had nowhere else to send it, which is not the same fact as finishing the job. (Only edges into __end__ count — an on_error edge back into the graph is a declared recovery path, and taking one and then finishing still reports completed.) Before that outcome existed, a spent budget was byte-identical to a clean completed; the checkpoint was deleted at __end__, wiping state.traversals, and the next dispatch replayed the identical doomed loop with a fresh budget, forever. The predicate is evaluated before the budget for exactly this reason: a skip only counts as an escalation when the spent budget actually changed a routing decision, not when the edge would not have fired anyway.
  • graph.max_node_visits is the global fuse behind all of it.

Load-time validation rejects

An unknown to target; a node unreachable from entry; a cycle with no exit or no bounded back edge; a when referencing an undeclared state key or unknown root; a writes: entry the node is not a declared writer of; a write to a runtime-owned key; duplicate node names; a node with no outgoing or no default edge; a default edge that is not last; a missing nodes/<name>.md; an unknown agent: profile; an unknown builtin:; an invalid regex in a matches clause; a result.key not listed in the node's writes:; a non-string on_enter.tracker_status (which would otherwise make the whole on_enter silently vanish); and the __start__ sentinel misused — declared as a node, named by a to:, or read from via node.* / upstream.* on an edge leaving it.

conductor graph validate <file> prints them all at once.


agents: — named profiles

agents:
  builder:
    provider: codex
    command: "codex -c shell_environment_policy.inherit=all -c model_reasoning_effort=xhigh"
    model: gpt-5.5
    sandbox: danger-full-access
    ask_for_approval: never
    skip_git_repo_check: true
    turn_timeout_ms: 3600000
    stall_timeout_ms: 300000
    reports_cost: false          # current codex builds report no cost; say so explicitly
  reviewer:
    provider: claude
    command: claude
    model: claude-opus-5
    max_turns: 200               # --max-turns INSIDE one CLI call, not graph turns
    allowed_tools: [Read, Grep, Glob, Bash]
  fast:                          # "fast" = the shape of the work, not the model tier
    provider: claude
    command: claude
    model: claude-sonnet-5
    max_turns: 40
    stall_timeout_ms: 180000     # the stall clock measures silence, not slowness

Each profile is a raw map; the AgentClient for that provider narrows its own block. ServiceConfig knows nothing about Claude or Codex, which is what makes a new provider a new file rather than an edit to types.ts. createAgentClient throws on an unknown provider rather than defaulting — the name is a blind cast from YAML, so a typo that slipped past validation must not silently run claude on a codex machine.

Two behaviours worth knowing, both fixed on the way over from Symphony:

  • Claude models are settable. Symphony's buildClaudeArgs never passed --model, so the control tower reported the literal string claude for every run. Per-node model selection is the point of the whole agents: block.
  • cost_usd is number | null, never a silent 0. Symphony collapsed "provider did not report cost" into "cost was zero", which made a configured per_run_usd budget inert on a codex flow. A budget set against a profile whose reports_cost is false now earns a warning.

provider: command — a new CLI agent is config

Most CLI coding agents share one shape: a shell command, a prompt on argv or stdin, JSON or JSONL on stdout, optionally a resume flag.

agents:
  gemini:
    provider: command
    command: gemini
    prompt_via: argv              # argv | stdin
    args: ["--output-format", "json", "-p", "{{ prompt }}"]
    resume_args: ["--session", "{{ session_id }}"]
    parse:
      format: json                # json | jsonl | text
      session_id: sessionId       # dotted paths into the parsed payload
      tokens_input: usage.promptTokens
      tokens_output: usage.candidatesTokens
      cost_usd: null              # explicit null ⇒ reports_cost: false, so budgets warn
      text: response
      error_when: error

The parse selectors resolve through the same dotted-path accessor the edge predicates use — one implementation, no JSONPath dependency.

claude and codex stay hand-written on purpose. Their quirks are not expressible declaratively without making this schema baroque: codex's -p means profile while the prompt is positional, its usage arrives via recursive structural sniffing across nested JSONL events with additive-vs-overwrite semantics, and claude needs an out-of-band mtime heartbeat on ~/.claude/projects because it buffers stdout until exit.

Adding a genuinely odd provider is one file exporting a factory plus one line in the src/agents/index.ts registry. registerAgentProvider(name, factory) does it at runtime; tests/agents/index.test.ts proves a third provider needs no change to the executor, the loader, or ServiceConfig.


The determinism boundary

The LLM writes code and makes judgments; anything with a side effect outside the workspace is deterministic code.

| Operation | Who | Mechanism | |---|---|---| | git push | Runtime | publish_handoff builtin | | PR create, or reuse if one exists | Runtime | an existing PR is never rewritten — a human may have edited it | | PR labels | Runtime | best-effort, returns { failed } | | Proof artifact + ticket comment | Runtime | ist issue artifact / ist issue comment | | Branch name | Runtime | git rev-parse --abbrev-ref HEAD — read, never trusted | | Protected-branch refusal | Runtime | checked before the forge is contacted at all | | Validation / tests / lint / build | Runtime | kind: shell node | | Every routing decision | Runtime | the predicate evaluator | | "Does this ticket already have a plan?" | Runtime | has_plan builtin — one tracker call, no model turn | | Recording the validation verdict on the ticket | Runtime | record_validation builtin — the shell gate's own exit code | | What gets committed to shared state | Runtime | writes: allowlist + schema + 8 KB cap, transactional | | Tracker status transitions | Runtime (under tracker_writes: conductor) | on_enter.tracker_status | | PR merge | Runtime | merge_pr, pinned to a verified head commit | | Writing code | LLM | implement | | Plan / workpad | LLM | plan | | Pass/reject verdict | LLM | review — judgment, write-scoped to a verdict + reasons |

Symphony's IST.md Step 4 had the agent do the merge in prose: find the PR with gh pr list --head, inspect state,mergeable,reviewDecision,statusCheckRollup, re-check it, then gh pr merge --squash --delete-branch --match-head-commit "$HEAD_SHA". Ten steps of high-consequence, near-zero-judgment work — and --match-head-commit is exactly the kind of safety invariant you do not want an LLM to remember to include. Here the gating conditions are edge predicates over ForgeClient.getPullRequestStatus, visible in the workflow file and replayable from the log, and the head-commit pin is a runtime invariant asserted on the recorded argv in tests/forge/github.test.ts.

publish_handoff is one builtin, not eight. The steps are not independently useful or retryable — you cannot re-run addLabels without a PR — and each bail-out carries a hand-written ticket comment that is the operator diagnostic.


Checkpoints

<runtime_dir>/graph/<FLOW>/<identifier>.json, tmp+rename after every node, holding schema_version, cursor, tracker_state_at_write, state, node_history, traversals, node_visits.

Resuming the cursor and state is high value: a crash during review otherwise restarts at attempt 1 and re-burns plan and implement, the most expensive nodes.

The session_id is deliberately NOT resumed across a process restart. claude --resume <sid> and codex exec resume <sid> read the agent's own store, which may have rotated; a failed resume surfaces as a confusing "malformed output". A fresh session at the resumed node with the state object as context is strictly better, because the state carries the plan and the verdict forward.

Three guards, and the tracker always wins. The checkpoint is discarded and the graph re-enters at entry if schema_version is unknown, if tracker_state_at_write differs from the freshly-fetched state, or if the workspace directory is gone. So: checkpoint says implement but the tracker says merging ⇒ tracker wins, re-enter at __start__, let it route to land.

A separate file rather than the JSONL because the JSONL is append-only and the control tower replays it; the JSON is the fast resume path and the JSONL stays the audit log.

Workspace markers

Two dotfiles at the root of every per-issue workspace, both Conductor's:

| Marker | Written | Means | |---|---|---| | .conductor-owner | before after_create | which flow created this directory, so another flow's startup sweep will not rm -rf it — a guard that has to hold over a provision still in progress | | .conductor-provisioned | after after_create exits 0 | provisioning finished |

"The directory exists" was never evidence that anything was cloned into it. A directory without the sentinel is provisioned again on the next dispatch, and a failed after_create takes the directory with it — otherwise the failure is permanent, because nothing would ever re-run the hook there and every later dispatch hands an agent a broken tree. (A pre-sentinel workspace containing a .git is treated as provisioned, so upgrading does not delete a checkout that may hold uncommitted work.)

Graph position vs. tracker status

  • The graph owns its cursor. The tracker has no reviewing status, and adding one would couple internals to a shared human-facing board.
  • The tracker is the source of truth for eligibility and termination.
  • The bridge is one declared, one-directional projection: edges[].on_enter.tracker_status.
  • tracker_writes: conductor is what the IST flow ships with. The runtime performs every declared transition itself. Under agent — still available, and the default for a flow that omits the key — on_enter.tracker_status is an assertion instead: Conductor logs a warning when the tracker is not in the declared status after the node. That is a free diagnostic and an incremental path for a new flow, but it means nothing moves the ticket, which is exactly the gap the first full live run exposed.

What makes conductor safe on a proof-gated project. IST allows at most one proof_gate_enabled status per project, and IST's is review — so exactly one edge in the graph can ever be refused, and in_progress, rework, merging and done cannot. Two runtime facts guard that one edge: record_validation posts the deterministic gate's verdict so the PASSED validation run the gate requires actually exists, and publish_handoff reports pr.proof_recorded — the artifact POST's outcome, not the intent to make it. Both are observed, neither is self-reported.

A refusal keeps the run alive. It cannot fail the node: the write happens on the edge, after the node completed, after its writes committed, and after selectEdge already chose the destination. It deliberately does not fail the run either — that would mean up to max_attempts full re-walks of the graph to reach the same refusal, and a refusal is expected: it means a human must supply something. Instead the runtime differentiates the log (a gate refusal is a warn naming the missing requirements; a broken tracker is an error), emits a tracker_write_refused event, and posts one ticket comment naming what is missing — on the ticket, because that is where the human who must attach a screenshot is working.

requires_visual_proof remains an honour-system dependency on the agent running ist issue upload-proof. The runtime has no fact for it, so it cannot be guarded on an edge without trusting a self-reported key; the refusal comment names missing_visual_proof when it bites.

Statuses are checked against the flow's own vocabulary — the union of active_states, terminal_states, blocker_guard_states, rework_states and handoff.states — at startup and on every hot reload. As warnings, not load errors: the check needs buildConfig's defaults applied, and a typo'd status must not brick the daemon.


Concurrency, budget, failure isolation

The graph is a layer inside the issue slot; it does not change the outer accounting.

  • Across flows — independent detached processes.

  • Across issues — agent.max_concurrent_agents counts issues. One graph = one slot regardless of node count. The scarce resource is the per-issue workspace (a full clone), not the process. Node-granular counting would allow 10 issues × 3 nodes = 30 concurrent agent processes on a laptop.

  • Within an issue — max_parallel_nodes, the width of the frontier's admission semaphore. Fan-out semantics are correct and deterministic at 1; width only changes wall-clock.

    Branch-level fan-out is for read-mostly work. The frontier is a graph-level abstraction over a workspace that is still one flat clone: copy-on-branch protects state.values and nothing else, so every branch shares the same repoDir, git HEAD, node_modules and host Postgres. The loader derives each node's repo-write capability from its agent profile's tool grant and rejects a region with more than one write-capable branch — grounded rather than asserted, because it reads what the CLI is actually invoked with. But it is a capability check, not a sandbox: Bash on an agent node and any kind: shell node's run: stay open by design, each carrying a warning rather than an error, because closing them would ban the two headline use cases (parallel read-only reviewers, and independent validation suites). A region's join is gated on its branches' outcomes by join_when, which defaults to all_success exactly as Airflow's TriggerRule does — so a failed branch fails the region rather than letting the join aggregate the survivors. Anything that writes code fans out at the ticket level instead, where the orchestrator already gives every child its own clone, branch and PR. Per-branch git worktree isolation is the follow-on that would lift this.

  • Across child tickets — the second fan-out axis, and the one that is safe for work that writes code. A subtask is a real issue with its own key, so each child is an ordinary dispatch with its own clone, walk, branch and PR; max_concurrent_agents bounds how many run at once, and the blocker guard holds the parent until they land. The graph does not drive this — the poll loop already does — so the only thing the engine adds is the fields to route on (issue.parent, issue.subtasks, issue.subtask_progress) and a dispatch-time detail fetch to populate them. See conductor-runner/TEMPLATE.md, "Ticket-level fan-out".

  • Within a node — a turn loop bounded by node.max_turns.

Budgets: cost_budget.per_node_usd, per_run_usd (accumulated across every node of an attempt), and per_process_usd (halts all dispatch). Raise Symphony's numbers: an implement ⇄ checks ⇄ review cycle at max_loop_iterations: 3 is up to 4× Symphony's per-attempt spend, × max_attempts: 3.

cost_budget.enabled: false switches all three off in one word while leaving the numbers in the file. It defaults to true, because the failure mode of the other default is an unbounded overnight spend caused by a missing key. Enforcement goes through activeCostBudget(config), never config.cost_budget directly — one accessor so a cap added later cannot quietly ignore the flag.

Stall detection is per node, and never before the first one. RunningEntry.current_nodes maps each executing node to its own stall timeout and the effective timeout is the max — max, not min, so a legitimately long implement is not killed because a sibling router has a one-minute timeout. Shell and builtin nodes heartbeat too: every byte a subprocess prints resets the clock, so node.timeout_ms bounds the work rather than the reaper doing it by accident.

A run that has not emitted node_started is judged against 2 × hooks.timeout_ms instead (never below the default). Everything before the first node — createWorkspace, after_create, before_run — emits no events, so the reaper was policing that window with DEFAULT_STALL_TIMEOUT_MS (300000) whatever the flow configured; a hooks.timeout_ms above that had its own hook killed at five minutes, with cleanup: false, while the hook's subprocess kept running — which is how a half-provisioned workspace gets manufactured. Twice the hook budget is the smallest number that cannot fire during a legitimate provision, and it still reaps the one unbounded thing in that window: the OKF context fetch.

Retry stays attempt-granular, but giving up is per ticket. A failed node routes through its on_error edge; if none matches, the attempt returns error and the orchestrator's retry machinery takes over — with a checkpoint, so attempt N+1 resumes at the failed node. Retrying inside the graph and in the orchestrator would multiply max_attempts × node retries.

attempt cannot bound re-dispatch, though: it is a function argument and the poll tick always dispatches with attempt = 1, so it only ever bounded retries inside one chain. OrchestratorRuntimeState.failure_counts counts failed dispatches per issue, across chains, and agent.max_attempts is checked against that.

Giving up is durable when tracker.failure_status is set. state.completed is a Set in one process's memory: a ticket Conductor had given up on ("max attempts (3) reached") was dispatched again five minutes later by the next daemon. On exhaustion Conductor writes the configured status — outside active_states — and comments on the ticket with the failure and the log path. The tracker is the only store that survives a restart.

AbortController is issue-granular: one per run, passed to every node, killing the active subprocess and breaking the executor between nodes. The executor awaits in-flight node promises before returning, and shutdown() awaits every run's promise — without that, process.exit(0) in cli.ts can kill a subprocess mid-push.


Running alongside Symphony

Both daemons can run on the same project. Two things must be disjoint:

  1. The label gate. Symphony dispatches [symphony]; Conductor dispatches [conductor]. This handles dispatch.
  2. workspace.root. Symphony's startup cleanup calls fetchIssuesByStates(terminal_states) without assignee scoping — an explicit design choice there — and rm -rfs the matching workspaces. Conductor must use a different root than ~/symphony-workspaces/IST, or a Symphony restart could delete Conductor's working trees. This handles the filesystem.

The runtime directories also differ by default (~/.conductor vs ~/.symphony), so conductor status and symphony status never report each other's flows.


Why the executor is hand-rolled

Conductor's node bodies are spawn() of a CLI whose stdout is fully buffered until exit. Against that, most of what @langchain/langgraph sells is worth zero: streaming, MessagesAnnotation, tool binding and createReactAgent all assume an LLM API client, and there is none — no BaseMessage, no model call, no tool schema. interrupt() looks like the perfect fit for the review state and is not: that interrupt lasts hours to days and spans process restarts, and the tracker status field is already both the interrupt store and the resume token. RetryPolicy is actively harmful, because retry lives one layer out with exponential backoff, stall detection and release-on-reassign — a second layer would multiply max_attempts × node retries.

Measured cost: @langchain/langgraph closes over 19 transitive packages including langsmith, a telemetry surface that phones home unless explicitly disabled, on a machine whose flows run codex --sandbox danger-full-access. Conductor's own closure is 2 direct / 4 total (js-yaml→argparse, liquidjs→commander).

The decision stays reversible by design. GraphSpec is a plain serializable data structure — no functions, no classes, no closures — so compileToLangGraph(spec): StateGraph can be written later without touching the schema, the node files, or any node body. __end__ deliberately borrows LangGraph's END naming so a port stays mechanical.

(apps/agent-orchestrator does use LangGraph — in Python, server-hosted, driving an interactive AG-UI chat agent. Different deployment shape, different node bodies.)


Development

bash scripts/check.sh conductor      # lint + typecheck + build + tests
npm run test --workspace apps/conductor -- tests/graph/executor.test.ts

Tests use the three isolation styles the monorepo already uses, and most of the new surface is style 1 — which is the point of designing the graph as data.

  • Pure, no mocks: graph/routing, graph/loader, graph/state, config, tracker, orchestrator, control-tower, cli.
  • Injected seams (preferred): graph/executor.test.ts is the highest-leverage file — the executor takes an injected NodeRunner, so the whole scheduler is exercised with a literal fake and zero subprocess mocking. Same for agents/* (argv and parse are pure functions), forge/* (recording CommandRunner / injected HttpTransport), graph/shell-node, graph/builtins, graph/node-runner (injected spawnTurn).
  • vi.mock, kept at the edges only: agents/process.test.ts, where the subprocess boundary itself has to be faked to be tested at all.

Cross-layer: tests/ist-workflow.test.ts loads the live conductor-runner/workflows/IST.md, asserts the graph shape, and renders every node prompt against a sample issue — in both the populated and the empty state, so the {% if %} branches are both exercised. tests/example-template.test.ts does the same for examples/conductor-template/.

Preserve the house convention: non-obvious assertions carry a comment explaining why the alternative is wrong.