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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE +21 -0
- data/README.md +207 -0
- data/app/assets/feature_machine/default.css +62 -0
- data/app/controllers/api/feature_machine/base_controller.rb +109 -0
- data/app/controllers/api/feature_machine/bug_reports_controller.rb +32 -0
- data/app/controllers/api/feature_machine/feature_requests_controller.rb +31 -0
- data/app/controllers/api/feature_machine/maintainer/feature_requests_controller.rb +28 -0
- data/app/controllers/api/feature_machine/roadmaps_controller.rb +9 -0
- data/app/controllers/api/feature_machine/work_items_controller.rb +56 -0
- data/app/controllers/concerns/feature_machine/current_user.rb +28 -0
- data/app/controllers/concerns/feature_machine/engine_layout.rb +12 -0
- data/app/controllers/feature_machine/admin/attachments_controller.rb +17 -0
- data/app/controllers/feature_machine/admin/base_controller.rb +16 -0
- data/app/controllers/feature_machine/admin/feature_requests_controller.rb +24 -0
- data/app/controllers/feature_machine/admin/triage_controller.rb +9 -0
- data/app/controllers/feature_machine/admin/work_item_relations_controller.rb +24 -0
- data/app/controllers/feature_machine/admin/work_items_controller.rb +88 -0
- data/app/controllers/feature_machine/base_controller.rb +10 -0
- data/app/controllers/feature_machine/bug_reports_controller.rb +29 -0
- data/app/controllers/feature_machine/feature_requests_controller.rb +27 -0
- data/app/controllers/feature_machine/mcp_controller.rb +36 -0
- data/app/controllers/feature_machine/roadmap_controller.rb +9 -0
- data/app/helpers/feature_machine/feature_requests_helper.rb +90 -0
- data/app/helpers/feature_machine/work_items_helper.rb +99 -0
- data/app/jobs/feature_machine/application_job.rb +6 -0
- data/app/jobs/feature_machine/request_embedding_job.rb +12 -0
- data/app/jobs/feature_machine/work_item_embedding_job.rb +12 -0
- data/app/models/feature_machine/request.rb +111 -0
- data/app/models/feature_machine/roadmap.rb +24 -0
- data/app/models/feature_machine/work_item.rb +203 -0
- data/app/models/feature_machine/work_item_relation.rb +60 -0
- data/app/prompts/feature_machine/drafter/_product_blurb.txt.erb +1 -0
- data/app/prompts/feature_machine/drafter/instructions.txt.erb +7 -0
- data/app/services/feature_machine/base_embedding_service.rb +52 -0
- data/app/services/feature_machine/drafter.rb +43 -0
- data/app/services/feature_machine/judge.rb +53 -0
- data/app/services/feature_machine/request_embedding_service.rb +14 -0
- data/app/services/feature_machine/triage_buckets.rb +146 -0
- data/app/services/feature_machine/work_item_drafter.rb +91 -0
- data/app/services/feature_machine/work_item_embedding_service.rb +14 -0
- data/app/services/feature_machine/work_item_spec_schema.rb +14 -0
- data/app/tools/feature_machine/mcp/base.rb +94 -0
- data/app/tools/feature_machine/mcp/get_work_item.rb +20 -0
- data/app/tools/feature_machine/mcp/list_bug_reports.rb +20 -0
- data/app/tools/feature_machine/mcp/list_feature_requests.rb +29 -0
- data/app/tools/feature_machine/mcp/list_work_items.rb +30 -0
- data/app/tools/feature_machine/mcp/read_roadmap.rb +22 -0
- data/app/tools/feature_machine/mcp/report_bug.rb +26 -0
- data/app/tools/feature_machine/mcp/request_feature.rb +27 -0
- data/app/tools/feature_machine/mcp/update_work_item.rb +33 -0
- data/app/tools/feature_machine/mcp.rb +25 -0
- data/app/views/api/feature_machine/feature_requests/index.json.jbuilder +2 -0
- data/app/views/api/feature_machine/feature_requests/show.json.jbuilder +1 -0
- data/app/views/api/feature_machine/maintainer/feature_requests/index.json.jbuilder +1 -0
- data/app/views/api/feature_machine/roadmaps/_work_item.json.jbuilder +12 -0
- data/app/views/api/feature_machine/roadmaps/show.json.jbuilder +10 -0
- data/app/views/api/feature_machine/work_items/_work_item.json.jbuilder +32 -0
- data/app/views/api/feature_machine/work_items/index.json.jbuilder +1 -0
- data/app/views/api/feature_machine/work_items/show.json.jbuilder +3 -0
- data/app/views/feature_machine/_default_styles.html.erb +5 -0
- data/app/views/feature_machine/_flash.html.erb +10 -0
- data/app/views/feature_machine/admin/triage/show.html.erb +120 -0
- data/app/views/feature_machine/admin/work_items/_form.html.erb +78 -0
- data/app/views/feature_machine/admin/work_items/edit.html.erb +40 -0
- data/app/views/feature_machine/admin/work_items/index.html.erb +80 -0
- data/app/views/feature_machine/admin/work_items/new.html.erb +21 -0
- data/app/views/feature_machine/admin/work_items/show.html.erb +168 -0
- data/app/views/feature_machine/admin/work_items/show.md.erb +31 -0
- data/app/views/feature_machine/bug_reports/index.html.erb +38 -0
- data/app/views/feature_machine/bug_reports/new.html.erb +83 -0
- data/app/views/feature_machine/feature_requests/_feature_request.html.erb +47 -0
- data/app/views/feature_machine/feature_requests/_feature_request.json.jbuilder +15 -0
- data/app/views/feature_machine/feature_requests/index.html.erb +44 -0
- data/app/views/feature_machine/feature_requests/new.html.erb +84 -0
- data/app/views/feature_machine/roadmap/_work_item.html.erb +34 -0
- data/app/views/feature_machine/roadmap/show.html.erb +70 -0
- data/app/views/layouts/feature_machine.html.erb +21 -0
- data/db/migrate/20260910180000_create_feature_requests.rb +18 -0
- data/db/migrate/20261004120000_create_work_items.rb +52 -0
- data/db/migrate/20261006120000_add_pull_request_url_to_work_items.rb +7 -0
- data/lib/feature_machine/auth.rb +89 -0
- data/lib/feature_machine/calibration/judge_cases.yml +137 -0
- data/lib/feature_machine/configuration.rb +165 -0
- data/lib/feature_machine/engine.rb +34 -0
- data/lib/feature_machine/judge_calibration.rb +85 -0
- data/lib/feature_machine/routes.rb +61 -0
- data/lib/feature_machine/version.rb +3 -0
- data/lib/feature_machine.rb +57 -0
- data/lib/generators/feature_machine/install/install_generator.rb +76 -0
- data/lib/generators/feature_machine/install/templates/initializer.rb +99 -0
- data/lib/tasks/feature_machine_calibration.rake +17 -0
- data/lib/tasks/feature_machine_embeddings.rake +10 -0
- 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,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
|