A Ruby port of GitHub Spec Kit, redesigned as a library / Rails-engine that drives Spec-Driven Development directly through RubyLLM.
spec_kit lets your Rails application generate the same artifacts the original Spec Kit produces — constitution.md, spec.md, plan.md, tasks.md, analysis.md, checklists, and an implementation guide — by calling an LLM through your existing RubyLLM configuration. It is library-first: there is no CLI, no IDE-agent integration, and no shell scripts to wire up. You import the gem, hand it a chat object, and call workflows.
The original Spec Kit is a Python CLI bootstrap tool. It writes slash-command files and templates into your project so an IDE-based AI agent (Copilot, Cursor, Claude, …) can interpret them at runtime. spec_kit flips that model: it runs the workflows itself and just hands you back the markdown.
That makes it a much better fit when you want SDD as a feature of your own application — for example, a Rails admin console where product managers can spec a new feature, generate a plan, and hand a polished tasks file off to engineering.
| Concern | spec_kit's choice |
|---|---|
| Driving the LLM | RubyLLM (dependency-injected — bring your own chat / model / API key) |
| Persistence | File-based, mirrors specs/NNN-feature-name/ from the original. Pluggable for future AR backend |
| Rails integration | Railtie + install generator + rake tasks (no controllers / routes shipped) |
| Templates / prompts | Bundled verbatim from the original Spec Kit, overridable per-project |
| AI-agent integrations | Dropped (RubyLLM is the integration) |
| Workflow engine | Plain Ruby methods (SpecKit::Workflows::Specify#call) instead of a YAML interpreter |
| Extensions / presets | Out of scope for v0.1; the simple Configuration#template_overrides hook covers most needs |
In your Rails application's Gemfile (must already include ruby_llm):
gem "ruby_llm", "~> 1.14"
gem "spec_kit", path: "../spec-kit-ruby/spec_kit_ruby" # or git: ..., or rubygems once publishedThis section wires RubyLLM, spec_kit, a service object that runs the full SDD pipeline, and a minimal API controller. Adjust names, auth, and error handling to match your app.
bundle install
bundle exec rails g spec_kit:installYou should have config/initializers/spec_kit.rb, memory/, and specs/.
git clone <repo> && cd spec-kit-ruby/spec_kit
cd spec_kit_ruby
bin/setup
spec.md
checklists/
requirements.md
plan.md
checklists/
security.md
accessibility.md
requirements.md # from Specify
security.md # from Checklist topic "security"
accessibility.md # from Checklist topic "accessibility"
.specify/
Here’s what changed in the README:
Added: “Complete implementation example (Rails + RubyLLM)”
A single walkthrough that includes:
Install — rails g spec_kit:install
config/initializers/ruby_llm.rb — minimal RubyLLM.configure + link to RubyLLM docs
config/initializers/spec_kit.rb — default_chat as a lambda (fresh chat per call), use_git / branch_numbering
app/services/sdd/pipeline.rb — Sdd::Pipeline#run_full! running Specify → Plan → Tasks → Analyze → Implement plus optional Checklist topics; optional snippet for Constitution
app/controllers/sdd/features_controller.rb — create action + rescuing Sdd::Pipeline::Error and SpecKit::Error
config/routes.rb — namespace :sdd + resources :features, only: [:create]
curl — JSON example with description, tech_stack, checklist_topics
On-disk layout — one tree listing under memory/, specs/…, .specify/ (single checklists/ subtree, no duplication)
Sdd::RunPipelineJob — optional ApplicationJob example
Other fixes
Gemfile path updated to spec_kit_ruby (matches this repo).
Development section now uses cd spec_kit_ruby.
The new section is inserted after Configuration and before Usage, so install → config → full example → rake/snippets.
If you want, we can add a second example that uses only write_to_disk: false and returns markdown in the JSON body (preview mode).This section wires RubyLLM, spec_kit, a service object that runs the full SDD pipeline, and a minimal API controller. Adjust names, auth, and error handling to match your app.
bundle install
bundle exec rails g spec_kit:installYou should have config/initializers/spec_kit.rb, memory/, and specs/.
git clone <repo> && cd spec-kit-ruby/spec_kit
cd spec_kit_ruby
bin/setup
spec.md
checklists/
requirements.md
plan.md
checklists/
security.md
accessibility.md
requirements.md # from Specify
security.md # from Checklist topic "security"
accessibility.md # from Checklist topic "accessibility"
.specify/
Here’s what changed in the README:
Added: “Complete implementation example (Rails + RubyLLM)”
A single walkthrough that includes:
Install — rails g spec_kit:install
config/initializers/ruby_llm.rb — minimal RubyLLM.configure + link to RubyLLM docs
config/initializers/spec_kit.rb — default_chat as a lambda (fresh chat per call), use_git / branch_numbering
app/services/sdd/pipeline.rb — Sdd::Pipeline#run_full! running Specify → Plan → Tasks → Analyze → Implement plus optional Checklist topics; optional snippet for Constitution
app/controllers/sdd/features_controller.rb — create action + rescuing Sdd::Pipeline::Error and SpecKit::Error
config/routes.rb — namespace :sdd + resources :features, only: [:create]
curl — JSON example with description, tech_stack, checklist_topics
On-disk layout — one tree listing under memory/, specs/…, .specify/ (single checklists/ subtree, no duplication)
Sdd::RunPipelineJob — optional ApplicationJob example
Other fixes
Gemfile path updated to spec_kit_ruby (matches this repo).
Development section now uses cd spec_kit_ruby.
The new section is inserted after Configuration and before Usage, so install → config → full example → rake/snippets.
If you want, we can add a second example that uses only write_to_disk: false and returns markdown in the JSON body (preview mode).Then run:
bundle install
bundle exec rails g spec_kit:installThe generator creates:
config/initializers/spec_kit.rb— wire-up for your RubyLLM defaultsmemory/.keep— whereconstitution.mdwill livespecs/.keep— where feature directories will live
Open config/initializers/spec_kit.rb:
SpecKit.configure do |config|
# spec_kit auto-detects Rails.root via the railtie. Override only if needed.
# config.project_root = Rails.root.join("workspace")
# `:sequential` (001-name) or `:timestamp` (YYYYMMDD-HHMMSS-name)
config.branch_numbering = :sequential
# Optional: try to create + check out a git branch named after each feature.
config.use_git = true
# IMPORTANT: the chat factory. Returning a fresh chat per invocation is
# recommended because RubyLLM chats accumulate context across `#ask` calls.
config.default_chat = -> { RubyLLM.chat(model: ENV.fetch("OPENAI_MODEL", "gpt-4o")) }
endspec_kit does not configure RubyLLM itself. Set up RubyLLM.configure in its own initializer (with your API keys, custom adapters, observability, etc.) — spec_kit just calls into whatever you already have.
This section wires RubyLLM, spec_kit, a service object that runs the full SDD pipeline, and a minimal API controller. Adjust names, auth, and error handling to match your app.
bundle install
bundle exec rails g spec_kit:installYou should have config/initializers/spec_kit.rb, memory/, and specs/.
Create config/initializers/ruby_llm.rb. The exact options depend on your provider; see the RubyLLM configuration docs.
# frozen_string_literal: true
# Minimal example — set keys for your provider (OpenAI, Anthropic, etc.).
RubyLLM.configure do |config|
config.openai_api_key = ENV.fetch("OPENAI_API_KEY")
# config.anthropic_api_key = ENV.fetch("ANTHROPIC_API_KEY") # if you use Claude
endEdit config/initializers/spec_kit.rb (created by the generator) so every workflow gets a fresh chat. RubyLLM chats accumulate context across #ask calls, so do not reuse one chat across workflows unless you intend that.
# frozen_string_literal: true
SpecKit.configure do |config|
config.branch_numbering = :sequential
config.use_git = false # set true if you want a git branch per feature
config.default_chat = lambda do
RubyLLM.chat(model: ENV.fetch("OPENAI_MODEL", "gpt-4o"))
end
endWith this in place you can call SpecKit::Workflows.run(:specify, arguments: "...") without passing chat: every time. You can still pass chat: to override per request.
Create app/services/sdd/pipeline.rb:
# frozen_string_literal: true
module Sdd
# Runs Spec-Driven Development workflows in order and returns paths to the
# artifacts under `specs/<feature-dir>/`. Uses `SpecKit.configuration.default_chat`
# unless you inject `chat:` per call.
class Pipeline
Error = Class.new(StandardError)
def initialize(project: SpecKit::Project.new, chat: nil)
@project = project
@chat = chat
end
# @param description [String] natural-language feature idea (required)
# @param tech_stack [String, nil] notes for Plan (framework, DB, etc.)
# @param topics [Array<String>] extra checklist topics, e.g. %w[security accessibility]
# @return [Hash] :feature (directory name) and :paths (symbol => string path)
def run_full!(description:, tech_stack: nil, topics: [])
raise Error, "description is required" if description.to_s.strip.empty?
feature = SpecKit::Workflows.run(
:specify,
project: @project,
chat: @chat,
arguments: description
).feature
SpecKit::Workflows.run(:plan, project: @project, chat: @chat, feature: feature, tech_stack: tech_stack)
SpecKit::Workflows.run(:tasks, project: @project, chat: @chat, feature: feature)
SpecKit::Workflows.run(:analyze, project: @project, chat: @chat, feature: feature)
SpecKit::Workflows.run(:implement, project: @project, chat: @chat, feature: feature)
topics.each do |topic|
SpecKit::Workflows.run(:checklist, project: @project, chat: @chat, feature: feature, topic: topic)
end
{
feature: feature.directory_name,
paths: {
spec: feature.spec_path.to_s,
plan: feature.plan_path.to_s,
tasks: feature.tasks_path.to_s,
analysis: feature.analysis_path.to_s,
implementation: feature.directory.join("implementation.md").to_s
}
}
end
end
endOptional one-shot constitution (run once per repo or when principles change):
SpecKit::Workflows.run(
:constitution,
arguments: "Test-first, small PRs, observability on errors, prefer boring Rails patterns."
)Create app/controllers/sdd/features_controller.rb:
# frozen_string_literal: true
module Sdd
class FeaturesController < ApplicationController
# Skip or replace with your authentication.
skip_before_action :verify_authenticity_token, if: -> { request.format.json? }
def create
result = Sdd::Pipeline.new.run_full!(
description: params.require(:description),
tech_stack: params[:tech_stack],
topics: Array(params[:checklist_topics]).presence || []
)
render json: result, status: :created
rescue Sdd::Pipeline::Error => e
render json: { error: e.message }, status: :unprocessable_entity
rescue SpecKit::Error => e
render json: { error: e.message }, status: :unprocessable_entity
end
end
endAdd routes in config/routes.rb:
namespace :sdd do
resources :features, only: [:create]
endexport OPENAI_API_KEY=...
export OPENAI_MODEL=gpt-4o
curl -sS -X POST http://localhost:3000/sdd/features \
-H "Content-Type: application/json" \
-d '{
"description": "Users can organize photos into albums and reorder them by drag-and-drop on one page.",
"tech_stack": "Ruby on Rails 7.1, PostgreSQL, Hotwire, Active Storage",
"checklist_topics": ["security", "accessibility"]
}'After a successful run you will have something like:
memory/
constitution.md # if you ran the :constitution workflow
specs/
001-photo-albums/ # exact name depends on description & numbering
spec.md
plan.md
research.md
data-model.md
quickstart.md
tasks.md
analysis.md
implementation.md
checklists/
requirements.md # from Specify
security.md # from Checklist topic "security"
accessibility.md # from Checklist topic "accessibility"
.specify/
feature.json # “current” feature pointer for rake tasks
# frozen_string_literal: true
class Sdd::RunPipelineJob < ApplicationJob
queue_as :default
def perform(description, tech_stack = nil, checklist_topics = [])
Sdd::Pipeline.new.run_full!(
description: description,
tech_stack: tech_stack,
topics: Array(checklist_topics)
)
# Notify user (email, Slack, Action Cable) with paths or attach to a record.
end
end
# Sdd::RunPipelineJob.perform_later("Build invoicing…", "Rails 7, Stripe", %w[security])This example is intentionally focused on wiring. Add authorization, idempotency, storing feature IDs in Active Record, and streaming responses if users should watch progress.
# Generate / update the project constitution
bundle exec rake "spec_kit:constitution[Code quality, Test-first, Rails idioms]"
# Spec a new feature
bundle exec rake "spec_kit:specify[Build a photo album organizer with drag-and-drop reordering]"
# Plan + research/data-model/quickstart for the most-recent feature
bundle exec rake "spec_kit:plan[Ruby on Rails 7, PostgreSQL, Stimulus]"
# Break the plan into tasks
bundle exec rake spec_kit:tasks
# Cross-check spec/plan/tasks for gaps
bundle exec rake spec_kit:analyze
# Add a domain-specific checklist
bundle exec rake "spec_kit:checklist[security]"
# Generate the implementation guide
bundle exec rake spec_kit:implementAfter spec_kit:specify, .specify/feature.json records the most-recently created feature so the subsequent plan/tasks/analyze/implement tasks operate on it without you naming it. Pass an explicit feature name as the second argument when you need to target an older one:
bundle exec rake "spec_kit:plan[Ruby + Rails,001-photo-albums]"The whole API surface is Ruby — call workflows wherever it makes sense in your app:
class FeaturesController < ApplicationController
def create
project = SpecKit::Project.new
chat = RubyLLM.chat(model: "gpt-4o")
result = SpecKit::Workflows::Specify.new(project: project, chat: chat)
.call(arguments: params[:description])
render json: {
feature: result.feature.directory_name,
spec: result.path_for(:spec).to_s,
content: result.content_for(:spec)
}
end
endEach workflow returns a SpecKit::Workflows::Result:
result.feature # SpecKit::Feature (or nil for Constitution)
result.path_for(:spec) # Pathname to the written file
result.content_for(:spec) # The raw markdown
result.metadata[:verdict] # Workflow-specific extras (analyze, checklist, …)class GenerateFeaturePlanJob < ApplicationJob
queue_as :default
def perform(feature_directory)
feature = SpecKit::Project.new.feature(feature_directory)
SpecKit::Workflows.run(:plan,
feature: feature,
tech_stack: "Ruby on Rails 7 + PostgreSQL",
chat: RubyLLM.chat(model: "gpt-4o-mini"))
end
endSet config.write_to_disk = false (or pass an LLM-only chat) to generate artifacts without touching the filesystem — handy for "preview before save" UX:
SpecKit.configuration.write_to_disk = false
result = SpecKit::Workflows::Specify.new(project: project, chat: chat)
.call(arguments: "...")
preview = result.content_for(:spec)| Workflow | Inputs | Outputs |
|---|---|---|
SpecKit::Workflows::Constitution |
a directive string | memory/constitution.md |
SpecKit::Workflows::Specify |
a feature description | specs/NNN-name/spec.md, …/checklists/requirements.md |
SpecKit::Workflows::Clarify |
a feature | structured questions; later: revised spec.md |
SpecKit::Workflows::Plan |
a feature, optional tech-stack notes | plan.md, research.md, data-model.md, quickstart.md |
SpecKit::Workflows::Tasks |
a feature with a plan | tasks.md |
SpecKit::Workflows::Analyze |
a feature with spec+plan+tasks | analysis.md plus a :verdict metadata key |
SpecKit::Workflows::Checklist |
a feature, a topic | checklists/<topic>.md |
SpecKit::Workflows::Implement |
a feature with tasks | implementation.md |
spec_kit ships verbatim copies of the original Spec Kit's template and prompt files under data/spec_kit/:
- Templates (skeletons populated by the LLM):
spec-template.md,plan-template.md,tasks-template.md,constitution-template.md,checklist-template.md. - Prompts (system instructions for each workflow):
specify.md,plan.md,tasks.md,clarify.md,analyze.md,checklist.md,implement.md,constitution.md.
To override any of them per-project, point Configuration#template_overrides (or #prompt_overrides) at your local copy:
SpecKit.configure do |config|
config.template_overrides = {
spec: Rails.root.join("config/spec_kit/spec-template.md").to_s
}
config.prompt_overrides = {
plan: Rails.root.join("config/spec_kit/plan.md").to_s
}
endSpecKit::Llm adapts to any RubyLLM-shaped object. The duck-typed contract is:
#with_instructions(text)returningself, then#ask(prompt)returning a response (RubyLLM ≥ 1.x).#ask(prompt, system: text)for custom adapters.#call(prompt:, system:)for proc-shaped fakes (used internally by tests).
The response can be a String, anything with #content, or a Hash with :content. This means:
- You can swap RubyLLM for a stubbed chat in tests (see
spec/support/fake_chat.rb). - You can wrap RubyLLM in observability / retry / cost-tracking middleware and still pass the wrapped object straight to spec_kit.
A specs/NNN-feature-name/ directory produced by spec_kit is byte-compatible with the layout the original Spec Kit produces — same filenames, same headings, same template structure. You can use both tools on the same repo without conflict.
The reverse is also true: if a teammate ran specify init first and produced an existing specs/ tree, spec_kit will pick it up: Project#features will list the existing directories, and Project#current_feature will respect a pre-existing .specify/feature.json.
cd spec_kit_ruby
bin/setup
bundle exec rspec
bundle exec ruby -Ilib bin/consoleMIT. Portions of the bundled templates and prompt content are © GitHub, Inc. and used under the terms of the original Spec Kit MIT license. See LICENSE.