Skip to content

Agent-agnostic engine: adopting DarkFactory's architecture into SDLC

State: open/active · Genre: plan

Notes on what to take from the Python DarkFactory harness. The goal: move SDLC’s orchestration control out of Claude markdown and into a standalone engine that any agent can drive, and have agents talk to that engine over MCP (or another channel) — instead of running all orchestration inside Claude.

Status: exploration / decision input. Not a committed plan yet. Date: 2026-06-27. Companion reports: darkfactory-engine-map.md · sdlc-current-architecture.md


  1. DarkFactory already is the engine-first design described here. Same author, built standalone in Python (extracted from pumice’s tools/prd-harness; design history in PRD-110 / its vision.md). It runs a PRD through a workflow, which is a plain ordered list of steps. The engine owns all control flow, all git/GitHub changes, and all status changes. The agent only writes code, stages it, and prints one line saying pass or fail. That is the inversion you’re asking for.

  2. SDLC’s deterministic code is already an engine in all but name. Every op is defineOp({ path, input: ZodSchema, output: ZodSchema, handler }) with an injected OpCtx — ops do not read the ambient cwd or env, so they can run off the main process. registry.ts:310 says the MCP JSON Schema is derived from the op input. The lease system uses git refs + --force-with-lease CAS + fencing tokens, which does more than DarkFactory’s process filelock. So the job is to move control flow into the engine you already have, not to build one.

  3. The part that really lives in Claude today is the control loop — orchestrate/SKILL.md and the 1430-line task-work/SKILL.md. About 80% of each is already “call an op, check the result.” Much of the rest (escape recovery, marker naming, lenient last-line parsing) exists only because one LLM reads another LLM’s text output. That code goes away once both sides are deterministic.

  4. “Talk over MCP” mixes up two directions. Keep them separate:

    • Agent → engine (an agent calls an op: acquire a lease, classify a PR, get the next task, change status). This is what MCP is for. SDLC is already set up for it; the MCP server is a generated adapter over the existing op registry, not a rewrite.
    • Engine → agent (the engine runs an LLM as a worker). MCP does not do this. Use the Claude Agent SDK (headless, structured input/output, can attach MCP servers, streaming). It replaces DarkFactory’s claude --print + regex-sentinel subprocess.
  5. Suggested shape: keep SDLC’s TypeScript ops and its lease system; copy DarkFactory’s patterns (DAG runner, declarative workflow/task types, the “engine owns all changes” rule, an explicit status machine, the rework guard) onto that base in TS; expose the ops over MCP for agent → engine; run the LLM through the Agent SDK for engine → agent. Do it in five phases, each useful on its own. No big rewrite.


Both systems do the same job: take a ticket from spec to merged PR with an LLM in the loop. They differ in who is in charge.

DarkFactorySDLC (today)
In charge (control flow)Python engine (runner.run_tasks, graph/_execution)Claude (markdown skills)
Agent’s rolea step type — one-shot claude --print, sentinel linethe orchestrator — reads orchestrate/task-work skills
Deterministic codethe whole orchestrator + builtinsthe sdlc ops (lease, pr classify, quality, …) — the tools
How a workflow is writtenas data (Workflow([...Task]))as markdown prose (1430-line SKILL.md)
Status changesengine-set (set_status builtins at fixed positions)implied by skill prose; no state machine
Coordinationprocess filelockdistributed git-ref leases + fencing tokens
Agent callsubprocess + regex on stdoutClaude is the process; sub-agents via the Agent tool
Runtime deps3 (pyyaml, filelock, rich)Bun/TS + git + gh

The goal — “a core engine that runs tasks and works with any agent, instead of orchestration only in Claude” — is exactly moving each row from the right column to the left. DarkFactory is a working version of that end state, by the same author, passing mypy --strict.

They are the same design seen from opposite ends: SDLC’s TS CLI is like DarkFactory’s builtins; SDLC’s Claude skills are like DarkFactory’s agent prompts plus workflow recipes. What DarkFactory adds is the deterministic spine that runs them in order.


