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,44 @@
1
+ <% product = FeatureMachine.config.product_name %>
2
+ <% roadmap = FeatureMachine.config.host_path(:roadmap_path, self) %>
3
+ <% bug_reports = FeatureMachine.config.host_path(:bug_reports_path, self) %>
4
+ <% button = "cn-button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 group/button select-none cn-button-size-default" %>
5
+ <% content_for :title, "Your feature requests — #{product}" %>
6
+ <% content_for :head do %>
7
+ <meta name="robots" content="noindex">
8
+ <% end %>
9
+
10
+ <div class="cn-container">
11
+ <%= render "feature_machine/flash" %>
12
+ <header class="cn-page-header">
13
+ <p class="cn-page-header-eyebrow">Roadmap</p>
14
+ <h1 class="cn-page-header-title">Your feature requests</h1>
15
+ <p class="cn-page-header-lede">
16
+ What you've asked for and where each request stands. Only you can see
17
+ this page.<% if roadmap %> What requests turn into is on the <%= link_to "roadmap", roadmap %>.<% end %>
18
+ </p>
19
+ <div class="mt-5 flex flex-wrap items-center gap-3">
20
+ <a href="<%= new_feature_request_path %>" data-slot="button" data-variant="default" data-size="default"
21
+ class="<%= button %> cn-button-variant-default"><span data-slot="label" class="contents">Request a feature</span></a>
22
+ <% if bug_reports %>
23
+ <a href="<%= bug_reports %>" data-slot="button" data-variant="ghost" data-size="default"
24
+ class="<%= button %> cn-button-variant-ghost"><span data-slot="label" class="contents">Your bug reports</span></a>
25
+ <% end %>
26
+ </div>
27
+ </header>
28
+
29
+ <% if @feature_requests.empty? %>
30
+ <div data-slot="empty" class="cn-empty flex w-full min-w-0 flex-1 flex-col items-center justify-center text-center text-balance">
31
+ <div data-slot="empty-header" class="cn-empty-header flex max-w-sm flex-col items-center">
32
+ <div data-slot="empty-icon" data-variant="icon" class="cn-empty-media flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0 cn-empty-media-icon">
33
+ <svg xmlns="http://www.w3.org/2000/svg" class="size-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/></svg>
34
+ </div>
35
+ <h2 data-slot="empty-title" class="cn-empty-title cn-font-heading">No feature requests yet</h2>
36
+ <p data-slot="empty-description" class="cn-empty-description text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary">What would make <%= product %> more useful to you? Ask, and follow it here.</p>
37
+ </div>
38
+ </div>
39
+ <% else %>
40
+ <div class="cn-entry-list" id="feature-requests">
41
+ <%= render partial: "feature_machine/feature_requests/feature_request", collection: @feature_requests %>
42
+ </div>
43
+ <% end %>
44
+ </div>
@@ -0,0 +1,84 @@
1
+ <% product = FeatureMachine.config.product_name %>
2
+ <% new_bug_report = FeatureMachine.config.host_path(:new_bug_report_path, self) %>
3
+ <% errors = @feature_request.errors %>
4
+ <% button = "cn-button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 group/button select-none cn-button-size-default" %>
5
+ <% field_class = "cn-field grid group/field w-full" %>
6
+ <% label_class = "cn-label flex items-center select-none cn-field-label group/field-label peer/field-label flex w-fit items-center" %>
7
+ <% hint_class = "cn-field-description [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4 font-normal leading-normal" %>
8
+ <% content_for :title, "Request a feature — #{product}" %>
9
+ <% content_for :head do %>
10
+ <meta name="robots" content="noindex">
11
+ <% end %>
12
+
13
+ <div class="cn-container">
14
+ <%= render "feature_machine/flash" %>
15
+ <header class="cn-page-header">
16
+ <p class="cn-page-header-eyebrow">Roadmap</p>
17
+ <h1 class="cn-page-header-title">Request a feature</h1>
18
+ <p class="cn-page-header-lede">
19
+ Tell us what you're trying to do. Only you and the maintainers see what
20
+ you write; you can follow it under
21
+ <%= link_to "your feature requests", feature_requests_path %>.
22
+ <% if new_bug_report %><%= link_to "Reporting a bug instead?", new_bug_report %><% end %>
23
+ </p>
24
+ </header>
25
+
26
+ <%# Plain form_with: the same Field / Input / Textarea / Button markup the
27
+ host's Poetry builder renders, written out so the engine carries no
28
+ dependency on the host's UI kit. Field order inside each cn-field:
29
+ label, control, hint, error. aria-required rides the label-station
30
+ rule (never the native attribute); aria-describedby points at the
31
+ hint and, on a bounce, the error. %>
32
+ <%= form_with(model: @feature_request, class: "flex max-w-xl flex-col gap-5") do |form| %>
33
+ <% if errors.any? %>
34
+ <div data-slot="alert" data-variant="destructive" role="alert" class="cn-alert relative w-full group/alert cn-alert-variant-destructive">
35
+ <div data-slot="alert-title" class="cn-alert-title [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3">That didn't go through</div>
36
+ <div data-slot="alert-description" class="cn-alert-description [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3">
37
+ <ul class="list-inside list-disc">
38
+ <% errors.full_messages.each do |message| %>
39
+ <li><%= message %></li>
40
+ <% end %>
41
+ </ul>
42
+ </div>
43
+ </div>
44
+ <% end %>
45
+
46
+ <% title_errors = errors[:title] %>
47
+ <div data-slot="field" data-invalid="<%= title_errors.any? %>" class="<%= field_class %>">
48
+ <%= form.label :title, class: label_class, data: { slot: "field-label" } %>
49
+ <%= form.text_field :title,
50
+ class: "cn-input w-full min-w-0 outline-none",
51
+ placeholder: "e.g. Let me export my data as a CSV file",
52
+ maxlength: 120, minlength: 8, autofocus: true,
53
+ "aria-required": true,
54
+ "aria-invalid": (true if title_errors.any?),
55
+ "aria-describedby": ["feature_request_title_hint", ("feature_request_title_error" if title_errors.any?)].compact.join(" ") %>
56
+ <p id="feature_request_title_hint" data-slot="field-description" class="<%= hint_class %>">One line, the way you'd say it to a friend.</p>
57
+ <% if title_errors.any? %>
58
+ <p id="feature_request_title_error" data-slot="field-error" class="cn-field-error font-normal"><%= title_errors.to_sentence %></p>
59
+ <% end %>
60
+ </div>
61
+
62
+ <% description_errors = errors[:description] %>
63
+ <div data-slot="field" data-invalid="<%= description_errors.any? %>" class="<%= field_class %>">
64
+ <%= form.label :description, class: label_class, data: { slot: "field-label" } %>
65
+ <%= form.text_area :description,
66
+ class: "cn-textarea flex field-sizing-content min-h-16 w-full outline-none",
67
+ rows: 6, maxlength: 2000,
68
+ placeholder: "What are you trying to do, and what gets in the way today?",
69
+ "aria-invalid": (true if description_errors.any?),
70
+ "aria-describedby": ["feature_request_description_hint", ("feature_request_description_error" if description_errors.any?)].compact.join(" ") %>
71
+ <p id="feature_request_description_hint" data-slot="field-description" class="<%= hint_class %>">The problem, not the implementation. We'll figure out the how.</p>
72
+ <% if description_errors.any? %>
73
+ <p id="feature_request_description_error" data-slot="field-error" class="cn-field-error font-normal"><%= description_errors.to_sentence %></p>
74
+ <% end %>
75
+ </div>
76
+
77
+ <div class="flex items-center gap-3">
78
+ <button type="submit" name="commit" value="Request it" data-slot="button" data-variant="default" data-size="default"
79
+ class="<%= button %> cn-button-variant-default"><span data-slot="label" class="contents">Request it</span></button>
80
+ <a href="<%= feature_requests_path %>" data-slot="button" data-variant="ghost" data-size="default"
81
+ class="<%= button %> cn-button-variant-ghost"><span data-slot="label" class="contents">Back to your requests</span></a>
82
+ </div>
83
+ <% end %>
84
+ </div>
@@ -0,0 +1,34 @@
1
+ <%# One roadmap entry: the public face of a work item. No spec; that is the
2
+ maintainer's working document. %>
3
+ <article id="<%= dom_id(work_item) %>" data-slot="nr-entry" data-state="ready"
4
+ class="cn-nr-entry flex gap-4 py-5 border-b border-border group/entry cn-nr-entry-state-ready">
5
+ <div data-slot="nr-entry-body" class="cn-nr-entry-body order-1 min-w-0 flex-1">
6
+ <div class="cn-nr-entry-title-row flex flex-wrap items-baseline gap-3">
7
+ <h3 data-slot="nr-entry-title" class="cn-nr-entry-title"><%= work_item.title %></h3>
8
+ <span class="cn-nr-entry-status"><%= work_item_status(work_item, public: true) %></span>
9
+ </div>
10
+
11
+ <div data-slot="nr-entry-meta" class="cn-nr-entry-meta mt-2 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm">
12
+ <span class="cn-nr-entry-meta-item"><%= work_kind_label(work_item) %></span>
13
+ <% if work_item.shipped_at.present? %>
14
+ <span class="cn-nr-entry-meta-sep" aria-hidden="true">&middot;</span>
15
+ <span class="cn-nr-entry-meta-item">shipped <%= work_item.shipped_at.strftime("%b %-d, %Y") %></span>
16
+ <% end %>
17
+ <% if (demand = work_item_demand(work_item)) %>
18
+ <span class="cn-nr-entry-meta-sep" aria-hidden="true">&middot;</span>
19
+ <span class="cn-nr-entry-meta-item"><%= demand %></span>
20
+ <% end %>
21
+ </div>
22
+
23
+ <p data-slot="nr-entry-summary" class="cn-nr-entry-summary mt-2"><%= work_item.summary %></p>
24
+
25
+ <% if work_item.progress_note.present? %>
26
+ <div data-slot="nr-entry-details" class="cn-nr-entry-details mt-4 pt-4">
27
+ <div class="cn-entry-section">
28
+ <h4 class="cn-entry-section-title">Progress</h4>
29
+ <p class="cn-entry-section-content"><%= simple_format(work_item.progress_note, {}, wrapper_tag: "span") %></p>
30
+ </div>
31
+ </div>
32
+ <% end %>
33
+ </div>
34
+ </article>
@@ -0,0 +1,70 @@
1
+ <% product = FeatureMachine.config.product_name %>
2
+ <% button = "cn-button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 group/button select-none cn-button-size-default" %>
3
+ <% new_bug_report = FeatureMachine.config.host_path(:new_bug_report_path, self) %>
4
+ <% bug_reports = FeatureMachine.config.host_path(:bug_reports_path, self) %>
5
+ <% content_for :title, "Roadmap — #{product}" %>
6
+ <% content_for :head do %>
7
+ <meta name="robots" content="noindex">
8
+ <% end %>
9
+
10
+ <div class="cn-container">
11
+ <%= render "feature_machine/flash" %>
12
+ <header class="cn-page-header">
13
+ <p class="cn-page-header-eyebrow">Roadmap</p>
14
+ <h1 class="cn-page-header-title">What's being built, and what shipped</h1>
15
+ <p class="cn-page-header-lede">
16
+ Each entry is a piece of work shaped from what people asked for. Planned
17
+ work first, then the changelog.
18
+ </p>
19
+ <div class="mt-5 flex flex-wrap items-center gap-3">
20
+ <a href="<%= new_feature_request_path %>" data-slot="button" data-variant="default" data-size="default"
21
+ class="<%= button %> cn-button-variant-default"><span data-slot="label" class="contents">Request a feature</span></a>
22
+ <% if new_bug_report %>
23
+ <a href="<%= new_bug_report %>" data-slot="button" data-variant="outline" data-size="default"
24
+ class="<%= button %> cn-button-variant-outline"><span data-slot="label" class="contents">Report a bug</span></a>
25
+ <% end %>
26
+ <a href="<%= feature_requests_path %>" data-slot="button" data-variant="ghost" data-size="default"
27
+ class="<%= button %> cn-button-variant-ghost"><span data-slot="label" class="contents">Your requests</span></a>
28
+ <% if bug_reports %>
29
+ <a href="<%= bug_reports %>" data-slot="button" data-variant="ghost" data-size="default"
30
+ class="<%= button %> cn-button-variant-ghost"><span data-slot="label" class="contents">Your bug reports</span></a>
31
+ <% end %>
32
+ </div>
33
+ </header>
34
+
35
+ <% if @roadmap.empty? %>
36
+ <div data-slot="empty" class="cn-empty flex w-full min-w-0 flex-1 flex-col items-center justify-center text-center text-balance">
37
+ <div data-slot="empty-header" class="cn-empty-header flex max-w-sm flex-col items-center">
38
+ <h2 data-slot="empty-title" class="cn-empty-title cn-font-heading">Nothing on the roadmap yet</h2>
39
+ <p data-slot="empty-description" class="cn-empty-description text-muted-foreground">Requests are still being triaged. Planned work will appear here.</p>
40
+ </div>
41
+ </div>
42
+ <% end %>
43
+
44
+ <% @roadmap.upcoming.each do |status, work_items| %>
45
+ <% next if work_items.empty? %>
46
+ <section class="mt-12" aria-labelledby="roadmap-<%= status %>">
47
+ <h2 id="roadmap-<%= status %>" class="flex items-baseline gap-3">
48
+ <%= work_item_status(work_items.first, public: true) %>
49
+ <span class="text-sm text-muted-foreground"><%= pluralize(work_items.size, "item") %></span>
50
+ </h2>
51
+ <p class="cn-page-header-lede mt-2"><%= work_item_status_blurb(status) %></p>
52
+ <div class="cn-entry-list">
53
+ <%= render partial: "feature_machine/roadmap/work_item", collection: work_items %>
54
+ </div>
55
+ </section>
56
+ <% end %>
57
+
58
+ <% if @roadmap.changelog.any? %>
59
+ <section class="mt-12" aria-labelledby="changelog">
60
+ <h2 id="changelog" class="cn-page-header-title">Changelog</h2>
61
+ <p class="cn-page-header-lede mt-2">Features shipped and bugs fixed, newest first.</p>
62
+ <% @roadmap.changelog.each do |month, work_items| %>
63
+ <h3 class="changelog-month" id="changelog-<%= month.strftime("%Y-%m") %>"><%= month.strftime("%B %Y") %></h3>
64
+ <div class="cn-entry-list">
65
+ <%= render partial: "feature_machine/roadmap/work_item", collection: work_items %>
66
+ </div>
67
+ <% end %>
68
+ </section>
69
+ <% end %>
70
+ </div>
@@ -0,0 +1,21 @@
1
+ <%# The engine's standalone shell, used only when a host sets
2
+ FeatureMachine.config.layout = "feature_machine". No host assets are
3
+ assumed: hosts add their own via content_for :head or name their own layout. %>
4
+ <!DOCTYPE html>
5
+ <html lang="en">
6
+ <head>
7
+ <meta charset="UTF-8">
8
+ <meta name="viewport" content="width=device-width,initial-scale=1">
9
+ <title><%= content_for?(:title) ? content_for(:title) : FeatureMachine.config.product_name %></title>
10
+ <%= csrf_meta_tags %>
11
+ <%= csp_meta_tag %>
12
+ <%= yield :head %>
13
+ </head>
14
+ <body>
15
+ <% if notice.present? %><p role="status" data-slot="flash-notice"><%= notice %></p><% end %>
16
+ <% if alert.present? %><p role="alert" data-slot="flash-alert"><%= alert %></p><% end %>
17
+ <main>
18
+ <%= yield %>
19
+ </main>
20
+ </body>
21
+ </html>
@@ -0,0 +1,18 @@
1
+ class CreateFeatureRequests < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :feature_requests do |t|
4
+ t.references :user, null: false, foreign_key: true
5
+ t.string :title, null: false
6
+ t.text :description
7
+ # requested 0, planned 1, in_progress 2, shipped 3, declined 4 (FeatureRequest#status)
8
+ t.integer :status, null: false, default: 0
9
+ # Public progress note the maintainer writes when the status moves.
10
+ t.text :progress_note
11
+ t.datetime :shipped_at
12
+
13
+ t.timestamps
14
+ end
15
+
16
+ add_index :feature_requests, [ :status, :created_at ]
17
+ end
18
+ end
@@ -0,0 +1,52 @@
1
+ # Splits "what a user asked for" (feature_requests, now also bug reports) from
2
+ # "what we will build" (work_items). Requests are bucketed into work items by
3
+ # an admin during triage; work items relate to one another as a graph.
4
+ class CreateWorkItems < ActiveRecord::Migration[8.1]
5
+ def change
6
+ # The embedding columns below need pgvector; a fresh host may not have it yet.
7
+ enable_extension "vector"
8
+
9
+ create_table :work_items do |t|
10
+ t.string :title, null: false
11
+ # feature 0, bug 1 (WorkItem#kind)
12
+ t.integer :kind, null: false, default: 0
13
+ # draft 0, planned 1, in_progress 2, shipped 3, declined 4 (WorkItem#status)
14
+ t.integer :status, null: false, default: 0
15
+ # The hand-off spec.
16
+ t.text :problem
17
+ t.text :scope
18
+ t.text :acceptance_criteria
19
+ # Public: the one-paragraph roadmap/changelog entry and the progress note.
20
+ t.text :summary
21
+ t.text :progress_note
22
+ t.datetime :shipped_at
23
+ t.vector :embedding, limit: 1536
24
+ t.datetime :embedded_at
25
+ t.string :embedding_input_digest
26
+
27
+ t.timestamps
28
+ end
29
+ add_index :work_items, [ :status, :shipped_at ]
30
+
31
+ create_table :work_item_relations do |t|
32
+ t.references :from_work_item, null: false, foreign_key: { to_table: :work_items }
33
+ t.references :to_work_item, null: false, foreign_key: { to_table: :work_items }
34
+ t.string :kind, null: false, default: "related"
35
+
36
+ t.timestamps
37
+ end
38
+ add_index :work_item_relations, [ :from_work_item_id, :to_work_item_id, :kind ],
39
+ unique: true, name: "index_work_item_relations_uniqueness"
40
+
41
+ change_table :feature_requests do |t|
42
+ # feature 0, bug 1 (FeatureRequest#kind)
43
+ t.integer :kind, null: false, default: 0
44
+ t.references :work_item, foreign_key: true
45
+ t.datetime :embedded_at
46
+ t.string :embedding_input_digest
47
+ end
48
+ add_column :feature_requests, :embedding, :vector, limit: 1536
49
+
50
+ add_column :users, :admin, :boolean, null: false, default: false
51
+ end
52
+ end
@@ -0,0 +1,7 @@
1
+ # The pull request that implements a work item, attached by the agent that
2
+ # opened it when it hands the work back for review.
3
+ class AddPullRequestUrlToWorkItems < ActiveRecord::Migration[8.1]
4
+ def change
5
+ add_column :work_items, :pull_request_url, :string
6
+ end
7
+ end
@@ -0,0 +1,89 @@
1
+ module FeatureMachine
2
+ module Auth
3
+ # Does this credential allow `scope`, by the engine's rules? The credential
4
+ # answers for itself, and for `maintainer` the host's admin predicate must
5
+ # agree. This is the one place the rule lives; controllers and tools call
6
+ # it rather than `credential.allows?` directly.
7
+ def self.allows?(credential, scope)
8
+ return false unless credential&.allows?(scope.to_s)
9
+ return true unless scope.to_s == MAINTAINER
10
+
11
+ FeatureMachine.config.admin?(credential.user)
12
+ end
13
+
14
+ # The "Authorization: Bearer <token>" value of a Rack env, or nil.
15
+ def self.bearer_token(env)
16
+ header = env["HTTP_AUTHORIZATION"].to_s
17
+ scheme, token = header.split(" ", 2)
18
+ token&.strip.presence if scheme&.casecmp?("bearer")
19
+ end
20
+
21
+ # The credential a signed-in browser session carries. `user` is resolved
22
+ # on first use, so installing the middleware costs nothing on requests
23
+ # that never ask. A session user may read and write their own requests;
24
+ # maintainer access needs the host's admin predicate.
25
+ class SessionCredential
26
+ def initialize(request, config: FeatureMachine.config)
27
+ @request = request
28
+ @config = config
29
+ end
30
+
31
+ def user
32
+ return @user if defined?(@user)
33
+
34
+ @user = @config.user_for(@request)
35
+ end
36
+
37
+ def allows?(scope)
38
+ return false unless user
39
+
40
+ case scope.to_s
41
+ when READ, WRITE then true
42
+ when MAINTAINER then @config.admin?(user)
43
+ else false
44
+ end
45
+ end
46
+
47
+ def session?
48
+ true
49
+ end
50
+ end
51
+
52
+ # Resolves a bearer token through `config.authenticate_api` and sets the
53
+ # env key when it finds a credential. A missing or bad token sets nothing,
54
+ # so the next middleware may still find one. Install before Session.
55
+ class Bearer
56
+ def initialize(app, resolver: nil)
57
+ @app = app
58
+ @resolver = resolver
59
+ end
60
+
61
+ def call(env)
62
+ if env[ENV_KEY].nil? && (resolver = @resolver || FeatureMachine.config.authenticate_api)
63
+ token = Auth.bearer_token(env)
64
+ credential = resolver.call(token) if token
65
+ env[ENV_KEY] = credential if credential
66
+ end
67
+
68
+ @app.call(env)
69
+ end
70
+ end
71
+
72
+ # Sets a SessionCredential when nothing earlier in the stack set a
73
+ # credential and `config.session_auth` is on. Install last, after the
74
+ # host's session middleware.
75
+ class Session
76
+ def initialize(app)
77
+ @app = app
78
+ end
79
+
80
+ def call(env)
81
+ if env[ENV_KEY].nil? && FeatureMachine.config.session_auth
82
+ env[ENV_KEY] = SessionCredential.new(ActionDispatch::Request.new(env))
83
+ end
84
+
85
+ @app.call(env)
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,137 @@
1
+ # Mutation calibration cases for FeatureMachine::Judge.
2
+ #
3
+ # Each case is one base request plus two mutants:
4
+ # equivalent: the same request in different words. The answer must not move.
5
+ # faulty: one fact flipped. The answer must move to `faulty.expect`.
6
+ #
7
+ # A judge that moves on paraphrase is reading surface wording; a judge that
8
+ # holds still on a flipped fact isn't reading at all. Requests are drawn from
9
+ # the acceptance personas (lib/acceptance_personas.rb) and `open_items` mirrors
10
+ # the active work items the maintainer persona seeds, so `duplicate_of_open_item`
11
+ # has the same context it gets in triage.
12
+ #
13
+ # Expectations: probability questions use "high" (>= 0.5) or "low" (< 0.5);
14
+ # score questions use one of the judge's labels. `thresholds` bound how far an
15
+ # equivalent mutant may drift from the base before it counts as moved.
16
+ #
17
+ # Run against a live model with `rake feature_machine:calibrate_judge` (opt-in,
18
+ # never in CI). judge_cases_test.rb only checks this file's shape.
19
+
20
+ thresholds:
21
+ probability_drift: 0.3
22
+ score_drift: 0
23
+
24
+ open_items:
25
+ - "[feature] Pin articles to the top of a public list"
26
+ - "[feature] Pending connection suggestions over MCP"
27
+ - "[feature] Webhooks for digest events"
28
+ - "[feature] Mark an article to reread later"
29
+
30
+ cases:
31
+ - id: bug-stuck-drafts
32
+ question: bug
33
+ persona: drafter
34
+ base:
35
+ kind: bug
36
+ title: My drafts never finish fetching details
37
+ description: Three links have said 'Fetching details' since yesterday.
38
+ expect: high
39
+ equivalent:
40
+ kind: bug
41
+ title: Saved links stuck on 'Fetching details'
42
+ description: Since yesterday, three of my saved links are still showing 'Fetching details' and never fill in.
43
+ faulty:
44
+ kind: bug
45
+ title: Show progress while drafts fetch details
46
+ description: Fetching details takes a few seconds on my links. A progress bar would tell me it's working.
47
+ expect: low
48
+
49
+ - id: bug-csv-export
50
+ question: bug
51
+ persona: newcomer
52
+ base:
53
+ kind: feature
54
+ title: Export my library as CSV
55
+ description: I'd like to download everything I've saved as a CSV so I can keep a copy in a spreadsheet.
56
+ expect: low
57
+ equivalent:
58
+ kind: feature
59
+ title: Download my saved articles as a spreadsheet
60
+ description: Could there be a CSV download of my whole library? I want a backup I can open in a spreadsheet.
61
+ faulty:
62
+ kind: feature
63
+ title: Export my library as CSV
64
+ description: I downloaded everything I've saved as a CSV, but the file is empty apart from the header row.
65
+ expect: high
66
+
67
+ - id: duplicate-pinning
68
+ question: duplicate_of_open_item
69
+ persona: curator
70
+ base:
71
+ kind: feature
72
+ title: Keep my favourite articles at the top of my public page
73
+ description: I want two or three pieces to stay first on my public list instead of sliding down as I save new ones.
74
+ expect: high
75
+ equivalent:
76
+ kind: feature
77
+ title: Let me pin a few articles on my public list
78
+ description: New saves push my best pieces off the top of my public page. I'd like to fix a handful in place above everything else.
79
+ faulty:
80
+ kind: feature
81
+ title: Hide some articles from my public page
82
+ description: I want two or three pieces to stay off my public list while still being in my library.
83
+ expect: low
84
+
85
+ - id: duplicate-mcp-tagging
86
+ question: duplicate_of_open_item
87
+ persona: agentic
88
+ base:
89
+ kind: feature
90
+ title: Let agents tag articles over MCP
91
+ description: My agent can save and reflect on articles but has no way to add or remove tags on them.
92
+ expect: low
93
+ equivalent:
94
+ kind: feature
95
+ title: MCP tool for article tags
96
+ description: Agents can add articles and reflections through MCP, but there's no tool to set an article's tags.
97
+ faulty:
98
+ kind: feature
99
+ title: Let agents see suggested connections over MCP
100
+ description: My agent can create connections but has no way to list the ones the app has already suggested.
101
+ expect: high
102
+
103
+ - id: clarity-reading-time
104
+ question: clarity
105
+ persona: reader
106
+ base:
107
+ kind: feature
108
+ title: Show reading time on my public list
109
+ description: My newsletter subscribers click through from my public list and ask how long each piece is. A minutes estimate next to each article would let them pick what fits their time.
110
+ expect: High
111
+ equivalent:
112
+ kind: feature
113
+ title: Reading-time estimates on public list articles
114
+ description: People who subscribe to my newsletter browse my public list and want to know how long an article takes before opening it. Please show an estimated read time beside each one.
115
+ faulty:
116
+ kind: feature
117
+ title: Make my public list better
118
+ description: My newsletter subscribers use my public list. It could be better for them.
119
+ expect: Low
120
+
121
+ - id: clarity-no-description
122
+ question: clarity
123
+ persona: Edge_Case-99
124
+ base:
125
+ kind: feature
126
+ title: No description
127
+ description: ""
128
+ expect: Low
129
+ equivalent:
130
+ kind: feature
131
+ title: An idea
132
+ description: ""
133
+ faulty:
134
+ kind: feature
135
+ title: Search my reflections, not just article titles
136
+ description: I write a lot of reflections and can't find them later. The library search only matches titles, so I want it to match the text of my reflections too.
137
+ expect: High