brainiac-featureparity 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5e779e16dcca90b0d9197b375590889530e09c9cef791ebf5c08af05bf01152d
4
+ data.tar.gz: 3a59dc6ffe38d72fe0034c9aa3180faf08e0dc27bc0d8aa2b1ae9cc4192a1743
5
+ SHA512:
6
+ metadata.gz: b239f505695e27823695c4db25265848b267a68ae752533aae0f535248aa68a86e86bf7887da5bd5804d2b04ca546ce05222580627e67d182f91d54a0c5c9069
7
+ data.tar.gz: bc016d62bb67fc6d3f522c3d3f0b5f02f5189725fa216b2f98946615ed496779ec83b95ead917eeccf892d630fb893c985caaa32bc3221f019ecdf58975f5d25
data/README.md ADDED
@@ -0,0 +1,135 @@
1
+ # brainiac-featureparity
2
+
3
+ FeatureParity plugin for [Brainiac](https://github.com/stowzilla/brainiac): your agents take
4
+ work straight from [FeatureParity](https://featureparity.dev).
5
+
6
+ - **Start work.** Someone presses **Start work** on a requirement and picks one of your
7
+ agents. That agent gets a worktree for the requirement and implements it, with tests
8
+ bound to it (`fp:<slug>`).
9
+ - **Tags.** Someone tags your agent in a requirement's comments (for example "please
10
+ re-check the suspect tests"). The agent reads the comment, does it, and replies on the
11
+ requirement.
12
+ - **Coverage gaps** (opt-in). A requirement goes live on a surface with no tests. An agent
13
+ writes them.
14
+ - **Rejections.** A human rejects a change your agent proposed. The same agent reworks it in
15
+ its existing worktree.
16
+
17
+ Every delivery is signed by FeatureParity, and the plugin refuses anything it can't verify.
18
+
19
+ ## Install
20
+
21
+ You need Brainiac running with a public URL that reaches it (Brainiac's setup uses an
22
+ [ngrok](https://ngrok.com) tunnel), and an account on FeatureParity.
23
+
24
+ ```bash
25
+ gem install brainiac # if you don't have Brainiac yet
26
+ brainiac setup # its own first-run setup
27
+ brainiac install featureparity
28
+ brainiac featureparity setup # writes ~/.brainiac/featureparity.json
29
+ brainiac restart
30
+ ```
31
+
32
+ Every repository your agents should work in must be a project in `~/.brainiac/projects.json`
33
+ (Brainiac's usual setup). FeatureParity names projects by their slug (`fp projects`). Name the
34
+ Brainiac project the same, or map it (see Configuration).
35
+
36
+ ## Connect FeatureParity
37
+
38
+ ### 1. Point your agents at Brainiac
39
+
40
+ In FeatureParity, open **Agents & API keys** (under your account). For each agent that runs
41
+ on this Brainiac, click **Wake-up** and enter:
42
+
43
+ ```
44
+ https://<your-public-url>/featureparity
45
+ ```
46
+
47
+ FeatureParity shows a signing secret (`whsec_...`) **once**. Add it to
48
+ `~/.brainiac/featureparity.json`:
49
+
50
+ ```json
51
+ { "signing_secrets": ["whsec_..."] }
52
+ ```
53
+
54
+ All your agents can share the one address, because each delivery names the agent it's for.
55
+ Each key has its own secret, so list every one.
56
+
57
+ The agent key's name has to match the Brainiac agent: a key named `galen` or shown as `Galen`
58
+ reaches the Brainiac agent `Galen`. Map names that differ with `"agents"`.
59
+
60
+ ### 2. Optional: project-wide events
61
+
62
+ For events no single agent owns, add a webhook in **Project Settings → Integrations**: same
63
+ URL, provider **generic**. Subscribe to **Change rejected** and, if you turn on
64
+ `dispatch_gaps`, **Coverage gap**. Add its signing secret to `signing_secrets` too.
65
+
66
+ If your agents already have wake-up addresses, there's no need to subscribe this webhook to
67
+ **Work started** or **Agent tagged**. If you do, the plugin notices the repeat and starts the
68
+ agent only once.
69
+
70
+ ### 3. Try it
71
+
72
+ Open an approved requirement, click **Start work**, and pick your agent. The page tells you
73
+ whether the agent received it. `brainiac logs` shows the dispatch. Each webhook's **Recent
74
+ deliveries** in FeatureParity shows what was sent and what Brainiac answered.
75
+
76
+ ## Configuration
77
+
78
+ `~/.brainiac/featureparity.json` (reloaded when it changes):
79
+
80
+ ```json
81
+ {
82
+ "api_base_url": "https://api.featureparity.dev",
83
+ "environment": "prod",
84
+ "accept_environments": ["prod"],
85
+ "signing_secrets": ["whsec_..."],
86
+ "agents": { "claude-laptop": "Galen" },
87
+ "default_agent": "Galen",
88
+ "projects": { "feature-parity": "feature_parity" },
89
+ "dispatch_gaps": false,
90
+ "gap_agent": "Galen"
91
+ }
92
+ ```
93
+
94
+ | Key | What it does |
95
+ |-----|--------------|
96
+ | `signing_secrets` | The `whsec_` secrets FeatureParity showed you. With any set, unsigned or wrongly signed deliveries are refused (401), and so is one stamped more than 5 minutes away from now. Leave it empty only while setting up. |
97
+ | `agents` | FeatureParity agent key name (or display name) → Brainiac agent name, for names that differ. Otherwise they match case-insensitively. |
98
+ | `default_agent` | Who takes **Start work** when no agent was picked. |
99
+ | `projects` | FeatureParity project slug → Brainiac project key, for keys that differ. A project in `projects.json` can also declare `"featureparity_project": "<slug>"`. |
100
+ | `dispatch_gaps` | Start an agent on `coverage.gap_detected` to write the missing tests. Off by default, because approving a batch of requirements reports a gap for each one. |
101
+ | `gap_agent` | Who writes those tests (defaults to `default_agent`). |
102
+ | `environment`, `accept_environments` | Which FeatureParity environments this Brainiac serves. A delivery from a preview environment (`fizzy-1234`) is ignored unless listed here. `["*"]` accepts all. |
103
+
104
+ ## Events
105
+
106
+ | Event | What happens |
107
+ |-------|--------------|
108
+ | `requirement.work_started` | The named agent (or `default_agent`) gets the requirement's worktree (`fp-<slug>`, reused if it exists) and implements it. |
109
+ | `requirement.agent_mentioned` | The tagged agent, if it runs here, answers the comment, working in the requirement's worktree when there is one. |
110
+ | `coverage.gap_detected` | With `dispatch_gaps`, `gap_agent` writes tests for the surfaces listed. Otherwise it's acknowledged. |
111
+ | `requirement.change_rejected` | The agent that proposed the change reworks it in its worktree, with the reviewer's reason. |
112
+ | others | Acknowledged. |
113
+
114
+ The plugin answers within FeatureParity's 5-second window and does the work in the
115
+ background. FeatureParity retries a delivery that gets no answer; the plugin acts on each
116
+ delivery, and each Start work, comment or gap, once.
117
+
118
+ ## CLI
119
+
120
+ ```bash
121
+ brainiac featureparity setup # create the config and show these steps
122
+ brainiac featureparity config # show the config (secrets shortened)
123
+ brainiac featureparity status # what's set up
124
+ brainiac featureparity help
125
+ ```
126
+
127
+ ## Requirements
128
+
129
+ - Brainiac >= 0.1.8
130
+ - Ruby >= 3.4
131
+ - A FeatureParity account (agent wake-up addresses and signed deliveries need the current FeatureParity)
132
+
133
+ ## License
134
+
135
+ MIT
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brainiac
4
+ module Plugins
5
+ module Featureparity
6
+ # CLI commands for the FeatureParity plugin.
7
+ # Accessed via `brainiac featureparity <command>`.
8
+ module CLI
9
+ CONFIG_FILE = File.join(BRAINIAC_DIR, "featureparity.json")
10
+
11
+ class << self
12
+ def cli(args)
13
+ command = args.shift
14
+ case command
15
+ when "setup"
16
+ setup_command
17
+ when "config"
18
+ config_command
19
+ when "status"
20
+ status_command
21
+ when "help", nil
22
+ help_command
23
+ else
24
+ puts "Unknown command: #{command}"
25
+ puts "Run 'brainiac featureparity help' for usage."
26
+ exit 1
27
+ end
28
+ end
29
+
30
+ def completions
31
+ %w[setup config status help]
32
+ end
33
+
34
+ def configured?
35
+ File.exist?(CONFIG_FILE)
36
+ end
37
+
38
+ def help_text
39
+ "Manage FeatureParity webhook integration"
40
+ end
41
+
42
+ private
43
+
44
+ def setup_command
45
+ puts "Setting up FeatureParity plugin..."
46
+
47
+ if File.exist?(CONFIG_FILE)
48
+ puts "Config file already exists at #{CONFIG_FILE}"
49
+ print "Overwrite? [y/N] "
50
+ answer = $stdin.gets&.strip&.downcase
51
+ return unless answer == "y"
52
+ end
53
+
54
+ config = {
55
+ "api_base_url" => "https://api.featureparity.dev",
56
+ "signing_secrets" => [],
57
+ "default_agent" => nil,
58
+ "dispatch_gaps" => false,
59
+ "authorized_users" => [],
60
+ "authorized_role_ids" => []
61
+ }
62
+
63
+ File.write(CONFIG_FILE, JSON.pretty_generate(config))
64
+ puts "Created config at #{CONFIG_FILE}\n\n"
65
+ puts <<~STEPS
66
+ Next steps (your public URL is whatever reaches this server, e.g. an ngrok tunnel):
67
+ 1. In FeatureParity, Agents & API keys: give each agent that runs here the wake-up address
68
+ https://<your-public-url>/featureparity, and put the secret it shows in "signing_secrets".
69
+ 2. Optional: Project Settings > Integrations > Add webhook, same URL, provider 'generic'
70
+ ('Change rejected', 'Coverage gap'). Add its secret to "signing_secrets" too.
71
+ 3. Agents match by name and projects by slug; map any that differ with "agents"/"projects".
72
+
73
+ STEPS
74
+ puts "Run 'brainiac restart' to load the plugin."
75
+ end
76
+
77
+ def config_command
78
+ unless File.exist?(CONFIG_FILE)
79
+ puts "No config file found. Run 'brainiac featureparity setup' first."
80
+ exit 1
81
+ end
82
+
83
+ config = JSON.parse(File.read(CONFIG_FILE))
84
+ # Never print the signing secrets themselves.
85
+ config["signing_secrets"] = Array(config["signing_secrets"]).map { |s| "#{s.to_s[0, 10]}…" }
86
+ puts "FeatureParity Config (#{CONFIG_FILE}):"
87
+ puts JSON.pretty_generate(config)
88
+ end
89
+
90
+ def status_command
91
+ puts "FeatureParity Plugin Status"
92
+ puts "=" * 30
93
+
94
+ if File.exist?(CONFIG_FILE)
95
+ config = JSON.parse(File.read(CONFIG_FILE))
96
+ puts "Config: ✓ #{CONFIG_FILE}"
97
+ puts "API URL: #{config['api_base_url']}"
98
+ secrets = Array(config["signing_secrets"]).size
99
+ puts(secrets.zero? ? "Signatures: ✗ not checked (add signing_secrets)" : "Signatures: ✓ #{secrets} secret(s)")
100
+ gap_agent = config["gap_agent"] || config["default_agent"]
101
+ puts "Coverage gaps: #{config['dispatch_gaps'] ? "dispatched to #{gap_agent}" : 'acknowledged only'}"
102
+ else
103
+ puts "Config: ✗ Not configured (run 'brainiac featureparity setup')"
104
+ end
105
+
106
+ puts
107
+ puts "Webhook endpoint: POST /featureparity"
108
+ puts "API endpoint: GET /api/featureparity"
109
+ end
110
+
111
+ def help_command
112
+ puts <<~HELP
113
+ brainiac featureparity — #{help_text}
114
+
115
+ Usage:
116
+ brainiac featureparity setup Create config file and show setup instructions
117
+ brainiac featureparity config Show current configuration
118
+ brainiac featureparity status Show plugin status
119
+ brainiac featureparity help Show this help message
120
+
121
+ Webhook Events:
122
+ The plugin receives webhooks from FeatureParity and dispatches agents
123
+ when requirements are rejected, evidence fails, or coverage gaps are detected.
124
+
125
+ Currently handled:
126
+ - requirement.change_rejected → Dispatch agent to fix rejected requirement
127
+
128
+ Configuration:
129
+ Config is stored at ~/.brainiac/featureparity.json
130
+ HELP
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brainiac
4
+ module Plugins
5
+ module Featureparity
6
+ # Config file at ~/.brainiac/featureparity.json
7
+ #
8
+ # {
9
+ # "api_base_url": "https://api.featureparity.dev",
10
+ # "environment": "prod",
11
+ # "accept_environments": ["prod", "staging"],
12
+ # "signing_secrets": ["whsec_..."],
13
+ # "agents": { "galen": "Galen" },
14
+ # "default_agent": "Galen",
15
+ # "projects": { "feature-parity": "feature_parity" },
16
+ # "dispatch_gaps": false,
17
+ # "gap_agent": "Galen",
18
+ # "authorized_users": [],
19
+ # "authorized_role_ids": []
20
+ # }
21
+ #
22
+ # signing_secrets: the whsec_ secrets FeatureParity showed you, one per project webhook
23
+ # or agent wake-up address pointed here. When any are set, an unsigned or wrongly
24
+ # signed delivery is refused (401). Leave empty only while setting up.
25
+ # agents: FeatureParity agent key name (or display name) → Brainiac agent name, for when
26
+ # they differ. Otherwise the names are matched case-insensitively.
27
+ # default_agent: who takes "Start work" with no agent named.
28
+ # projects: FeatureParity project slug → key in ~/.brainiac/projects.json, for when they
29
+ # differ. A project entry can also say "featureparity_project": "<slug>".
30
+ # dispatch_gaps: start an agent on coverage.gap_detected (write the missing tests).
31
+ # Off by default: approving a batch of requirements reports a gap for each.
32
+ #
33
+ # Environment matching:
34
+ # - `environment`: Which environment this brainiac instance represents (default: "prod")
35
+ # - `accept_environments`: Which environments' webhooks to process (default: same as environment)
36
+ #
37
+ # This prevents dev/ephemeral webhooks from triggering prod agents and vice versa.
38
+ # An ephemeral FP environment like "fizzy-1234" sends webhooks with that environment
39
+ # in the metadata — unless accept_environments includes "fizzy-1234", those are ignored.
40
+ module Config
41
+ CONFIG_FILE = File.join(BRAINIAC_DIR, "featureparity.json")
42
+ @config = nil
43
+ @config_mtime = nil
44
+
45
+ class << self
46
+ def load!
47
+ @config = read_config_file
48
+ @config_mtime = File.exist?(CONFIG_FILE) ? File.mtime(CONFIG_FILE) : nil
49
+ LOG.info "[FeatureParity] Config loaded from #{CONFIG_FILE}" if @config
50
+ end
51
+
52
+ def current
53
+ reload_if_changed
54
+ @config || {}
55
+ end
56
+
57
+ def reload_if_changed
58
+ return unless File.exist?(CONFIG_FILE)
59
+
60
+ mtime = File.mtime(CONFIG_FILE)
61
+ return if @config_mtime && mtime == @config_mtime
62
+
63
+ @config = read_config_file
64
+ @config_mtime = mtime
65
+ LOG.debug "[FeatureParity] Config reloaded" if LOG.debug?
66
+ end
67
+
68
+ def api_base_url
69
+ current["api_base_url"] || "https://api.featureparity.dev"
70
+ end
71
+
72
+ # Which environment this brainiac instance represents
73
+ def environment
74
+ current["environment"] || "prod"
75
+ end
76
+
77
+ # Which environments' webhooks to process. Defaults to just this environment.
78
+ # Use ["prod", "staging", "dev"] to accept webhooks from multiple environments.
79
+ # Use ["*"] to accept webhooks from any environment (useful for dev/testing).
80
+ def accept_environments
81
+ current["accept_environments"] || [environment]
82
+ end
83
+
84
+ # Check if this brainiac instance should process a webhook from the given environment.
85
+ # Returns true if:
86
+ # - accept_environments includes "*" (accept all)
87
+ # - accept_environments includes the webhook's environment
88
+ # - The webhook doesn't specify an environment (legacy/backward compatibility)
89
+ def should_accept_environment?(webhook_environment)
90
+ # No environment specified in webhook = backward compatibility, accept it
91
+ return true if webhook_environment.nil? || webhook_environment.to_s.strip.empty?
92
+
93
+ # Accept all environments
94
+ return true if accept_environments.include?("*")
95
+
96
+ # Check if this specific environment is accepted
97
+ accept_environments.include?(webhook_environment.to_s)
98
+ end
99
+
100
+ def signing_secrets
101
+ Array(current["signing_secrets"]).map(&:to_s).reject(&:empty?)
102
+ end
103
+
104
+ def agent_map
105
+ (current["agents"] || {}).transform_keys { |k| k.to_s.downcase }
106
+ end
107
+
108
+ def default_agent
109
+ current["default_agent"]
110
+ end
111
+
112
+ def project_map
113
+ current["projects"] || {}
114
+ end
115
+
116
+ def dispatch_gaps?
117
+ current["dispatch_gaps"] == true
118
+ end
119
+
120
+ def gap_agent
121
+ current["gap_agent"] || default_agent
122
+ end
123
+
124
+ def authorized_users
125
+ current["authorized_users"] || []
126
+ end
127
+
128
+ def authorized_role_ids
129
+ current["authorized_role_ids"] || []
130
+ end
131
+
132
+ private
133
+
134
+ def read_config_file
135
+ return {} unless File.exist?(CONFIG_FILE)
136
+
137
+ JSON.parse(File.read(CONFIG_FILE))
138
+ rescue JSON::ParserError => e
139
+ LOG.warn "[FeatureParity] Invalid JSON in config: #{e.message}"
140
+ {}
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end