brainiac 0.4.5 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ad4e2a76bc43943ef2dd556f67fa2d3b4181836dc5abf04c236944fe08c2bd74
4
- data.tar.gz: a26c2581219ed65257deef259809099e4c03b2ec08f7d95d4fb649f831ce6d44
3
+ metadata.gz: 4f17a8d317d56d8048d7f894c2c60832dd31db1feaa38a3692e566e995c54683
4
+ data.tar.gz: 8f9d3e551d2a6fb4e0db4bb0514a2f37ea484cabdc18e9a8232d2fc509c7aa05
5
5
  SHA512:
6
- metadata.gz: 1bdfbf38d91e481e7b51b1f8701fa446bb67206989b651bcb1f7f0bd920b7c18e6f011d21f4e9e59cbab3bcb80a878808e299d4f82f054cb5fc18b1d6bc78f25
7
- data.tar.gz: 2c19663ee311472f732ae4c685b0708990deaa0d1101ac52191da5d5bc751444b8b572ae72c1fb002d4ea8158fcd4c8d61557dcd440e7ad0ce0454b10f9541a9
6
+ metadata.gz: 6f97132829338b279fe162668705141ba70de45fbffcbc8524b3a56d1c7dd3c9143664643001b41c0c60ef9290ddfef847f7b60e01d2465ea5aec48a3cf41d9a
7
+ data.tar.gz: cc521e5d9ceb8c5f578b049c6b11f3b48b6f5371bbb288d0fab54f9798564d5e6940ffb7327784f3962f4f2ba858ddaee6229aa1ae304b73aec4b1f48edb4d93
data/README.md CHANGED
@@ -13,6 +13,7 @@ brainiac install fizzy # Card management
13
13
  brainiac install discord # Conversational bots
14
14
  brainiac install github # PR reviews, CI notifications
15
15
  brainiac install zoho # Email notifications and triage
16
+ brainiac install featureparity # Agents take work from FeatureParity
16
17
  ```
17
18
 
18
19
  Because each agent maintains its own unique persona and configuration, you can deploy a specialized team—all managed through `~/.brainiac/agents.json`. Project tracking is handled via `~/.brainiac/projects.json`, and because the system reloads configurations dynamically, you can iterate on your agent workflows in real-time without ever needing a restart.
@@ -34,6 +35,7 @@ Each plugin adds its own webhook routes and event handling. Here's what's availa
34
35
  | GitHub | Workflow run | `brainiac-github` | Notifies on CI failures and deploys |
35
36
  | Discord | @bot mention | `brainiac-discord` | Each agent has its own bot — @mention routes directly, conversational |
36
37
  | Zoho Mail | Incoming email | `brainiac-zoho` | Rule-based matching, notifies via Discord or triages to card |
38
+ | FeatureParity | Start work / agent tagged | `brainiac-featureparity` | Picked agent implements the requirement, or answers the comment, in an `fp-<slug>` worktree |
37
39
 
38
40
  ### Inline Tags
39
41
 
@@ -331,6 +333,16 @@ A provider without `readonly_args` can't be used read-only; the call fails inste
331
333
 
332
334
  `claude` is the only `stream_format` so far. Lines that aren't JSON pass through unchanged.
333
335
 
336
+ `subagents` is optional and writes the "Subagents" section of the agent's prompt. `tool` is the CLI's subagent tool, `usage` lines become how-to bullets, and `models` maps each job to a cheaper model. The session's own model (the `[fable]`/`[opus]` tag) stays the orchestrator: it plans, decides and reviews, and hands research and well-specified labor down a tier. A provider without the block gets no Subagents section, so an agent is never told to call a tool its CLI doesn't have.
337
+
338
+ ```json
339
+ "subagents": {
340
+ "tool": "Agent",
341
+ "models": { "research and file sweeps": "haiku", "well-specified implementation": "sonnet" },
342
+ "usage": ["Pass `model` (`haiku`, `sonnet`, `opus`, `fable`) to pick the tier. Omit it and the subagent runs on your model."]
343
+ }
344
+ ```
345
+
334
346
  Which provider runs is decided in this order: a `[cli:X]` tag, the agent's `cli_provider` in `agents.json`, the project's `cli_provider`, then `default_cli_provider` in `brainiac.json`. With none of those set, brainiac uses the `AGENT_CLI` env var, or kiro-cli.
335
347
 
336
348
  #### Asking an agent from a plugin
@@ -396,6 +408,7 @@ brainiac install discord # Discord bots
396
408
  brainiac install fizzy # Fizzy card management
397
409
  brainiac install github # GitHub webhooks
398
410
  brainiac install zoho # Zoho Mail
411
+ brainiac install featureparity # FeatureParity requirements
399
412
  ```