The most important finding from the SDLC report: it changes the project from “build an engine” to “move control flow into the engine.”

Why the SDLC substrate is already engine-grade and adapter-neutral:

  • Ops are pure, typed, and get their context by injection. Every capability is defineOp({ path, input: ZodSchema, output: ZodSchema, handler }) in solutions/ontological/lib/registry.ts. Handlers receive an injected OpCtx ({ projectRoot, dryRun, io, git, gh, sdlcConfig }) instead of reading process state. That is what lets an op run off-process. There are already ~19 nouns / ~70 verbs.
  • The CLI is already a generated generic adapter (registry_adapter.ts) with no per-verb code. An MCP server is a sibling adapter, not a rewrite — registry.ts:310 says the MCP JSON Schema is derived from the op input.
  • Coordination is already distributed and authoritative. Leases are git refs (refs/sdlc/tasks/<id>); claims use git push --force-with-lease CAS; the owner is a host UUID, with same-host inherit and steal-on-expired plus a rotating fence token. Task status: frontmatter is a “workflow cache” (task/schema.ts:74) — the lease ref is the real source of truth. This is closer to a distributed lock service (etcd, Chubby) than to flock.
  • The “is there work?” check is already code — the orchestrate watch op (orchestrator/ops/watch.ts) checks three conditions over live state and prints at most one PENDING … line. No LLM.
  • PR classification is already a pure function — pr classify (pr/ops/classify.ts) returns a verdict in priority order (MERGED→CLOSED→CONFLICTS→CI-FAILED→NEEDS-RESPONSE→CLEAN). The skill is a one-line wrapper.

So DarkFactory’s deterministic half (builtins, status flips, “engine owns changes”) already has a richer counterpart shipping in SDLC. What SDLC is missing is DarkFactory’s sequencing layer: the DAG runner, the declarative workflow, and the explicit state machine that tie those ops together without a human-language reader (Claude) in the middle.


3. “Talk over MCP” is two channels, not one

Section titled “3. “Talk over MCP” is two channels, not one”

The request says “MCP or some other means to communicate between Claude and the SDLC engine.” There are two channels, and mixing them up is the most common way this design goes wrong.

3a. Agent → engine — this is what MCP is for

Section titled “3a. Agent → engine — this is what MCP is for”

