feature_machine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +16 -0
  3. data/LICENSE +21 -0
  4. data/README.md +207 -0
  5. data/app/assets/feature_machine/default.css +62 -0
  6. data/app/controllers/api/feature_machine/base_controller.rb +109 -0
  7. data/app/controllers/api/feature_machine/bug_reports_controller.rb +32 -0
  8. data/app/controllers/api/feature_machine/feature_requests_controller.rb +31 -0
  9. data/app/controllers/api/feature_machine/maintainer/feature_requests_controller.rb +28 -0
  10. data/app/controllers/api/feature_machine/roadmaps_controller.rb +9 -0
  11. data/app/controllers/api/feature_machine/work_items_controller.rb +56 -0
  12. data/app/controllers/concerns/feature_machine/current_user.rb +28 -0
  13. data/app/controllers/concerns/feature_machine/engine_layout.rb +12 -0
  14. data/app/controllers/feature_machine/admin/attachments_controller.rb +17 -0
  15. data/app/controllers/feature_machine/admin/base_controller.rb +16 -0
  16. data/app/controllers/feature_machine/admin/feature_requests_controller.rb +24 -0
  17. data/app/controllers/feature_machine/admin/triage_controller.rb +9 -0
  18. data/app/controllers/feature_machine/admin/work_item_relations_controller.rb +24 -0
  19. data/app/controllers/feature_machine/admin/work_items_controller.rb +88 -0
  20. data/app/controllers/feature_machine/base_controller.rb +10 -0
  21. data/app/controllers/feature_machine/bug_reports_controller.rb +29 -0
  22. data/app/controllers/feature_machine/feature_requests_controller.rb +27 -0
  23. data/app/controllers/feature_machine/mcp_controller.rb +36 -0
  24. data/app/controllers/feature_machine/roadmap_controller.rb +9 -0
  25. data/app/helpers/feature_machine/feature_requests_helper.rb +90 -0
  26. data/app/helpers/feature_machine/work_items_helper.rb +99 -0
  27. data/app/jobs/feature_machine/application_job.rb +6 -0
  28. data/app/jobs/feature_machine/request_embedding_job.rb +12 -0
  29. data/app/jobs/feature_machine/work_item_embedding_job.rb +12 -0
  30. data/app/models/feature_machine/request.rb +111 -0
  31. data/app/models/feature_machine/roadmap.rb +24 -0
  32. data/app/models/feature_machine/work_item.rb +203 -0
  33. data/app/models/feature_machine/work_item_relation.rb +60 -0
  34. data/app/prompts/feature_machine/drafter/_product_blurb.txt.erb +1 -0
  35. data/app/prompts/feature_machine/drafter/instructions.txt.erb +7 -0
  36. data/app/services/feature_machine/base_embedding_service.rb +52 -0
  37. data/app/services/feature_machine/drafter.rb +43 -0
  38. data/app/services/feature_machine/judge.rb +53 -0
  39. data/app/services/feature_machine/request_embedding_service.rb +14 -0
  40. data/app/services/feature_machine/triage_buckets.rb +146 -0
  41. data/app/services/feature_machine/work_item_drafter.rb +91 -0
  42. data/app/services/feature_machine/work_item_embedding_service.rb +14 -0
  43. data/app/services/feature_machine/work_item_spec_schema.rb +14 -0
  44. data/app/tools/feature_machine/mcp/base.rb +94 -0
  45. data/app/tools/feature_machine/mcp/get_work_item.rb +20 -0
  46. data/app/tools/feature_machine/mcp/list_bug_reports.rb +20 -0
  47. data/app/tools/feature_machine/mcp/list_feature_requests.rb +29 -0
  48. data/app/tools/feature_machine/mcp/list_work_items.rb +30 -0
  49. data/app/tools/feature_machine/mcp/read_roadmap.rb +22 -0
  50. data/app/tools/feature_machine/mcp/report_bug.rb +26 -0
  51. data/app/tools/feature_machine/mcp/request_feature.rb +27 -0
  52. data/app/tools/feature_machine/mcp/update_work_item.rb +33 -0
  53. data/app/tools/feature_machine/mcp.rb +25 -0
  54. data/app/views/api/feature_machine/feature_requests/index.json.jbuilder +2 -0
  55. data/app/views/api/feature_machine/feature_requests/show.json.jbuilder +1 -0
  56. data/app/views/api/feature_machine/maintainer/feature_requests/index.json.jbuilder +1 -0
  57. data/app/views/api/feature_machine/roadmaps/_work_item.json.jbuilder +12 -0
  58. data/app/views/api/feature_machine/roadmaps/show.json.jbuilder +10 -0
  59. data/app/views/api/feature_machine/work_items/_work_item.json.jbuilder +32 -0
  60. data/app/views/api/feature_machine/work_items/index.json.jbuilder +1 -0
  61. data/app/views/api/feature_machine/work_items/show.json.jbuilder +3 -0
  62. data/app/views/feature_machine/_default_styles.html.erb +5 -0
  63. data/app/views/feature_machine/_flash.html.erb +10 -0
  64. data/app/views/feature_machine/admin/triage/show.html.erb +120 -0
  65. data/app/views/feature_machine/admin/work_items/_form.html.erb +78 -0
  66. data/app/views/feature_machine/admin/work_items/edit.html.erb +40 -0
  67. data/app/views/feature_machine/admin/work_items/index.html.erb +80 -0
  68. data/app/views/feature_machine/admin/work_items/new.html.erb +21 -0
  69. data/app/views/feature_machine/admin/work_items/show.html.erb +168 -0
  70. data/app/views/feature_machine/admin/work_items/show.md.erb +31 -0
  71. data/app/views/feature_machine/bug_reports/index.html.erb +38 -0
  72. data/app/views/feature_machine/bug_reports/new.html.erb +83 -0
  73. data/app/views/feature_machine/feature_requests/_feature_request.html.erb +47 -0
  74. data/app/views/feature_machine/feature_requests/_feature_request.json.jbuilder +15 -0
  75. data/app/views/feature_machine/feature_requests/index.html.erb +44 -0
  76. data/app/views/feature_machine/feature_requests/new.html.erb +84 -0
  77. data/app/views/feature_machine/roadmap/_work_item.html.erb +34 -0
  78. data/app/views/feature_machine/roadmap/show.html.erb +70 -0
  79. data/app/views/layouts/feature_machine.html.erb +21 -0
  80. data/db/migrate/20260910180000_create_feature_requests.rb +18 -0
  81. data/db/migrate/20261004120000_create_work_items.rb +52 -0
  82. data/db/migrate/20261006120000_add_pull_request_url_to_work_items.rb +7 -0
  83. data/lib/feature_machine/auth.rb +89 -0
  84. data/lib/feature_machine/calibration/judge_cases.yml +137 -0
  85. data/lib/feature_machine/configuration.rb +165 -0
  86. data/lib/feature_machine/engine.rb +34 -0
  87. data/lib/feature_machine/judge_calibration.rb +85 -0
  88. data/lib/feature_machine/routes.rb +61 -0
  89. data/lib/feature_machine/version.rb +3 -0
  90. data/lib/feature_machine.rb +57 -0
  91. data/lib/generators/feature_machine/install/install_generator.rb +76 -0
  92. data/lib/generators/feature_machine/install/templates/initializer.rb +99 -0
  93. data/lib/tasks/feature_machine_calibration.rake +17 -0
  94. data/lib/tasks/feature_machine_embeddings.rake +10 -0
  95. metadata +207 -0