400
413
 
401
414
  Each plugin has its own setup command (e.g. `brainiac github setup`) and documentation. See [Plugins](#plugins-gem-based-handlers) for details.
@@ -407,6 +420,7 @@ Each plugin provides its own webhook endpoint. Point your tools at your ngrok UR
407
420
  - **Fizzy:** `https://your-ngrok.ngrok-free.app/fizzy/<board-key>` (see `brainiac fizzy setup`)
408
421
  - **GitHub:** `https://your-ngrok.ngrok-free.app/github` (see `brainiac github setup`)
409
422
  - **Zoho:** `https://your-ngrok.ngrok-free.app/zoho` (see `brainiac zoho setup`)
423
+ - **FeatureParity:** `https://your-ngrok.ngrok-free.app/featureparity`, set as each agent's wake-up address in FeatureParity (see `brainiac featureparity setup`)
410
424
 
411
425
  #### 8. Start
412
426
 
@@ -1282,7 +1296,7 @@ The `Brainiac::Plugins` namespace is defined in `lib/brainiac.rb`. Plugin module
1282
1296
  | github | `brainiac-github` | GitHub webhooks — PR reviews, PR comments, PR merges, CI workflow notifications, deploy tracking |
1283
1297
  | zoho | `brainiac-zoho` | Zoho Mail webhooks — rule-based email notifications, AI triage, OAuth for content fetching |
1284
1298
  | basecamp | `brainiac-basecamp` | Basecamp epic orchestration — card tables, review gates, epic branches |
1285
- | featureparity | `brainiac-featureparity` | FeatureParity integration for Brainiac agents |
1299
+ | featureparity | `brainiac-featureparity` | FeatureParity — starts the agent picked for a requirement, answers comments that tag it, writes missing tests, reworks rejected proposals; verifies signed deliveries |
1286
1300
 
1287
1301
  ### Backward Compatibility
1288
1302
 
@@ -35,7 +35,8 @@ def load_cli_provider(provider_name)
35
35
  %w[prompt_flag readonly_args list_models_command resume_flag resume_args resume_id_flag new_session_id_flag
36
36
  session_list_command session_id_field session_directory_field session_updated_field
37
37
  session_list_path session_dir output_last_message_flag cwd_flag config_override_flag
38
- effort_config_key effort_map title_flag heaviness_probe settings_model_cmd stream_args stream_format].each do |key|
38
+ effort_config_key effort_map title_flag heaviness_probe settings_model_cmd stream_args stream_format
39
+ subagents].each do |key|
39
40
  next unless raw[key]
40
41
  next if raw[key].respond_to?(:empty?) && raw[key].empty?
41
42
 
@@ -935,7 +936,7 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
935
936
  log_file = File.join(chdir, "tmp/agent-#{log_name}-#{timestamp}.log")
936
937
  FileUtils.mkdir_p(File.dirname(log_file))
937
938
 
938
- prompt_file = write_agent_prompt_file(prompt, log_name, timestamp)
939
+ prompt_file = write_agent_prompt_file(fill_subagent_prompt(prompt, resolved, model: model), log_name, timestamp)
939
940
  output_file = prepare_output_file(resolved, log_name, timestamp)
940
941
 
