A method for building software with agents, packaged as a single binary that runs against any repository.
Generating code stopped being the hard part. Generating the right code — code that fits the requirement, respects the boundary, and can be trusted without a senior engineer reconstructing the whole chain of intent behind it — is still the bottleneck. Teams solve it by hand-rolling an internal harness of prompts, conventions, review rituals and glue scripts, and then discover the harness has become a second codebase nobody budgeted for.
sf is that harness, extracted from a working one, made language-neutral, and
reduced to a single method:
Every rule that matters is written twice — once as prose that says why, once as a check that fails. And every check has a mutation that proves it fires.
It ships 34 rules across seven layers — from where an error type may be defined to whether your CI still runs a vulnerability scanner — plus 4 rule templates an interview fills in with your own package and directory names. It protects its own configuration, so an agent cannot reach a green build by turning a rule off.
Everything else in this repository is a consequence of that sentence.
Works on any repository, in any language. Nothing to configure first, and it will not change a line of your code.
Download the binary for your platform from the latest release — no toolchain, no compile:
# macOS on Apple silicon; swap the target for x86_64-apple-darwin
# or x86_64-unknown-linux-gnu
curl -fsSLO https://github.com/nicolasmelo1/software-factory/releases/latest/download/sf-aarch64-apple-darwin
curl -fsSLO https://github.com/nicolasmelo1/software-factory/releases/latest/download/sf-aarch64-apple-darwin.sha256
shasum -a 256 -c sf-aarch64-apple-darwin.sha256
chmod +x sf-aarch64-apple-darwin && mv sf-aarch64-apple-darwin ~/.local/bin/sfOr build it, if you have cargo:
cargo install --git https://github.com/nicolasmelo1/software-factory --tag v0.4.0 --lockedPin the tag. The rule catalog ships inside the binary, so tracking the
tip of main means an upstream commit can change what an enabled rule matches
and turn your build red with nothing in your repository having moved. sf init
writes the same pinned form into the CI workflow it generates.
sf --version reports the version and the catalog digest, because the
version number alone does not identify the rules:
sf 0.4.0 (catalog f4c2b4b783a5, 38 rules)
One static binary, no runtime, nothing to clone. Building it is a single compile — around a minute cold, a few seconds if you already have the crates cached. Everything below runs in well under a second, even on a large monorepo.
If sf is then "command not found": cargo installed it to ~/.cargo/bin,
which is not on your PATH. The rustup installer adds that directory for
you; Homebrew, apt and Nix do not, and cargo says so in a warning at the end
of the install that is easy to scroll past. Fix it once:
echo 'export PATH="$HOME/.cargo/bin:$PATH"' >> ~/.zshrc && exec zsh
# ~/.bashrc for bashcd ~/code/your-project
sf skillsIt asks where. There is no default on purpose — these skills are about this repository's factory, and writing them into every project on the machine is a decision you should make rather than one this makes for you:
Where should the skills go?
1 /Users/you/code/your-project/.claude/skills (this repository only)
2 /Users/you/.claude/skills (every project on this machine)
[1]
Use --project or --user to skip the question, --dir for anywhere else. In
a script or CI it refuses to guess and tells you to pass one.
Then invoke the skill by name. It will not be reached for on its own:
/factory-init set up software-factory in this repo
The factory-init skill takes it from there. It
reads your codebase first and answers whatever the code can answer itself —
which framework, where the client lives, whether there are already
domain//application/ directories. Then it asks you the rest in rounds, each
question numbered, each with a recommendation:
❓ Q2 — Architecture: I can see
packages/*/domain/,application/andinfrastructure/, so this looks domain-driven. Butapps/api/src/routes/users.tsopens a database connection directly. Is the layering the intent or the reality?➡️ I'd answer
dddand freeze today's 40 violations with a six-month review date — but pick that deliberately, because it commits you to fixing them.
Layered or DDD, repositories or ORM-in-services, Zod or Pydantic and where those schemas live, which packages the client must never import, whether anything shares mutable state across threads. Those answers generate rules carrying your own package names — not a generic starter set. See The interview for the full decision tree.
No agent? One command instead.
cd ~/code/your-project
sf init --language typescript --layer L1,L4,L5,L6
git config core.hooksPath .githooksUse --language for what you actually have: python, typescript, go,
rust, ruby, or several comma-separated. --layer L1,L4,L5,L6 is the honest day-one
set — code quality, documentation cadence, the self-proving layer, and the
security tooling. You get the generic rules; the structural ones stay off until
you run the interview or write them yourself.
Either way, you end up with something like:
wrote 47 files:
.software-factory/policy.yaml # which rules are on
docs/rules.md # why each one exists
.allowed-root-files
.github/workflows/software-factory.yml # CI, with the security tools wired in
.githooks/pre-commit
.software-factory/mutations/... # a tiny broken repo per rule
.software-factory/ratchet.yaml (106 existing violations frozen)
Nothing is red yet. Every violation that already existed was frozen with a six-month review date. Only new ones fail — which is what makes this adoptable on a codebase with years of history.
sf verify✓ L1.NO_BLANKET_SUPPRESSION — 1 finding(s): Bare `# noqa` disables every rule on the line...
✓ L4.DOC_LINKS_RESOLVE — 1 finding(s): link target `../src/pricing/README.md` does not exist
...
17/17 enabled rules proven to fire
Every rule was just run against a repository built to violate it. This is the step that separates enforcement from decoration: a check with a typo in it passes silently forever and looks exactly like a check that works.
echo "# scratch notes" > NOTES.md
sf check! medium L4.ROOT_FILES_ARE_DECLARED — New top-level files are declared before they appear
why `NOTES.md`, `PLAN.md`, `SUMMARY.md` at the repository root is the most
recognizable signature of agent-authored work, and each one is context
that belongs in a plan, a pull request body or a commit message —
somewhere with a lifecycle.
fix Move the content to the plans directory or the pull request
description. If the file really belongs at the root, add it to the
allowlist in the same commit.
NOTES.md — `NOTES.md` is at the repository root but not declared
1 findings across 1 rules (106 frozen by the ratchet)
rm NOTES.md # green againThat output shape is the design. The failure message is the only documentation an agent reliably reads, so every rule carries its reasoning to the point of failure rather than leaving it in a file nobody opens.
The generated workflow already runs sf verify then sf check on every pull
request, with the security tooling for your languages wired in. Commit it:
git add -A && git commit -m "chore: adopt software-factory"Exit codes are hierarchical, so CI can tell the difference between "the tool
could not run" and "the repository has violations": 3 bootstrap failed,
2 config error, 1 findings, 0 clean.
sf check red on L5.NO_INERT_RULE |
A rule is switched on and pointed at nothing here. Give it a scope, or disable it in policy.yaml and write down why. |
| Too many findings to face | sf ratchet --months 6 freezes today's state. It is debt with a due date, not permission. |
| A rule seems wrong | sf explain <RULE> gives the full reasoning. If it is genuinely wrong, change it — that is one of the four honest resolutions. |
| Want to see everything available | sf catalog, and sf interview for the decisions that generate rules. |
sf: command not found after installing |
~/.cargo/bin is not on your PATH — see step 1. |
The numbers are identity, not sequence — grouped by what they are about. The adoption order is below, and it is different.
34 rules, plus 4 templates the interview instantiates with your own names.
| Layer | What it checks | |
|---|---|---|
| L0 | Shape | Where things live — error types, data access, entrypoints, layer boundaries |
| L1 | Grain | How code reads — complexity ceiling, banned escape hatches, no blanket suppressions |
| L2 | Contract | No drift from the source of truth — hash locks, dated exceptions, and the guardrail's own protection |
| L3 | Effect | A real actor achieved the observable outcome, and the evidence has not gone stale |
| L4 | Cadence | Docs, plans and rules stay attached to each other |
| L5 | Meta | Every check is proven to fire, and none is enabled but inert |
| L6 | Hazard | The defect classes this repository hunts — vulnerabilities, secrets, dead code, races, deadlock shapes |
Run sf catalog for the rules, sf explain <RULE> for the reasoning behind any
one of them.
Day one: L1, L4, L5. This is sf init's default, and it is a deliberate
recommendation rather than a shortcut. L1 costs an hour. L4 costs three
markdown files. L5 is what makes either of them mean anything.
L0 after the third occurrence of a pattern. Cementing a shape you have seen twice is how you cement the wrong one. Wait until the repetition tells you what the shape actually is.
L6 as soon as you have CI — sf init --layer L1,L4,L5,L6 writes the steps
for you. It is the cheapest large win here: the tools already exist and are
better than anything this could contain, and what actually rots is whether they
are still wired in.
L2's guardrail lock immediately, the rest when a second surface derives from a first — a generated client, a schema and its migrations, a design token and its stylesheet.
L0 after the third occurrence of a pattern. Cementing a shape you have seen twice is how you cement the wrong one. Wait until the repetition tells you what the shape actually is.
L3 when there is a customer-visible flow worth proving. It is the most valuable layer and the most expensive one; it earns its cost only once something real can break.
Every rule here is enforced by a file an agent can edit. The shortest path from a red build to a green one is not fixing the code — it is disabling the rule, widening a glob, or deleting a workflow step, and at the diff level all three are indistinguishable from a fix. Two rules close that door.
L2.FACTORY_CONFIG_IS_LOCKED hash-locks the policy, the ratchet, the local
rules, the root allowlist, the CI workflow and the hooks. Editing any of them
without sf lock in the same commit fails. The lock does not make the edit
impossible — it makes it undeniable, as a second deliberate line in the diff on
a path a code owner watches.
L2.POLICY_ONLY_TIGHTENS reads the edit and decides which direction it
went:
sf check --changed origin/mainA rule disabled or removed, an exclusion added, a scope narrowed, a ceiling raised, a gate weakened, a new violation frozen, a review date pushed out — all fail. Tightening passes silently, so the rule never taxes the direction you want.
Alongside them, .allowed-root-files blocks the NOTES.md / PLAN.md reflex,
the dependency lock turns adding a package into a reviewable act, and
L2.NO_PERMANENT_EXCEPTION fails the build when a frozen exception outlives its
review date.
sf does not reimplement a vulnerability database, a secret scanner or a race
detector. Those tools exist, they are better than anything that could live
here, and they differ per language. What is missing in most repositories is not
the tool — it is the guarantee that the tool is still wired in. So a rule
names a concern, and the check asserts that something covering it actually runs
in your CI or task runner.
| Concern | Python | TypeScript | Go | Rust | Ruby |
|---|---|---|---|---|---|
| Dependency vulnerabilities | pip-audit | npm audit, osv-scanner | govulncheck | cargo audit | bundler-audit |
| Committed secrets | gitleaks, detect-secrets, trufflehog (language-independent) | ← | ← | ← | ← |
| Insecure patterns | bandit, semgrep | semgrep, eslint-plugin-security | gosec | clippy, cargo-geiger | brakeman, semgrep |
| Dead code | vulture | knip, ts-prune | staticcheck | dead_code, cargo udeps | — |
| Data races | — | — | go test -race | ThreadSanitizer, loom | — |
| Performance regression | pytest-benchmark | vitest bench | go test -bench | criterion | — |
sf init writes these steps into the generated workflow for the languages you
selected. A concern with no listed tool for a language is not a violation —
that is a statement about the ecosystem, not about your repository.
Nothing decides whether a program deadlocks. It is undecidable in general, and a tool claiming otherwise teaches you to trust it wrongly. What is decidable is the shape that causes the deadlocks and starvation people actually ship, and two rules enforce exactly that:
L6.NO_BLOCKING_CALL_WHILE_HOLDING_A_LOCK— a network call, a sleep, a subprocess or anawaitinside a region that holds a lock. Holding a lock across something slow turns mutual exclusion into a queue, which is starvation; awaiting while holding a synchronous lock can park the continuation on a thread that then blocks on that same lock, and nothing moves again.L6.ONE_LOCK_AT_A_TIME— a second lock acquired while the first is held. It cannot tell a correctly ordered pair from a dangerous one; ordering is a global property and the rule sees one function. It makes the second acquisition visible, which is the part that is otherwise invisible. If the pair is genuinely correct, freeze it in the ratchet with the ordering written beside it — now the ordering is documented, which is the only thing that ever prevents the inversion.
Data races get the same honesty: no static check finds them, so the rule requires the dynamic detector to run instead.
A check with a typo in its query, a glob that matches nothing, or a scope that excludes the entire source tree passes silently forever — and reads exactly like a check that works. A green build proves nothing about a rule that never ran.
So every rule ships with the smallest repository that violates it, and
sf verify runs each rule against its own mutation:
$ sf verify
✓ L0.EXCEPTIONS_HAVE_ONE_HOME — 1 finding(s): `OrderRejectedError` is defined outside its allowed location
✓ L1.COMPLEXITY_CEILING — 1 finding(s): `price` has 6 independent paths, ceiling is 4
✓ L1.NO_BLANKET_SUPPRESSION — 1 finding(s): Bare `# noqa` disables every rule on the line...
✓ L4.DOC_LINKS_RESOLVE — 1 finding(s): link target `../src/pricing/README.md` does not exist
...
12/12 enabled rules proven to fire
The generated pre-commit hook and CI workflow run sf verify before
sf check, because a check that stopped firing is the cheaper failure to find
first.
L3.GATE_HAS_FRESH_EVIDENCE is the method's sharpest edge and the reason the
rest of it exists.
# .software-factory/policy.yaml
gates:
checkout:
activation: ["src/checkout/**"]
evidence: "evidence/checkout.json"- Activation comes from touched paths. Not a label, not a checkbox, not a
sentence in a pull request. Touch
src/checkout/**and the gate is on. - The manifest is re-verified, never trusted.
sfre-reads the referenced report, recomputes its SHA-256, and re-checks every required assertion in the raw report. A summary cannot assert a pass the report never contained. - An
unsupportedassertion is not a pass. The most common way a green gate proves nothing is an assertion the harness could not evaluate being counted as one that succeeded. - Evidence expires when the code moves. The manifest records a digest of the activation paths. Change the implementation and the evidence dies with it, instead of quietly certifying something it never saw.
- The goal is checked for leaked answers. A goal naming the source tree is a replay recipe, not a customer asking for something.
sf seal checkout # recompute every digest from what is actually on diskseal only recomputes digests — it cannot launder a failing report into a
passing one.
The catalog is the portable asset. The binary is just what runs it.
id: L0.EXCEPTIONS_HAVE_ONE_HOME
layer: L0
title: Error types live in one canonical module per domain
severity: high
statement: >-
Define every error type in its domain's canonical errors module.
why: >-
An agent asked to "add an error for X" will define it wherever it is already
editing. Three months later nobody can answer "what can this domain fail
with?" without reading every file.
fix: >-
Move the class definition into the domain's errors module and import it back
where it is raised.
check:
kind: shape
languages:
python:
query: |
(class_definition
name: (identifier) @name
(#match? @name "(Error|Exception)$")) @target
typescript:
query: |
(class_declaration
name: (type_identifier) @name
(#match? @name "(Error|Exception)$")) @target
go:
query: |
(type_declaration
(type_spec name: (type_identifier) @name
(#match? @name "(Error|Err)$"))) @target
defaults:
must_live_in: ["**/exceptions.py", "**/errors.ts", "**/errors.go"]why and fix are mandatory: the catalog refuses to load a rule missing
either. A rule with no reasoning is a wall an agent hits with no way to tell
whether it is protecting something or just old.
Structural rules are tree-sitter queries plus a constraint on where matches may live. The engine knows nothing about controllers, repositories or exceptions — that vocabulary lives entirely in the catalog, which is what lets one rule mean the same thing in four languages.
A language may also carry an unless query: the same shape plus whatever makes
it acceptable, whose matches cancel the ones above on that line. It exists
because negation over siblings is not expressible in a tree-sitter query, and
some rules need it — L1.SKIPPED_TESTS_STATE_A_REASON asks a TypeScript skip
for a comment on the line above, because it.skip('name', fn) has no parameter
for a reason and a comment is the only place one can live.
typescript:
query: |
(expression_statement (call_expression ... )) @target
unless: |
((comment) . (expression_statement (call_expression ... )) @target)Adding a language is a grammar plus one query per rule you want it to
cover. Adding a rule is a YAML file in .software-factory/rules/ and a
fixture under .software-factory/mutations/<RULE_ID>/.
Adding a check the structural kinds cannot express is kind: command: a
rule whose failure only a subprocess can decide (a schema export, a codegen
step, a linter this repo already trusts) reports a finding on nonzero exit,
with no fork required. It needs sf check --allow-commands to actually run —
see Checks this tool cannot express.
check:
kind: command
run: "make export-openapi && git diff --exit-code -- contracts/"Languages today: Python, TypeScript/TSX, Go, Rust, Ruby.
sf verify requires every language a rule declares to be shown tripping it,
otherwise three broken queries hide behind one that works.
A deprecation rule for Tailwind 3, or a shape rule for the QuickBooks v3 API,
is only correct while that version is the one installed. Give the instance a
when, and it activates from the manifest:
rules:
L1.NO_BLANKET_SUPPRESSION@tailwind3:
enabled: true
when:
dependency: tailwindcss
manifest: package.json
version: "^3"Once the pin moves to ^4, that instance stops running, because it is about a
version this repository no longer has. It does not go quiet: L5.NO_INERT_RULE
names it, the range it was written for and the version found, so the answer is
to repoint it or remove it. A condition that silently disabled itself would
hand an agent a way to switch a rule off by editing a dependency.
A when naming a package no manifest declares is a finding for the same
reason, as is one naming a manifest that is missing or in a format this binary
cannot read. Every way of not deciding is reported; none of them is a skip.
- The manifest range, never the lock. The lock is more accurate and there
are several lock formats per ecosystem. The range in the manifest is what
the team decided, and the decision is what the rule is about. It is also
already covered by
L2.DEPENDENCIES_CHANGE_DELIBERATELY, so the input cannot move without a lock update in the same commit. - Manifests read:
package.json,Cargo.toml,pyproject.toml(PEP 621 and Poetry),requirements*.txt,Gemfile,go.mod. - Ranges accepted:
^3,~1.2,>=5,<4, and a bare series like3or3.4. The question is whether the pin is still in the series the rule was written for, which the release numbers answer on their own.
What this does not do is check that the rule's content is right for the
version it claims. A regex written for Tailwind 3 stays a regex written for
Tailwind 3 whether or not the condition matches; reading the upstream
changelog and turning it into patterns is a job for
factory-author.
A monorepo needs the same rule twice: a complexity ceiling of 12 in the new
packages and 20 in the one nobody has had time to split up. Write the policy
key as RULE@name:
rules:
L1.COMPLEXITY_CEILING:
enabled: true
options: {max: 12, scope: ["packages/api/**", "packages/web/**"]}
L1.COMPLEXITY_CEILING@legacy:
enabled: true
options: {max: 20, scope: ["packages/legacy/**"]}Both resolve to one catalog rule with one written reason, and each instance gets its own findings and its own ratchet entries — so paying down the legacy debt does not require touching the other packages' allowance.
Some invariants only exist between repositories: a public contract and the private service that serves it, a schema and the client generated from it. Neither checkout can see the other, so nothing in either notices when they drift.
Declare the other checkouts — usually symlinks to sibling clones — and one policy governs all of them:
project:
name: acme-workspace
languages: [python, typescript]
roots: ["acme-public", "acme-private"]Findings keep the declared prefix (acme-private/packages/api/handler.py), so
a rule reads the same wherever the checkout actually lives. Symlinks are
followed only for declared roots, never during the ordinary walk — a
package manager's symlink farm would otherwise be walked as source.
Each repository can still run its own sf check for its own rules. The
workspace runs the ones that are about the relationship.
Some drift is only decidable by regenerating: export the API schema, run the generator, compare. No glob or query says that, and a hash lock cannot either, because the artifact is supposed to change whenever its source does.
L2.DERIVED_ARTIFACTS_MATCH_THEIR_SOURCE runs a command instead:
L2.DERIVED_ARTIFACTS_MATCH_THEIR_SOURCE:
enabled: true
options:
run: "make export-openapi && git diff --exit-code -- contracts/"What that buys over a plain CI step is everything around it: the written reason printed where it fails, a mutation fixture proving it still fails when it should, a place in the same report, and a policy that cannot be quietly loosened. It is also the bridge for a bespoke checker a team already has — wrap it, and it gets the same treatment as everything else.
Commands are refused unless asked for:
sf check --allow-commands # or SF_ALLOW_COMMANDS=1A policy file is data that travels with a clone, so running whatever it says would make cloning a repository dangerous. Without the flag the rule still reports — a check that did not run is not a check that passed.
sf ratchet --months 6Every existing violation is frozen by a content-stable key. New ones fail
immediately. Each frozen set carries a review_by date, and
L2.NO_PERMANENT_EXCEPTION fails the build once it passes — the date does not
force the fix, it forces the conversation, on a day someone chose in advance
rather than never.
Keys are derived from content, not line numbers, so moving code around does not silently un-freeze the ratchet — and adding a key by hand to silence a new violation is a visible line in a reviewed diff.
Re-seeding recomputes the frozen keys, never the deadline: a date already
accepted survives, and a newly frozen violation does not reset the clock on the
debt beside it. This matters because sf ratchet is part of the prescribed
order after any guardrail change — a run that stamped today + N months on
every entry would push every deadline out on every unrelated change, which
L2.POLICY_ONLY_TIGHTENS then rejects. Renewing a date that has genuinely
expired is a deliberate edit to the file, with the reasoning in the pull
request, which is the conversation the date exists to force.
sf init |
Scaffold policy, docs, CI, hooks, fixtures; seed locks and ratchet |
sf check |
Run every enabled rule. --format json|markdown, --changed <ref>, --rule <ID>, --allow-commands |
sf verify |
Prove every enabled rule fires on its mutation fixture |
sf explain <RULE> |
What the rule requires, why it exists, how to fix a violation |
sf catalog |
List the rules. --layer L0 |
sf interview |
The decision tree an interview walks. --json for an agent |
sf skills |
Install the agent skills. Asks where; --project, --user or --dir to say |
sf ratchet |
Freeze today's violations. --months N |
sf lock |
Rewrite hash locks from disk |
sf fixtures |
Write the mutation fixtures for every enabled rule |
sf docs |
Regenerate the rule sections of docs/rules.md, preserving your own prose |
sf seal <gate> |
Recompute the digests in a gate's evidence manifest |
Exit codes are hierarchical, so a caller can tell "the tool could not run" from
"the repository has violations": 3 bootstrap failed, 2 config error, 1
findings, 0 clean.
Bare sf init cannot know whether you use repositories or call the ORM from
services, whether errors live per-module or in one file, or which package the
client must never import. So it enables the generic rules and leaves the
structural ones off.
The interview fixes that, and it is deliberately split in two:
- The conversation belongs to the agent. It reads your repo, answers what the code can answer, and pushes back when you say "layered" and a route handler opens a database connection.
- The mapping does not. Which rules an answer produces lives in
sf interview, as data. Two agents interviewing the same team land on the same policy — otherwise it is just each agent's taste with extra steps.
sf interview # the decision tree, and what each answer enforces
sf interview --json # the same, for an agent conducting itTwelve decisions, walked as a tree in rounds: what the repository is, how it is organised, which framework, how code reaches the database, where error types live, what validates the boundary and where those schemas sit, how the client fetches and stores state, which packages the client may never import, whether anything shares mutable state across threads, and what is generated rather than written.
Answers go in a file, and the file is the decision:
# .software-factory/answers.yaml
version: 1
answers:
kind: backend-service
architecture: hexagonal
framework: fastapi
data_access: repositories
errors_home: per-module
validation: pydantic
validation_placement: with-the-handler
concurrency: shared-state
generated: "src/generated/**, **/*_pb2.py"sf init --language python --layer L1,L4,L5,L6 --answers .software-factory/answers.yamlThat enables the L0 rules those answers justify (and only those), points them
at the right directories, switches off the ones that cannot mean anything here,
instantiates repo-specific rules from templates with your own package names
filled in — each with a fixture proving it fires — and writes
docs/architecture-decisions.md recording who decided what.
A real example, on a TypeScript monorepo with a Next.js client:
$ sf init --language typescript --layer L1,L4,L5,L6 --answers answers.yaml
.software-factory/rules/client-never-imports-the-data-layer.yaml
.software-factory/rules/no-fetch-inside-an-effect.yaml
docs/architecture-decisions.md
.software-factory/ratchet.yaml (118 existing violations frozen)
$ sf verify
14/14 enabled rules proven to fire
$ sf check
✓ 14 rules, no findings (118 frozen by the ratchet)
The generated L0.CLIENT_NEVER_IMPORTS_THE_DATA_LAYER carries that repo's
actual package names in its tree-sitter query and its own apps/web/** in the
constraint. It found zero violations — the boundary already held, and now
nothing can quietly break it.
Change an answer and re-run. Do not hand-edit the generated policy, or the decision record stops describing what is enforced.
Four agent skills for Claude Code, shipped inside the binary so they cannot
drift out of step with the sf they drive. Install them once:
sf skills # asks: this repository, or every project
sf skills --project # <root>/.claude/skills, no question
sf skills --user # ~/.claude/skills, no questionInvoke them by name. /factory-init, /factory-evidence, /factory-triage
and so on: an agent
may pick one up from its description when the conversation matches, but that is
not something to rely on, and a skill that silently did not load looks exactly
like one that did and had nothing to say.
Their job is to author policy and produce evidence, never to remember rules; that is what the binary is for.
One boundary runs through all four: an agent proposes policy, a human merges it. That is the only thread separating a factory from a system grading its own homework, and no amount of tooling substitutes for it.
The one you use first. It runs the interview above.
You:
/factory-init set up software-factory in this repo
It reads the codebase before asking anything, answers what the code can answer, and asks in rounds — each question numbered, each with a recommendation:
❓ Q2 — Architecture: I can see
packages/*/domain/,application/andinfrastructure/, so this looks domain-driven. Butapps/api/src/routes/users.tsopens a database connection directly. Is the layering the intent or the reality?➡️ I'd answer
dddand freeze today's 40 violations with a six-month review date, rather thannone-yet— but I want you to pick that deliberately, because it is a commitment to fix them.
Then it applies the answers, and reads the result back to you in numbers: what was frozen and when it comes due, which rules were switched off and why, which repo-specific rules were generated. Re-run it whenever an architectural decision changes.
You are lead on a TypeScript monorepo. It is the third time this month you have written "don't import the db directly in a component, go through the API".
You:
/factory-authorthird PR this month where someone imports@acme/dbinsideapps/web. I want this to stop being my comment and become a check.
It does not say "good idea, I'll remember". It checks the rule does not already
exist (sf catalog), writes the YAML with a mandatory why — written for the
person who will want to delete this rule in a year — writes the smallest
repository that violates it, runs sf verify --rule, and if it does not fire
it fixes the rule, not the fixture. Then sf ratchet if there is existing
debt, and it tells you how many it froze and when they come due.
What you get is a pull request: a rule, a fixture, a section in the rules document. You read it and merge it.
Its other trigger is opening a phase of work — "I'm rewriting billing, I want a completion gate" — where it designs the activation paths and required assertions, and insists the assertions be observations read back from the system, not claims the actor makes about itself.
You:
/factory-evidencecreate and prove the checkout gate for this service.
If no harness exists, it reads how the product starts, identifies the public
flow and observations, then writes the runnable harness and L3 gate. The
harness becomes an activation path, so editing it expires the evidence it
produced. If a harness already exists and its evidence is stale, it runs that
program again; it never re-seals an old run and calls it proof.
It runs the real thing: starts the app, drives it through the entry point a customer would use, collects the observations, writes the report, and only then seals. And it tells you plainly when an assertion did not pass:
"
refund.settledcame backunsupported— the harness could not evaluate it. That is not a pass. The finding is the product's behaviour, not the gate."
If the gate cannot go green, it stops and says so. A gate that will not pass is usually reporting a real defect, and the defect is worth more than the build.
You:
/factory-triageCI is red, 12 findings, sort it out.
It reads the report (which already carries each rule's reasoning) and works in a
specific order: sf verify failures first — a rule that stopped firing
means every green build since then proved nothing.
The part that earns its disk space is the list of what is not a resolution:
widening a glob, adding a ratchet key for a violation you just wrote, pushing a
review_by out, disabling the rule, suppressing at the source. In a diff, each
of those is indistinguishable from a fix. The skill is told to name which one it
would pick, say why, and stop.
It has four honest resolutions, and one of them is fix the rule. If the rule
is wrong, you change it deliberately, update its why, and re-run verify.
That happened while building this: the Exit condition marker did not accept
**bold**. The rule was wrong, not the document.
The safety net underneath is mechanical now — if it (or any agent) tries to
disable a rule to go green, L2.POLICY_ONLY_TIGHTENS catches it, and it
survives even if the agent runs sf lock to cover the trail.
It does not replace your linter, type checker or test suite — it enforces the decisions those tools have no opinion about. It does not review code for correctness. It does not run your tests; L3 checks that something ran, proved what it claimed, and has not gone stale since.
And it is deliberately small. A method you cannot read in an afternoon is a method nobody will adopt.
sf is written in Rust and Rust is one of its target languages, so this
repository runs its own rules against its own source, with its own mutation
fixtures, in its own CI: 30 rules enabled, 30 proven to fire, no findings.
Four of the 34 are switched off here and one is frozen with a review date,
each as a written decision in docs/rules.md — because
L5.NO_INERT_RULE refuses to let a rule be enabled and pointed at nothing.
See docs/method.md for the reasoning behind the layering.
MIT.