brainiac 0.0.12 → 0.0.14
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 +4 -4
- data/Gemfile.lock +2 -2
- data/README.md +47 -1
- data/bin/brainiac +18 -27
- data/lib/brainiac/config.rb +37 -3
- data/lib/brainiac/handlers/shared/inline_tags.rb +43 -13
- data/lib/brainiac/helpers.rb +178 -2
- data/lib/brainiac/intent.rb +179 -0
- data/lib/brainiac/routes/api.rb +61 -0
- data/lib/brainiac/version.rb +1 -1
- data/lib/brainiac.rb +1 -0
- data/receiver.rb +6 -0
- data/templates/brainiac.json.example +8 -1
- data/templates/hooks/pre-commit +14 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '04080c41c24921ed739b0d28ad15ce61b8eb5b5c12935d0cb0b1d72c6b0deeab'
|
|
4
|
+
data.tar.gz: bbb1dceb84ecaba924ada9189e8b63320828c035da4a6ff466ba8b47c4c30708
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '0699a2277c78aa2c33e57b6ee4394ac7be8d27bc483a9f3866dd27e644e40fbdea4b85168b3452c6cbbd4fce42da6e282a9609e93541802b2c751f9845c55ea2'
|
|
7
|
+
data.tar.gz: adcd7eab6269a33b278499e3b873b072f488f7b06b18378507fba456f883b1c525b7dd8d8ce82b13e6464ab5184007e0887dfa9a82a69910f8f8d0164660ff9b
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
brainiac (0.0.
|
|
4
|
+
brainiac (0.0.14)
|
|
5
5
|
puma (~> 7.2)
|
|
6
6
|
rackup (~> 2.3)
|
|
7
7
|
sinatra (~> 4.1)
|
|
@@ -84,7 +84,7 @@ DEPENDENCIES
|
|
|
84
84
|
CHECKSUMS
|
|
85
85
|
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
|
|
86
86
|
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
|
87
|
-
brainiac (0.0.
|
|
87
|
+
brainiac (0.0.14)
|
|
88
88
|
json (2.19.9) sha256=9b9025b7cdddafa38d316eca0b2358488e42d417045c1b90d216a9fefe46b79a
|
|
89
89
|
language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
|
|
90
90
|
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
|
data/README.md
CHANGED
|
@@ -43,7 +43,9 @@ All four channels support inline tags in message/comment text. Tags are stripped
|
|
|
43
43
|
| ----------------------------------------------------------------------------- | ----- | ------- | ------ | ----------------------------------------------------------------------------------------------------- |
|
|
44
44
|
| `[project:XYZ]` | — | ✓ | — | Override which project the agent works in (Discord only — Fizzy uses card tags, GitHub uses the repo) |
|
|
45
45
|
| `[opus]` `[sonnet]` `[haiku]` `[deepseek]` `[minimax]` `[minimax21]` `[qwen]` | ✓ | ✓ | ✓ | Override the model for this dispatch |
|
|
46
|
-
| `[worktree:branch-name]` | ✓ |
|
|
46
|
+
| `[worktree:branch-name]` | ✓ | ✓ | — | Direct the agent to a specific worktree/branch (legacy syntax) |
|
|
47
|
+
| `[branch:branch-name]` | ✓ | ✓ | ✓ | Target an existing branch — reuses its worktree if a work item exists |
|
|
48
|
+
| `[workitem:wi-abc123]` | ✓ | ✓ | ✓ | Target a specific work item by ID — agent works in its worktree |
|
|
47
49
|
| `[plan]` | ✓ | ✓ | — | Activate planning mode — agent gathers requirements before coding |
|
|
48
50
|
|
|
49
51
|
Model keys come from the project's `allowed_models` config. Fizzy also supports model selection via card tags (e.g. adding an `opus` tag to the card).
|
|
@@ -868,6 +870,50 @@ curl http://localhost:4567/api/users/fladamd # Find by identifier
|
|
|
868
870
|
|
|
869
871
|
The registry reloads automatically on every webhook and via `POST /api/reload`.
|
|
870
872
|
|
|
873
|
+
## Work Items
|
|
874
|
+
|
|
875
|
+
Work items are the universal tracking unit in Brainiac — they connect a branch/worktree to one or more communication channels. A single work item can span Discord, Fizzy, and GitHub simultaneously.
|
|
876
|
+
|
|
877
|
+
### Structure
|
|
878
|
+
|
|
879
|
+
Work items are stored in `~/.brainiac/work_items.json`:
|
|
880
|
+
|
|
881
|
+
```json
|
|
882
|
+
{
|
|
883
|
+
"wi-a1b2c3d4": {
|
|
884
|
+
"id": "wi-a1b2c3d4",
|
|
885
|
+
"branch": "fizzy-42-fix-login",
|
|
886
|
+
"worktree": "/home/you/Code/marketplace--fizzy-42-fix-login",
|
|
887
|
+
"project": "marketplace",
|
|
888
|
+
"agent": "Sherlock",
|
|
889
|
+
"sources": {
|
|
890
|
+
"fizzy": { "card_internal_id": "uuid-here", "card_number": 42 },
|
|
891
|
+
"github": { "prs": [{"number": 8, "url": "https://github.com/org/repo/pull/8"}] },
|
|
892
|
+
"discord": { "thread_id": "1234567890" }
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
The **branch** is the universal join key. When any channel needs to find a work item, it looks up by branch name. This means:
|
|
899
|
+
|
|
900
|
+
- A Fizzy card assignment creates a work item with `sources.fizzy`
|
|
901
|
+
- When a PR is opened for that branch, `sources.github` is attached automatically
|
|
902
|
+
- A Discord `[branch:fizzy-42-fix-login]` message targets the same worktree
|
|
903
|
+
|
|
904
|
+
### Targeting Work Items
|
|
905
|
+
|
|
906
|
+
From Discord or any channel, use inline tags to target existing work items:
|
|
907
|
+
|
|
908
|
+
- **`[branch:branch-name]`** — find the work item for this branch, work in its worktree
|
|
909
|
+
- **`[workitem:wi-abc123]`** — target a work item by its ID directly
|
|
910
|
+
|
|
911
|
+
This enables cross-channel workflows without creating duplicate worktrees.
|
|
912
|
+
|
|
913
|
+
### Migration
|
|
914
|
+
|
|
915
|
+
The old format (keyed by Fizzy card internal ID) is automatically migrated on first read. No manual intervention needed — existing `work_items.json` files are transparently upgraded to the new source-agnostic format.
|
|
916
|
+
|
|
871
917
|
## Worktree Management
|
|
872
918
|
|
|
873
919
|
When a card is assigned, Brainiac creates a git worktree for the agent to work in. Two config files in the project root control how gitignored files are handled:
|
data/bin/brainiac
CHANGED
|
@@ -655,7 +655,8 @@ def list_projects
|
|
|
655
655
|
default_marker = config["default"] ? " (default)" : ""
|
|
656
656
|
puts " #{key}#{default_marker}"
|
|
657
657
|
puts " Path: #{config["repo_path"]}"
|
|
658
|
-
|
|
658
|
+
tags = config["tags"] || config["project_tags"]&.join(", ")
|
|
659
|
+
puts " Tags: #{tags}" if tags
|
|
659
660
|
puts " GitHub: #{config["github_repo"]}" if config["github_repo"]
|
|
660
661
|
puts " Agent: #{config["agent_cli"]} (model: #{config["agent_model"]})"
|
|
661
662
|
puts
|
|
@@ -2295,54 +2296,44 @@ when "update", "upgrade"
|
|
|
2295
2296
|
end
|
|
2296
2297
|
|
|
2297
2298
|
puts "Updating #{gem_name}..."
|
|
2298
|
-
|
|
2299
|
-
|
|
2299
|
+
output, status = Open3.capture2e("gem install #{gem_name}")
|
|
2300
|
+
puts output
|
|
2301
|
+
unless status.success?
|
|
2300
2302
|
puts ""
|
|
2301
2303
|
puts "Failed to update #{gem_name}."
|
|
2302
2304
|
exit 1
|
|
2303
2305
|
end
|
|
2304
2306
|
|
|
2305
|
-
#
|
|
2306
|
-
|
|
2307
|
-
new_version = begin
|
|
2308
|
-
spec = Gem::Specification.find_by_name(gem_name)
|
|
2309
|
-
spec.version.to_s
|
|
2310
|
-
rescue Gem::MissingSpecError
|
|
2311
|
-
"unknown"
|
|
2312
|
-
end
|
|
2307
|
+
# Parse installed version from gem install output
|
|
2308
|
+
new_version = output[/Successfully installed #{Regexp.escape(gem_name)}-(\S+)/, 1]
|
|
2313
2309
|
|
|
2314
|
-
if
|
|
2315
|
-
puts "✓ #{gem_name} is already at the latest version (#{new_version})"
|
|
2316
|
-
else
|
|
2310
|
+
if new_version
|
|
2317
2311
|
puts "✓ Updated #{gem_name}: #{current_version || "new"} → #{new_version}"
|
|
2318
2312
|
puts " Restart the server to activate: brainiac restart"
|
|
2313
|
+
else
|
|
2314
|
+
puts "✓ #{gem_name} is already at the latest version (#{current_version})"
|
|
2319
2315
|
end
|
|
2320
2316
|
else
|
|
2321
2317
|
# Update brainiac itself: brainiac update
|
|
2322
2318
|
current_version = BRAINIAC_VERSION
|
|
2323
2319
|
|
|
2324
2320
|
puts "Updating brainiac gem..."
|
|
2325
|
-
|
|
2326
|
-
|
|
2321
|
+
output, status = Open3.capture2e("gem install brainiac")
|
|
2322
|
+
puts output
|
|
2323
|
+
unless status.success?
|
|
2327
2324
|
puts ""
|
|
2328
2325
|
puts "Failed to update brainiac."
|
|
2329
2326
|
exit 1
|
|
2330
2327
|
end
|
|
2331
2328
|
|
|
2332
|
-
#
|
|
2333
|
-
|
|
2334
|
-
new_version = begin
|
|
2335
|
-
spec = Gem::Specification.find_by_name("brainiac")
|
|
2336
|
-
spec.version.to_s
|
|
2337
|
-
rescue Gem::MissingSpecError
|
|
2338
|
-
"unknown"
|
|
2339
|
-
end
|
|
2329
|
+
# Parse installed version from gem install output
|
|
2330
|
+
new_version = output[/Successfully installed brainiac-(\S+)/, 1]
|
|
2340
2331
|
|
|
2341
|
-
if
|
|
2342
|
-
puts "✓ brainiac is already at the latest version (#{new_version})"
|
|
2343
|
-
else
|
|
2332
|
+
if new_version
|
|
2344
2333
|
puts "✓ Updated brainiac: #{current_version} → #{new_version}"
|
|
2345
2334
|
puts " Restart the server to apply: brainiac restart"
|
|
2335
|
+
else
|
|
2336
|
+
puts "✓ brainiac is already at the latest version (#{current_version})"
|
|
2346
2337
|
end
|
|
2347
2338
|
end
|
|
2348
2339
|
|
data/lib/brainiac/config.rb
CHANGED
|
@@ -5,6 +5,7 @@ require "openssl"
|
|
|
5
5
|
require "open3"
|
|
6
6
|
require "fileutils"
|
|
7
7
|
require "logger"
|
|
8
|
+
require "securerandom"
|
|
8
9
|
require "net/http"
|
|
9
10
|
require "uri"
|
|
10
11
|
|
|
@@ -37,15 +38,48 @@ end
|
|
|
37
38
|
BRAINIAC_CONFIG = load_brainiac_config
|
|
38
39
|
|
|
39
40
|
# --- Default agent name ---
|
|
40
|
-
# Priority: AI_AGENT_NAME env var → brainiac.json "default_agent" → error.
|
|
41
|
-
|
|
41
|
+
# Priority: AI_AGENT_NAME env var → brainiac.json "default_agent" → first agent in agents.json → error.
|
|
42
|
+
def resolve_default_agent
|
|
43
|
+
# 1. Env var
|
|
44
|
+
name = ENV.fetch("AI_AGENT_NAME", nil)
|
|
45
|
+
return name if name
|
|
46
|
+
|
|
47
|
+
# 2. brainiac.json
|
|
48
|
+
name = BRAINIAC_CONFIG["default_agent"]
|
|
49
|
+
return name if name
|
|
50
|
+
|
|
51
|
+
# 3. First agent in agents.json
|
|
52
|
+
if File.exist?(AGENT_REGISTRY_FILE)
|
|
53
|
+
agents = begin
|
|
54
|
+
JSON.parse(File.read(AGENT_REGISTRY_FILE))
|
|
55
|
+
rescue StandardError
|
|
56
|
+
{}
|
|
57
|
+
end
|
|
58
|
+
first_agent = agents.values.first
|
|
59
|
+
if first_agent
|
|
60
|
+
agent_name = first_agent["fizzy_name"] || first_agent["display_name"] || agents.keys.first.capitalize
|
|
61
|
+
warn <<~MSG
|
|
62
|
+
[Brainiac] No default agent configured — using "#{agent_name}" (first agent in agents.json).
|
|
63
|
+
To set explicitly, either:
|
|
64
|
+
export AI_AGENT_NAME="#{agent_name}"
|
|
65
|
+
Or add to ~/.brainiac/brainiac.json:
|
|
66
|
+
{ "default_agent": "#{agent_name}" }
|
|
67
|
+
MSG
|
|
68
|
+
return agent_name
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# 4. Nothing found
|
|
42
73
|
raise <<~MSG
|
|
43
|
-
No default agent configured
|
|
74
|
+
No default agent configured and no agents found in #{AGENT_REGISTRY_FILE}.
|
|
75
|
+
Set one of:
|
|
44
76
|
1. Environment variable: export AI_AGENT_NAME="YourAgent"
|
|
45
77
|
2. In ~/.brainiac/brainiac.json: { "default_agent": "YourAgent" }
|
|
46
78
|
MSG
|
|
47
79
|
end
|
|
48
80
|
|
|
81
|
+
AI_AGENT_NAME = resolve_default_agent
|
|
82
|
+
|
|
49
83
|
LOG_LEVEL = ENV.fetch("LOG_LEVEL", "info").downcase
|
|
50
84
|
LOG = Logger.new($stdout)
|
|
51
85
|
LOG.level = case LOG_LEVEL
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
# Shared inline tag parsing for handler messages.
|
|
4
4
|
#
|
|
5
5
|
# Messages from any channel can contain inline tags like:
|
|
6
|
-
# [project:my-project], [opus], [effort:high], [cli:grok], [chat], [plan]
|
|
6
|
+
# [project:my-project], [opus], [effort:high], [cli:grok], [chat], [plan],
|
|
7
|
+
# [branch:feature-xyz], [workitem:wi-abc123]
|
|
7
8
|
#
|
|
8
9
|
# This module provides a single parser that extracts all tags and returns
|
|
9
10
|
# a structured result with the cleaned text.
|
|
@@ -31,9 +32,21 @@ def parse_inline_tags(text)
|
|
|
31
32
|
planning: false,
|
|
32
33
|
deploy_intent: nil,
|
|
33
34
|
worktree_override: nil,
|
|
35
|
+
work_item: nil,
|
|
36
|
+
branch_override: nil,
|
|
34
37
|
clean_text: text.dup
|
|
35
38
|
}
|
|
36
39
|
|
|
40
|
+
parse_value_tags(result)
|
|
41
|
+
parse_flag_tags(result)
|
|
42
|
+
parse_work_item_tags(result)
|
|
43
|
+
parse_model_tag(result)
|
|
44
|
+
|
|
45
|
+
result[:clean_text].strip!
|
|
46
|
+
result
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def parse_value_tags(result)
|
|
37
50
|
# [project:my-project]
|
|
38
51
|
if (match = result[:clean_text].match(/\[project:(\S+)\]/i))
|
|
39
52
|
result[:project] = match[1]
|
|
@@ -52,6 +65,14 @@ def parse_inline_tags(text)
|
|
|
52
65
|
result[:clean_text].sub!(match[0], "")
|
|
53
66
|
end
|
|
54
67
|
|
|
68
|
+
# [deploy] or [deploy:dev01]
|
|
69
|
+
if (match = result[:clean_text].match(/\[deploy(?::([^\]]+))?\]/i))
|
|
70
|
+
result[:deploy_intent] = match[1]&.strip&.downcase || :auto
|
|
71
|
+
result[:clean_text].sub!(match[0], "")
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def parse_flag_tags(result)
|
|
55
76
|
# [chat], [question], [?]
|
|
56
77
|
if result[:clean_text].match?(/\[(chat|question|\?)\]/i)
|
|
57
78
|
result[:chat_mode] = true
|
|
@@ -59,23 +80,35 @@ def parse_inline_tags(text)
|
|
|
59
80
|
end
|
|
60
81
|
|
|
61
82
|
# [plan]
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
83
|
+
return unless result[:clean_text].match?(/\[plan\]/i)
|
|
84
|
+
|
|
85
|
+
result[:planning] = true
|
|
86
|
+
result[:clean_text].sub!(/\[plan\]/i, "")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def parse_work_item_tags(result)
|
|
90
|
+
# [worktree:branch-name] — legacy syntax, still supported
|
|
91
|
+
if (match = result[:clean_text].match(/\[worktree:([^\]]+)\]/))
|
|
92
|
+
result[:worktree_override] = match[1].strip
|
|
93
|
+
result[:clean_text].sub!(match[0], "")
|
|
65
94
|
end
|
|
66
95
|
|
|
67
|
-
# [
|
|
68
|
-
if (match = result[:clean_text].match(/\[
|
|
69
|
-
result[:
|
|
96
|
+
# [branch:branch-name] — preferred syntax for targeting a branch/worktree
|
|
97
|
+
if (match = result[:clean_text].match(/\[branch:([^\]]+)\]/i))
|
|
98
|
+
result[:branch_override] = match[1].strip
|
|
99
|
+
# Also set worktree_override for backward compat with plugins that read it
|
|
100
|
+
result[:worktree_override] ||= result[:branch_override]
|
|
70
101
|
result[:clean_text].sub!(match[0], "")
|
|
71
102
|
end
|
|
72
103
|
|
|
73
|
-
# [
|
|
74
|
-
if (match = result[:clean_text].match(/\[
|
|
75
|
-
result[:
|
|
104
|
+
# [workitem:wi-abc123] — target a specific work item by ID
|
|
105
|
+
if (match = result[:clean_text].match(/\[workitem:([^\]]+)\]/i))
|
|
106
|
+
result[:work_item] = match[1].strip
|
|
76
107
|
result[:clean_text].sub!(match[0], "")
|
|
77
108
|
end
|
|
109
|
+
end
|
|
78
110
|
|
|
111
|
+
def parse_model_tag(result)
|
|
79
112
|
# Model tag: any remaining [word] that isn't a known tag — detected separately
|
|
80
113
|
# because it depends on the project's allowed_models config. We just capture
|
|
81
114
|
# the raw match here for the caller to resolve.
|
|
@@ -83,7 +116,4 @@ def parse_inline_tags(text)
|
|
|
83
116
|
result[:model_tag] = match[1].downcase
|
|
84
117
|
result[:clean_text].sub!(match[0], "")
|
|
85
118
|
end
|
|
86
|
-
|
|
87
|
-
result[:clean_text].strip!
|
|
88
|
-
result
|
|
89
119
|
end
|
data/lib/brainiac/helpers.rb
CHANGED
|
@@ -112,7 +112,8 @@ end
|
|
|
112
112
|
def load_work_item_map
|
|
113
113
|
return {} unless File.exist?(WORK_ITEM_MAP_FILE)
|
|
114
114
|
|
|
115
|
-
JSON.parse(File.read(WORK_ITEM_MAP_FILE))
|
|
115
|
+
raw = JSON.parse(File.read(WORK_ITEM_MAP_FILE))
|
|
116
|
+
migrate_work_item_map(raw)
|
|
116
117
|
rescue JSON::ParserError
|
|
117
118
|
{}
|
|
118
119
|
end
|
|
@@ -121,6 +122,162 @@ def save_work_item_map(map)
|
|
|
121
122
|
File.write(WORK_ITEM_MAP_FILE, JSON.pretty_generate(map))
|
|
122
123
|
end
|
|
123
124
|
|
|
125
|
+
# Migrate old-format work item maps (keyed by Fizzy card internal ID with flat structure)
|
|
126
|
+
# to the new source-agnostic format (keyed by work item ID with sources hash).
|
|
127
|
+
# Old format: { "fizzy-uuid" => { "number" => 42, "branch" => "...", "worktree" => "...", "project" => "...", "agent" => "..." } }
|
|
128
|
+
# New format: { "wi-abc123" => { "id" => "wi-...", "branch" => "...", "worktree" => "...",
|
|
129
|
+
# "project" => "...", "agent" => "...", "sources" => { "fizzy" => { ... } } } }
|
|
130
|
+
def migrate_work_item_map(raw)
|
|
131
|
+
return raw if raw.empty?
|
|
132
|
+
|
|
133
|
+
# Detect: if any entry has a "sources" key, it's already new format (or mixed)
|
|
134
|
+
# If none have "sources", it's entirely old format
|
|
135
|
+
needs_migration = raw.values.any? { |v| v.is_a?(Hash) && !v.key?("sources") }
|
|
136
|
+
return raw unless needs_migration
|
|
137
|
+
|
|
138
|
+
migrated = {}
|
|
139
|
+
raw.each do |key, entry|
|
|
140
|
+
next unless entry.is_a?(Hash)
|
|
141
|
+
|
|
142
|
+
if entry.key?("sources")
|
|
143
|
+
# Already new format
|
|
144
|
+
migrated[key] = entry
|
|
145
|
+
else
|
|
146
|
+
# Old format — migrate. Generate a work item ID from the branch or card number.
|
|
147
|
+
work_item_id = generate_work_item_id(branch: entry["branch"], card_number: entry["number"])
|
|
148
|
+
migrated[work_item_id] = {
|
|
149
|
+
"id" => work_item_id,
|
|
150
|
+
"branch" => entry["branch"],
|
|
151
|
+
"worktree" => entry["worktree"],
|
|
152
|
+
"project" => entry["project"],
|
|
153
|
+
"agent" => entry["agent"],
|
|
154
|
+
"sources" => {
|
|
155
|
+
"fizzy" => {
|
|
156
|
+
"card_internal_id" => key,
|
|
157
|
+
"card_number" => entry["number"]
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
# Preserve PR tracking if it exists
|
|
162
|
+
migrated[work_item_id]["sources"]["github"] = { "prs" => entry["prs"] } if entry["prs"]
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
migrated
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Generate a deterministic work item ID from available identifiers.
|
|
169
|
+
# Priority: branch name (universal join key), then card number fallback.
|
|
170
|
+
def generate_work_item_id(branch: nil, card_number: nil)
|
|
171
|
+
if branch
|
|
172
|
+
"wi-#{Digest::SHA256.hexdigest(branch)[0..7]}"
|
|
173
|
+
elsif card_number
|
|
174
|
+
"wi-card-#{card_number}"
|
|
175
|
+
else
|
|
176
|
+
"wi-#{SecureRandom.hex(4)}"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Find a work item by its branch name. Returns [work_item_id, info] or nil.
|
|
181
|
+
# This is the primary lookup method — branch is the universal join key.
|
|
182
|
+
def find_work_item_by_branch(branch)
|
|
183
|
+
return nil unless branch
|
|
184
|
+
|
|
185
|
+
map = load_work_item_map
|
|
186
|
+
map.each do |work_item_id, info|
|
|
187
|
+
next unless info.is_a?(Hash) && info["branch"] == branch
|
|
188
|
+
|
|
189
|
+
return [work_item_id, info]
|
|
190
|
+
end
|
|
191
|
+
nil
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Find a work item by its ID. Returns the info hash or nil.
|
|
195
|
+
def find_work_item_by_id(work_item_id)
|
|
196
|
+
return nil unless work_item_id
|
|
197
|
+
|
|
198
|
+
map = load_work_item_map
|
|
199
|
+
map[work_item_id]
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Find a work item by a Fizzy card internal ID (for backward compat with Fizzy plugin).
|
|
203
|
+
# Returns [work_item_id, info] or nil.
|
|
204
|
+
def find_work_item_by_card(card_internal_id)
|
|
205
|
+
return nil unless card_internal_id
|
|
206
|
+
|
|
207
|
+
map = load_work_item_map
|
|
208
|
+
map.each do |work_item_id, info|
|
|
209
|
+
next unless info.is_a?(Hash)
|
|
210
|
+
|
|
211
|
+
fizzy_source = info.dig("sources", "fizzy")
|
|
212
|
+
next unless fizzy_source && fizzy_source["card_internal_id"] == card_internal_id
|
|
213
|
+
|
|
214
|
+
return [work_item_id, info]
|
|
215
|
+
end
|
|
216
|
+
nil
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Register a new work item or update an existing one.
|
|
220
|
+
# Returns the work item ID.
|
|
221
|
+
def register_work_item(branch:, worktree: nil, project: nil, agent: nil, source: nil, source_data: {})
|
|
222
|
+
map = load_work_item_map
|
|
223
|
+
|
|
224
|
+
# Check if a work item already exists for this branch
|
|
225
|
+
existing_id = nil
|
|
226
|
+
map.each do |wid, info|
|
|
227
|
+
if info.is_a?(Hash) && info["branch"] == branch
|
|
228
|
+
existing_id = wid
|
|
229
|
+
break
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
work_item_id = existing_id || generate_work_item_id(branch: branch)
|
|
234
|
+
|
|
235
|
+
if existing_id
|
|
236
|
+
# Update existing entry — merge in new source, update worktree/agent if provided
|
|
237
|
+
map[work_item_id]["worktree"] = worktree if worktree
|
|
238
|
+
map[work_item_id]["agent"] = agent if agent
|
|
239
|
+
map[work_item_id]["sources"] ||= {}
|
|
240
|
+
map[work_item_id]["sources"][source] = source_data if source
|
|
241
|
+
else
|
|
242
|
+
# Create new entry
|
|
243
|
+
map[work_item_id] = {
|
|
244
|
+
"id" => work_item_id,
|
|
245
|
+
"branch" => branch,
|
|
246
|
+
"worktree" => worktree,
|
|
247
|
+
"project" => project,
|
|
248
|
+
"agent" => agent,
|
|
249
|
+
"sources" => source ? { source => source_data } : {}
|
|
250
|
+
}
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
save_work_item_map(map)
|
|
254
|
+
work_item_id
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Add or update a source on an existing work item.
|
|
258
|
+
# Returns true if the work item was found and updated, false otherwise.
|
|
259
|
+
def register_work_item_source(source:, source_data:, work_item_id: nil, branch: nil) # rubocop:disable Naming/PredicateMethod
|
|
260
|
+
map = load_work_item_map
|
|
261
|
+
|
|
262
|
+
# Find by ID or branch
|
|
263
|
+
target_id = work_item_id
|
|
264
|
+
unless target_id
|
|
265
|
+
map.each do |wid, info|
|
|
266
|
+
if info.is_a?(Hash) && info["branch"] == branch
|
|
267
|
+
target_id = wid
|
|
268
|
+
break
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
return false unless target_id && map[target_id]
|
|
274
|
+
|
|
275
|
+
map[target_id]["sources"] ||= {}
|
|
276
|
+
map[target_id]["sources"][source] = source_data
|
|
277
|
+
save_work_item_map(map)
|
|
278
|
+
true
|
|
279
|
+
end
|
|
280
|
+
|
|
124
281
|
def slugify(title, max_length: 40)
|
|
125
282
|
title.downcase.gsub(/[^a-z0-9\s-]/, "").strip.gsub(/\s+/, "-").slice(0, max_length).chomp("-")
|
|
126
283
|
end
|
|
@@ -242,8 +399,27 @@ def resolve_resume(resume, resolved, chdir)
|
|
|
242
399
|
false
|
|
243
400
|
end
|
|
244
401
|
|
|
402
|
+
# Check if intent detection says to skip dispatching the agent.
|
|
403
|
+
# Returns true if the message should be skipped, false otherwise.
|
|
404
|
+
# Only runs if a raw message is provided, an agent is named, and intent is enabled.
|
|
405
|
+
def intent_skip?(message, agent_name:, source: nil, channel: nil)
|
|
406
|
+
return false unless message && agent_name && intent_config["enabled"]
|
|
407
|
+
|
|
408
|
+
intent_channel = channel || source&.to_s || "conversation"
|
|
409
|
+
unless check_intent(message, agent_name: agent_name, channel: intent_channel)
|
|
410
|
+
LOG.info "[Intent] Skipping dispatch for #{agent_name} — message classified as not requiring response"
|
|
411
|
+
return true
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
false
|
|
415
|
+
end
|
|
416
|
+
|
|
245
417
|
def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil, effort: nil, agent_name: nil, card_number: nil, comment_id: nil,
|
|
246
|
-
source: nil, source_context: {}, skip_column_move: false, cli_provider: nil, resume: false
|
|
418
|
+
source: nil, source_context: {}, skip_column_move: false, cli_provider: nil, resume: false,
|
|
419
|
+
message: nil, channel: nil)
|
|
420
|
+
# Intent gate: if a raw message is provided, check whether the agent should respond.
|
|
421
|
+
return nil if intent_skip?(message, agent_name: agent_name, source: source, channel: channel)
|
|
422
|
+
|
|
247
423
|
resolved = resolve_project_cli_config(project_config, cli_provider_override: cli_provider, agent_name: agent_name)
|
|
248
424
|
chdir ||= resolved["repo_path"]
|
|
249
425
|
model ||= resolved["agent_model"]
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Local LLM-based intent detection.
|
|
4
|
+
#
|
|
5
|
+
# Uses a lightweight local model (via Ollama) to classify whether an incoming
|
|
6
|
+
# message requires an agent to respond. This saves expensive API tokens by
|
|
7
|
+
# filtering out messages that are just humans chatting, reactions, or noise.
|
|
8
|
+
#
|
|
9
|
+
# Also detects when an agent's own response implies pending work — if the agent
|
|
10
|
+
# said "I'll do X" but the session ended without completing the work, this
|
|
11
|
+
# signals that the agent should be re-dispatched.
|
|
12
|
+
#
|
|
13
|
+
# Configuration (in ~/.brainiac/brainiac.json):
|
|
14
|
+
#
|
|
15
|
+
# "intent": {
|
|
16
|
+
# "enabled": true,
|
|
17
|
+
# "endpoint": "http://localhost:11434/api/generate",
|
|
18
|
+
# "model": "gemma3:4b",
|
|
19
|
+
# "timeout": 10,
|
|
20
|
+
# "temperature": 0.1
|
|
21
|
+
# }
|
|
22
|
+
#
|
|
23
|
+
# Plugins use this via:
|
|
24
|
+
# requires_response = check_intent(message, agent_name: "Galen", context: "Discord thread")
|
|
25
|
+
# return unless requires_response
|
|
26
|
+
#
|
|
27
|
+
# Returns true (agent should respond), false (skip), or true on any failure
|
|
28
|
+
# (fail-open so we never accidentally ignore a real request).
|
|
29
|
+
|
|
30
|
+
INTENT_CONFIG_DEFAULTS = {
|
|
31
|
+
"enabled" => false,
|
|
32
|
+
"endpoint" => "http://localhost:11434/api/generate",
|
|
33
|
+
"model" => "gemma3:4b",
|
|
34
|
+
"timeout" => 10,
|
|
35
|
+
"temperature" => 0.1
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
INTENT_PROMPT_TEMPLATE = <<~PROMPT
|
|
39
|
+
You are a message router for a {{CHANNEL}}. An AI agent named {{AGENT_NAME}} is participating in this conversation. Your job: determine if the latest message requires {{AGENT_NAME}} to take action or respond.
|
|
40
|
+
|
|
41
|
+
Rules:
|
|
42
|
+
- If the message is giving {{AGENT_NAME}} instructions, asking {{AGENT_NAME}} a question, or continuing a conversation WITH {{AGENT_NAME}} → yes
|
|
43
|
+
- If the message is humans talking to each other and {{AGENT_NAME}} is not being addressed → no
|
|
44
|
+
- If the message is a simple acknowledgment (like "thanks", "ok", "got it") directed at {{AGENT_NAME}}'s previous work → no
|
|
45
|
+
- If the message is asking a question to another person or agent → no
|
|
46
|
+
- If uncertain, lean toward yes (better to respond unnecessarily than miss a request)
|
|
47
|
+
|
|
48
|
+
Respond with ONLY "yes" or "no" — nothing else.
|
|
49
|
+
|
|
50
|
+
Latest message:
|
|
51
|
+
{{MESSAGE}}
|
|
52
|
+
PROMPT
|
|
53
|
+
|
|
54
|
+
PENDING_WORK_PROMPT_TEMPLATE = <<~PROMPT
|
|
55
|
+
You are analyzing a message posted by an AI agent named {{AGENT_NAME}}. Your job: determine if this message indicates the agent intends to do more work that hasn't been completed yet.
|
|
56
|
+
|
|
57
|
+
Rules:
|
|
58
|
+
- If the agent says it will do something, is about to start work, or promises a follow-up action → yes
|
|
59
|
+
- If the agent is reporting completed work, summarizing what was done, or delivering a final answer → no
|
|
60
|
+
- If the agent is asking a clarifying question and waiting for a human response → no
|
|
61
|
+
- If the agent says things like "give me a sec", "I'll implement this", "let me do that", "working on it" → yes
|
|
62
|
+
- If uncertain, lean toward no (avoid unnecessary re-dispatches)
|
|
63
|
+
|
|
64
|
+
Respond with ONLY "yes" or "no" — nothing else.
|
|
65
|
+
|
|
66
|
+
Agent's message:
|
|
67
|
+
{{MESSAGE}}
|
|
68
|
+
PROMPT
|
|
69
|
+
|
|
70
|
+
# Load intent config from brainiac.json, merging with defaults.
|
|
71
|
+
def intent_config
|
|
72
|
+
raw = BRAINIAC_CONFIG["intent"] || {}
|
|
73
|
+
INTENT_CONFIG_DEFAULTS.merge(raw)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Check whether a message requires an agent to respond.
|
|
77
|
+
#
|
|
78
|
+
# @param message [String] The message text to classify
|
|
79
|
+
# @param agent_name [String] The agent being addressed
|
|
80
|
+
# @param channel [String] Context description (e.g., "Discord thread", "Fizzy card comment")
|
|
81
|
+
# @return [Boolean] true if the agent should respond, false if it can be skipped
|
|
82
|
+
def check_intent(message, agent_name:, channel: "conversation")
|
|
83
|
+
config = intent_config
|
|
84
|
+
return true unless config["enabled"]
|
|
85
|
+
return true if message.nil? || message.strip.empty?
|
|
86
|
+
|
|
87
|
+
prompt = INTENT_PROMPT_TEMPLATE
|
|
88
|
+
.gsub("{{AGENT_NAME}}", agent_name)
|
|
89
|
+
.gsub("{{CHANNEL}}", channel)
|
|
90
|
+
.gsub("{{MESSAGE}}", message.strip)
|
|
91
|
+
|
|
92
|
+
LOG.info "[Intent] Checking intent for #{agent_name} (#{channel}): #{message.strip.slice(0, 80)}..."
|
|
93
|
+
response = query_local_llm(prompt, config)
|
|
94
|
+
result = positive_intent?(response)
|
|
95
|
+
LOG.info "[Intent] Result: #{result ? "RESPOND" : "SKIP"} (model: #{config["model"]})"
|
|
96
|
+
result
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
LOG.warn "[Intent] Classification failed (fail-open): #{e.message}"
|
|
99
|
+
true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Query the local LLM via Ollama's HTTP API.
|
|
103
|
+
#
|
|
104
|
+
# @param prompt [String] The classification prompt
|
|
105
|
+
# @param config [Hash] Intent configuration
|
|
106
|
+
# @return [String] Raw response text from the model
|
|
107
|
+
def query_local_llm(prompt, config)
|
|
108
|
+
uri = URI(config["endpoint"])
|
|
109
|
+
payload = {
|
|
110
|
+
model: config["model"],
|
|
111
|
+
prompt: prompt,
|
|
112
|
+
stream: false,
|
|
113
|
+
options: { temperature: config["temperature"] }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
117
|
+
http.open_timeout = config["timeout"]
|
|
118
|
+
http.read_timeout = config["timeout"]
|
|
119
|
+
|
|
120
|
+
request = Net::HTTP::Post.new(uri.path, "Content-Type" => "application/json")
|
|
121
|
+
request.body = JSON.generate(payload)
|
|
122
|
+
|
|
123
|
+
response = http.request(request)
|
|
124
|
+
raise "Ollama returned #{response.code}: #{response.body&.slice(0, 200)}" unless response.is_a?(Net::HTTPSuccess)
|
|
125
|
+
|
|
126
|
+
body = JSON.parse(response.body)
|
|
127
|
+
body["response"] || ""
|
|
128
|
+
rescue Errno::ECONNREFUSED
|
|
129
|
+
raise "Ollama not running at #{config["endpoint"]}"
|
|
130
|
+
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
131
|
+
raise "Ollama timed out after #{config["timeout"]}s"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Parse the LLM's yes/no response into a boolean.
|
|
135
|
+
# Fail-open: anything that isn't clearly "no" returns true.
|
|
136
|
+
#
|
|
137
|
+
# @param response [String] Raw response from the model
|
|
138
|
+
# @return [Boolean]
|
|
139
|
+
def positive_intent?(response)
|
|
140
|
+
cleaned = response.to_s.strip.downcase.gsub(/[^a-z]/, "")
|
|
141
|
+
cleaned != "no"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Check whether an agent's own message implies pending work that wasn't completed.
|
|
145
|
+
# Used to detect when an agent posted "I'll do X" but the session ended without
|
|
146
|
+
# actually doing the work — signaling the agent should be re-dispatched.
|
|
147
|
+
#
|
|
148
|
+
# Fail-closed: returns false on any error (don't re-dispatch on classification failure).
|
|
149
|
+
#
|
|
150
|
+
# @param message [String] The agent's posted message
|
|
151
|
+
# @param agent_name [String] The agent who posted it
|
|
152
|
+
# @return [Boolean] true if the message implies uncommitted work
|
|
153
|
+
def check_pending_work(message, agent_name:)
|
|
154
|
+
config = intent_config
|
|
155
|
+
return false unless config["enabled"]
|
|
156
|
+
return false if message.nil? || message.strip.empty?
|
|
157
|
+
|
|
158
|
+
prompt = PENDING_WORK_PROMPT_TEMPLATE
|
|
159
|
+
.gsub("{{AGENT_NAME}}", agent_name)
|
|
160
|
+
.gsub("{{MESSAGE}}", message.strip)
|
|
161
|
+
|
|
162
|
+
response = query_local_llm(prompt, config)
|
|
163
|
+
result = pending_work_detected?(response)
|
|
164
|
+
LOG.info "[Intent] Pending work detected in #{agent_name}'s message" if result
|
|
165
|
+
result
|
|
166
|
+
rescue StandardError => e
|
|
167
|
+
LOG.warn "[Intent] Pending work check failed (fail-closed): #{e.message}"
|
|
168
|
+
false
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Parse the LLM's yes/no response for pending work detection.
|
|
172
|
+
# Fail-closed: anything that isn't clearly "yes" returns false.
|
|
173
|
+
#
|
|
174
|
+
# @param response [String] Raw response from the model
|
|
175
|
+
# @return [Boolean]
|
|
176
|
+
def pending_work_detected?(response)
|
|
177
|
+
cleaned = response.to_s.strip.downcase.gsub(/[^a-z]/, "")
|
|
178
|
+
cleaned == "yes"
|
|
179
|
+
end
|
data/lib/brainiac/routes/api.rb
CHANGED
|
@@ -354,6 +354,67 @@ post "/api/cron/reload" do
|
|
|
354
354
|
{ status: "reloaded", jobs: CRON_JOBS.size }.to_json
|
|
355
355
|
end
|
|
356
356
|
|
|
357
|
+
# --- Intent ---
|
|
358
|
+
|
|
359
|
+
get "/api/intent/config" do
|
|
360
|
+
content_type :json
|
|
361
|
+
config = intent_config
|
|
362
|
+
status = if config["enabled"]
|
|
363
|
+
begin
|
|
364
|
+
uri = URI(config["endpoint"].sub("/api/generate", "/api/tags"))
|
|
365
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
366
|
+
http.open_timeout = 3
|
|
367
|
+
http.read_timeout = 3
|
|
368
|
+
response = http.get(uri.path)
|
|
369
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
370
|
+
models = JSON.parse(response.body).fetch("models", []).map { |m| m["name"] }
|
|
371
|
+
model_available = models.any? { |m| m.start_with?(config["model"].split(":").first) }
|
|
372
|
+
model_available ? "ok" : "model_not_found"
|
|
373
|
+
else
|
|
374
|
+
"ollama_error"
|
|
375
|
+
end
|
|
376
|
+
rescue Errno::ECONNREFUSED
|
|
377
|
+
"ollama_not_running"
|
|
378
|
+
rescue StandardError => e
|
|
379
|
+
"error: #{e.message}"
|
|
380
|
+
end
|
|
381
|
+
else
|
|
382
|
+
"disabled"
|
|
383
|
+
end
|
|
384
|
+
config.merge("status" => status).to_json
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
post "/api/intent/check" do
|
|
388
|
+
content_type :json
|
|
389
|
+
request.body.rewind
|
|
390
|
+
payload = JSON.parse(request.body.read)
|
|
391
|
+
|
|
392
|
+
message = payload["message"]
|
|
393
|
+
agent = payload["agent"] || AI_AGENT_NAME
|
|
394
|
+
channel = payload["channel"] || "conversation"
|
|
395
|
+
|
|
396
|
+
halt 400, { error: "Missing 'message' field" }.to_json unless message && !message.empty?
|
|
397
|
+
|
|
398
|
+
result = check_intent(message, agent_name: agent, channel: channel)
|
|
399
|
+
{ should_respond: result, agent: agent, channel: channel }.to_json
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
post "/api/intent/pending-work" do
|
|
403
|
+
content_type :json
|
|
404
|
+
request.body.rewind
|
|
405
|
+
payload = JSON.parse(request.body.read)
|
|
406
|
+
|
|
407
|
+
message = payload["message"]
|
|
408
|
+
agent = payload["agent"] || AI_AGENT_NAME
|
|
409
|
+
|
|
410
|
+
halt 400, { error: "Missing 'message' field" }.to_json unless message && !message.empty?
|
|
411
|
+
|
|
412
|
+
result = check_pending_work(message, agent_name: agent)
|
|
413
|
+
{ pending_work: result, agent: agent }.to_json
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# --- Cron Logs ---
|
|
417
|
+
|
|
357
418
|
get "/api/cron/logs" do
|
|
358
419
|
content_type :json
|
|
359
420
|
job_id = params["id"]
|
data/lib/brainiac/version.rb
CHANGED
data/lib/brainiac.rb
CHANGED
data/receiver.rb
CHANGED
|
@@ -24,6 +24,7 @@ require_relative "lib/brainiac/helpers"
|
|
|
24
24
|
require_relative "lib/brainiac/notifications"
|
|
25
25
|
require_relative "lib/brainiac/cron"
|
|
26
26
|
require_relative "lib/brainiac/restart"
|
|
27
|
+
require_relative "lib/brainiac/intent"
|
|
27
28
|
require_relative "lib/brainiac/plugins"
|
|
28
29
|
require_relative "lib/brainiac/handlers/shared/git"
|
|
29
30
|
require_relative "lib/brainiac/handlers/shared/inline_tags"
|
|
@@ -98,6 +99,11 @@ configure do
|
|
|
98
99
|
end
|
|
99
100
|
|
|
100
101
|
LOG.info "[Brainiac] Starting v#{BRAINIAC_VERSION} on port #{settings.port} (#{settings.environment})"
|
|
102
|
+
if intent_config["enabled"]
|
|
103
|
+
LOG.info "[Intent] Enabled — model: #{intent_config["model"]}, endpoint: #{intent_config["endpoint"]}"
|
|
104
|
+
else
|
|
105
|
+
LOG.info "[Intent] Disabled (enable in brainiac.json → intent.enabled: true)"
|
|
106
|
+
end
|
|
101
107
|
|
|
102
108
|
# --- Dashboard authentication ---
|
|
103
109
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Pre-commit hook: auto-run `bundle` when version.rb changes
|
|
3
|
+
# This ensures Gemfile.lock stays in sync with gemspec version bumps.
|
|
4
|
+
|
|
5
|
+
if git diff --cached --name-only | grep -q "version\.rb$"; then
|
|
6
|
+
echo "🔒 version.rb changed — running bundle to sync Gemfile.lock..."
|
|
7
|
+
bundle install --quiet
|
|
8
|
+
if [ $? -ne 0 ]; then
|
|
9
|
+
echo "❌ bundle install failed. Fix the issue and try again."
|
|
10
|
+
exit 1
|
|
11
|
+
fi
|
|
12
|
+
git add Gemfile.lock
|
|
13
|
+
echo "✅ Gemfile.lock updated and staged."
|
|
14
|
+
fi
|
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.0.
|
|
4
|
+
version: 0.0.14
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andy Davis
|
|
@@ -133,6 +133,7 @@ files:
|
|
|
133
133
|
- lib/brainiac/handlers/shared/inline_tags.rb
|
|
134
134
|
- lib/brainiac/helpers.rb
|
|
135
135
|
- lib/brainiac/hooks.rb
|
|
136
|
+
- lib/brainiac/intent.rb
|
|
136
137
|
- lib/brainiac/notifications.rb
|
|
137
138
|
- lib/brainiac/plugins.rb
|
|
138
139
|
- lib/brainiac/prompts.rb
|
|
@@ -159,6 +160,7 @@ files:
|
|
|
159
160
|
- templates/brainiac.json.example
|
|
160
161
|
- templates/cli-providers/grok.json.example
|
|
161
162
|
- templates/cli-providers/kiro.json.example
|
|
163
|
+
- templates/hooks/pre-commit
|
|
162
164
|
- templates/plugins.json.example
|
|
163
165
|
- templates/roles/code-reviewer.md.example
|
|
164
166
|
- templates/roles/general-engineer.md.example
|