brainiac 0.0.12 → 0.0.13
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/lib/brainiac/config.rb +1 -0
- data/lib/brainiac/handlers/shared/inline_tags.rb +43 -13
- data/lib/brainiac/helpers.rb +158 -1
- data/lib/brainiac/version.rb +1 -1
- data/templates/hooks/pre-commit +14 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 900af8a7e0c45dd36e8d3dd0eeebe9f72d5b31da6f97f55d847542aade0e8bec
|
|
4
|
+
data.tar.gz: e9c685222d55fb1820062f04b695c6c7233531b2198e4edd12a79c8013cbe3d8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8955a6fb660975dfac37f1772494e8d6d0aec2d09f4dd3a94715dd2357df111824557a2d475adeae2ff11d68ae6fedb1556c5bb3b8c397b46fafe75a5993c5c1
|
|
7
|
+
data.tar.gz: 620f1e0d10401ba5892d019d491c2fd2eda8d34098f9ab1729e9ba00bf434a6e0f1bb185b12b8ab329aad4d2745f0beb4fed7cffd5151bdb3d0c7cab090021c6
|
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.13)
|
|
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.13)
|
|
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/lib/brainiac/config.rb
CHANGED
|
@@ -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
|
data/lib/brainiac/version.rb
CHANGED
|
@@ -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.13
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andy Davis
|
|
@@ -159,6 +159,7 @@ files:
|
|
|
159
159
|
- templates/brainiac.json.example
|
|
160
160
|
- templates/cli-providers/grok.json.example
|
|
161
161
|
- templates/cli-providers/kiro.json.example
|
|
162
|
+
- templates/hooks/pre-commit
|
|
162
163
|
- templates/plugins.json.example
|
|
163
164
|
- templates/roles/code-reviewer.md.example
|
|
164
165
|
- templates/roles/general-engineer.md.example
|