7 min read agentseffecttypescript

A quality bar is a workspace artifact, not a prompt

Smith profiles a repository in conversation, dry-runs the blast radius, vendors the chosen rules into the project, and locks legacy debt behind a line-sensitive ratchet.

On this page

The easiest way to make a coding agent respect a repository’s style is to add another paragraph to its prompt.

The easiest way to discover that paragraph was advisory is to inspect the third retry: the model fixed the named type error by adding as any, skipped the failing test, and declared success in immaculate prose.

Efferent’s latest Smith flow takes a different route. smith profile has a conversation about the repository’s quality bar, but the conversation does not become the bar. Locking the result writes a config, project-owned static rule modules, standing doctrine, and a baseline of existing violations. Future forge runs are rejected by those artifacts whether or not the model remembers the interview.

The interesting implementation is the transition from negotiated judgment to deterministic ownership.

The setup session may draft, never arm

The profile architect is an agent with read tools and exactly one write-shaped action: propose_profile. It explores manifests, source layout, CI, lint config, and existing scripts; asks the human only questions that change the profile; then replaces a draft under .efferent/profile-draft/.

It cannot write foundry.config.ts and cannot mint a baseline. Only the human’s :lock crosses that boundary.

That split gives the model a useful role without giving its confidence authority. The model is good at reading a repository and proposing that bun test is authoritative or that domain must not import providers. It is not entitled to silently arm those claims for every future run.

Headless mode keeps the same boundary. smith profile -p must record uncertain decisions as assumptions; --yes is the explicit lock. Automation can remove the interview, not the act of authorization.

Every proposal is executed before it is discussed

propose_profile materializes a complete temporary config and runs it against the real workspace. The response is not “looks good.” It is a blast-radius report:

rule effect/no-let: 212 existing findings → grandfathered at lock
rule quality/no-skipped-tests: 3 existing findings → grandfathered at lock
boundaries: 7 existing violations
check bun-test: green

The dry run disables typecheck deliberately. On a legacy repository, thousands of unrelated compiler diagnostics would drown the information the proposal is trying to expose: what these new rules and boundaries would add. Typecheck is enabled in the final locked config when a tsconfig.json exists.

writeFileSync(
  join(draftDir, "foundry.config.ts"),
  renderConfigSource(proposal, {
    gatesImport: hasModules ? Option.some("./gates/index.js") : Option.none(),
    typecheck: false, // draft: measure the proposed profile, not all compiler debt
  }),
)

This is the profile version of red-first, with a crucial difference. A task-specific acceptance check should be red before the feature exists. A standing regression check such as bun test is supposed to be green. The dry run does not apply one slogan to both; it reports the observed state and lets the claim determine what that state means.

Packs are source libraries, not hidden built-ins

Foundry ships three reusable packs. effect covers no-let, no-try-catch, Option, schema, and fold idioms. quality catches paradigm-neutral gate gaming such as skipped tests and empty catches. effect-architecture enforces the file roles of an Effect-native core: contracts in .entity.ts / .usecase.ts, behavior in adjacent .functions.ts, Context.Tag services in .port.ts, concrete Layers at adapter edges, and no raw Promise orchestration in core files.

Choosing a pack does not leave the workspace dependent on Foundry’s private registry. At lock, Smith copies the selected rule source into:

.efferent/gates/
├── effect/rules.ts
├── quality/rules.ts
└── index.ts

foundry.config.ts imports the resulting rules array. Custom rules sit beside vendored ones and implement the same plain structural shape — id, severity, description, fix hint, and a check function over a TypeScript source file.

The workspace now owns the exact code enforcing its profile. It can review a rule in the same PR as the code it rejects, pin today’s semantics while Efferent evolves, and edit or remove the rule without waiting for a framework release. Foundry supplies a library; the project assembles a policy.

This also avoids a subtle trust problem. A config containing only string ids is not a complete quality contract if a future dependency upgrade can silently change what those ids do. Vendoring turns the implementation into repository state.

Custom rule know-how travels with the profile

When the profile needs a project-specific rule, the architect first loads the bundled gate-rule-authoring skill. That skill teaches three implementation patterns — text, AST, and type-aware — plus the fail-closed contract and message/fix-hint discipline.