941
942
  cmd = build_agent_cmd(resolved, agent_config_name: agent_config_name, model: model, effort: effort,
@@ -142,7 +142,7 @@ def check_intent(message, agent_name:, channel: "conversation", context: nil)
142
142
  # Deterministic pre-check: if the message explicitly names another agent
143
143
  # and does NOT name this agent, skip without consulting the LLM.
144
144
  # This handles the common case of "Effie, tell me another" when checking Galen.
145
- if intent_names_other_agent?(message, agent_name)
145
+ if intent_names_other_agent?(message, agent_name, context)
146
146
  LOG.info "[Intent] Deterministic skip for #{agent_name} — message addresses another agent"
147
147
  return false
148
148
  end
@@ -327,11 +327,16 @@ end
327
327
  # - "Was Effie nice to you?" (asking about Effie)
328
328
  # - "I liked what Effie said" (referencing Effie)
329
329
  #
330
+ # Agents running on other machines aren't in AGENT_REGISTRY, so bot authors seen in
331
+ # the conversation context ("Name [bot]: ...") count as agents too.
332
+ #
330
333
  # @param message [String] The message text
331
334
  # @param agent_name [String] The agent being evaluated
335
+ # @param context [String, nil] Conversation history
332
336
  # @return [Boolean] true if another agent is directly addressed and this one isn't
333
- def intent_names_other_agent?(message, agent_name)
334
- return false unless defined?(AGENT_REGISTRY) && !AGENT_REGISTRY.empty?
337
+ def intent_names_other_agent?(message, agent_name, context = nil)
338
+ names = intent_agent_display_names.keys | context_bot_names(context).map(&:downcase)
339
+ return false if names.empty?
335
340
 
336
341
  msg_lower = message.downcase.strip
337
342
  agent_lower = agent_name.downcase
@@ -340,9 +345,7 @@ def intent_names_other_agent?(message, agent_name)
340
345
  return false if directly_addressed_to?(msg_lower, agent_lower)
341
346
 
342
347
  # Check if any OTHER agent is directly addressed (not merely mentioned)
343
- AGENT_REGISTRY.each do |key, entry|
344
- display = entry.is_a?(Hash) ? (entry["display_name"] || key.capitalize) : key.capitalize
345
- other_lower = display.downcase
348
+ names.each do |other_lower|
346
349
  next if other_lower == agent_lower
347
350
  next unless msg_lower.match?(/\b#{Regexp.escape(other_lower)}\b/)
348
351
 
@@ -403,64 +406,66 @@ def build_intent_agent_roster(agent_name)
403
406
  names.join(", ")
404
407
  end
405
408
 
409
+ # Map of lowercased agent display name => display name, from the local AGENT_REGISTRY.
410
+ def intent_agent_display_names
411
+ return {} unless defined?(AGENT_REGISTRY) && !AGENT_REGISTRY.empty?
412
+
413
+ AGENT_REGISTRY.each_with_object({}) do |(key, entry), names|
414
+ display = entry.is_a?(Hash) ? (entry["display_name"] || key.capitalize) : key.capitalize
415
+ names[display.downcase] = display
416
+ end
417
+ end
418
+
419
+ # Parse a context line ("username: message", or "username [bot]: message" when the
420
+ # plugin knows the author is a bot). Returns [username, bot?] or nil.
421
+ def parse_context_author(line)
422
+ match = line.match(/\A(\S+?)( \[bot\])?:\s/)
423
+ match && [match[1], !match[2].nil?]
424
+ end
425
+
426
+ # Usernames of every bot author in the context.
427
+ def context_bot_names(context)
428
+ return [] if context.nil?
429
+
430
+ context.lines.filter_map do |line|
431
+ username, bot = parse_context_author(line)
432
+ username if bot
433
+ end.uniq
434
+ end
435
+
406
436
  # Detect the last agent to respond in the conversation context.
407
437
  # Returns a prompt-friendly string telling the model who was last to respond.
408
438
  #
409
- # The context is formatted as "username: message\n..." lines.
410
- # We scan backwards to find the most recent line authored by a known agent.
411
- #
412
439
  # @param context [String, nil] Conversation history
413
440
  # @param agent_name [String] The current agent being evaluated
414
441
  # @return [String] Prompt block describing the last responder
415
442
  def detect_last_responder(context, _agent_name = nil)
416
- return "No agent has spoken yet." if context.nil? || context.strip.empty?
417
- return "No agent has spoken yet." unless defined?(AGENT_REGISTRY) && !AGENT_REGISTRY.empty?
418
-
419
- # Build a lookup of agent display names (lowercase) to their proper display name
420
- agent_names = {}
421
- AGENT_REGISTRY.each do |key, entry|
422
- display = entry.is_a?(Hash) ? (entry["display_name"] || key.capitalize) : key.capitalize
423
- agent_names[display.downcase] = display
424
- end
425
-
426
- # Scan context lines in reverse to find the last agent message
427
- lines = context.strip.lines.reverse
428
- lines.each do |line|
429
- # Lines are formatted as "username: message content"
430
- match = line.match(/\A(\S+?):\s/)
431
- next unless match
443
+ responder = detect_last_responder_name(context)
444
+ return "No agent has spoken yet." unless responder
432
445
 
433
- username = match[1].downcase
434
- next unless agent_names.key?(username)
435
-
436
- responder = agent_names[username]
437
- return "#{responder} was the last to speak. The human is continuing the conversation with #{responder}."
438
- end
439
-
440
- "No agent has spoken yet."
446
+ "#{responder} was the last to speak. The human is continuing the conversation with #{responder}."
441
447
  end
442
448
 
443
449
  # Detect the last agent to respond — returns just the display name (or nil).
444
450
  # Used for deterministic conversational-flow checks without LLM involvement.
445
451
  #
452
+ # The context is formatted as "username: message\n..." lines. We scan backwards for
453
+ # the most recent line from a known agent, or from any bot — an agent on another
454
+ # machine is still the one the human is talking to.
455
+ #
446
456
  # @param context [String, nil] Conversation history
447
457
  # @return [String, nil] Display name of last agent to speak, or nil
448
458
  def detect_last_responder_name(context)
449
459
  return nil if context.nil? || context.strip.empty?
450
- return nil unless defined?(AGENT_REGISTRY) && !AGENT_REGISTRY.empty?
451
460
 
452
- agent_names = {}
453
- AGENT_REGISTRY.each do |key, entry|
454
- display = entry.is_a?(Hash) ? (entry["display_name"] || key.capitalize) : key.capitalize
455
- agent_names[display.downcase] = display
456
- end
461
+ agent_names = intent_agent_display_names
457
462
 
458
463
  context.strip.lines.reverse_each do |line|
459
- match = line.match(/\A(\S+?):\s/)
460
- next unless match
464
+ username, bot = parse_context_author(line)
465
+ next unless username
461
466
 
462
- username = match[1].downcase
463
- return agent_names[username] if agent_names.key?(username)
467
+ return agent_names[username.downcase] if agent_names.key?(username.downcase)
468
+ return username if bot
464
469
  end
465
470
 
466
471
  nil
@@ -112,34 +112,7 @@ PROMPT_CORE = <<~PROMPT
112
112
  If the task is ambiguous or you're uncertain about requirements, ask before starting.
113
113
  If you're 90% sure, proceed. If you're 60% sure, ask.
114
114
 
115
- ## Subagents (Delegating Work)
116
- You have access to the `use_subagent` tool, which spawns independent child agents that run
117
- in parallel and report back. Use them to preserve your context window for implementation.
118
-
119
- **When to use subagents:**
120
- - Cross-repo investigation ("how does opszilla-android call this endpoint?")
121
- - Heavy codebase research before implementation (reading many files you won't need later)
122
- - Parallel tasks that don't depend on each other
123
- - When your context is getting heavy and you need to offload research
124
-
125
- **When NOT to use subagents:**
126
- - Simple, directed lookups (one file, one function, one grep)
127
- - Tasks that require your brain context, persona, or memory
128
- - Posting comments or external communication (only you can do that)
129
-
130
- **How to use them effectively:**
131
- - Be specific in your query — tell the subagent exactly what to find and where to look
132
- - Include relevant file paths and repo locations in the query
133
- - Use `relevant_context` to pass information the subagent needs
134
- - You can specify `agent_name` to use a specialized agent (e.g., "robin" for Android research)
135
- - Run `ListAgents` first if you want to see available specialized agents
136
- - Up to 4 subagents can run in parallel
137
- - To discover project locations for cross-repo work, run: `brainiac list`
138
-
139
- **Limitations:** Subagents don't get your brain context, persona, or memory.
140
- They can read files and run commands, but cannot post to Discord, GitHub, or other channels.
141
- They're excellent researchers — use them as such.
142
-
115
+ {{SUBAGENTS}}
143
116
  ## Image Reading Limits
144
117
  Read at most 4-5 images per tool call. Summarize what you saw before reading more.
145
118
  Loading too many images at once can exceed the API request size limit and crash your session.
@@ -251,6 +224,66 @@ def populate_prompt_vars(vars, agent_name:, board_key:)
251
224
  vars["AGENT_ROSTER"] ||= roster_lines
252
225
  end
253
226
 
227
+ # The Subagents section of PROMPT_CORE, built from the provider's `subagents` block in
228
+ # cli-providers/<name>.json. Rendered at dispatch time (run_agent) because that's where the
229
+ # provider is resolved. Providers without the block get no section: telling an agent to call
230
+ # a tool its CLI doesn't have is worse than saying nothing.
231
+ #
232
+ # "subagents": {
233
+ # "tool": "Agent",
234
+ # "models": { "research": "haiku", "implement": "sonnet" },
235
+ # "usage": ["Pass `model` to pick the tier."]
236
+ # }
237
+ def subagent_prompt_section(subagents, model: nil)
238
+ return "" unless subagents.is_a?(Hash) && subagents["tool"]
239
+
240
+ lines = ["## Subagents (Delegating Work)"]
241
+ lines << "You have the `#{subagents["tool"]}` tool, which spawns child agents that run in parallel and report back."
242
+ lines << "Their file dumps stay out of your context, so long sessions stay lean."
243
+ lines << ""
244
+ lines << "**Delegate when the work is big, not because of the channel.** A card or a Discord request that"
245
+ lines << "spans many files or repos is worth delegating. A five-tool-call answer is not: each subagent"
246
+ lines << "starts cold and rereads everything, so it costs more than doing it yourself."
247
+ lines << ""
248
+ lines << "**Good jobs for subagents:** cross-repo investigation, sweeping many files you won't need later,"
249
+ lines << "independent tasks that can run in parallel, and mechanical edits you've already specified exactly."
250
+ lines << ""
251
+ lines << "**Keep for yourself:** design and architecture decisions, judgment calls, anything that needs your"
252
+ lines << "brain context, persona, or memory, final review of the change, and all external communication."
253
+ lines << "Subagents gather facts and do well-specified labor; they don't make the call. If a subagent's"
254
+ lines << "report drives a decision, verify the part that matters before acting on it."
255
+ append_subagent_models(lines, subagents["models"], model)
256
+ lines << ""
257
+ lines << "**How to use them:**"
258
+ Array(subagents["usage"]).each { |line| lines << "- #{line}" }
259
+ lines << "- Brief each subagent like a peer: the goal, exact files and repos, and what you've ruled out."
260
+ lines << "- Run `brainiac list` to find project paths for cross-repo work."
261
+ lines << ""
262
+ lines << "**Limitations:** Subagents don't get your brain context, persona, or memory, and can't post to"
263
+ lines << "Discord, GitHub, or other channels."
264
+ "#{lines.join("\n")}\n\n"
265
+ end
266
+
267
+ # Model tiers: you stay on the session's model (the [fable]/[opus] tag) for planning and the
268
+ # final word; cheaper tiers take the labor.
269
+ def append_subagent_models(lines, models, model)
270
+ return unless models.is_a?(Hash) && !models.empty?
271
+
272
+ lines << ""
273
+ lines << "**Model tiers:** You#{" (#{model})" if model} are the orchestrator. Pick a cheaper model for each subagent's job:"
274
+ models.each { |job, tier| lines << "- #{job}: `#{tier}`" }
275
+ lines << "Never hand a design or review decision down a tier to save money; that's the expensive part you're here for."
276
+ end
277
+
278
+ # Fill the {{SUBAGENTS}} slot left in PROMPT_CORE. A no-op for prompts that don't have it
279
+ # (resume prompts, cron prompts).
280
+ def fill_subagent_prompt(prompt, resolved, model: nil)
281
+ return prompt unless prompt.include?("{{SUBAGENTS}}")
282
+
283
+ section = subagent_prompt_section(resolved["subagents"], model: model)
284
+ prompt.sub("{{SUBAGENTS}}\n", section).sub("{{SUBAGENTS}}", section)
285
+ end
286
+
254
287
  # Lean prompt for resumed sessions. The previous session already has the full context
255
288
  # (role, persona, knowledge, core instructions, channel prompts). We only send the new
256
289
  # comment and any fresh card context so the agent knows what changed.
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Brainiac
4
4
  # @return [String] the current gem version
5
- VERSION = "0.4.5"
5
+ VERSION = "0.5.0"
6
6
  end
@@ -28,5 +28,14 @@
28
28
  "comment": "Optional. Lets brainiac record how 'heavy' a finished session was (context window usage + credits) in the durable session history. type selects the probe strategy; db_path points at the provider's local usage store. Omit this block to record timing/identity only.",
29
29
  "type": "kiro_sqlite",
30
30
  "db_path": "~/.local/share/kiro-cli/data.sqlite3"
31
+ },
32
+ "subagents": {
33
+ "comment": "Optional. Renders the Subagents section of the agent prompt. tool is the CLI's subagent tool; usage lines are appended as how-to bullets; models (job => tier) tells the agent which cheaper model to hand each job. Omit the block for CLIs with no subagent tool.",
34
+ "tool": "use_subagent",
35
+ "usage": [
36
+ "Use `relevant_context` to pass information the subagent needs.",
37
+ "Set `agent_name` for a specialized agent (e.g. \"robin\" for Android research); run `ListAgents` to see them.",
38
+ "Up to 4 subagents can run in parallel."
39
+ ]
31
40
  }
32
41
  }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brainiac
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.5
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis