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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7890616cf0466e9c991c0e1209ff482eae548c205d151746c3f3065d2c239033
4
- data.tar.gz: 76f04a3697a1070932a9754281828fb6d49869c3efe1b0efc9a89c98f3ee9bba
3
+ metadata.gz: 900af8a7e0c45dd36e8d3dd0eeebe9f72d5b31da6f97f55d847542aade0e8bec
4
+ data.tar.gz: e9c685222d55fb1820062f04b695c6c7233531b2198e4edd12a79c8013cbe3d8
5
5
  SHA512:
6
- metadata.gz: 975a3cff959457bd2bf6f622010d3f016643f36aea981874e9424fb278666eda22441fd9f0e5938806849eaa4165f62c2f1c5fd1be10ad32b501afcecfacfb1a
7
- data.tar.gz: 2c94aeefd3190ad1c8f074665486988d1b0440b4cb104a5dae39d670bb5559db0d5abeb64a4d4da132c6ff8e2d385e004ee26d11568a6eaf7313a822f854065d
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.12)
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.12)
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]` | ✓ | | — | Direct the agent to a specific worktree instead of the card's default |
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:
@@ -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
 
@@ -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
- if result[:clean_text].match?(/\[plan\]/i)
63
- result[:planning] = true
64
- result[:clean_text].sub!(/\[plan\]/i, "")
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
- # [deploy] or [deploy:dev01]
68
- if (match = result[:clean_text].match(/\[deploy(?::([^\]]+))?\]/i))
69
- result[:deploy_intent] = match[1]&.strip&.downcase || :auto
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
- # [worktree:branch-name]
74
- if (match = result[:clean_text].match(/\[worktree:([^\]]+)\]/))
75
- result[:worktree_override] = match[1].strip
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
@@ -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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Brainiac
4
4
  # @return [String] the current gem version
5
- VERSION = "0.0.12"
5
+ VERSION = "0.0.13"
6
6
  end
@@ -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.12
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