Skip to content

Commit 22b316b

Browse files
[OSCER-309] Explore generator for reference files as agent rules for downstream projects (#310)
## Summary Closes #309 — Explore generator for reference files as agent rules for downstream projects Implements `strata:rules` Rails generator that produces path-scoped agent rule files for the Application Form SDK component. Generator reads SDK source files and renders ERB templates into target agent's rules directory (`.agents/rules/strata-sdk/`, `.claude/rules/strata-sdk/`, etc.). Rules contain embedded source code excerpts that stay current on re-generation. ## Changes - Add SourceExcerptHelper module for reading SDK source files and extracting documentation sections - Add RulesGenerator skeleton with git root detection and --agent flag support - Add application_form root rule template with overview + quick reference - Add 3 application_form sub-templates: core-class, attributes, determinable with full source embeds - Add comprehensive RSpec test suite (39 examples, all passing) - Update docs/generators.md with strata:rules documentation - Update rule paths with **/app prefix for nested app directories (monorepo support) ## Test plan Manually tested rule generation and loading in: - Antigravity - Cursor - Claude Rules load correctly and are scoped to relevant file paths. Example of how my agent in Antigravity used these files: <img width="1157" height="673" alt="Screenshot 2026-04-17 at 1 53 42 PM" src="https://github.com/user-attachments/assets/1a1856f8-2ea0-47cf-ad0c-d525f47de4ed" /> <img width="871" height="1313" alt="Screenshot 2026-04-17 at 2 19 40 PM" src="https://github.com/user-attachments/assets/758bf8b8-540d-404e-87db-eccc90c255b1" /> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0b11524 commit 22b316b

11 files changed

Lines changed: 1260 additions & 0 deletions

File tree

docs/generators.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,21 @@ Creates a standard set of files required for implementing a staff dashboard in a
8585
bin/rails generate strata:staff
8686
```
8787

88+
### strata:rules
89+
90+
Generates path-scoped agent rule files for Strata SDK features. Rules embed current SDK source code so re-running the generator updates rules to match the installed version. [See full usage guide](../lib/generators/strata/rules/USAGE)
91+
92+
```bash
93+
bin/rails generate strata:rules FEATURE_NAME [--agent AGENT]
94+
```
95+
96+
Supported features: `application_form`, `all`
97+
98+
Output directory (defaults to `.agents/rules/strata-sdk/`):
99+
- `--agent claude``.claude/rules/strata-sdk/`
100+
- `--agent cursor``.cursor/rules/strata-sdk/`
101+
- `--agent copilot``.copilot/rules/strata-sdk/`
102+
88103
## Generator Dependencies
89104

90105
- When generating a case, the generator will check for the existence of associated business process and application form classes

lib/generators/strata/rules/USAGE

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
Description:
2+
Generates path-scoped agent rule files for Strata SDK features.
3+
Rules embed current SDK source code excerpts so re-running the
4+
generator updates rules to match the installed SDK version.
5+
6+
Output goes to .agents/rules/strata-sdk/ by default.
7+
Use --agent to target a specific agent's rules directory.
8+
9+
Examples:
10+
bin/rails generate strata:rules application_form
11+
bin/rails generate strata:rules application_form --agent claude
12+
bin/rails generate strata:rules application_form --agent cursor
13+
bin/rails generate strata:rules all
14+
15+
Supported features:
16+
application_form Application form model, attributes, and determinable
17+
all Generate rules for all supported features
18+
19+
Agent directories:
20+
--agent claude .claude/rules/strata-sdk/
21+
--agent cursor .cursor/rules/strata-sdk/
22+
--agent copilot .copilot/rules/strata-sdk/
23+
(default) .agents/rules/strata-sdk/
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# frozen_string_literal: true
2+
3+
require "rails/generators"
4+
require "erb"
5+
require_relative "source_excerpt_helper"
6+
7+
module Strata
8+
module Generators
9+
# Generates path-scoped agent rule files for Strata SDK features.
10+
# Rules embed current SDK source code so re-running the generator
11+
# updates rules to match the installed SDK version.
12+
class RulesGenerator < Rails::Generators::NamedBase
13+
source_root File.expand_path("templates", __dir__)
14+
include SourceExcerptHelper
15+
16+
SUPPORTED_FEATURES = %w[application_form].freeze
17+
18+
# Manifest of all SDK source files referenced by templates.
19+
# Specs validate these paths still exist, catching drift when
20+
# SDK files are renamed or removed.
21+
SOURCE_REFERENCES = {
22+
"application_form" => {
23+
files: %w[
24+
app/models/strata/application_form.rb
25+
app/lib/strata/attributes.rb
26+
app/lib/strata/attributes/address_attribute.rb
27+
app/lib/strata/attributes/name_attribute.rb
28+
app/lib/strata/attributes/memorable_date_attribute.rb
29+
app/models/concerns/strata/determinable.rb
30+
app/views/strata/application_forms/index.html.erb
31+
app/views/strata/application_forms/show.html.erb
32+
app/views/strata/shared/_form_buttons.html.erb
33+
app/views/strata/shared/_exit_link.html.erb
34+
app/views/strata/shared/_breadcrumbs.html.erb
35+
lib/generators/strata/application_form_views/templates/layout.html.erb.tt
36+
lib/generators/strata/application_form_views/templates/edit_page.html.erb.tt
37+
docs/intake-application-forms.md
38+
docs/multi-page-form-flows.md
39+
]
40+
}
41+
}.freeze
42+
43+
AGENT_DIRS = {
44+
"claude" => ".claude/rules",
45+
"cursor" => ".cursor/rules",
46+
"copilot" => ".copilot/rules"
47+
}.freeze
48+
49+
DEFAULT_RULES_DIR = ".agents/rules"
50+
51+
desc "Generates path-scoped agent rules for Strata SDK features"
52+
53+
class_option :agent, type: :string,
54+
desc: "Target agent (claude, cursor, copilot). Default: writes to .agents/rules/"
55+
class_option :force, type: :boolean, default: true,
56+
desc: "Overwrite existing rule files"
57+
58+
def validate_feature_name
59+
return if name == "all"
60+
61+
unless SUPPORTED_FEATURES.include?(name.underscore)
62+
raise Thor::Error, "Unknown feature '#{name}'. Supported: #{SUPPORTED_FEATURES.join(', ')}, all"
63+
end
64+
end
65+
66+
def generate_rules
67+
features = name == "all" ? SUPPORTED_FEATURES : [ name.underscore ]
68+
features.each do |feature|
69+
# Root rule file
70+
render_erb_template(
71+
"#{feature}.md.erb",
72+
File.join(output_dir, "strata-sdk", "strata-#{feature.dasherize}.md")
73+
)
74+
75+
# Sub-rule files from feature subdirectory
76+
sub_template_dir = File.join(self.class.source_root, feature)
77+
next unless File.directory?(sub_template_dir)
78+
79+
Dir.glob("#{sub_template_dir}/*.md.erb").sort.each do |sub_template_path|
80+
sub_name = File.basename(sub_template_path, ".md.erb")
81+
render_erb_template(
82+
File.join(feature, "#{sub_name}.md.erb"),
83+
File.join(output_dir, "strata-sdk", "strata-#{feature.dasherize}", "#{sub_name}.md")
84+
)
85+
end
86+
end
87+
end
88+
89+
private
90+
91+
# Walk up from destination_root to find the git root (.git directory).
92+
# Falls back to destination_root if no .git is found (e.g., in tests).
93+
def git_root
94+
dir = Pathname.new(destination_root).expand_path
95+
loop do
96+
return dir if (dir + ".git").exist?
97+
parent = dir.parent
98+
return Pathname.new(destination_root).expand_path if parent == dir
99+
dir = parent
100+
end
101+
end
102+
103+
def output_dir
104+
agent_subdir = AGENT_DIRS.fetch(options[:agent]&.downcase, DEFAULT_RULES_DIR)
105+
git_root.join(agent_subdir).to_s
106+
end
107+
108+
def render_erb_template(template_relative_path, destination)
109+
source_path = File.join(self.class.source_root, template_relative_path)
110+
erb_content = File.read(source_path, encoding: "UTF-8")
111+
rendered = ERB.new(erb_content, trim_mode: "-").result(binding)
112+
create_file destination, rendered
113+
end
114+
end
115+
end
116+
end
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# frozen_string_literal: true
2+
3+
module Strata
4+
module Generators
5+
# Helper methods for reading SDK source files and documentation sections.
6+
# Included in RulesGenerator to make helpers available in ERB template bindings.
7+
module SourceExcerptHelper
8+
def sdk_root
9+
Strata::Engine.root
10+
end
11+
12+
# Reads a file relative to the SDK root. Returns content as a string.
13+
# Raises if the file does not exist.
14+
def read_sdk_file(relative_path)
15+
full_path = sdk_root.join(relative_path)
16+
raise "SDK file not found: #{relative_path} (looked at #{full_path})" unless File.exist?(full_path)
17+
File.read(full_path, encoding: "UTF-8").strip
18+
end
19+
20+
# Extracts a markdown section by heading from a doc file in docs/.
21+
# Returns everything between the matched heading and the next heading
22+
# of the same or higher level. Returns empty string if heading not found.
23+
def excerpt_doc_section(doc_filename, section_heading)
24+
doc_path = sdk_root.join("docs", doc_filename)
25+
raise "Doc file not found: #{doc_filename} (looked at #{doc_path})" unless File.exist?(doc_path)
26+
27+
content = File.read(doc_path, encoding: "UTF-8")
28+
lines = content.lines
29+
30+
heading_idx = lines.index { |l| l.strip.match?(/^#+\s+#{Regexp.escape(section_heading)}\s*$/) }
31+
return "" unless heading_idx
32+
33+
level = lines[heading_idx].match(/^(#+)/)[1].length
34+
35+
body_lines = []
36+
lines[(heading_idx + 1)..].each do |line|
37+
if line.match?(/^(#+)\s/)
38+
line_level = line.match(/^(#+)/)[1].length
39+
break if line_level <= level
40+
end
41+
body_lines << line
42+
end
43+
44+
body_lines.join.strip
45+
end
46+
end
47+
end
48+
end
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
paths:
3+
- "**/app/models/**/*application_form*.rb"
4+
- "**/app/controllers/**/*application_forms*.rb"
5+
---
6+
7+
# Strata SDK: Application Forms
8+
9+
Rules for building application forms with the Strata SDK. Application forms implement intake processes for government digital services.
10+
11+
## What is an Application Form?
12+
13+
<%= excerpt_doc_section("intake-application-forms.md", "What is an Application Form?") %>
14+
15+
## Build Recipe
16+
17+
Building a new application form = 6 steps: generate model, test model, generate controller, test controller, build views, test views. Full recipe with commands + test templates:
18+
19+
→ **`strata-application-form/recipe.md`** (auto-loads when editing any application form file)
20+
21+
22+
23+
## Detailed Reference (Sub-rules)
24+
25+
For implementation details, these sub-rules load automatically when working on application form files:
26+
27+
| File | Contents |
28+
|------|----------|
29+
| `strata-sdk/strata-application-form/recipe.md` | Build recipe: 6-step gen→test workflow with commands and test templates |
30+
| `strata-sdk/strata-application-form/core-class.md` | Full ApplicationForm source, event system, submission internals |
31+
| `strata-sdk/strata-application-form/attributes.md` | Strata::Attributes module source, column mappings, attribute type details |
32+
| `strata-sdk/strata-application-form/determinable.md` | Strata::Determinable source, recording determinations |
33+
| `strata-sdk/strata-application-form/views.md` | Generated question-page templates, shared partials, index/show views, required I18n keys |
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
paths:
3+
- "**/app/models/**/*application_form*.rb"
4+
- "**/app/models/**/*form*.rb"
5+
---
6+
7+
# Strata SDK: Strata Attributes
8+
9+
Full source and usage for the `Strata::Attributes` module and the `strata_attribute` DSL.
10+
11+
## Attributes Module Source
12+
13+
```ruby
14+
<%= read_sdk_file("app/lib/strata/attributes.rb") %>
15+
```
16+
17+
## Column Mappings by Type
18+
19+
| Attribute Type | Database Column(s) | Ruby Type |
20+
|---------------|-------------------|-----------|
21+
| `name` | `{attr}_first`, `{attr}_middle`, `{attr}_last`, `{attr}_suffix` | Hash → `Strata::Name` |
22+
| `address` | `{attr}_street_line_1`, `_street_line_2`, `_city`, `_state`, `_zip_code` | Hash → `Strata::Address` |
23+
| `memorable_date` | `{attr}_date` | Date (via hash: year/month/day) |
24+
| `money` | `{attr}` (integer, cents) | `Strata::Money` |
25+
| `tax_id` | `{attr}` (string) | String |
26+
| `array` | `{attr}` (jsonb) | Array of hashes |
27+
| `us_date` | `{attr}` (date) | Date |
28+
| `range` | `{attr}_min`, `{attr}_max` | Hash |
29+
| `year_month` | `{attr}_year`, `{attr}_month` | Hash (2 integers) |
30+
| `year_quarter` | `{attr}_year`, `{attr}_quarter` | Hash (2 integers) |
31+
32+
## Address Attribute Source
33+
34+
```ruby
35+
<%= read_sdk_file("app/lib/strata/attributes/address_attribute.rb") %>
36+
```
37+
38+
## Name Attribute Source
39+
40+
```ruby
41+
<%= read_sdk_file("app/lib/strata/attributes/name_attribute.rb") %>
42+
```
43+
44+
## Memorable Date Attribute Source
45+
46+
```ruby
47+
<%= read_sdk_file("app/lib/strata/attributes/memorable_date_attribute.rb") %>
48+
```
49+
50+
## Declaration Pattern
51+
52+
```ruby
53+
class MyForm < Strata::ApplicationForm
54+
strata_attribute :applicant_name, :name # 4 columns
55+
strata_attribute :home_address, :address # 5 columns
56+
strata_attribute :birth_date, :memorable_date # 1 column: birth_date_date
57+
strata_attribute :salary, :money # 1 column: salary (integer, cents)
58+
strata_attribute :ssn, :tax_id # 1 column: ssn (string)
59+
end
60+
```
61+
62+
## Migration Generation
63+
64+
```bash
65+
bin/rails generate strata:migration AddFieldsToMyForm applicant_name:name salary:money
66+
```
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
paths:
3+
- "**/app/models/**/*application_form*.rb"
4+
- "**/app/controllers/**/*application_forms*.rb"
5+
---
6+
7+
# Strata SDK: ApplicationForm — Core Class
8+
9+
Full source and implementation details for `Strata::ApplicationForm`.
10+
11+
## Source Code
12+
13+
```ruby
14+
<%= read_sdk_file("app/models/strata/application_form.rb") %>
15+
```
16+
17+
## Creating a Form Model
18+
19+
1. **Generate:**
20+
```bash
21+
bin/rails generate strata:application_form PassportApplicationForm \
22+
name:name birth_date:memorable_date ssn:tax_id residential_address:address
23+
```
24+
25+
2. **Run migrations:**
26+
```bash
27+
bin/rails db:migrate
28+
```
29+
30+
3. **Add submission validations** (use `:submit` context):
31+
```ruby
32+
class PassportApplicationForm < Strata::ApplicationForm
33+
strata_attribute :applicant_name, :name
34+
strata_attribute :birth_date, :memorable_date
35+
strata_attribute :ssn, :tax_id
36+
strata_attribute :residential_address, :address
37+
38+
validates :applicant_name_first, presence: true, on: :submit
39+
validates :ssn, presence: true, on: :submit
40+
end
41+
```
42+
43+
## Events
44+
45+
| Event | When | Payload |
46+
|-------|------|---------|
47+
| `{ClassName}Created` | After record created | `{ application_form_id: id }` |
48+
| `{ClassName}Submitted` | After `submit_application` succeeds | `{ application_form_id: id, submitted_at: timestamp }` |
49+
50+
These events integrate with `Strata::BusinessProcess` via `start_on_application_form_created` and transition handlers.
51+
52+
## Submission Internals
53+
54+
`submit_application` does the following in order:
55+
1. Validates with `:submit` context returns `false` if invalid
56+
2. Runs `:submit` callbacks (`before_submit`, `after_submit`)
57+
3. Sets `status` to `:submitted` and `submitted_at` to `Time.current`
58+
4. Saves the record
59+
5. Publishes `{ClassName}Submitted` event

0 commit comments

Comments
 (0)