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
Summary
Section titled “Summary”-
DarkFactory already is the engine-first design described here. Same author, built standalone in Python (extracted from
pumice’stools/prd-harness; design history in PRD-110 / itsvision.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. -
SDLC’s deterministic code is already an engine in all but name. Every op is
defineOp({ path, input: ZodSchema, output: ZodSchema, handler })with an injectedOpCtx— ops do not read the ambient cwd or env, so they can run off the main process.registry.ts:310says the MCP JSON Schema is derived from the opinput. The lease system uses git refs +--force-with-leaseCAS + fencing tokens, which does more than DarkFactory’s processfilelock. So the job is to move control flow into the engine you already have, not to build one. -
The part that really lives in Claude today is the control loop —
orchestrate/SKILL.mdand the 1430-linetask-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. -
“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.
-
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.
1. Two systems built the opposite way
Section titled “1. Two systems built the opposite way”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.
| DarkFactory | SDLC (today) | |
|---|---|---|
| In charge (control flow) | Python engine (runner.run_tasks, graph/_execution) | Claude (markdown skills) |
| Agent’s role | a step type — one-shot claude --print, sentinel line | the orchestrator — reads orchestrate/task-work skills |
| Deterministic code | the whole orchestrator + builtins | the sdlc ops (lease, pr classify, quality, …) — the tools |
| How a workflow is written | as data (Workflow([...Task])) | as markdown prose (1430-line SKILL.md) |
| Status changes | engine-set (set_status builtins at fixed positions) | implied by skill prose; no state machine |
| Coordination | process filelock | distributed git-ref leases + fencing tokens |
| Agent call | subprocess + regex on stdout | Claude is the process; sub-agents via the Agent tool |
| Runtime deps | 3 (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.
2. You already own most of the engine
Section titled “2. You already own most of the engine”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 })insolutions/ontological/lib/registry.ts. Handlers receive an injectedOpCtx({ 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:310says 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 usegit push --force-with-leaseCAS; the owner is a host UUID, with same-host inherit and steal-on-expired plus a rotating fence token. Taskstatus: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 toflock. - The “is there work?” check is already code — the
orchestrate watchop (orchestrator/ops/watch.ts) checks three conditions over live state and prints at most onePENDING …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
outputschema. 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:
| Option | What it is | Verdict |
|---|---|---|
| Subprocess + sentinel (DarkFactory today) | pnpm dlx @anthropic-ai/claude-code --print --output-format stream-json …, prompt on stdin, regex-match PRD_EXECUTE_OK: in stdout | Works, 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 results | Recommended. 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 service | Run the engine as a daemon agents POST to | Fine 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.
4.5 An explicit status state machine
Section titled “4.5 An explicit status state machine”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.
4.6 DAG execution and stacked PRs
Section titled “4.6 DAG execution and stacked PRs”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.
4.7 The rework loop with a loop guard
Section titled “4.7 The rework loop with a loop guard”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
filelockis 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
@builtindict (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 plainpath.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/prself-readies, but work always stops at an open PR; nothing auto-merges” is a deliberate safety choice. DarkFactory also leavesreview→donemanual, but SDLC’s is written into theautonomyenum. Keep it; make it allowlist-enforced (§4.3).
6. Where the LLM stays in the loop
Section titled “6. Where the LLM stays in the loop”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-statefacts; the engine can pre-filter and only escalate the unclear cases). - Readiness reading —
gap-reportstops 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:
Option A — Copy the patterns into TS on the existing base (recommended)
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 --strictreference by the same author, so the port is mostly a direct rewrite of a settled design (the type-keyedPhaseStatebus, the four step types, the templatecompose, 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.)
8. A phased path (no big rewrite)
Section titled “8. A phased path (no big rewrite)”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.
9. Risks and trade-offs
Section titled “9. Risks and trade-offs”- 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-adoptionwork. 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.
10. Open questions / decisions to make
Section titled “10. Open questions / decisions to make”- 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→doneautomation: DarkFactory left this open (PRD-110); SDLC’s merge-gate stance answers it (stays manual/human). Confirm that holds under the engine.
Appendix: source material
Section titled “Appendix: source material”- 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).