{"slug": "enforcing-your-ruby-style-guide-on-ai-generated-code", "title": "Enforcing Your Ruby Style Guide on AI-Generated Code", "summary": "Thoughtbot engineers built a Claude Code hook that automatically runs RuboCop against any Ruby files an AI coding agent modifies, then gives the agent a chance to fix violations it can catch. The hook enforces team coding conventions automatically rather than relying on the agent to remember them, as part of a broader practice called harness engineering that uses tools and guardrails to improve AI output quality. The approach adds deterministic feedback to the non-deterministic nature of AI-generated code, catching mistakes that rules alone cannot prevent.", "body_md": "As AI-assisted software development becomes more widely adopted, more of the Ruby code in our Rails apps is being written by agents. Each team has its own conventions for how that code should look and behave, and we want those conventions enforced automatically rather than relying on the agent to remember them on its own. This is part of a broader practice called harness engineering, using tools, guardrails, validators, and persistence to increase the probability that our agents produce the outcomes we want. A capable model is only part of the equation. The rest is everything we put around it, including the context it operates within, the rules it follows, and the checks that catch its mistakes.\n\nThe concept of harness engineering in software development is still in its early stages and there aren’t many resources on how to implement an agent harness within the context of Rails applications. At thoughtbot, we’re experimenting with how to encode how we work into various tools and contexts in order to increase the quality of the AI output. This post walks through one specific piece of the harness we’ve been building. It’s a Claude Code hook that runs RuboCop against any Ruby files the agent touches, gives the agent a chance to fix what it can, and surfaces what it can’t.\n\n##\n[\nRules as the First Layer\n](#rules-as-the-first-layer)\n\nWe recently released a [set of Claude Code rules](https://github.com/thoughtbot/guides/tree/main/rails/ai-rules)\ndesigned to be dropped into a project’s `.claude/`\n\ndirectory so that coding agents can follow thoughtbot’s Rails\nconventions when writing code. It aims to ensure that when coding agents generate or modify code in a Rails\nproject, that they adhere to conventions like TDD, RESTful routes, and strong params. You can use this as a\nstarting point to add information specific to your project and the coding agent will use and update it when doing\nwork. Think of it as a living memory for your coding agent, keeping track of architectural decisions, edge cases,\nand team conventions.\n\nThe rules and context in these files are the\n[feedforward](https://martinfowler.com/articles/harness-engineering.html#FeedforwardAndFeedback)/[inferential](https://martinfowler.com/articles/harness-engineering.html#ComputationalVsInferential)\naspect of our user harness. They guide the agent before and during work so that it increases the odds of getting\nthe job right the first time. A linter can flag a 250-line controller action that’s doing too much but it can’t\ntell you which of those lines belong in the model. That’s where the agent can really add value, and where a good\nset of rules makes the difference.\n\nBut rules alone aren’t enough. A good set of rules and a detailed yet concise `CLAUDE.md`\n\nfile can greatly increase\nthe quality of the agent’s code, but because results are non-deterministic, it isn’t guaranteed that the agent\nwon’t make mistakes. This is where adding a\n[feedback](https://martinfowler.com/articles/harness-engineering.html#FeedforwardAndFeedback)/[computational](https://martinfowler.com/articles/harness-engineering.html#ComputationalVsInferential)\naspect to our user harness can empower agents to fix their own mistakes and produce the results we want with less\nand less hand-holding. The rest of this post focuses on one specific feedback loop, using a Claude Code hook to run\nRuboCop on the Ruby files the agent has touched, and giving it a chance to fix any violations.\n\n##\n[\nClaude Code Hooks for Deterministic Behavior\n](#claude-code-hooks-for-deterministic-behavior)\n\nThis aspect of the user harness gives us deterministic control over the output of the code by using\n[hooks](https://code.claude.com/docs/en/hooks-guide). Hooks are custom shell commands, LLM prompts, or HTTP\nendpoints we define that can run when certain events happen in Claude Code’s lifecycle. This way, we can enforce\ncertain actions always run rather than hoping the agent decides to do them.\n\nYour custom hooks and Claude Code communicate with each other via `stdin`\n\n, `stdout`\n\n, `stderr`\n\n, and exit codes. When\nyour custom hook is executed, Claude Code passes event-specific data as JSON to your script’s `stdin`\n\n. Then your\nscript tells Claude Code what to do next by either writing to `stdout`\n\nor `stderr`\n\nwith a specific exit code. These\nscripts can run linters or prevent the agent from taking destructive actions, for example. An exit code of `0`\n\ntells Claude Code to proceed with whatever action it was performing. For many events your script hooks into, an\nexit code of `2`\n\n(with a `stderr`\n\nmessage) is used by Claude Code as feedback. Claude Code will use this\ninformation to block whatever event triggered it and take corrective action.\n\n##\n[\nEnforcing Ruby Style Guide Adherence\n](#enforcing-ruby-style-guide-adherence)\n\nLets look at an example with Rubocop. You may already have a pre-commit hook that runs rubocop with the\n`--autocorect`\n\nflag to fix things that are considered safe to auto-fix like style linting rules. Having this in a\npre-commit hook that’s shared across your team, ensures you have a last line of defense when shipping code.\nDepending on the plugins you use though, there may be errors that surface which require judgement and reasoning in\norder to fix. These are fixes you make manually and that sometimes require knowledge of the architecture and other\nparts of the codebase. Injecting Rubocop into an agent’s lifecycle in the form of a hook (in addition to a\npre-commit hook) can increase the trustworthiness of the agent’s output. Violations come back to the agent\nimmediately while the change is in working memory and the agent can fix them in the same turn. These include fixes\nof the more complicated errors that require knowledge of other parts of the codebase. Here’s a simplified setup to\nget this up and running on your project.\n\nIn `.claude/hooks/rubocop-gate.sh`\n\n, we’ll add a script that runs Rubocop and instructs the agent on how to fix\nerrors that may require some reasoning.\n\n``` bash\n#!/bin/bash\nset -uo pipefail\n\nINPUT=$(cat)\ncd \"$CLAUDE_PROJECT_DIR\"\n\n# Find Ruby files Claude added, modified, or newly created (not yet tracked).\nruby_files() {\n  {\n    git diff --name-only --diff-filter=AM HEAD -- '*.rb' '*.rake' 'Gemfile' 'Rakefile';\n    git ls-files --others --exclude-standard -- '*.rb' '*.rake';\n  } | sort -u\n}\n\nRUBY_FILES=$(ruby_files)\n\nif [ -z \"$RUBY_FILES\" ]; then\n  exit 0\nfi\n\n# Second stop attempt: Claude already got one chance to fix violations.\n# Surface anything still broken, then let it stop.\nif [ \"$(echo \"$INPUT\" | jq -r '.stop_hook_active')\" = \"true\" ]; then\n  REMAINING=$(bundle exec rubocop --force-exclusion $RUBY_FILES 2>&1)\n  if [ $? -ne 0 ]; then\n    echo \"RuboCop violations remain after one retry. Surfacing for review:\" >&2\n    echo \"$REMAINING\" >&2\n  fi\n  exit 0\nfi\n\nOUTPUT=$(bundle exec rubocop --force-exclusion --autocorrect $RUBY_FILES 2>&1)\nSTATUS=$?\n\nif [ $STATUS -ne 0 ]; then\n  cat >&2 <<EOF\nRuboCop found violations that could not be auto-corrected. Fix them before completing the task.\n\nSee .claude/rules/rubocop.md for guidance on how to handle different violation types\n(especially Rails, ThreadSafety, and judgment-call cops).\n\nViolations:\n$OUTPUT\nEOF\n  exit 2\nfi\n\nexit 0\n```\n\nThe hook runs RuboCop against just the Ruby files in the diff, blocks the agent’s stop event if violations can’t\nbe auto-corrected, and gives the agent exactly one chance to fix them before stopping work. The `stop_hook_active`\n\nfield in Claude Code’s JSON payload tells us whether this is Claude’s first attempt to stop work or a retry.\nIt’s false on Claude’s first stop attempt and true when Claude is retrying after we blocked once. The first time\nwe run the script, rubocop runs with `--autocorrect`\n\nand exits 2 if any violations remain. Then, the agent feeds that\noutput to Claude as the next instruction along with a pointer to `.claude/rules/rubocop.md`\n\nfor guidance on cops\nthat require a judgement call. If it can’t fix all the violations, the second rubocop execution skips autocorrect\n(we’re only reporting at this point, not changing files), prints any leftover violations to stderr for you to\naddress, and exits 0 so the agent can stop. Remember to `chmod +x`\n\nthis file.\n\nHere’s an example `.claude/rules/rubocop.md`\n\nfile. It provides guidance to the agent on how to fix errors that\nrequire some reasoning. It’s based on the cops we use at thoughtbot. These instructions will vary depending on\nwhich Rubocop plugins you use and your team’s preferences but it provides a good starting point.\n\n```\n## RuboCop conventions\n\nSome cops require judgment that autocorrect can't apply. When RuboCop\nsurfaces one of them, the rules below help decide how to respond.\n\nDon't reach for inline `# rubocop:disable` or `# rubocop:todo` to make\nviolations go away. If a cop genuinely doesn't fit this codebase, surface it in your final response.\n\n### Rails/OutputSafety\nNever silence `Rails/OutputSafety` — `html_safe` and `raw` are XSS vectors.\nIf you think a specific use is safe, surface it and let the user decide.\n\n### ThreadSafety\n\nNever silence ThreadSafety violations. These cops catch real concurrency\nbugs and the right fix usually depends on architectural context.\n\n1. Describe what the cop caught.\n2. List the possible fixes — typically `RequestStore`/` Current`, instance\n   state, a frozen constant, a mutex, or accepting the violation if the app\n   runs single-threaded.\n3. Wait for direction.\n\n### Surface, don't refactor\n\nWhen the obvious fix would change behavior or hurt readability:\n\n- `Rails/SkipsModelValidations` — `update_columns` / `update_all` /\n  `update_counters` skip callbacks intentionally for counter caches, audit\n  fields, or bulk operations. Don't quietly refactor to `update` — that\n  changes behavior. Surface with reasoning.\n- `Rails/HasManyOrHasOneDependent` — usually a real bug, but occasionally\n  the association is intentionally orphan-tolerant. Surface rather than\n  picking a `dependent:` value.\n- `RSpec/MultipleExpectations`, `RSpec/NestedGroups` — restructuring often\n  hurts readability. If the test reads better as-is, surface and say so.\n  Readability beats the cop.\n- `RSpec/AnyInstance` — usually a real smell but sometimes legitimately\n  needed in legacy code.\n```\n\nLastly, we need to add config to the `.claude/settings.json`\n\nfile in order to register the `Stop`\n\nhook.\n\n```\n{\n  // ....\n  \"hooks\": {\n    \"Stop\": [\n      {\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"${CLAUDE_PROJECT_DIR}/.claude/hooks/rubocop-gate.sh\",\n            \"timeout\": 120\n          }\n        ]\n      }\n    ]\n  }\n}\n```\n\nNow, when your agent completes some work that involves adding or modifying Ruby files, it’ll automatically run\nRubocop and attempt to fix any violations that weren’t caught by `--autocorrect`\n\n.\n\n##\n[\nOne step further\n](#one-step-further)\n\nIn addition to giving the agent guidance on how to fix certain violations, you may have noticed that the\n`.claude/rules/rubocop.md`\n\nfile also provides instructions on which cops should never be silenced. Cops such as\n`ThreadSafety`\n\nor `Lint/Debugger`\n\ncops. These are cops that if silenced could cause bugs to be shipped to\nproduction. While keeping this as an enforcement rule helps the agent do the right thing the first time around,\nwe can take this one step further by taking a more deterministic approach. We can explicitly prevent the agent\nfrom silencing certain cops by configuring a `.rubocop_strict.yml`\n\nfile. This will disable the silencing of cops\nthat may be silenced on a per file bases in the `.rubocop_todo.yml`\n\nconfig.\n\n```\n# .rubocop_strict.yml\n\nLint/Debugger: # i.e. binding.irb or debugger statements\n  Enabled: true\n  Exclude: []\n\nThreadSafety/ClassAndModuleAttributes:\n  Enabled: true\n  Exclude: []\n\nThreadSafety/ClassInstanceVariable:\n  Enabled: true\n  Exclude: []\n\n# ...other cops you don't want disabled\n# .rubocop.yml\n\nrequire:\n  - rubocop-thread_safety\n\ninherit_from:\n    # .rubocop_strict.yml must go last to override potential excludes in other files\n  - .rubocop_todo.yml\n  - .rubocop_strict.yml\n\nAllCops:\n  NewCops: enable\n  TargetRubyVersion: 3.2  # adjust to your project\n```\n\nFor extra confidence that our agent won’t silence certain cops by slapping on a `rubocop:disable`\n\nor\n`rubocop:todo`\n\ndirective, we can also create our own custom cop that deterministically prevents this from\nhappening. Consider our `ThreadSafety`\n\ncop example from before.\n\n```\n# lib/rubocop/cops/thread_safety/no_inline_disable.rb\n\n# frozen_string_literal: true\n\nmodule RuboCop\n  module Cop\n    module ThreadSafety\n      # Forbids inline directives that disable ThreadSafety cops.\n      #\n      class NoInlineDisable < RuboCop::Cop::Base\n        MSG = \"ThreadSafety cops cannot be disabled inline. \" \\\n              \"See .claude/rules/rubocop.md for guidance.\"\n\n        DIRECTIVE_REGEX = /#\\s*rubocop:(?:disable|todo)\\s+([^\\n]+)/\n\n        def on_new_investigation\n          processed_source.comments.each do |comment|\n            match = comment.text.match(DIRECTIVE_REGEX)\n            next unless match\n\n            cops = match[1].split(/\\s*,\\s*/).map(&:strip)\n            next unless cops.any? { |c| c.start_with?(\"ThreadSafety/\") }\n\n            add_offense(comment.source_range)\n          end\n        end\n      end\n    end\n  end\nend\n# .rubocop_strict.yml\n\n# ... previous config\n\nThreadSafety/NoInlineDisable:\n  Enabled: true\n  Exclude: []\n  Include:\n    - '**/*.rb'\n    - '**/*.rake'\n    - '**/Rakefile'\n    - '**/Gemfile'\n# .rubocop.yml\n\nrequire:\n  - rubocop-thread_safety\n  - ./lib/rubocop/cops/thread_safety_extensions\n\ninherit_from:\n    # .rubocop_strict.yml must go last to override potential excludes in other files\n  - .rubocop_todo.yml\n  - .rubocop_strict.yml\n\nAllCops:\n  NewCops: enable\n  TargetRubyVersion: 3.2  # adjust to your project\n```\n\nThe more enforcement we can push into the toolchain itself, the more confident we can be the agent won’t accidently introduce bugs. Not every cop needs this treatment. Reserve it for the ones where silencing would ship a bug to production: thread safety, debuggers left in code, output safety, anything that touches concurrency or security for example.\n\n##\n[\nOne piece of the harness\n](#one-piece-of-the-harness)\n\nThe RuboCop example here is one specific feedback loop, but the same pattern works for any tool that gives you a clear pass/fail signal on the agent’s output. Wire it into a Stop hook, give the agent a chance to fix what comes back, and surface what it can’t. Hooks themselves are just one tool in the broader practice of harness engineering. We’re still in the early days of figuring out what a good Rails agent harness looks like, and a lot of what we’ve shared here will probably look different in six months as we keep iterating. The harness that works best for your team will come from paying attention to where your agent actually struggles on your codebase, and encoding those fixes back into rules, context, subagents, and hooks of your own.", "url": "https://wpnews.pro/news/enforcing-your-ruby-style-guide-on-ai-generated-code", "canonical_source": "https://feed.thoughtbot.com/link/24077/17355952/enforcing-your-ruby-style-guide-on-ai-generated-code", "published_at": "2026-06-08 00:00:00+00:00", "updated_at": "2026-06-11 18:37:19.467735+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "ai-products", "generative-ai", "large-language-models"], "entities": ["thoughtbot", "Claude Code", "RuboCop", "Rails"], "alternates": {"html": "https://wpnews.pro/news/enforcing-your-ruby-style-guide-on-ai-generated-code", "markdown": "https://wpnews.pro/news/enforcing-your-ruby-style-guide-on-ai-generated-code.md", "text": "https://wpnews.pro/news/enforcing-your-ruby-style-guide-on-ai-generated-code.txt", "jsonld": "https://wpnews.pro/news/enforcing-your-ruby-style-guide-on-ai-generated-code.jsonld"}}