When any agent (an engine-spawned worker, or a person’s interactive Claude session) needs to run a deterministic op — acquire a lease, classify a PR, get the next task, change status, run the quality gate — it should call an MCP tool backed by an sdlc op.

  • Today this happens by shelling out to bun run …/sdlc.ts <noun> <verb> and reading one SCREAMING-KEBAB marker line off stdout (ACQUIRED task=… lease_id=…, TASK-WORK-DONE pr=#42). That marker system — and orchestrate’s escape/lenient/anomaly recovery — exists only because an LLM reads another process’s free text and sometimes adds extra prose. It caused two duplicate task-work runs on 2026-05-28 (see the lease convention doc).
  • An MCP adapter replaces “argv in, exit code + scraped marker out” with a typed tool call that returns structured JSON validated by the op’s Zod output schema. The whole marker/recovery cost goes away for MCP callers.
  • The same MCP server serves both engine-spawned workers and a person’s interactive Claude. CLI and MCP become sibling adapters over one registry. This is the lowest-risk, highest-value first step (Phase 1 below).

3b. Engine → agent — this is the Agent SDK’s job, not MCP

Section titled “3b. Engine → agent — this is the Agent SDK’s job, not MCP”

When the engine decides “this step needs an LLM to write code, answer a review, or judge a thin spec,” it has to run an agent as a worker. MCP does not do this. MCP exposes tools to an agent; it is not a way for a program to run an agent. Three real options:

OptionWhat it isVerdict
Subprocess + sentinel (DarkFactory today)pnpm dlx @anthropic-ai/claude-code --print --output-format stream-json …, prompt on stdin, regex-match PRD_EXECUTE_OK: in stdoutWorks, 3 deps. But the pass/fail contract is a regex over free text — both reports call this the weakest part
Claude Agent SDK (@anthropic-ai/claude-agent-sdk / claude_agent_sdk)Programmatic headless Claude: structured input/output, attachable MCP servers, tool allowlists, streaming, typed resultsRecommended. Gives you the sentinel’s intent as real structured output, and lets you hand the worker the §3a MCP server directly
Plain HTTP/JSON-RPC serviceRun the engine as a daemon agents POST toFine if you want non-Claude agents or remote workers, but heavier. Wait until multi-host/multi-agent is a real need

Putting it together: the engine runs the LLM through the Agent SDK; it hands that worker a scoped MCP handle (§3a) plus a tool allowlist; the worker does bounded work and returns structured output (the improved sentinel). One channel in (MCP, agent → engine), one call-out mechanism (Agent SDK, engine → agent). Do not make MCP do both.


4. What to adopt from DarkFactory (ranked)

Section titled “4. What to adopt from DarkFactory (ranked)”

Ranked by value-per-effort, given SDLC’s existing base.

4.1 The deterministic control loop (highest value)

Section titled “4.1 The deterministic control loop (highest value)”

DarkFactory’s runner.run_tasks walks a step list, dispatching by type and stopping on the first failure. graph/_execution.execute_graph re-reads PRDs on each pass (so planning can add to the DAG) and drives multi-PRD runs. This is the thing SDLC keeps in markdown.

SDLC’s orchestrate/SKILL.md tick is already ~80% “call an op, check the result”: the watch gate, in-flight counting (task inflight), control-plane lease check, pickup order (task next), verdict routing (a fixed table), and the digest (orchestrate log-tick) are all ops. Move the loop body into an sdlc orchestrate tick op. Claude’s role shrinks to the few real judgment calls (§6). This deletes the escape-recovery/marker code and makes the loop unit-testable instead of eval-only.

4.2 The declarative workflow/task types (SDLC has no equivalent)

Section titled “4.2 The declarative workflow/task types (SDLC has no equivalent)”

DarkFactory writes a lifecycle as data:

workflow = PRD_IMPLEMENTATION_TEMPLATE.compose(
name="default", applies_to=_applies_to_everything, priority=0,
middle=[
AgentTask(name="implement", prompts=["prompts/role.md","prompts/task.md"],
tools=["Read","Edit","Write","Glob","Grep","Bash(just:*)",
"Bash(git add:*)","Bash(git status:*)"], # no commit/push/gh
model_from_capability=True, retries=1,
verify_prompts=["prompts/verify.md"]),
ShellTask("test", cmd="just test", on_failure="retry_agent"),
ShellTask("lint", cmd="just lint format-check",on_failure="retry_agent"),
ShellTask("typecheck", cmd="just typecheck", on_failure="retry_agent"),
],
)

WorkflowTemplate.compose(open=[...], middle=[...], close=[...]) checks the shape of the middle slot. There are four step types: BuiltIn (a deterministic op), AgentTask (an LLM worker), ShellTask (a check, with fail/retry_agent/ ignore policy), and InteractiveTask. State passes between steps through PhaseState, a store keyed by type holding frozen dataclasses (replaced, never mutated — a simple message bus between steps).

SDLC’s version of this is a 1430-line prose skill. Turning task-work into a composed Workflow of BuiltIn(op) + AgentTask(SDK) + ShellTask(quality) makes the lifecycle readable, diffable, testable, and specializable by task type — and applies_to(task) predicate routing replaces “the skill reads tags and decides.” This is the highest-value item, even though §4.1 is the highest-leverage.

4.3 “Engine owns all changes; the agent only writes and stages” — enforced by allowlist, not trust

Section titled “4.3 “Engine owns all changes; the agent only writes and stages” — enforced by allowlist, not trust”

DarkFactory’s agent allowlist grants git add/status/diff/log but never commit, push, or gh. The prompt tells it: “you are one link in a larger harness… you MUST NOT run git commit, create branches, push, or invoke other agents.” All changes are builtins the engine runs (commit, push_branch, create_pr, set_status), further sandboxed by --disallowed-tools Edit(../).

SDLC already does some of this (commit-to-main via an ephemeral worktree, ops own PR creation), but it relies on convention. Making the allowlist the enforcement is both a correctness and a security property: the worker physically cannot push or merge, so a confused or misbehaving agent cannot escape its sandbox. It also makes the “nothing ever auto-merges; a human stays at the merge gate” rule true in fact, not just in prose.

4.4 A real pass/fail contract at the agent boundary — improve the sentinel, don’t copy it

Section titled “4.4 A real pass/fail contract at the agent boundary — improve the sentinel, don’t copy it”

DarkFactory’s contract is re.compile(r"PRD_EXECUTE_OK:\s*…") matched against free text on stdout (failure beats success; exit code cross-checked). Both reports call this the weakest part of DarkFactory. Adopt the idea — a deterministic, machine-checked verdict at the agent boundary — but do it as Agent SDK structured output / a tool call, not a regex. SDLC’s Zod schemas already exist, so the improved contract is “the worker calls a report_result tool whose output schema is validated.” That gives you the sentinel’s intent with none of the free-text fragility.

Both reports found the same gap: SDLC has no declarative state machine. task update checks that status is a legal enum value and that closed/* ⇒ completion_note, but which status may follow which lives scattered across skill prose. DarkFactory enforces transitions in the engine via set_status builtins at fixed workflow positions (ready→in-progress before the agent, in-progress→ review after checks, in-progress→blocked on failure).

Move SDLC’s status machine into the engine: a checked transition table, with the lease ref as the real claim and status: as its validated copy. This is a self-contained item (Phase 2). It fixes a weak area without touching the control loop.

SDLC has depends_on and dispatch filtering in task next, but DarkFactory has full DAG execution: Tarjan cycle detection, Kahn topological sort with natural-number tie-breaks, a separate containment tree (epics with children route to a planning workflow), and resolve_base_ref, which stacks a dependent task’s branch/PR on its one finished dependency. If SDLC wants to auto-run dependent task chains (not just independent ready tasks), this is the model. Lower priority — pull it in only when chain execution is an actual goal.

DarkFactory’s rework/ package handles “changes requested.” It reuses the existing worktree/PR (no new branch), fetches unresolved review threads with a single GitHub GraphQL call, renders them into {{REWORK_FEEDBACK}}, re-runs the agent at effort_level="max", and — importantly — a ReworkGuard keeps a per-PRD “no change” counter and blocks the PRD after 2 no-change cycles, so an agent can’t loop forever. SDLC’s pr-respond skill does the reuse-and-address part but has no such guard. The guard is a small, high-value safety net to adopt (it matches your own orchestrate idle-pacing instinct). The single-call GraphQL thread fetch is also more efficient than polling REST.


5. What not to adopt — where SDLC is already better

Section titled “5. What not to adopt — where SDLC is already better”

Moving toward the engine must not lose these:

  • Leases over filelock. Keep SDLC’s git-ref lease system. DarkFactory’s process filelock is single-host, kernel-released, with no fencing and no audit trail. SDLC’s is distributed, fenced, auditable, and re-entrant on the same host by design. Build the engine on top of it.
  • Op substrate over a fixed builtin registry. DarkFactory’s builtins are a package-internal @builtin dict (to extend the deterministic surface you edit DarkFactory). SDLC’s ops are Zod-typed with injection and a generated CLI/MCP adapter — a better base. Don’t replace it; sequence it.
  • Git-backed writes over write_text. DarkFactory’s PRD writes are plain path.write_text (no temp file + os.replace, no fsync); durability leans on file locks. SDLC commits frontmatter through git. Keep git; don’t copy the non-atomic write.
  • The merge-gate stance. SDLC’s “autonomous/pr self-readies, but work always stops at an open PR; nothing auto-merges” is a deliberate safety choice. DarkFactory also leaves review→done manual, but SDLC’s is written into the autonomy enum. Keep it; make it allowlist-enforced (§4.3).

Engine-first does not mean “remove the model.” The SDLC report lists the judgment calls that can’t be made deterministic. These stay as bounded AgentTask steps, not control flow:

  • Relevance check at pickup — “does this task still match reality?” (backed by task probe-state facts; the engine can pre-filter and only escalate the unclear cases).
  • Readiness reading — gap-report stops at facts (missing sections, left-in placeholders, unresolved touchpoints). Three judgments stay with the model: “is this section thin?”, “is this acceptance criterion subjective?”, “is this corpus assumption actually wrong?”
  • Writing the code (task-work implement) and answering review comments (pr-respond) — can’t be made deterministic.
  • Acceptance criteria that can’t be automated — UI feel, copy, subjective quality.

DarkFactory draws the same line: deterministic engine for control flow; opus AgentTasks for real judgment (for example, epic decomposition in the planning workflow). The rule is: the engine decides when to ask the model; the model never decides the control flow.


7. The main decision: language and consolidation

Section titled “7. The main decision: language and consolidation”

The fork in the road. Three options:

Section titled “Option A — Copy the patterns into TS on the existing base (recommended)”

Build the DAG runner, the workflow/task types, the status machine, and the rework guard in TypeScript, on top of the existing op registry and lease system. DarkFactory becomes the reference design, not the running code.

  • ✅ Keeps the lease system and ~70 ops; keeps the generated CLI/MCP adapter.
  • ✅ One language, one toolchain, one test suite; no Python/TS double maintenance.
  • ✅ DarkFactory is a working, mypy --strict reference by the same author, so the port is mostly a direct rewrite of a settled design (the type-keyed PhaseState bus, the four step types, the template compose, the sentinel→structured change).
  • ⚠️ It is still real work (a DAG runner + workflow loader + state bus), even with the design settled.

Option B — Adopt DarkFactory’s Python engine as-is; rewrite the ops in Python

Section titled “Option B — Adopt DarkFactory’s Python engine as-is; rewrite the ops in Python”

Make DarkFactory the engine; reimplement SDLC’s ops as DarkFactory builtins.

  • ✅ Reuses a finished, tested engine right away.
  • ❌ Drops the git-ref lease system (or forces a Python rewrite of it), the Zod/MCP-derivable op substrate, and the generated adapter — the parts where SDLC is ahead.
  • ❌ Two repos / two languages unless SDLC’s TS surface is fully retired.

Option C — Hybrid: Python engine drives, TS ops exposed over MCP

Section titled “Option C — Hybrid: Python engine drives, TS ops exposed over MCP”

DarkFactory’s Python engine orchestrates and calls SDLC’s TS ops (over MCP or the CLI) for the deterministic work SDLC already has.

  • ✅ Reuses both halves as-is.
  • ❌ A permanent two-language boundary on the hot path; every op call crosses a process/serialization boundary; hardest to debug and change.

Recommendation: Option A. The parts where SDLC leads (leases, typed ops, MCP-derivable adapter) are exactly what B and C would throw away, while the thing you gain from DarkFactory is the design, not code you couldn’t rewrite — and you wrote the design. Copy the patterns; keep the base. Treat DarkFactory’s Python as the working spec for the TS engine.

(If the real intent is instead to retire the TS surface and standardize on Python everywhere, that is a valid but much bigger call — flag it before starting, because it flips this recommendation.)


Each phase is useful and shippable on its own. You can stop after any one.

Phase 0 — (already done) The Zod-typed op substrate with OpCtx injection. The engine primitives exist.

Phase 1 — Expose the engine over MCP. Generate an sdlc MCP server from the op registry (the registry.ts:310 derivation). Point interactive Claude — and later, engine-spawned workers — at it. Gain: deterministic op calls become typed tool calls; the marker-parsing cost goes away for MCP callers. Risk: low — it’s a new adapter beside the CLI; nothing existing changes.

Phase 2 — Move the status state machine into the engine. Replace prose transition rules with a checked transition table in code; the lease ref is the real claim, status: a validated copy. Gain: fixes a weak area; self-contained. Risk: low — touches task update, not the control loop.

Phase 3 — Build the deterministic orchestration tick. Move orchestrate/SKILL.md’s loop body into sdlc orchestrate tick (already ~80% op calls). Claude shrinks to the §6 judgment calls. Gain: deletes the escape-recovery/marker code; the loop becomes unit-testable. Risk: medium — this is the brain transplant; put it behind a flag and run it in shadow mode against the skill first.

Phase 4 — Workflow/task types + DAG runner. Port DarkFactory’s Workflow, the four step types, WorkflowTemplate.compose, the PhaseState bus, and the run_tasks dispatch into TS. Re-express task-work as a composed workflow. Add DAG execution + stacked PRs (§4.6) if/when chain execution is a goal. Gain: the lifecycle becomes data. Risk: medium — the largest build, but a settled design.

Phase 5 — Engine runs the agent. The runner runs AgentTask steps through the Claude Agent SDK, handing each worker the Phase-1 MCP server + a tool allowlist (§4.3) and collecting structured output (§4.4). Adopt the rework guard (§4.7). Gain: the engine is now in charge; Claude is a bounded worker — the end state. Risk: medium — depends on Phases 1–4.

At the end you have DarkFactory’s architecture on SDLC’s stronger base, with MCP as the agent → engine channel and the Agent SDK as the engine → agent call-out.


  • Less LLM flexibility in orchestration. Hard-coding the control loop removes the model’s ability to improvise around new situations. Fix: keep workflow recipes declarative and route genuinely unclear cases to AgentTasks (§6); the engine decides when to ask, not what the answer is.
  • Double maintenance drift if a Python engine and a TS base both exist (Options B/C). Fix: Option A — one language.
  • Build cost of a DAG runner + workflow types. Real, but bounded — DarkFactory is a finished reference by the same author, so this is a rewrite, not research.
  • Shadow-mode gap. A deterministic tick can differ from the skill in edge cases. Fix: run Phase 3 in shadow mode (compute the tick’s decision, log it, but still let the skill act) until the two agree across many real ticks.
  • Copying the sentinel by mistake. If Phase 5 copies DarkFactory’s literal PRD_EXECUTE_OK: regex instead of using SDK structured output, you import its worst property. Fix: §4.4 — structured output from the start.
  • Overlap with the in-flight analysis/darkfactory-workflow-adoption work. A sibling worktree is already looking at DarkFactory workflow adoption; this doc is the engine/architecture companion. Line up scope before building so the two don’t diverge on the workflow types.

  • Language strategy: confirm Option A (copy patterns into TS) vs B (standardize on Python) vs C (hybrid). Everything downstream depends on this.
  • Engine → agent mechanism: Claude Agent SDK (recommended) vs keep subprocess+sentinel vs stand up an HTTP daemon. Depends on whether non-Claude / remote / multi-host workers are a near-term need.
  • How far to make the tick deterministic: which of today’s “judgment” steps are actually judgment vs convention an engine can do? §6 is the starting split; check it against real orchestrator logs.
  • DAG chain execution: do we want auto-run dependent task chains + stacked PRs (§4.6) now, or keep dispatching only independent ready tasks?
  • Relationship to analysis/darkfactory-workflow-adoption: merge these two explorations, or keep engine vs workflow as separate tracks?
  • review→done automation: DarkFactory left this open (PRD-110); SDLC’s merge-gate stance answers it (stays manual/human). Confirm that holds under the engine.

  • PRD-110 / DarkFactory vision.md — the original design intent (“Separates orchestration (deterministic) from per-PRD implementation (workflow-driven)”).
  • darkfactory-engine-map.md — full map of the DarkFactory Python engine (control flow, DAG, workflow/task types, builtins, agent invocation, state/concurrency, status model, rework, coupling).
  • sdlc-current-architecture.md — full map of SDLC’s current orchestration (skill/op boundary, lifecycle, lease system, op inventory, state, agent coupling).
  • Local checkouts analyzed: /Users/sksizer2/Developer/DarkFactory, /Users/sksizer2/Developer/dev (this repo).

← Back to Notes