After lock, the same skill is copied into .efferent/skills/. The future forge coder can extend the profile using the same local procedure. Mechanism knowledge travels with the workspace; the actual policy remains in .efferent/gates/ and foundry.config.ts.

There is no code generation service hidden behind this. A custom gate is ordinary TypeScript using the workspace’s own typescript dependency. If its check throws, the gate reports a finding rather than passing. A crashing rule is noisy, but it never becomes a hole.

Lock is a one-way transition

:lock refuses to overwrite an existing foundry.config.ts. This is not a convenience command for regenerating project policy on every run. The first lock establishes repository-owned files; later changes are normal code review.

Lock performs the transition in this order:

  1. copy vendored and custom rule modules into .efferent/gates/;
  2. write the final config with typecheck armed when applicable;
  3. write .efferent/rules.md only if AGENTS.md, CLAUDE.md, or an existing rules file does not already outrank it;
  4. run the final gate suite fail-closed;
  5. fingerprint every current error finding into .foundry/baseline.json;
  6. copy the authoring skill and remove the draft.

The final run matters. A draft may have disabled typecheck, and files may have moved since the last proposal. The baseline must describe the exact policy that will govern the forge, not an earlier approximation.

The baseline fingerprints a line, not a count

Arming strict rules on legacy code creates a choice: clean the whole repository before doing useful work, or tolerate old violations forever. Foundry’s ratchet chooses neither.

Each existing error is fingerprinted from rule id, file path, and normalized source-line content. During a forge run, the baseline wrapper removes only findings with matching fingerprints before the verdict:

const kept = baseline.has(fingerprint(finding, sourceLine))
  ? []
  : [finding] 

A pre-existing let on an untouched line stays silent. Add a new one and it fails. Edit the grandfathered line and its content changes, so the old fingerprint no longer matches: touch it, fix it.

This is why a baseline of counts is not enough. “212 violations allowed” would let a coder remove one old violation and spend the freed slot on a new one elsewhere. Identity-based debt cannot be moved around like inventory.

The strictness bias shows up in failure handling too. If Foundry cannot read the source line while recomputing a fingerprint, the finding almost certainly will not match and therefore remains. A broken ratchet fails toward rejection, not accidental forgiveness.

The quality bar reaches the model from the config

Deterministic gates act after implementation, but waiting until rejection wastes an attempt. Smith renders the armed rules into three bounded prompt forms:

  • full — descriptions, fix hints, and dependency boundaries on attempt one and after a context fold;
  • compact — rule ids on ordinary retries, where the conversation still holds the full brief;
  • judge — the standing contract plus an instruction not to re-litigate style, only to notice evasion.
const full = [
  "## Quality bar (ARMED — deterministic gates WILL reject violations)",
  ...rules.map(
    (rule) => `- [${rule.id}] ${rule.description} — fix: ${rule.fixHint}`,
  ),
  ...boundariesDigest(config),
].join("\n")

The config is the single source. There is no handwritten “please avoid let” paragraph to drift from the rule that actually runs. The forward brief helps the coder write cleanly the first time; the gate remains the authority when it does not.

The three forms also acknowledge context economics. Repeating 2,500 characters on every retry is wasteful; omitting the bar after a lossy fold is dangerous. Different lifecycle points get different projections of the same contract.

What this costs

A profile can encode the wrong policy very effectively. The dry run exposes blast radius, not wisdom. Vendored rules become code the project must maintain. Fingerprinting by source line means harmless formatting changes can reawaken old debt. The TypeScript rule engine cannot inspect non-TypeScript projects, which receive command checks and doctrine but no fake promise of AST enforcement.

And the interview is still a model conversation. It may propose fashionable rules the repository never practiced. That is why the prompt requires evidence from real files, why uncertainty must be named, and why only the human locks.

The payoff is not “the agent knows our style.” It is more concrete: the workspace contains an inspectable artifact that teaches the coder forward, rejects violations afterward, survives legacy debt without accepting new debt, and remains owned by the team after the setup agent leaves.

Conversation discovers the contract. A dry run measures it. A human arms it. From then on, the repository speaks for itself.