brainiac-basecamp 0.0.1

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.
@@ -0,0 +1,411 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Brainiac
6
+ module Plugins
7
+ module Basecamp
8
+ module Cli
9
+ BRAINIAC_DIR = ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
10
+ CONFIG_FILE = File.join(BRAINIAC_DIR, "basecamp.json")
11
+
12
+ class << self
13
+ def run(args)
14
+ command = args.shift
15
+
16
+ case command
17
+ when "setup"
18
+ cmd_setup
19
+ when "config"
20
+ cmd_config
21
+ when "status"
22
+ cmd_status
23
+ when "epics"
24
+ cmd_epics(args)
25
+ when "link"
26
+ cmd_link(args)
27
+ when "bot"
28
+ cmd_bot(args)
29
+ when "projects"
30
+ cmd_projects(args)
31
+ when "set"
32
+ cmd_set(args)
33
+ else
34
+ print_help
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def cmd_setup
41
+ puts "Basecamp Plugin Setup"
42
+ puts "====================="
43
+ puts ""
44
+
45
+ # Check if basecamp CLI is installed
46
+ unless system("which basecamp > /dev/null 2>&1")
47
+ puts "❌ basecamp CLI not found. Install it first:"
48
+ puts " curl -fsSL https://basecamp.com/install-cli | bash"
49
+ return
50
+ end
51
+ puts "✓ basecamp CLI found"
52
+
53
+ # Check auth status
54
+ auth_output = `basecamp auth status --json 2>/dev/null`
55
+ if $?.success? && !auth_output.empty?
56
+ puts "✓ Authenticated with Basecamp"
57
+ else
58
+ puts "❌ Not authenticated. Run: basecamp auth login"
59
+ return
60
+ end
61
+
62
+ # Create config if it doesn't exist
63
+ unless File.exist?(CONFIG_FILE)
64
+ default_config = {
65
+ "bot_accounts" => {},
66
+ "project_mappings" => {},
67
+ "epic_prefix" => "Epic:",
68
+ "fizzy_account_id" => nil,
69
+ "review_gate" => "on_complete",
70
+ "notifications" => {
71
+ "epic_started" => true,
72
+ "task_dispatched" => true,
73
+ "task_completed" => true,
74
+ "epic_completed" => true
75
+ }
76
+ }
77
+ File.write(CONFIG_FILE, JSON.pretty_generate(default_config))
78
+ puts "✓ Created #{CONFIG_FILE}"
79
+ else
80
+ puts "✓ Config exists at #{CONFIG_FILE}"
81
+ end
82
+
83
+ puts ""
84
+ puts "Next steps:"
85
+ puts " 1. Set Fizzy account ID: brainiac basecamp set fizzy-account-id <your-fizzy-account-id>"
86
+ puts " 2. Add bot accounts: brainiac basecamp bot add <name> <person-id> <agent>"
87
+ puts " 3. Map projects: brainiac basecamp projects map <brainiac-key> <basecamp-id>"
88
+ puts " 4. Set review gate: brainiac basecamp set review-gate <on_complete|on_pr_merge>"
89
+ puts " 5. Set up webhooks: basecamp webhooks create \"https://your-ngrok/basecamp\" --types \"Todo,Todolist\" --in <project>"
90
+ puts " 6. Restart brainiac: brainiac restart"
91
+ end
92
+
93
+ def cmd_config
94
+ if File.exist?(CONFIG_FILE)
95
+ config = JSON.parse(File.read(CONFIG_FILE))
96
+ puts JSON.pretty_generate(config)
97
+ else
98
+ puts "No config found at #{CONFIG_FILE}"
99
+ puts "Run: brainiac basecamp setup"
100
+ end
101
+ end
102
+
103
+ def cmd_status
104
+ puts "Basecamp Plugin Status"
105
+ puts "======================"
106
+ puts ""
107
+
108
+ # Check CLI
109
+ if system("which basecamp > /dev/null 2>&1")
110
+ puts " CLI: ✓ installed"
111
+ else
112
+ puts " CLI: ✗ not found"
113
+ return
114
+ end
115
+
116
+ # Check auth
117
+ auth_output = `basecamp auth status --json 2>/dev/null`
118
+ if $?.success?
119
+ puts " Auth: ✓ authenticated"
120
+ else
121
+ puts " Auth: ✗ not authenticated"
122
+ end
123
+
124
+ # Check config
125
+ if File.exist?(CONFIG_FILE)
126
+ config = JSON.parse(File.read(CONFIG_FILE))
127
+ puts " Config: ✓ #{CONFIG_FILE}"
128
+ puts " Bots: #{config['bot_accounts']&.size || 0} configured"
129
+ puts " Maps: #{config['project_mappings']&.size || 0} project mappings"
130
+ else
131
+ puts " Config: ✗ not configured"
132
+ end
133
+
134
+ # Check active epics
135
+ epics_file = File.join(BRAINIAC_DIR, "basecamp_epics.json")
136
+ if File.exist?(epics_file)
137
+ epics = JSON.parse(File.read(epics_file))
138
+ active = (epics["epics"] || []).count { |e| e["status"] == "active" }
139
+ total = (epics["epics"] || []).size
140
+ puts " Epics: #{active} active, #{total} total"
141
+ else
142
+ puts " Epics: none"
143
+ end
144
+ end
145
+
146
+ def cmd_epics(args)
147
+ epics_file = File.join(BRAINIAC_DIR, "basecamp_epics.json")
148
+ unless File.exist?(epics_file)
149
+ puts "No epics found."
150
+ return
151
+ end
152
+
153
+ data = JSON.parse(File.read(epics_file))
154
+ epics = data["epics"] || []
155
+
156
+ if args.first == "--all"
157
+ display_epics = epics
158
+ else
159
+ display_epics = epics.select { |e| e["status"] == "active" }
160
+ end
161
+
162
+ if display_epics.empty?
163
+ puts "No #{args.first == '--all' ? '' : 'active '}epics."
164
+ return
165
+ end
166
+
167
+ display_epics.each do |epic|
168
+ tasks = epic["tasks"] || []
169
+ complete = tasks.count { |t| t["status"] == "complete" }
170
+ in_flight = tasks.count { |t| t["status"] == "in_flight" }
171
+ total = tasks.size
172
+
173
+ status_icon = epic["status"] == "active" ? "🚀" : "✅"
174
+ puts "#{status_icon} #{epic['title']}"
175
+ puts " Agent: #{epic['agent']} | Tasks: #{complete}/#{total} complete, #{in_flight} in-flight"
176
+ puts " Started: #{epic['started_at']}"
177
+ puts ""
178
+ end
179
+ end
180
+
181
+ def cmd_link(args)
182
+ fizzy_card = args.shift
183
+ basecamp_url = args.shift
184
+
185
+ unless fizzy_card && basecamp_url
186
+ puts "Usage: brainiac basecamp link <fizzy-card-number> <basecamp-todo-url>"
187
+ return
188
+ end
189
+
190
+ puts "TODO: Link Fizzy card ##{fizzy_card} to #{basecamp_url}"
191
+ puts "(This will be used for manual linking outside of epic orchestration)"
192
+ end
193
+
194
+ def cmd_bot(args)
195
+ action = args.shift
196
+
197
+ case action
198
+ when "add"
199
+ name = args.shift
200
+ person_id = args.shift
201
+ agent = args.shift
202
+
203
+ unless name && person_id && agent
204
+ puts "Usage: brainiac basecamp bot add <name> <basecamp-person-id> <default-agent>"
205
+ puts ""
206
+ puts "Example:"
207
+ puts " brainiac basecamp bot add andy-server 12345 Galen"
208
+ return
209
+ end
210
+
211
+ config = load_config
212
+ config["bot_accounts"] ||= {}
213
+ config["bot_accounts"][name] = {
214
+ "person_id" => person_id,
215
+ "default_agent" => agent
216
+ }
217
+ save_config(config)
218
+ puts "✓ Added bot account '#{name}' (person_id: #{person_id}, agent: #{agent})"
219
+
220
+ when "list"
221
+ config = load_config
222
+ bots = config["bot_accounts"] || {}
223
+ if bots.empty?
224
+ puts "No bot accounts configured."
225
+ else
226
+ bots.each do |name, account|
227
+ puts " #{name}: person_id=#{account['person_id']}, agent=#{account['default_agent']}"
228
+ end
229
+ end
230
+
231
+ when "remove"
232
+ name = args.shift
233
+ unless name
234
+ puts "Usage: brainiac basecamp bot remove <name>"
235
+ return
236
+ end
237
+ config = load_config
238
+ if config["bot_accounts"]&.delete(name)
239
+ save_config(config)
240
+ puts "✓ Removed bot account '#{name}'"
241
+ else
242
+ puts "Bot account '#{name}' not found"
243
+ end
244
+
245
+ else
246
+ puts "Usage: brainiac basecamp bot <add|list|remove>"
247
+ end
248
+ end
249
+
250
+ def cmd_projects(args)
251
+ action = args.shift
252
+
253
+ case action
254
+ when "map"
255
+ brainiac_key = args.shift
256
+ basecamp_id = args.shift
257
+
258
+ unless brainiac_key && basecamp_id
259
+ puts "Usage: brainiac basecamp projects map <brainiac-project-key> <basecamp-project-id>"
260
+ puts ""
261
+ puts "Example:"
262
+ puts " brainiac basecamp projects map marketplace 12345"
263
+ return
264
+ end
265
+
266
+ config = load_config
267
+ config["project_mappings"] ||= {}
268
+ config["project_mappings"][brainiac_key] = {
269
+ "basecamp_project_id" => basecamp_id
270
+ }
271
+ save_config(config)
272
+ puts "✓ Mapped '#{brainiac_key}' → Basecamp project #{basecamp_id}"
273
+
274
+ when "list"
275
+ config = load_config
276
+ mappings = config["project_mappings"] || {}
277
+ if mappings.empty?
278
+ puts "No project mappings configured."
279
+ else
280
+ mappings.each do |key, mapping|
281
+ puts " #{key} → Basecamp project #{mapping['basecamp_project_id']}"
282
+ end
283
+ end
284
+
285
+ when "unmap"
286
+ key = args.shift
287
+ unless key
288
+ puts "Usage: brainiac basecamp projects unmap <brainiac-project-key>"
289
+ return
290
+ end
291
+ config = load_config
292
+ if config["project_mappings"]&.delete(key)
293
+ save_config(config)
294
+ puts "✓ Removed mapping for '#{key}'"
295
+ else
296
+ puts "Mapping for '#{key}' not found"
297
+ end
298
+
299
+ else
300
+ puts "Usage: brainiac basecamp projects <map|list|unmap>"
301
+ end
302
+ end
303
+
304
+ def print_help
305
+ puts <<~HELP
306
+ Usage: brainiac basecamp <command>
307
+
308
+ Commands:
309
+ setup Interactive setup guide
310
+ config Show current config
311
+ status Check plugin status
312
+ epics [--all] List active epics (--all for completed too)
313
+ link <card> <url> Link a Fizzy card to a Basecamp todo
314
+ bot add <name> <person-id> <agent> Add a bot account mapping
315
+ bot list List bot accounts
316
+ bot remove <name> Remove a bot account
317
+ projects map <key> <basecamp-id> Map a Brainiac project to Basecamp
318
+ projects list List project mappings
319
+ projects unmap <key> Remove a project mapping
320
+ set fizzy-account-id <id> Set Fizzy account ID (for card URLs)
321
+ set review-gate <mode> Set review gate (on_complete or on_pr_merge)
322
+ set epic-prefix <prefix> Set epic todolist prefix (default: "Epic:")
323
+
324
+ Config file: ~/.brainiac/basecamp.json
325
+ Epics state: ~/.brainiac/basecamp_epics.json
326
+
327
+ Review gate modes:
328
+ on_complete — Advance to next task as soon as agent finishes (default)
329
+ on_pr_merge — Wait for PR to be merged before advancing (review gate)
330
+ HELP
331
+ end
332
+
333
+ def cmd_set(args)
334
+ key = args.shift
335
+ value = args.shift
336
+
337
+ unless key && value
338
+ puts "Usage: brainiac basecamp set <key> <value>"
339
+ puts ""
340
+ puts "Keys:"
341
+ puts " fizzy-account-id <id> Fizzy account ID (for card URLs)"
342
+ puts " review-gate <mode> on_complete or on_pr_merge"
343
+ puts " epic-prefix <prefix> Todolist prefix for epic detection"
344
+ return
345
+ end
346
+
347
+ config = load_config
348
+
349
+ case key
350
+ when "fizzy-account-id"
351
+ config["fizzy_account_id"] = value
352
+ save_config(config)
353
+ puts "✓ Set fizzy_account_id = #{value}"
354
+ puts " Card URLs will be: https://app.fizzy.do/#{value}/cards/NNNN"
355
+ when "review-gate"
356
+ unless %w[on_complete on_pr_merge epic_branch].include?(value)
357
+ puts "Error: review-gate must be 'on_complete', 'on_pr_merge', or 'epic_branch'"
358
+ return
359
+ end
360
+ config["review_gate"] = value
361
+ save_config(config)
362
+ puts "✓ Set review_gate = #{value}"
363
+ case value
364
+ when "on_pr_merge"
365
+ puts " Epic tasks will wait for PR merge before advancing to next task"
366
+ when "epic_branch"
367
+ puts " Epic tasks auto-merge into an epic branch. Final PR to main when epic completes."
368
+ puts " Best for overnight/autonomous execution."
369
+ else
370
+ puts " Epic tasks advance immediately when agent completes"
371
+ end
372
+ when "epic-prefix"
373
+ config["epic_prefix"] = value
374
+ save_config(config)
375
+ puts "✓ Set epic_prefix = #{value}"
376
+ when "profile"
377
+ config["basecamp_profile"] = value
378
+ save_config(config)
379
+ puts "✓ Set basecamp_profile = #{value}"
380
+ puts " All basecamp CLI commands will use --profile #{value}"
381
+ puts " Set up the profile: basecamp profile create #{value} && basecamp auth login --profile #{value}"
382
+ else
383
+ puts "Unknown key: #{key}"
384
+ puts "Valid keys: fizzy-account-id, review-gate, epic-prefix, profile"
385
+ end
386
+ end
387
+
388
+ def load_config
389
+ if File.exist?(CONFIG_FILE)
390
+ JSON.parse(File.read(CONFIG_FILE))
391
+ else
392
+ {}
393
+ end
394
+ end
395
+
396
+ def save_config(config)
397
+ File.write(CONFIG_FILE, JSON.pretty_generate(config))
398
+ end
399
+ end
400
+ end
401
+
402
+ def self.cli(args)
403
+ Cli.run(args)
404
+ end
405
+
406
+ def self.completions
407
+ %w[setup config status epics link bot projects set]
408
+ end
409
+ end
410
+ end
411
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+
6
+ module Brainiac
7
+ module Plugins
8
+ module Basecamp
9
+ # Wrapper around the `basecamp` CLI binary.
10
+ # Shells out with --json for structured responses.
11
+ module Client
12
+ class << self
13
+ # Set the current agent profile for basecamp CLI commands.
14
+ # The profile name matches the agent name (e.g., "galen" → basecamp --profile galen).
15
+ #
16
+ # @param agent_name [String, nil] Agent name to use as profile
17
+ def with_agent_profile(agent_name)
18
+ @current_agent_profile = agent_name&.downcase
19
+ end
20
+
21
+ # Clear the current agent profile.
22
+ def clear_agent_profile
23
+ @current_agent_profile = nil
24
+ end
25
+
26
+ # Run a basecamp CLI command as a specific agent.
27
+ #
28
+ # @param agent_name [String] Agent name (used as profile)
29
+ # @param args [Array<String>] CLI arguments
30
+ # @return [Hash] Parsed JSON response
31
+ def run_as(agent_name, *args)
32
+ run(*args, profile: agent_name.downcase)
33
+ end
34
+ # Run a basecamp CLI command and return parsed JSON.
35
+ #
36
+ # @param args [Array<String>] CLI arguments
37
+ # @param profile [String, nil] Named profile to use (defaults to agent name)
38
+ # @return [Hash] Parsed JSON response
39
+ # @raise [ClientError] If the command fails
40
+ def run(*args, profile: nil)
41
+ cmd = ["basecamp"]
42
+ # Use the specified profile, or fall back to the current agent's name as profile
43
+ profile ||= @current_agent_profile
44
+ cmd += ["--profile", profile] if profile
45
+ cmd += args.flatten
46
+
47
+ LOG.debug "[Basecamp:Client] Running: #{cmd.join(' ')}" if defined?(LOG) && LOG.debug?
48
+
49
+ stdout, stderr, status = Open3.capture3(*cmd)
50
+
51
+ unless status.success?
52
+ error_body = parse_json_safe(stdout) || parse_json_safe(stderr)
53
+ error_msg = error_body&.dig("error") || stderr.strip.split("\n").first || "Unknown error"
54
+ raise ClientError.new(error_msg, exit_code: status.exitstatus, response: error_body)
55
+ end
56
+
57
+ result = parse_json_safe(stdout)
58
+ unless result
59
+ raise ClientError.new("Failed to parse JSON response", exit_code: 0, response: nil)
60
+ end
61
+
62
+ result
63
+ end
64
+
65
+ # Run a basecamp CLI command, returning nil on failure instead of raising.
66
+ #
67
+ # @param args [Array<String>] CLI arguments
68
+ # @param profile [String, nil] Named profile to use
69
+ # @return [Hash, nil] Parsed JSON response or nil
70
+ def run_safe(*args, profile: nil)
71
+ run(*args, profile: profile)
72
+ rescue ClientError => e
73
+ LOG.warn "[Basecamp:Client] Command failed: #{e.message}" if defined?(LOG)
74
+ nil
75
+ end
76
+
77
+ # Get a todo by ID with subtasks info.
78
+ #
79
+ # @param todo_id [String, Integer] Todo ID
80
+ # @param project [String, Integer] Basecamp project/bucket ID
81
+ # @return [Hash, nil]
82
+ def get_todo(todo_id, project:)
83
+ run("todos", "show", todo_id.to_s, "--in", project.to_s, "--json")
84
+ end
85
+
86
+ # List subtasks (steps) for a todo.
87
+ #
88
+ # @param todo_id [String, Integer] Parent todo ID
89
+ # @param project [String, Integer] Basecamp project/bucket ID
90
+ # @return [Hash, nil]
91
+ def get_subtasks(todo_id, project:)
92
+ # Basecamp models todo subtasks as Kanban::Step records
93
+ # Use the recordings list filtered by type and parent
94
+ run("recordings", "list", "--in", project.to_s, "--type", "Kanban::Step",
95
+ "--all", "--json")
96
+ end
97
+
98
+ # Complete a subtask.
99
+ #
100
+ # @param step_id [String, Integer] Step/subtask ID
101
+ # @param project [String, Integer] Basecamp project/bucket ID
102
+ # @return [Hash, nil]
103
+ def complete_subtask(step_id, project:)
104
+ # Use raw API to complete a step
105
+ run("api", "put",
106
+ "/buckets/#{project}/card_tables/steps/#{step_id}/completions.json",
107
+ "--data", '{"completion":"on"}', "--json")
108
+ end
109
+
110
+ # Add a comment to a recording (todo, card, message, etc.)
111
+ #
112
+ # @param recording_id [String, Integer] The recording to comment on
113
+ # @param content [String] Comment content (Markdown)
114
+ # @param project [String, Integer] Basecamp project/bucket ID
115
+ # @return [Hash, nil]
116
+ def add_comment(recording_id, content, project:)
117
+ run("comments", "create", recording_id.to_s, content, "--in", project.to_s, "--json")
118
+ end
119
+
120
+ # Complete a todo.
121
+ #
122
+ # @param todo_id [String, Integer] Todo ID
123
+ # @return [Hash, nil]
124
+ def complete_todo(todo_id)
125
+ run("todos", "complete", todo_id.to_s, "--json")
126
+ end
127
+
128
+ # List projects.
129
+ #
130
+ # @return [Hash]
131
+ def list_projects
132
+ run("projects", "list", "--json")
133
+ end
134
+
135
+ # Parse a Basecamp URL to extract IDs.
136
+ #
137
+ # @param url [String] Basecamp URL
138
+ # @return [Hash, nil]
139
+ def parse_url(url)
140
+ run("url", "parse", url, "--json")
141
+ end
142
+
143
+ # Check auth status.
144
+ #
145
+ # @return [Hash, nil]
146
+ def auth_status
147
+ run_safe("auth", "status", "--json")
148
+ end
149
+
150
+ private
151
+
152
+ def parse_json_safe(str)
153
+ return nil if str.nil? || str.strip.empty?
154
+
155
+ JSON.parse(str)
156
+ rescue JSON::ParserError
157
+ nil
158
+ end
159
+ end
160
+ end
161
+
162
+ # Error class for CLI failures.
163
+ class ClientError < StandardError
164
+ attr_reader :exit_code, :response
165
+
166
+ def initialize(message, exit_code: nil, response: nil)
167
+ @exit_code = exit_code
168
+ @response = response
169
+ super(message)
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Brainiac
6
+ module Plugins
7
+ module Basecamp
8
+ # Configuration loader for brainiac-basecamp.
9
+ #
10
+ # Config file: ~/.brainiac/basecamp.json
11
+ module Config
12
+ BRAINIAC_DIR = ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
13
+ CONFIG_FILE = File.join(BRAINIAC_DIR, "basecamp.json")
14
+
15
+ # Default config structure.
16
+ DEFAULT_CONFIG = {
17
+ "bot_accounts" => {},
18
+ "project_mappings" => {},
19
+ "epic_prefix" => "Epic:",
20
+ "fizzy_account_id" => nil,
21
+ "review_gate" => "on_complete",
22
+ "review_gates" => [],
23
+ "notifications" => {
24
+ "epic_started" => true,
25
+ "task_dispatched" => true,
26
+ "task_completed" => true,
27
+ "epic_completed" => true
28
+ }
29
+ }.freeze
30
+
31
+ class << self
32
+ # Load and cache config. Reloads if file has changed since last read.
33
+ #
34
+ # @return [Hash]
35
+ def current
36
+ return @config if @config && @config_mtime == config_mtime
37
+
38
+ load!
39
+ @config
40
+ end
41
+
42
+ # Force reload config from disk.
43
+ def load!
44
+ if File.exist?(CONFIG_FILE)
45
+ raw = JSON.parse(File.read(CONFIG_FILE))
46
+ @config = DEFAULT_CONFIG.merge(raw)
47
+ else
48
+ @config = DEFAULT_CONFIG.dup
49
+ end
50
+ @config_mtime = config_mtime
51
+ rescue JSON::ParserError => e
52
+ LOG.error "[Basecamp] Config parse error: #{e.message}" if defined?(LOG)
53
+ @config = DEFAULT_CONFIG.dup
54
+ end
55
+
56
+ # Get bot account config for a given Basecamp person ID.
57
+ #
58
+ # @param person_id [String, Integer] Basecamp person ID
59
+ # @return [Hash, nil] Bot account config or nil
60
+ def bot_account_for_person(person_id)
61
+ current["bot_accounts"].find do |_key, account|
62
+ account["person_id"].to_s == person_id.to_s
63
+ end&.then { |key, account| account.merge("key" => key) }
64
+ end
65
+
66
+ # Get the Basecamp project ID for a Brainiac project key.
67
+ #
68
+ # @param brainiac_project [String] Brainiac project key
69
+ # @return [String, nil] Basecamp project ID
70
+ def basecamp_project_for(brainiac_project)
71
+ mapping = current["project_mappings"][brainiac_project]
72
+ mapping&.dig("basecamp_project_id")
73
+ end
74
+
75
+ # Get the Brainiac project key for a Basecamp project/bucket ID.
76
+ #
77
+ # @param basecamp_project_id [String, Integer] Basecamp bucket ID
78
+ # @return [String, nil] Brainiac project key
79
+ def brainiac_project_for(basecamp_project_id)
80
+ current["project_mappings"].find do |_key, mapping|
81
+ mapping["basecamp_project_id"].to_s == basecamp_project_id.to_s
82
+ end&.first
83
+ end
84
+
85
+ # The prefix used to identify epic todolists (e.g. "Epic: My Feature")
86
+ #
87
+ # @return [String]
88
+ def epic_prefix
89
+ current["epic_prefix"] || "Epic:"
90
+ end
91
+
92
+ # The Fizzy account ID (for building card URLs like app.fizzy.do/<id>/cards/N).
93
+ #
94
+ # @return [String, nil]
95
+ def fizzy_account_id
96
+ current["fizzy_account_id"]
97
+ end
98
+
99
+ # Review gate mode: "on_complete" (advance immediately) or "on_pr_merge" (wait for merge).
100
+ #
101
+ # @return [String]
102
+ def review_gate
103
+ current["review_gate"] || "on_complete"
104
+ end
105
+
106
+ private
107
+
108
+ def config_mtime
109
+ File.exist?(CONFIG_FILE) ? File.mtime(CONFIG_FILE) : nil
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end