@@ -0,0 +1,165 @@
1
+ module FeatureMachine
2
+ # Everything the engine needs from its host, as plain values and callables.
3
+ # Set it once in config/initializers/feature_machine.rb:
4
+ #
5
+ # FeatureMachine.configure do |config|
6
+ # config.current_user = ->(request) { ... } # the host's user, or nil
7
+ # config.admin = ->(user) { user.admin? } # may this user triage?
8
+ # config.authenticate_api = ->(token) { ... } # bearer token -> credential, or nil
9
+ # config.product_name = "My App"
10
+ # config.token_help_url = "https://my.app/token"
11
+ # end
12
+ #
13
+ # A credential is any object answering `user` and `allows?(scope)` for the
14
+ # scopes in FeatureMachine::SCOPES. The host's token model never has to be
15
+ # known to the engine.
16
+ class Configuration
17
+ attr_accessor :user_class, :current_user, :admin, :authenticate_api, :session_auth,
18
+ :product_name, :token_help_url, :protected_resource_url,
19
+ :embedding_model, :drafter_model, :drafter_provider, :drafter_fallbacks,
20
+ :judgment_model, :judgment_provider, :roadmap_path, :bug_reports_path, :new_bug_report_path,
21
+ :admin_nav_partial, :user_label, :admin_triage_path, :layout,
22
+ :on_status_change, :parent_controller, :admin_parent_controller,
23
+ :api_parent_controller, :sign_in_path, :flash_in_pages,
24
+ :default_stylesheet
25
+
26
+ # The engine's own pages, linked by default. A host that draws its own
27
+ # roadmap or bug board points these at it instead, or sets nil to drop the
28
+ # link. respond_to? keeps a link out when its route isn't drawn.
29
+ DEFAULT_ROADMAP_PATH = lambda do |view, work_item = nil|
30
+ view.roadmap_path(anchor: (view.dom_id(work_item) if work_item)) if view.respond_to?(:roadmap_path)
31
+ end
32
+ DEFAULT_BUG_REPORTS_PATH = ->(view) { view.bug_reports_path if view.respond_to?(:bug_reports_path) }
33
+ DEFAULT_NEW_BUG_REPORT_PATH = ->(view) { view.new_bug_report_path if view.respond_to?(:new_bug_report_path) }
34
+ DEFAULT_ADMIN_TRIAGE_PATH = ->(view) { view.admin_triage_path if view.respond_to?(:admin_triage_path) }
35
+ # Where a signed-out visitor to an engine page is sent, given the
36
+ # controller. Rails' authentication generator draws new_session_path.
37
+ DEFAULT_SIGN_IN_PATH = ->(controller) { controller.new_session_path if controller.respond_to?(:new_session_path) }
38
+
39
+ def initialize
40
+ @user_class = "User"
41
+ @current_user = ->(_request) { nil }
42
+ @admin = ->(user) { user.respond_to?(:admin?) && user.admin? }
43
+ @authenticate_api = nil
44
+ @session_auth = true
45
+ @product_name = "this app"
46
+ @token_help_url = nil
47
+ @protected_resource_url = nil
48
+ @embedding_model = nil
49
+ @drafter_model = nil
50
+ @drafter_provider = nil
51
+ @drafter_fallbacks = []
52
+ @judgment_model = nil
53
+ @judgment_provider = nil
54
+ @roadmap_path = DEFAULT_ROADMAP_PATH
55
+ @bug_reports_path = DEFAULT_BUG_REPORTS_PATH
56
+ @new_bug_report_path = DEFAULT_NEW_BUG_REPORT_PATH
57
+ @admin_nav_partial = nil
58
+ @user_label = ->(user) { "user ##{user.id}" }
59
+ @admin_triage_path = DEFAULT_ADMIN_TRIAGE_PATH
60
+ @product_blurb = nil
61
+ @layout = nil
62
+ @on_status_change = nil
63
+ @parent_controller = "ApplicationController"
64
+ @admin_parent_controller = nil
65
+ @api_parent_controller = "ActionController::API"
66
+ @sign_in_path = DEFAULT_SIGN_IN_PATH
67
+ @flash_in_pages = true
68
+ # Inline the engine's cn-* styles on every page. They sit in a CSS
69
+ # layer, so a host's own cn-* rules win; a host with a full cn design
70
+ # system can still turn them off.
71
+ @default_stylesheet = true
72
+ end
73
+
74
+ # The classes engine controllers inherit from, named as strings so the
75
+ # initializer never autoloads app code. Pages inherit the host's
76
+ # ApplicationController (its layout, its auth filters); admin pages inherit
77
+ # admin_parent_controller, falling back to the same; the API inherits
78
+ # ActionController::API unless the host has its own base for error shape,
79
+ # rate-limit headers and the like. The engine adds its own admin check and
80
+ # token auth on top of whichever it gets.
81
+ def admin_parent_controller
82
+ @admin_parent_controller || parent_controller
83
+ end
84
+
85
+ # The layout engine pages render through. nil (the default) means the
86
+ # host's own: engine controllers inherit the host's ApplicationController
87
+ # and so its layout, nav and stylesheets. A host with no layout of its own
88
+ # (or one it doesn't want the engine in) names one, e.g. "feature_machine"
89
+ # for the engine's minimal standalone shell. `.md` responses never use it.
90
+
91
+ # One line the drafter uses to describe the product to the model, e.g.
92
+ # "My App (a todo list with an API for agents)". Falls back to product_name
93
+ # so a host that sets nothing still gets a truthful, if terse, prompt.
94
+ attr_writer :product_blurb
95
+
96
+ def product_blurb
97
+ @product_blurb.to_s.empty? ? product_name : @product_blurb
98
+ end
99
+
100
+ # Links the engine's views offer: a lambda given the view context plus any
101
+ # record the link is about, returning a path, or nil to leave the link out.
102
+ # They default to the engine's own pages (DEFAULT_*_PATH); a host with its
103
+ # own roadmap or bug board points them there.
104
+ #
105
+ # admin_triage_path: ->(view) { ... } the host's triage inbox
106
+ # roadmap_path: ->(view, work_item = nil) { ... } the roadmap, anchored on an item if given
107
+ # bug_reports_path: ->(view) { ... } the host's bug board
108
+ # new_bug_report_path: ->(view) { ... } where to file a bug
109
+ def host_path(name, view, *args)
110
+ callable = public_send(name)
111
+ callable&.call(view, *args)
112
+ end
113
+
114
+ # The host's own admin navigation, rendered at the top of every engine
115
+ # admin page when set (a partial path such as "admin/nav"); nil renders
116
+ # nothing. The engine never names a host route or page itself.
117
+ def admin_nav_partial?
118
+ !admin_nav_partial.nil?
119
+ end
120
+
121
+ # How a requester is named wherever the engine shows one (admin pages,
122
+ # specs handed to agents). The host knows its user model; the engine
123
+ # only knows it has an id.
124
+ def label_for(user)
125
+ user_label.call(user)
126
+ end
127
+
128
+ # Any RubyLLM embedding model name. nil turns embeddings off: requests and
129
+ # work items are never embedded, and triage shows a flat newest-first list
130
+ # instead of similarity buckets. pgvector only decides where vectors live.
131
+ def embeddings?
132
+ !embedding_model.nil?
133
+ end
134
+
135
+ # Any RubyLLM chat model name, with an optional explicit provider and an
136
+ # ordered list of fallback models for when it is unavailable. nil turns the
137
+ # model off: forming a work item still works, the draft is a plain digest
138
+ # of the requests instead of a written specification.
139
+ def drafter?
140
+ !drafter_model.nil?
141
+ end
142
+
143
+ # A RubyLLM model whose provider does structured judgments (OpenAI,
144
+ # Ollama, or TypeSafe's Jev, which OpenRouter also serves). nil turns judging off: triage shows the
145
+ # similarity buckets unranked, with no bug/duplicate/clarity flags.
146
+ # judgment_provider names the provider outright, for a model id the
147
+ # registry doesn't know, such as one on a local Jev-compatible server
148
+ # (:typesafe with RubyLLM's typesafe_api_base pointed at it).
149
+ def judge?
150
+ !judgment_model.nil?
151
+ end
152
+
153
+ # The engine never trusts a credential's own answer for the one scope with
154
+ # teeth: before any maintainer action it asks the host again whether the
155
+ # user may triage. A host that misconfigures its tokens still can't hand
156
+ # the queue to a customer.
157
+ def admin?(user)
158
+ !!(user && admin.call(user))
159
+ end
160
+
161
+ def user_for(request)
162
+ current_user.call(request)
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,34 @@
1
+ module FeatureMachine
2
+ # The engine's Rails hook: installs the two auth middlewares on the host's
3
+ # stack once its initializers have run, and adds the engine's prompts to
4
+ # RubyLLM's search path. Both middlewares are no-ops until configured
5
+ # (Bearer without `authenticate_api`, Session with `session_auth = false`),
6
+ # so installing is unconditional and tests can flip the configuration
7
+ # without rebuilding the stack.
8
+ #
9
+ # Models, controllers, views, jobs, services, prompts and the MCP tools live
10
+ # in the engine's app/. It is not isolated: its routes are drawn into the
11
+ # host's own route set by the feature_machine_routes macro (see Routes), so
12
+ # its pages use the host's unprefixed route helpers.
13
+ class Engine < ::Rails::Engine
14
+ # Without isolate_namespace the name would default to
15
+ # "feature_machine_engine", and so would the install:migrations task.
16
+ engine_name "feature_machine"
17
+
18
+ # The engine ships its three migrations in db/migrate. A new host copies
19
+ # them with `bin/rails feature_machine:install:migrations`. A host that already
20
+ # has copies with the same class names gets all three skipped, and its
21
+ # schema.rb doesn't change. Nothing adds the engine's path
22
+ # to the host's migrations_paths, so they never run twice.
23
+ initializer "feature_machine.auth_middleware", after: :load_config_initializers do |app|
24
+ app.middleware.use FeatureMachine::Auth::Bearer
25
+ app.middleware.use FeatureMachine::Auth::Session
26
+ end
27
+
28
+ # RubyLLM::Prompt only looks in Rails.root/app/prompts by default. The host
29
+ # root stays first, so a host app can override any engine prompt.
30
+ initializer "feature_machine.prompts" do
31
+ RubyLLM::Prompt.roots << root.join("app/prompts")
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,85 @@
1
+ require "yaml"
2
+
3
+ module FeatureMachine
4
+ # Runs the mutation calibration cases (calibration/judge_cases.yml)
5
+ # through a judge and says which ones it got wrong. Three checks per case:
6
+ #
7
+ # base lands on base.expect
8
+ # equivalent stays within the drift threshold of the base answer
9
+ # faulty lands on faulty.expect
10
+ #
11
+ # The judge is anything that answers `call(text, context)` with a hash-like
12
+ # judgment (`fetch(question)` returning an answer with #probability or
13
+ # #score). The default calls FeatureMachine::Judge against the configured
14
+ # judgment_model, so `rake feature_machine:calibrate_judge` costs real tokens.
15
+ # Tests pass a fake.
16
+ class JudgeCalibration
17
+ CASES_PATH = File.expand_path("calibration/judge_cases.yml", __dir__)
18
+ SCORE_LEVELS = %w[Low Mid High].freeze
19
+ Request = Struct.new(:kind, :title, :description, keyword_init: true)
20
+ Check = Struct.new(:case_id, :variant, :ok, :detail, keyword_init: true)
21
+
22
+ def self.load(path = CASES_PATH)
23
+ new(YAML.safe_load_file(path))
24
+ end
25
+
26
+ def initialize(file, judge: nil)
27
+ @file = file
28
+ @judge = judge || ->(text, open_items) { Judge.judge_text(text, open_items) }
29
+ end
30
+
31
+ def run
32
+ @file.fetch("cases").flat_map { |c| check(c) }
33
+ end
34
+
35
+ private
36
+
37
+ def check(c)
38
+ question = c.fetch("question").to_sym
39
+ base = answer(c["base"], question)
40
+ equivalent = answer(c["equivalent"], question)
41
+ faulty = answer(c["faulty"], question)
42
+
43
+ [
44
+ Check.new(case_id: c["id"], variant: "base", ok: label(base, question) == c["base"]["expect"],
45
+ detail: "#{format(base)} (want #{c["base"]["expect"]})"),
46
+ Check.new(case_id: c["id"], variant: "equivalent", ok: (base - equivalent).abs <= max_drift(question),
47
+ detail: "#{format(equivalent)} vs base #{format(base)} (max drift #{max_drift(question)})"),
48
+ Check.new(case_id: c["id"], variant: "faulty", ok: label(faulty, question) == c["faulty"]["expect"],
49
+ detail: "#{format(faulty)} (want #{c["faulty"]["expect"]})")
50
+ ]
51
+ end
52
+
53
+ # A probability as a Float, or a score as a level index (Integer).
54
+ def answer(variant, question)
55
+ request = Request.new(kind: variant["kind"], title: variant["title"], description: variant["description"])
56
+ result = @judge.call(Judge.request_text(request), context).fetch(question)
57
+ score_question?(question) ? result.score.round : result.probability
58
+ end
59
+
60
+ def label(value, question)
61
+ return SCORE_LEVELS.fetch(value) if score_question?(question)
62
+
63
+ value >= 0.5 ? "high" : "low"
64
+ end
65
+
66
+ def max_drift(question)
67
+ key = score_question?(question) ? "score_drift" : "probability_drift"
68
+ @file.fetch("thresholds").fetch(key)
69
+ end
70
+
71
+ def score_question?(question) = question == :clarity
72
+
73
+ def format(value) = value.is_a?(Integer) ? SCORE_LEVELS.fetch(value) : value.round(2).to_s
74
+
75
+ def context
76
+ @context ||= Judge.items_text(@file.fetch("open_items").map { |title| title_item(title) })
77
+ end
78
+
79
+ # "[feature] Pin articles..." back into the kind/title shape items_text reads.
80
+ def title_item(line)
81
+ kind, title = line.match(/\A\[(\w+)\] (.+)\z/).captures
82
+ Request.new(kind: kind, title: title)
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,61 @@
1
+ require "action_dispatch/routing/mapper"
2
+
3
+ module FeatureMachine
4
+ # The engine's routes, drawn into the host's own route set rather than
5
+ # mounted. The engine isn't isolated, so its
6
+ # controllers and views use the host's unprefixed helpers
7
+ # (feature_requests_path, admin_work_items_path); a mount would move those
8
+ # onto an engine proxy. The host calls the macro once per scope, inside the
9
+ # namespace that scope belongs to:
10
+ #
11
+ # feature_machine_routes :web # top level, before any catch-all
12
+ # namespace :admin do
13
+ # feature_machine_routes :admin
14
+ # end
15
+ # namespace :api, defaults: { format: :json } do
16
+ # feature_machine_routes :api
17
+ # end
18
+ # feature_machine_routes :mcp # POST /mcp, for agents
19
+ #
20
+ # Paths and helper names match what the first host drew by hand, so URLs
21
+ # don't move. The macro now also draws bug reports, the roadmap, triage,
22
+ # attachments and their APIs: a host that still draws any of those itself
23
+ # must drop its own, or Rails refuses the duplicate route name at boot. A
24
+ # host that already serves MCP skips :mcp and mounts FeatureMachine::Mcp's
25
+ # tools in its own server instead.
26
+ module Routes
27
+ SCOPES = %i[ web admin api mcp ].freeze
28
+
29
+ def feature_machine_routes(scope)
30
+ case scope
31
+ when :web
32
+ resources :feature_requests, only: [ :index, :new, :create ], path: "feature-requests", controller: "feature_machine/feature_requests"
33
+ resources :bug_reports, only: [ :index, :new, :create ], path: "bug-reports", controller: "feature_machine/bug_reports"
34
+ get "/roadmap", to: "feature_machine/roadmap#show", as: :roadmap, format: false
35
+ when :admin
36
+ resource :triage, only: [ :show ], controller: "/feature_machine/admin/triage"
37
+ resources :attachments, only: [ :create ], controller: "/feature_machine/admin/attachments"
38
+ resources :feature_requests, only: [ :update ], controller: "/feature_machine/admin/feature_requests"
39
+ resources :work_items, controller: "/feature_machine/admin/work_items" do
40
+ member { post :draft }
41
+ resources :relations, only: [ :create, :destroy ], controller: "/feature_machine/admin/work_item_relations"
42
+ end
43
+ when :api
44
+ resources :feature_requests, only: [ :index, :create ], controller: "feature_machine/feature_requests"
45
+ resources :work_items, only: [ :index, :show, :update ], controller: "feature_machine/work_items"
46
+ resources :bug_reports, only: [ :index, :create ], controller: "feature_machine/bug_reports"
47
+ resource :roadmap, only: [ :show ], controller: "feature_machine/roadmaps"
48
+ namespace :maintainer do
49
+ resources :feature_requests, only: [ :index ], controller: "/api/feature_machine/maintainer/feature_requests"
50
+ end
51
+ when :mcp
52
+ post "/mcp", to: "feature_machine/mcp#handle", as: :feature_machine_mcp
53
+ get "/mcp", to: "feature_machine/mcp#reject_get"
54
+ else
55
+ raise ArgumentError, "feature_machine_routes scope must be one of #{SCOPES.inspect}, got #{scope.inspect}"
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ ActionDispatch::Routing::Mapper.include(FeatureMachine::Routes)
@@ -0,0 +1,3 @@
1
+ module FeatureMachine
2
+ VERSION = "0.1.0".freeze
3
+ end
@@ -0,0 +1,57 @@
1
+ # feature_machine: the feature-request -> triage -> work-item -> agent loop,
2
+ # packaged as a Rails engine.
3
+ #
4
+ # The gem's entry point. The host loads it from its Gemfile, so
5
+ # Bundler.require runs it before the application class exists and the
6
+ # namespace is there before Zeitwerk descends into app/*/feature_machine/.
7
+ # This file and lib/feature_machine/ are the engine's configuration and host
8
+ # seams.
9
+ #
10
+ # Require the runtime gems the engine names at boot (the prompts initializer
11
+ # touches RubyLLM::Prompt). A host may load them already; a fresh
12
+ # host that only adds feature_machine doesn't, and fails to boot without these.
13
+ require "ruby_llm"
14
+ require "neighbor"
15
+ require "jbuilder"
16
+ require "mcp"
17
+ require_relative "feature_machine/version"
18
+ require_relative "feature_machine/configuration"
19
+ require_relative "feature_machine/auth"
20
+ require_relative "feature_machine/engine"
21
+ require_relative "feature_machine/routes"
22
+
23
+ module FeatureMachine
24
+ # The Rack env key every engine controller and MCP tool reads its credential
25
+ # from. The middlewares in FeatureMachine::Auth set it; a host with its own
26
+ # authentication middleware can set it directly instead.
27
+ ENV_KEY = "feature_machine.credential".freeze
28
+
29
+ # The three scopes the engine asks about. A host's token model may know
30
+ # twenty; the engine only ever asks these.
31
+ READ = "read".freeze
32
+ WRITE = "write".freeze
33
+ MAINTAINER = "maintainer".freeze
34
+ SCOPES = [ READ, WRITE, MAINTAINER ].freeze
35
+
36
+ class << self
37
+ def config
38
+ @config ||= Configuration.new
39
+ end
40
+
41
+ def configure
42
+ yield config
43
+ config
44
+ end
45
+
46
+ # The engine's default cn-* stylesheet (app/assets/feature_machine/default.css),
47
+ # read once. Engine pages inline it unless config.default_stylesheet is false.
48
+ def default_stylesheet
49
+ @default_stylesheet ||= Engine.root.join("app/assets/feature_machine/default.css").read.freeze
50
+ end
51
+
52
+ # Tests only: forget the host's configuration.
53
+ def reset_config!
54
+ @config = nil
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,76 @@
1
+ require "rails/generators"
2
+
3
+ module FeatureMachine
4
+ module Generators
5
+ # rails g feature_machine:install
6
+ #
7
+ # Writes config/initializers/feature_machine.rb with every knob in
8
+ # FeatureMachine::Configuration, set to its default or commented out, and
9
+ # says what to run next. Also draws the engine's routes into
10
+ # config/routes.rb. Migrations come from the engine's own
11
+ # install:migrations task so a host can re-run it after upgrades.
12
+ class InstallGenerator < Rails::Generators::Base
13
+ source_root File.expand_path("templates", __dir__)
14
+
15
+ desc "Creates config/initializers/feature_machine.rb for the feature_machine engine."
16
+
17
+ def copy_initializer
18
+ template "initializer.rb", "config/initializers/feature_machine.rb"
19
+ end
20
+
21
+ # Rails' authentication generator redirects to root_url after sign-in,
22
+ # and a fresh `rails new` has no root route, so the first login 500s.
23
+ # Not the engine's bug, but every first-time installer hits it.
24
+ def check_root_route
25
+ routes = File.join(destination_root, "config/routes.rb")
26
+ return unless rails_authentication? && File.exist?(routes)
27
+ return if File.read(routes).match?(/^\s*root\b/)
28
+
29
+ say_status :warn, "config/routes.rb has no root route; Rails' sign-in redirects to root_url and will 500 until you add one", :yellow
30
+ end
31
+
32
+ # The engine's routes are drawn into the host's route set (see
33
+ # FeatureMachine::Routes), so the generator adds the four macro calls
34
+ # rather than a mount line. Skipped if the host already calls the macro.
35
+ def add_routes
36
+ routes = File.join(destination_root, "config/routes.rb")
37
+ unless File.exist?(routes)
38
+ say_status :skip, "config/routes.rb not found; add the feature_machine_routes calls yourself", :yellow
39
+ return
40
+ end
41
+ return say_status(:identical, "feature_machine_routes in config/routes.rb", :blue) if File.read(routes).include?("feature_machine_routes")
42
+
43
+ route <<~RUBY
44
+ feature_machine_routes :web
45
+ namespace :admin do
46
+ feature_machine_routes :admin
47
+ end
48
+ namespace :api, defaults: { format: :json } do
49
+ feature_machine_routes :api
50
+ end
51
+ feature_machine_routes :mcp
52
+ RUBY
53
+ end
54
+
55
+ def show_next_steps
56
+ say <<~MSG
57
+
58
+ feature_machine is configured in config/initializers/feature_machine.rb.
59
+ Next:
60
+ bin/rails feature_machine:install:migrations
61
+ bin/rails db:migrate
62
+ Then check current_user and admin, and set authenticate_api for your app.
63
+ Signed-in users get /feature-requests, /bug-reports and /roadmap;
64
+ admins get /admin/triage and /admin/work_items; agents get /api and /mcp.
65
+ MSG
66
+ end
67
+
68
+ private
69
+ # `bin/rails generate authentication` writes app/models/session.rb and
70
+ # a signed session_id cookie; that's the convention we key off.
71
+ def rails_authentication?
72
+ File.exist?(File.join(destination_root, "app/models/session.rb"))
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,99 @@
1
+ # What the feature_machine engine needs from this app. Every setting is shown
2
+ # with its default; the first three are the ones a real host must change.
3
+ # Lambdas stay lazy: initializers may not autoload app constants.
4
+ FeatureMachine.configure do |config|
5
+ # The user model the engine's requests and work items belong to.
6
+ config.user_class = "User"
7
+
8
+ <% if rails_authentication? -%>
9
+ # The signed-in user for a request, or nil. This app has Rails'
10
+ # authentication generator (app/models/session.rb), so the session cookie
11
+ # it sets answers the question.
12
+ config.current_user = ->(request) { Session.find_by(id: request.cookie_jar.signed[:session_id])&.user }
13
+ <% else -%>
14
+ # The signed-in user for a request, or nil. The default treats everyone as
15
+ # signed out, so nothing is readable until this is set.
16
+ config.current_user = ->(_request) { nil }
17
+ <% end -%>
18
+
19
+ # May this user triage requests and manage work items?
20
+ config.admin = ->(user) { user.respond_to?(:admin?) && user.admin? }
21
+
22
+ # Bearer token -> credential (any object answering `user` and
23
+ # `allows?(scope)` for "read", "write" and "maintainer"), or nil. Without
24
+ # it the API takes no tokens and maintainer endpoints refuse everything.
25
+ # config.authenticate_api = ->(token) { ApiToken.authenticate(token) }
26
+
27
+ # Signed-in users may use the API through their session cookie (writes are
28
+ # CSRF-checked). Turn off for a token-only API.
29
+ config.session_auth = true
30
+
31
+ # How the engine names this app in its pages and in the drafter's prompt.
32
+ config.product_name = "<%= Rails.application.class.module_parent_name.underscore.humanize %>"
33
+ # config.product_blurb = "My App (a todo list with an API for agents)"
34
+
35
+ # Where users get an API token, and the OAuth protected-resource metadata
36
+ # URL sent on 401s. nil leaves them out.
37
+ # config.token_help_url = "https://example.com/token"
38
+ # config.protected_resource_url = "https://example.com/.well-known/oauth-protected-resource"
39
+
40
+ # Any RubyLLM embedding model groups similar requests; nil turns embeddings
41
+ # off and triage falls back to a flat newest-first list.
42
+ # config.embedding_model = "text-embedding-3-small"
43
+
44
+ # Any RubyLLM chat model drafts work-item specs; nil drafts a plain digest.
45
+ # config.drafter_model = "gpt-4.1-mini"
46
+ # config.drafter_provider = :openai
47
+ # config.drafter_fallbacks = []
48
+
49
+ # A RubyLLM chat model ranks untriaged requests (bug? duplicate? clear?);
50
+ # nil keeps triage unranked. It needs a provider with structured judgments:
51
+ # OpenAI, Ollama, or TypeSafe ("jev-latest"). With only an OpenRouter key,
52
+ # use "jev-latest" and set RubyLLM's typesafe_api_base to
53
+ # "https://openrouter.ai/api" and typesafe_api_key to that key. For a local
54
+ # Jev-compatible server, point typesafe_api_base at it and name the provider
55
+ # so the server's own model id resolves.
56
+ # config.judgment_model = nil
57
+ # config.judgment_provider = nil # e.g. :typesafe
58
+
59
+ # The layout engine pages render through. nil uses this app's own; set
60
+ # "feature_machine" for the engine's minimal standalone shell.
61
+ # config.layout = nil
62
+
63
+ # Engine pages show their own notices ("Thanks. Its status will update
64
+ # here."), since a new app's layout doesn't render flash. Turn this off if
65
+ # yours does, or they'll show twice.
66
+ # config.flash_in_pages = true
67
+
68
+ # Engine pages inline default styles for their cn-* classes, in a CSS layer
69
+ # so your own cn-* rules win. Turn off if your design system styles them all.
70
+ # config.default_stylesheet = true
71
+
72
+ # What engine controllers inherit from, as class names. Pages and admin
73
+ # pages get this app's ApplicationController (its layout and auth filters);
74
+ # the API gets ActionController::API unless you have a base of your own.
75
+ # config.parent_controller = "ApplicationController"
76
+ # config.admin_parent_controller = "ApplicationController"
77
+ # config.api_parent_controller = "ActionController::API"
78
+
79
+ # Where a signed-out visitor to an engine page goes, given the controller.
80
+ # The default finds new_session_path (Rails' authentication generator);
81
+ # nil answers 401.
82
+ # config.sign_in_path = ->(controller) { controller.new_session_path }
83
+
84
+ # The engine links its own roadmap, bug reports and triage pages. Point a
85
+ # lambda at your own page instead, or set nil to drop the link.
86
+ # config.roadmap_path = ->(view, work_item = nil) { view.roadmap_path }
87
+ # config.bug_reports_path = ->(view) { view.bug_reports_path }
88
+ # config.new_bug_report_path = ->(view) { view.new_bug_report_path }
89
+ # config.admin_triage_path = ->(view) { view.admin_triage_path }
90
+
91
+ # This app's admin nav partial, rendered atop engine admin pages, and how a
92
+ # requester is named in them and in what agents read.
93
+ # config.admin_nav_partial = "admin/nav"
94
+ # config.user_label = ->(user) { "user ##{user.id}" }
95
+
96
+ # Called with the request after each committed status change, e.g. to tell
97
+ # the requester. Keep it fast (enqueue a job); nil does nothing.
98
+ # config.on_status_change = ->(request) { StatusNoteJob.perform_later(request.id) }
99
+ end
@@ -0,0 +1,17 @@
1
+ # Live-model calibration for FeatureMachine::Judge (opt-in, never in CI: it
2
+ # spends tokens on config.judgment_model). Fails if an equivalent mutant moves
3
+ # an answer past threshold, or a faulty mutant doesn't move it.
4
+ namespace :feature_machine do
5
+ desc "Run the Judge mutation calibration cases against config.judgment_model"
6
+ task calibrate_judge: :environment do
7
+ abort "feature_machine.judgment_model is nil; nothing to calibrate." unless FeatureMachine.config.judge?
8
+
9
+ require "feature_machine/judge_calibration"
10
+ checks = FeatureMachine::JudgeCalibration.load.run
11
+ checks.each { |c| puts "#{c.ok ? "ok " : "FAIL"} #{c.case_id} #{c.variant}: #{c.detail}" }
12
+
13
+ failed = checks.reject(&:ok)
14
+ puts "\n#{checks.size - failed.size}/#{checks.size} checks held on #{FeatureMachine.config.judgment_model}."
15
+ abort "#{failed.size} calibration checks failed." if failed.any?
16
+ end
17
+ end
@@ -0,0 +1,10 @@
1
+ namespace :feature_machine do
2
+ desc "Embed requests filed before config.embedding_model was set"
3
+ task embed_requests: :environment do
4
+ abort "feature_machine.embedding_model is nil; nothing to embed." unless FeatureMachine.config.embeddings?
5
+
6
+ filled = FeatureMachine::Request.backfill_embeddings
7
+ missing = FeatureMachine::Request.unembedded.count
8
+ puts "Embedded #{filled}; #{missing} still missing#{' (see the log for why)' if missing.positive?}."
9
+ end
10
+ end