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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c4b036261f92e29292d948f5bbef5a4d84d5a1bd3323d8cde92b6ae498c45f2f
4
+ data.tar.gz: 4800b671dac9fa5b1840ff84f513de37eeb198993c87833f68ea54f9bede0c7f
5
+ SHA512:
6
+ metadata.gz: 1ba4481ff6c8dcb0892de61b6b2cf1280c898c6057ce9734ce8feaac6bc839b1461a90196d7c45eb656395e83188e0fca5d6ecb0d9610034fdbab06cd54cdf61
7
+ data.tar.gz: 83f8946ce62ea59a18f41c96130bee8195097156b0e6202296ec1edfb6d0935a0fbf67dfea15515b21da112dcb55867d103c4127d27ee2dee6e79cce9d5f5b77
data/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-11-09)
4
+
5
+ First public release.
6
+
7
+ - Mountable engine: feature requests and bug reports in, triage, work items out.
8
+ - Roadmap page and JSON API, maintainer API, and an MCP server for agents.
9
+ - `rails g feature_machine:install` initializer and `feature_machine:install:migrations`.
10
+ With Rails' authentication generator present, the initializer reads the
11
+ session cookie for `current_user`, and the generator warns when there's no
12
+ root route for sign-in to land on.
13
+ - Optional embeddings (pgvector via neighbor) for duplicate detection, and a
14
+ RubyLLM drafter and judge behind `FeatureMachine.configure`.
15
+ - Maintainer MCP tools (`list_work_items`, `get_work_item`, `update_work_item`)
16
+ so a coding agent can pick up planned items and attach a pull request.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Valentino Stoll
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,207 @@
1
+ # feature_machine
2
+
3
+ Feature requests, work items, and the judge pipeline, packaged as a Rails engine.
4
+
5
+ Your users file feature requests and bug reports, on your site or through
6
+ their own agents over MCP. You triage them into work items, a judge ranks and
7
+ groups them, and your own coding agent can pick up a planned item over MCP and
8
+ attach the pull request for you to approve.
9
+
10
+ ## Requirements
11
+
12
+ - Ruby 3.3+, Rails 8.1+
13
+ - Postgres with the pgvector extension
14
+ - `ruby_llm` 2.1+ (only called if you name a model; see [Models](#models))
15
+
16
+ ## Install
17
+
18
+ ```ruby
19
+ # Gemfile
20
+ gem "feature_machine"
21
+ ```
22
+
23
+ ```
24
+ bundle install
25
+ bin/rails g feature_machine:install
26
+ bin/rails feature_machine:install:migrations
27
+ bin/rails db:migrate
28
+ ```
29
+
30
+ The generator writes `config/initializers/feature_machine.rb` and draws the
31
+ engine's routes into `config/routes.rb`:
32
+
33
+ ```ruby
34
+ feature_machine_routes :web # /feature-requests, /bug-reports, /roadmap
35
+ namespace :admin do
36
+ feature_machine_routes :admin # /admin/triage, /admin/work_items
37
+ end
38
+ namespace :api, defaults: { format: :json } do
39
+ feature_machine_routes :api # /api/feature_requests, /api/bug_reports, /api/roadmap,
40
+ end # /api/work_items, /api/maintainer/feature_requests
41
+ feature_machine_routes :mcp # POST /mcp, for agents
42
+ ```
43
+
44
+ Then tell the engine three things about your app in the initializer:
45
+
46
+ ```ruby
47
+ FeatureMachine.configure do |config|
48
+ # Who is signed in. With Rails' authentication generator:
49
+ config.current_user = ->(request) { Session.find_by(id: request.cookie_jar.signed[:session_id])&.user }
50
+ # Who may triage. The work-items migration adds users.admin, so the default works.
51
+ config.admin = ->(user) { user.admin? }
52
+ # Bearer token -> credential: any object answering `user` and `allows?(scope)`
53
+ # for "read", "write" and "maintainer". Without it, agents can't connect.
54
+ config.authenticate_api = ->(token) { ApiToken.authenticate(token) }
55
+ end
56
+ ```
57
+
58
+ ## Upgrading an app that drew the routes by hand
59
+
60
+ The route macro now draws everything the engine ships: bug reports, the
61
+ roadmap, triage, attachments, and their APIs. If your `config/routes.rb` still
62
+ draws any of those next to `feature_machine_routes`, delete your copies, or
63
+ Rails stops at boot with "Invalid route name, already in use".
64
+
65
+ The engine's controllers no longer inherit `Admin::BaseController` or
66
+ `Api::BaseController` on their own. If yours add filters, a layout or headers
67
+ the engine's pages should keep, point the engine at them:
68
+
69
+ ```ruby
70
+ config.admin_parent_controller = "Admin::BaseController"
71
+ config.api_parent_controller = "Api::BaseController"
72
+ ```
73
+
74
+ Your API base keeps its own `render_error`. The engine reads the signed-in API
75
+ user through its own `api_user`, so your `current_user` is left alone.
76
+
77
+ ## Host contract
78
+
79
+ That is everything a fresh `rails new` app needs. The engine brings its own
80
+ admin and API base controllers, so the host doesn't need `current_user`,
81
+ `Admin::BaseController`, `Api::BaseController` or a `has_many` on its user
82
+ model.
83
+
84
+ - **Postgres with pgvector.** The migrations enable the `vector` extension.
85
+ - **A `users` table.** `feature_requests.user_id` references it, and the
86
+ work-items migration adds `users.admin`. Name another model with
87
+ `config.user_class`.
88
+ - **Pages inherit your `ApplicationController`**, so your layout and auth
89
+ filters apply. Signed-out visitors go to `new_session_path` when you have one
90
+ (`config.sign_in_path`) and get a 401 otherwise. To inherit something else,
91
+ set `config.parent_controller`, `config.admin_parent_controller` or
92
+ `config.api_parent_controller`. An API base of your own keeps its own
93
+ `render_error` and error handling.
94
+ - **The engine's pages use `cn-*` classes and ship plain default styles for
95
+ them**, inlined in a `@layer feature_machine` block, so a host without a
96
+ design system still renders cleanly and any `cn-*` rule of yours wins
97
+ without `!important`. Set `config.default_stylesheet = false` if your own
98
+ stylesheet covers them all. They show their own flash notices,
99
+ because a new app's layout doesn't; set `config.flash_in_pages = false` if
100
+ yours does.
101
+ - **Your own roadmap or bug board** replaces the engine's by pointing
102
+ `config.roadmap_path` (and friends) at it.
103
+ - **An MCP server of your own** can skip `feature_machine_routes :mcp` and add
104
+ `FeatureMachine::Mcp.for(credential)` to its tool list instead. A browser
105
+ session cookie is never an MCP credential; only bearer tokens are.
106
+
107
+ ### Models
108
+
109
+ Embeddings, the spec drafter and the triage judge are each off until you name a
110
+ RubyLLM model (`config.embedding_model`, `config.drafter_model`,
111
+ `config.judgment_model`). If a model is named but its provider key is missing
112
+ or the call fails, the feature steps aside: requests are still filed, triage
113
+ shows unranked groups, and forming a work item writes a plain digest instead
114
+ of a spec.
115
+
116
+ The triage judge uses RubyLLM's structured judgments, which only some providers
117
+ implement: OpenAI, Ollama, and TypeSafe's Jev models in ruby_llm 2.1. Pick a
118
+ `judgment_model` one of them serves. A chat model named through OpenRouter
119
+ can't judge: triage logs a warning and stays unranked.
120
+
121
+ OpenRouter does serve Jev's judgment API, so an OpenRouter key is enough:
122
+
123
+ ```ruby
124
+ RubyLLM.configure do |c|
125
+ c.typesafe_api_base = "https://openrouter.ai/api"
126
+ c.typesafe_api_key = ENV["OPENROUTER_API_KEY"]
127
+ end
128
+ FeatureMachine.config.judgment_model = "jev-latest"
129
+ ```
130
+
131
+ For a local
132
+ [Jev-compatible server](https://rubyllm.com/configuration-providers/#jev-compatible-apis),
133
+ point `typesafe_api_base` at it instead and set
134
+ `config.judgment_provider = :typesafe`, so the server's own model id resolves
135
+ without a registry entry. Embeddings and the drafter work on any provider RubyLLM
136
+ supports, including OpenRouter (set `config.drafter_provider = :openrouter`).
137
+
138
+ Requests filed before `embedding_model` was set never got an embedding, and
139
+ triage leaves them out of every group. Embed them once you switch it on:
140
+
141
+ ```
142
+ bin/rails feature_machine:embed_requests
143
+ ```
144
+
145
+ `bin/rails feature_machine:calibrate_judge` checks your `judgment_model`
146
+ against mutated cases: paraphrases mustn't move an answer, and changes that
147
+ alter the meaning must. It spends tokens, so it never runs in CI.
148
+ In November 2026, `jev-latest` held 18 of 18. Rerun it when you change models.
149
+
150
+ ## Agents over MCP
151
+
152
+ `feature_machine_routes :mcp` serves `POST /mcp`. Agents authenticate with a
153
+ bearer token through `config.authenticate_api`; a browser session cookie is
154
+ never accepted there.
155
+
156
+ Any token with `read` (and `write` for filing) sees:
157
+
158
+ | Tool | Does |
159
+ | --- | --- |
160
+ | `list_feature_requests` | The user's own requests, with status and the maintainer's note |
161
+ | `request_feature` | File a request on the user's behalf |
162
+ | `list_bug_reports` | The user's own bug reports, with status and note |
163
+ | `report_bug` | File a bug on the user's behalf |
164
+ | `read_roadmap` | In-progress and planned work, then the changelog |
165
+
166
+ A `maintainer` token whose user also passes `config.admin` additionally sees
167
+ `list_work_items`, `get_work_item` and `update_work_item`. Those aren't
168
+ advertised to anyone else, and the engine re-checks `config.admin` on every
169
+ maintainer call rather than trusting the token's scope alone. An agent can
170
+ move an item through its statuses and attach a pull request, but it can't
171
+ edit the spec, approve drafts or decline work.
172
+
173
+ ## Configuration reference
174
+
175
+ All set in `config/initializers/feature_machine.rb`. Lambdas are called per
176
+ request or per record; strings naming classes are constantized lazily, so the
177
+ initializer never autoloads app code.
178
+
179
+ | Setting | Default | What it does |
180
+ | --- | --- | --- |
181
+ | `current_user` | `->(request) { nil }` | The signed-in user for a request |
182
+ | `admin` | `user.admin?` if defined | May this user triage? |
183
+ | `authenticate_api` | `nil` | Bearer token to credential (`user`, `allows?(scope)`). `nil` = no API or MCP access |
184
+ | `session_auth` | `true` | Let a browser session act as a credential on the JSON API (never MCP) |
185
+ | `user_class` | `"User"` | The model `feature_requests.user_id` points at |
186
+ | `user_label` | `"user #<id>"` | How a requester is named on admin pages and in specs |
187
+ | `product_name` | `"this app"` | Used in copy and error messages |
188
+ | `product_blurb` | `product_name` | One line describing the product to the drafter |
189
+ | `token_help_url` | `nil` | Where scope errors tell users to get a token |
190
+ | `protected_resource_url` | `nil` | OAuth protected-resource metadata URL for 401 `WWW-Authenticate` |
191
+ | `embedding_model` | `nil` | RubyLLM embedding model. `nil` = no similarity grouping |
192
+ | `drafter_model`, `drafter_provider`, `drafter_fallbacks` | `nil`, `nil`, `[]` | Chat model that writes work-item specs. `nil` = plain digest |
193
+ | `judgment_model`, `judgment_provider` | `nil`, `nil` | Structured-judgment model for triage. `nil` = unranked |
194
+ | `on_status_change` | `nil` | `->(request) { ... }` after a request's status changes (mail the requester, etc.) |
195
+ | `parent_controller` | `"ApplicationController"` | Base for engine pages |
196
+ | `admin_parent_controller` | `parent_controller` | Base for engine admin pages |
197
+ | `api_parent_controller` | `"ActionController::API"` | Base for engine API controllers |
198
+ | `layout` | host's own | Layout for engine pages; `"feature_machine"` is a minimal standalone shell |
199
+ | `admin_nav_partial` | `nil` | Host partial rendered atop engine admin pages, e.g. `"admin/nav"` |
200
+ | `sign_in_path` | `new_session_path` if drawn | `->(controller) { ... }` where signed-out visitors go |
201
+ | `flash_in_pages` | `true` | Engine pages render their own flash notices |
202
+ | `default_stylesheet` | `true` | Engine pages inline default `cn-*` styles (in a CSS layer your rules override) |
203
+ | `roadmap_path`, `bug_reports_path`, `new_bug_report_path`, `admin_triage_path` | engine's pages | `->(view, ...) { ... }` to link your own pages instead; `nil` drops the link |
204
+
205
+ ## License
206
+
207
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,62 @@
1
+ /* feature_machine default styles for the cn-* classes its views use.
2
+ Rendered inline once per engine page unless config.default_stylesheet is
3
+ false. Everything sits in @layer feature_machine, and layered rules lose
4
+ to unlayered ones, so any host stylesheet that defines a cn-* class wins
5
+ without !important. Colors come from the shadcn-style custom properties
6
+ (--background, --foreground, --muted-foreground, --border, --primary,
7
+ --destructive, --radius) when the host has them, with neutral fallbacks. */
8
+ @layer feature_machine {
9
+ .cn-container { max-width: 48rem; margin: 0 auto; padding: 2rem 1rem; font-family: system-ui, sans-serif; color: var(--foreground, #18181b); line-height: 1.5; }
10
+ .cn-font-heading { font-weight: 600; letter-spacing: -0.01em; }
11
+
12
+ .cn-page-header { margin-bottom: 1.5rem; }
13
+ .cn-page-header-eyebrow { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted-foreground, #71717a); margin: 0; }
14
+ .cn-page-header-title { font-size: 1.75rem; margin: 0.25rem 0; }
15
+ .cn-page-header-lede { color: var(--muted-foreground, #71717a); margin: 0; }
16
+
17
+ .cn-button { display: inline-flex; align-items: center; gap: 0.375rem; border: 1px solid transparent; border-radius: var(--radius, 0.375rem); font: inherit; font-size: 0.875rem; font-weight: 500; text-decoration: none; cursor: pointer; }
18
+ .cn-button-size-default { padding: 0.5rem 1rem; }
19
+ .cn-button-size-sm { padding: 0.25rem 0.625rem; font-size: 0.8125rem; }
20
+ .cn-button-variant-default { background: var(--primary, #18181b); color: var(--primary-foreground, #fafafa); }
21
+ .cn-button-variant-outline { background: transparent; border-color: var(--border, #e4e4e7); color: inherit; }
22
+ .cn-button-variant-ghost { background: transparent; color: inherit; }
23
+ .cn-button-variant-ghost:hover, .cn-button-variant-outline:hover { background: var(--accent, #f4f4f5); }
24
+ .cn-button-variant-destructive { background: var(--destructive, #dc2626); color: #fff; }
25
+
26
+ .cn-field { display: flex; flex-direction: column; gap: 0.375rem; margin-bottom: 1rem; }
27
+ .cn-label, .cn-field-label { font-size: 0.875rem; font-weight: 500; }
28
+ .cn-field-description { font-size: 0.8125rem; color: var(--muted-foreground, #71717a); margin: 0; }
29
+ .cn-field-error { font-size: 0.8125rem; color: var(--destructive, #dc2626); margin: 0; }
30
+ .cn-input, .cn-textarea, .cn-native-select { font: inherit; padding: 0.5rem 0.75rem; border: 1px solid var(--input, var(--border, #e4e4e7)); border-radius: var(--radius, 0.375rem); background: var(--background, #fff); color: inherit; }
31
+ .cn-textarea { min-height: 6rem; resize: vertical; }
32
+
33
+ .cn-alert { border: 1px solid var(--border, #e4e4e7); border-radius: var(--radius, 0.375rem); padding: 0.75rem 1rem; margin-bottom: 1rem; }
34
+ .cn-alert-variant-destructive { border-color: var(--destructive, #dc2626); color: var(--destructive, #dc2626); }
35
+ .cn-alert-title { font-weight: 600; margin: 0; }
36
+ .cn-alert-description { margin: 0.25rem 0 0; }
37
+ .cn-flash, .feature-machine-flash { padding: 0.5rem 0.75rem; border-radius: var(--radius, 0.375rem); background: var(--muted, #f4f4f5); }
38
+
39
+ .cn-empty { text-align: center; padding: 3rem 1rem; border: 1px dashed var(--border, #e4e4e7); border-radius: var(--radius, 0.375rem); }
40
+ .cn-empty-header, .cn-empty-content { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; }
41
+ .cn-empty-media-icon { width: 2.5rem; height: 2.5rem; color: var(--muted-foreground, #71717a); }
42
+ .cn-empty-title { font-weight: 600; margin: 0; }
43
+ .cn-empty-description { color: var(--muted-foreground, #71717a); margin: 0; }
44
+
45
+ .cn-entry-list, .cn-relation-list, .cn-spec-evidence-list { list-style: none; margin: 0; padding: 0; }
46
+ .cn-entry-section { margin: 1.5rem 0; }
47
+ .cn-entry-section-title { font-size: 1.125rem; margin: 0 0 0.5rem; }
48
+ .cn-nr-entry { border-bottom: 1px solid var(--border, #e4e4e7); padding: 1rem 0; }
49
+ .cn-nr-entry-title-row { display: flex; align-items: baseline; justify-content: space-between; gap: 0.75rem; }
50
+ .cn-nr-entry-title { font-weight: 600; margin: 0; }
51
+ .cn-nr-entry-summary { margin: 0.25rem 0; }
52
+ .cn-nr-entry-meta { display: flex; flex-wrap: wrap; gap: 0.375rem; font-size: 0.8125rem; color: var(--muted-foreground, #71717a); }
53
+ .cn-nr-entry-status { font-size: 0.75rem; padding: 0.125rem 0.5rem; border-radius: 999px; background: var(--muted, #f4f4f5); }
54
+ .cn-nr-entry-actions, .cn-review-panel-actions, .cn-triage-bucket-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.5rem; }
55
+
56
+ .cn-triage-bucket, .cn-review-panel { border: 1px solid var(--border, #e4e4e7); border-radius: var(--radius, 0.375rem); padding: 1rem; margin-bottom: 1rem; }
57
+ .cn-triage-bucket-header { display: flex; justify-content: space-between; gap: 0.75rem; }
58
+ .cn-triage-bucket-title { font-weight: 600; margin: 0; }
59
+ .cn-triage-bucket-score, .cn-triage-bucket-demand, .cn-triage-summary, .cn-review-panel-lede { font-size: 0.8125rem; color: var(--muted-foreground, #71717a); }
60
+ .cn-triage-bucket-flag { font-size: 0.75rem; padding: 0.125rem 0.5rem; border-radius: 999px; background: var(--muted, #f4f4f5); }
61
+ .cn-triage-bucket-flag-warn { background: #fef3c7; color: #92400e; }
62
+ }
@@ -0,0 +1,109 @@
1
+ # The engine's API controllers authenticate from the Rack env, never from the
2
+ # host's token model: FeatureMachine::Auth::Bearer (or the host's own
3
+ # middleware) has already put a credential at FeatureMachine::ENV_KEY. The
4
+ # prose in 401/403 bodies comes from FeatureMachine.config.
5
+ #
6
+ # The parent is config.api_parent_controller (ActionController::API by
7
+ # default). A host base that already renders errors its own way (render_error,
8
+ # not-found and missing-parameter handling) keeps it; the fallbacks below only
9
+ # fill in what the parent lacks, so a fresh host's API works with no base of
10
+ # its own.
11
+ class Api::FeatureMachine::BaseController < FeatureMachine.config.api_parent_controller.constantize
12
+ inherited_private = ->(name) { private_method_defined?(name) || method_defined?(name) }
13
+
14
+ unless inherited_private.(:render_error)
15
+ rescue_from ActiveRecord::RecordNotFound do
16
+ render_error(status: :not_found, error: "Not found", code: "not_found",
17
+ resolution: "Verify the resource id belongs to the authenticated user.")
18
+ end
19
+ rescue_from ActionController::ParameterMissing do |exception|
20
+ render_error(status: :bad_request, error: exception.message, code: "parameter_missing",
21
+ resolution: "Include the required parameter in the request body.")
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ # Engine-owned names: a host base's own current_user or current_credential
28
+ # is left alone, and never stands in for the engine's.
29
+ attr_reader :api_user, :api_credential
30
+
31
+ unless inherited_private.(:render_error)
32
+ def render_error(status:, error:, code:, resolution: nil, errors: nil)
33
+ body = { error: error, code: code }
34
+ body[:resolution] = resolution if resolution
35
+ body[:errors] = errors if errors
36
+ render json: body, status: status
37
+ end
38
+ end
39
+
40
+ # Reads need `read`, writes need `write`; the work-item hand-off overrides
41
+ # it with `maintainer`.
42
+ unless inherited_private.(:required_scope)
43
+ def required_scope
44
+ request.get? || request.head? ? ::FeatureMachine::READ : ::FeatureMachine::WRITE
45
+ end
46
+ end
47
+
48
+ def authenticate_user_with_token
49
+ @api_credential = request.env[::FeatureMachine::ENV_KEY]
50
+ @api_user = @api_credential&.user
51
+ return render_unauthenticated unless @api_user
52
+ return render_session_write_forbidden if session_credential? && !(request.get? || request.head?)
53
+
54
+ enforce_scope
55
+ end
56
+
57
+ # The engine's rule, not the credential's: for `maintainer`, the host's admin
58
+ # predicate must agree (FeatureMachine::Auth.allows?).
59
+ def enforce_scope
60
+ return if ::FeatureMachine::Auth.allows?(api_credential, required_scope)
61
+
62
+ response.headers["WWW-Authenticate"] =
63
+ %(Bearer realm="#{feature_machine_config.product_name}", error="insufficient_scope", scope="#{required_scope}")
64
+ render_error(
65
+ status: :forbidden,
66
+ error: "Token lacks the #{required_scope} scope",
67
+ code: "insufficient_scope",
68
+ resolution: "This endpoint requires a token with the `#{required_scope}` scope.#{create_token_hint}"
69
+ )
70
+ end
71
+
72
+ def render_unauthenticated
73
+ config = feature_machine_config
74
+ response.headers["WWW-Authenticate"] = [
75
+ %(Bearer realm="#{config.product_name}", error="invalid_token", error_description="Missing or invalid bearer token."),
76
+ (%(resource="#{config.protected_resource_url}") if config.protected_resource_url)
77
+ ].compact.join(", ")
78
+
79
+ resolution = [
80
+ "Send Authorization: Bearer <token>.",
81
+ ("Humans create tokens at #{config.token_help_url}." if config.token_help_url),
82
+ ("See #{config.protected_resource_url} for protected-resource metadata." if config.protected_resource_url)
83
+ ].compact.join(" ")
84
+ render_error(status: :unauthorized, error: "Unauthorized", code: "unauthorized", resolution: resolution)
85
+ end
86
+
87
+ # A browser session may read through the API; writes take a bearer token,
88
+ # so a cross-site form can never act on a signed-in user's behalf here.
89
+ def render_session_write_forbidden
90
+ render_error(
91
+ status: :forbidden,
92
+ error: "Writes need a bearer token",
93
+ code: "session_write_forbidden",
94
+ resolution: "This request is authenticated by your browser session, which the API accepts for reads only. Send Authorization: Bearer <token> to write.#{create_token_hint}"
95
+ )
96
+ end
97
+
98
+ def session_credential?
99
+ api_credential.respond_to?(:session?) && api_credential.session?
100
+ end
101
+
102
+ def create_token_hint
103
+ feature_machine_config.token_help_url ? " Create one at #{feature_machine_config.token_help_url}." : ""
104
+ end
105
+
106
+ def feature_machine_config
107
+ ::FeatureMachine.config
108
+ end
109
+ end
@@ -0,0 +1,32 @@
1
+ # Agents report bugs for the user who handed them a token, and follow that
2
+ # user's own reports.
3
+ class Api::FeatureMachine::BugReportsController < Api::FeatureMachine::BaseController
4
+ before_action :authenticate_user_with_token
5
+
6
+ def index
7
+ @feature_requests = ::FeatureMachine::Request.owned_by(api_user).bug.includes(:user, :work_item).newest_first
8
+ render "api/feature_machine/feature_requests/index"
9
+ end
10
+
11
+ def create
12
+ @feature_request = ::FeatureMachine::Request.owned_by(api_user).bug.build(bug_report_params)
13
+
14
+ if @feature_request.save
15
+ render "api/feature_machine/feature_requests/show", status: :created
16
+ else
17
+ render_error(
18
+ status: :unprocessable_entity,
19
+ error: "Validation failed",
20
+ code: "validation_failed",
21
+ errors: @feature_request.errors.full_messages,
22
+ resolution: "Title is required (8-120 characters). Description is optional, up to 2000 characters."
23
+ )
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def bug_report_params
30
+ params.require(:bug_report).permit(:title, :description)
31
+ end
32
+ end
@@ -0,0 +1,31 @@
1
+ # Agents file feature requests for the user who handed them a token, and
2
+ # follow that user's own requests. No one reads anyone else's.
3
+ class Api::FeatureMachine::FeatureRequestsController < Api::FeatureMachine::BaseController
4
+ before_action :authenticate_user_with_token
5
+
6
+ def index
7
+ @feature_requests = ::FeatureMachine::Request.owned_by(api_user).feature.includes(:user, :work_item).newest_first
8
+ end
9
+
10
+ def create
11
+ @feature_request = ::FeatureMachine::Request.owned_by(api_user).feature.build(feature_request_params)
12
+
13
+ if @feature_request.save
14
+ render :show, status: :created
15
+ else
16
+ render_error(
17
+ status: :unprocessable_entity,
18
+ error: "Validation failed",
19
+ code: "validation_failed",
20
+ errors: @feature_request.errors.full_messages,
21
+ resolution: "Title is required (8-120 characters). Description is optional, up to 2000 characters."
22
+ )
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def feature_request_params
29
+ params.require(:feature_request).permit(:title, :description)
30
+ end
31
+ end
@@ -0,0 +1,28 @@
1
+ # The triage inbox as a maintainer's agent sees it: every user's requests and
2
+ # bug reports, so an agent can pull them into whatever tracker the maintainer
3
+ # triages from. Read-only on purpose. Bucketing a request into a work item,
4
+ # or declining it, stays with a human in /admin.
5
+ class Api::FeatureMachine::Maintainer::FeatureRequestsController < Api::FeatureMachine::BaseController
6
+ before_action :authenticate_user_with_token
7
+
8
+ # `untriaged` is the inbox: requested, not yet bucketed into a work item.
9
+ FILTERS = (%w[untriaged] + ::FeatureMachine::Request.statuses.keys).freeze
10
+
11
+ def index
12
+ filter = params[:status].presence || "untriaged"
13
+ unless FILTERS.include?(filter)
14
+ return render_error(status: :unprocessable_entity, error: "Unknown status", code: "validation_failed",
15
+ resolution: "status must be one of: #{FILTERS.join(', ')}.")
16
+ end
17
+
18
+ scope = filter == "untriaged" ? ::FeatureMachine::Request.untriaged : ::FeatureMachine::Request.where(status: filter)
19
+ @feature_requests = scope.includes(:user, :work_item).oldest_first
20
+ @feature_requests = @feature_requests.where(kind: params[:kind]) if ::FeatureMachine::Request.kinds.key?(params[:kind].to_s)
21
+ end
22
+
23
+ private
24
+
25
+ def required_scope
26
+ ::FeatureMachine::MAINTAINER
27
+ end
28
+ end
@@ -0,0 +1,9 @@
1
+ # The roadmap and changelog for a signed-up user's agent. Any `read` token
2
+ # works; there is no anonymous version.
3
+ class Api::FeatureMachine::RoadmapsController < Api::FeatureMachine::BaseController
4
+ before_action :authenticate_user_with_token
5
+
6
+ def show
7
+ @roadmap = ::FeatureMachine::Roadmap.new
8
+ end
9
+ end
@@ -0,0 +1,56 @@
1
+ # The hand-off surface for a maintainer's agents: read approved work items
2
+ # with their full spec, pick one up, and report progress. Drafts and declined
3
+ # items are invisible here, and nothing about triage (forming, speccing,
4
+ # approving, declining) is reachable: that stays with a human in /admin.
5
+ class Api::FeatureMachine::WorkItemsController < Api::FeatureMachine::BaseController
6
+ before_action :authenticate_user_with_token
7
+ before_action :set_work_item, only: %i[show update]
8
+
9
+ rescue_from ::FeatureMachine::WorkItem::InvalidTransition, with: :render_invalid_transition
10
+
11
+ STATUSES = ::FeatureMachine::WorkItem::MAINTAINER_STATUSES
12
+
13
+ # Defaults to the pickup queue: planned work, oldest first.
14
+ def index
15
+ status = params[:status].presence || "planned"
16
+ unless STATUSES.include?(status)
17
+ return render_error(status: :unprocessable_entity, error: "Unknown status", code: "validation_failed",
18
+ resolution: "status must be one of: #{STATUSES.join(', ')}.")
19
+ end
20
+
21
+ @work_items = ::FeatureMachine::WorkItem.where(status: status).includes(feature_requests: :user).order(:created_at)
22
+ end
23
+
24
+ def show
25
+ end
26
+
27
+ # Only `status`, `progress_note` and `pull_request_url`. The spec is the
28
+ # human's; an agent that disagrees with it says so in the progress note.
29
+ # Sending the status an item already has is a conflict, not a no-op: that is
30
+ # what stops a second agent picking up work someone already has. To add a
31
+ # note or a pull request, send only that.
32
+ def update
33
+ attributes = params.require(:work_item).permit(:status, :progress_note, :pull_request_url)
34
+ changes = attributes.slice(:progress_note, :pull_request_url)
35
+ attributes[:status].present? ? @work_item.agent_transition!(attributes[:status], changes) : @work_item.update!(changes)
36
+ render :show
37
+ rescue ActiveRecord::RecordInvalid => e
38
+ render_error(status: :unprocessable_entity, error: "Validation failed", code: "validation_failed", errors: e.record.errors.full_messages,
39
+ resolution: "Asking for review needs `pull_request_url`: a GitHub pull request URL.")
40
+ end
41
+
42
+ private
43
+
44
+ def required_scope
45
+ ::FeatureMachine::MAINTAINER
46
+ end
47
+
48
+ def set_work_item
49
+ @work_item = ::FeatureMachine::WorkItem.where(status: STATUSES).find(params[:id])
50
+ end
51
+
52
+ def render_invalid_transition(exception)
53
+ render_error(status: :conflict, error: exception.message, code: "invalid_transition",
54
+ resolution: "Re-read the work item: another agent may have picked it up, or it may already be shipped.")
55
+ end
56
+ end
@@ -0,0 +1,28 @@
1
+ module FeatureMachine
2
+ # Who is asking, by the host's own answer (config.current_user), never by a
3
+ # current_user method the host may not define. Pages need a signed-in user:
4
+ # when the host's own filters let a signed-out request through, it goes to
5
+ # config.sign_in_path, or gets a 401 if the host has none.
6
+ module CurrentUser
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ helper_method :feature_machine_user
11
+ end
12
+
13
+ private
14
+
15
+ def feature_machine_user
16
+ return @feature_machine_user if defined?(@feature_machine_user)
17
+
18
+ @feature_machine_user = FeatureMachine.config.user_for(request)
19
+ end
20
+
21
+ def require_feature_machine_user
22
+ return if feature_machine_user
23
+
24
+ path = FeatureMachine.config.sign_in_path&.call(self)
25
+ path ? redirect_to(path) : head(:unauthorized)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,12 @@
1
+ module FeatureMachine
2
+ # Engine pages render through FeatureMachine.config.layout. Rails treats a
3
+ # nil from the proc as "no opinion", so the host's inherited layout applies
4
+ # and engine pages look like the rest of the host's.
5
+ module EngineLayout
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ layout -> { FeatureMachine.config.layout }
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,17 @@
1
+ # Attaches untriaged requests (typically a whole triage bucket) to an
2
+ # existing work item.
3
+ class FeatureMachine::Admin::AttachmentsController < FeatureMachine::Admin::BaseController
4
+ def create
5
+ return redirect_to(admin_triage_path, alert: "Choose a work item to add those requests to.") if params[:work_item_id].blank?
6
+
7
+ # Only items still in play: attaching to a shipped or declined one would
8
+ # stamp the requests with that outcome straight away.
9
+ work_item = ::FeatureMachine::WorkItem.active.find(params[:work_item_id])
10
+ requests = ::FeatureMachine::Request.untriaged.where(id: params[:request_ids]).to_a
11
+ ::FeatureMachine::Request.transaction do
12
+ requests.each { |request| request.update!(work_item: work_item) }
13
+ end
14
+
15
+ redirect_to admin_triage_path, notice: "#{helpers.pluralize(requests.size, 'request')} attached to \"#{work_item.title}\"."
16
+ end
17
+ end