aidp 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +7 -0
  3. data/lib/aidp/cli/first_run_wizard.rb +28 -303
  4. data/lib/aidp/cli/issue_importer.rb +359 -0
  5. data/lib/aidp/cli.rb +151 -3
  6. data/lib/aidp/daemon/process_manager.rb +146 -0
  7. data/lib/aidp/daemon/runner.rb +232 -0
  8. data/lib/aidp/execute/async_work_loop_runner.rb +216 -0
  9. data/lib/aidp/execute/future_work_backlog.rb +411 -0
  10. data/lib/aidp/execute/guard_policy.rb +246 -0
  11. data/lib/aidp/execute/instruction_queue.rb +131 -0
  12. data/lib/aidp/execute/interactive_repl.rb +335 -0
  13. data/lib/aidp/execute/repl_macros.rb +651 -0
  14. data/lib/aidp/execute/steps.rb +8 -0
  15. data/lib/aidp/execute/work_loop_runner.rb +322 -36
  16. data/lib/aidp/execute/work_loop_state.rb +162 -0
  17. data/lib/aidp/harness/config_schema.rb +88 -0
  18. data/lib/aidp/harness/configuration.rb +48 -1
  19. data/lib/aidp/harness/ui/enhanced_workflow_selector.rb +2 -0
  20. data/lib/aidp/init/doc_generator.rb +256 -0
  21. data/lib/aidp/init/project_analyzer.rb +343 -0
  22. data/lib/aidp/init/runner.rb +83 -0
  23. data/lib/aidp/init.rb +5 -0
  24. data/lib/aidp/logger.rb +279 -0
  25. data/lib/aidp/setup/wizard.rb +777 -0
  26. data/lib/aidp/tooling_detector.rb +115 -0
  27. data/lib/aidp/version.rb +1 -1
  28. data/lib/aidp/watch/build_processor.rb +282 -0
  29. data/lib/aidp/watch/plan_generator.rb +166 -0
  30. data/lib/aidp/watch/plan_processor.rb +83 -0
  31. data/lib/aidp/watch/repository_client.rb +243 -0
  32. data/lib/aidp/watch/runner.rb +93 -0
  33. data/lib/aidp/watch/state_store.rb +105 -0
  34. data/lib/aidp/watch.rb +9 -0
  35. data/lib/aidp.rb +14 -0
  36. data/templates/implementation/simple_task.md +36 -0
  37. metadata +26 -1
@@ -0,0 +1,343 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "find"
4
+ require "json"
5
+
6
+ module Aidp
7
+ module Init
8
+ # Performs lightweight static analysis of the repository to detect languages,
9
+ # frameworks, config files, and quality tooling. Designed to run quickly and
10
+ # deterministically without external services.
11
+ class ProjectAnalyzer
12
+ IGNORED_DIRECTORIES = %w[
13
+ .git
14
+ .svn
15
+ .hg
16
+ node_modules
17
+ vendor
18
+ tmp
19
+ log
20
+ build
21
+ dist
22
+ coverage
23
+ .yardoc
24
+ ].freeze
25
+
26
+ LANGUAGE_EXTENSIONS = {
27
+ ".rb" => "Ruby",
28
+ ".rake" => "Ruby",
29
+ ".gemspec" => "Ruby",
30
+ ".js" => "JavaScript",
31
+ ".jsx" => "JavaScript",
32
+ ".ts" => "TypeScript",
33
+ ".tsx" => "TypeScript",
34
+ ".py" => "Python",
35
+ ".go" => "Go",
36
+ ".java" => "Java",
37
+ ".kt" => "Kotlin",
38
+ ".cs" => "C#",
39
+ ".php" => "PHP",
40
+ ".rs" => "Rust",
41
+ ".swift" => "Swift",
42
+ ".scala" => "Scala",
43
+ ".c" => "C",
44
+ ".cpp" => "C++",
45
+ ".hpp" => "C++",
46
+ ".m" => "Objective-C",
47
+ ".mm" => "Objective-C++",
48
+ ".hs" => "Haskell",
49
+ ".erl" => "Erlang",
50
+ ".ex" => "Elixir",
51
+ ".exs" => "Elixir",
52
+ ".clj" => "Clojure",
53
+ ".coffee" => "CoffeeScript"
54
+ }.freeze
55
+
56
+ CONFIG_FILES = %w[
57
+ .editorconfig
58
+ .rubocop.yml
59
+ .rubocop_todo.yml
60
+ .standardrb
61
+ .eslintrc
62
+ .eslintrc.js
63
+ .eslintrc.cjs
64
+ .eslintrc.json
65
+ .prettierrc
66
+ .prettierrc.js
67
+ .prettierrc.cjs
68
+ .prettierrc.json
69
+ .stylelintrc
70
+ .flake8
71
+ pyproject.toml
72
+ tox.ini
73
+ setup.cfg
74
+ package.json
75
+ Gemfile
76
+ Gemfile.lock
77
+ mix.exs
78
+ go.mod
79
+ go.sum
80
+ composer.json
81
+ Cargo.toml
82
+ Cargo.lock
83
+ pom.xml
84
+ build.gradle
85
+ build.gradle.kts
86
+ ].freeze
87
+
88
+ KEY_DIRECTORIES = %w[
89
+ app
90
+ src
91
+ lib
92
+ spec
93
+ test
94
+ tests
95
+ scripts
96
+ bin
97
+ config
98
+ docs
99
+ examples
100
+ packages
101
+ modules
102
+ ].freeze
103
+
104
+ TOOLING_HINTS = {
105
+ rubocop: [".rubocop.yml", ".rubocop_todo.yml"],
106
+ standardrb: [".standardrb"],
107
+ eslint: [".eslintrc", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json"],
108
+ prettier: [".prettierrc", ".prettierrc.js", ".prettierrc.cjs", ".prettierrc.json"],
109
+ stylelint: [".stylelintrc"],
110
+ flake8: [".flake8"],
111
+ black: ["pyproject.toml"],
112
+ pytest: ["pytest.ini", "pyproject.toml"],
113
+ jest: ["package.json"],
114
+ rspec: ["spec", ".rspec"],
115
+ minitest: ["test"],
116
+ gofmt: ["go.mod"],
117
+ cargo_fmt: ["Cargo.toml"]
118
+ }.freeze
119
+
120
+ FRAMEWORK_HINTS = {
121
+ "Rails" => {files: ["config/application.rb"], contents: [/rails/i]},
122
+ "Hanami" => {files: ["config/app.rb"], contents: [/hanami/i]},
123
+ "Sinatra" => {files: ["config.ru"], contents: [/sinatra/i]},
124
+ "React" => {files: ["package.json"], contents: [/react/]},
125
+ "Next.js" => {files: ["package.json"], contents: [/next/]},
126
+ "Express" => {files: ["package.json"], contents: [/express/]},
127
+ "Angular" => {files: ["angular.json"]},
128
+ "Vue" => {files: ["package.json"], contents: [/vue/]},
129
+ "Django" => {files: ["manage.py"], contents: [/django/]},
130
+ "Flask" => {files: ["requirements.txt"], contents: [/flask/]},
131
+ "FastAPI" => {files: ["pyproject.toml", "requirements.txt"], contents: [/fastapi/]},
132
+ "Phoenix" => {files: ["mix.exs"], contents: [/phoenix/]},
133
+ "Spring" => {files: ["pom.xml", "build.gradle", "build.gradle.kts"], contents: [/spring/]},
134
+ "Laravel" => {files: ["composer.json"], contents: [/laravel/]},
135
+ "Go Gin" => {files: ["go.mod"], contents: [/gin-gonic/]},
136
+ "Go Fiber" => {files: ["go.mod"], contents: [/github.com\/gofiber/]}
137
+ }.freeze
138
+
139
+ TEST_FRAMEWORK_HINTS = {
140
+ "RSpec" => {directories: ["spec"], dependencies: [/rspec/]},
141
+ "Minitest" => {directories: ["test"], dependencies: [/minitest/]},
142
+ "Cucumber" => {directories: ["features"]},
143
+ "Jest" => {dependencies: [/jest/]},
144
+ "Mocha" => {dependencies: [/mocha/]},
145
+ "Jasmine" => {dependencies: [/jasmine/]},
146
+ "Pytest" => {directories: ["tests", "test"], dependencies: [/pytest/]},
147
+ "NUnit" => {files: ["*.csproj"], contents: [/nunit/]},
148
+ "JUnit" => {dependencies: [/junit/]},
149
+ "Go test" => {files: ["*_test.go"]}
150
+ }.freeze
151
+
152
+ attr_reader :project_dir
153
+
154
+ def initialize(project_dir = Dir.pwd)
155
+ @project_dir = project_dir
156
+ end
157
+
158
+ def analyze
159
+ {
160
+ languages: detect_languages,
161
+ frameworks: detect_frameworks,
162
+ key_directories: detect_key_directories,
163
+ config_files: detect_config_files,
164
+ test_frameworks: detect_test_frameworks,
165
+ tooling: detect_tooling,
166
+ repo_stats: collect_repo_stats
167
+ }
168
+ end
169
+
170
+ private
171
+
172
+ def detect_languages
173
+ counts = Hash.new(0)
174
+
175
+ traverse_files do |relative_path|
176
+ ext = File.extname(relative_path)
177
+ language = LANGUAGE_EXTENSIONS[ext]
178
+ next unless language
179
+
180
+ full_path = File.join(project_dir, relative_path)
181
+ counts[language] += File.size(full_path)
182
+ end
183
+
184
+ counts.sort_by { |_lang, weight| -weight }.to_h
185
+ end
186
+
187
+ def detect_frameworks
188
+ frameworks = Set.new
189
+
190
+ FRAMEWORK_HINTS.each do |framework, rules|
191
+ if rules[:files]&.any? { |file| project_glob?(file) }
192
+ if rules[:contents]
193
+ frameworks << framework if rules[:contents].any? { |pattern| search_files_for_pattern(rules[:files], pattern) }
194
+ else
195
+ frameworks << framework
196
+ end
197
+ elsif rules[:contents]&.any? { |pattern| search_project_for_pattern(pattern) }
198
+ frameworks << framework
199
+ end
200
+ end
201
+
202
+ frameworks.to_a.sort
203
+ end
204
+
205
+ def detect_key_directories
206
+ KEY_DIRECTORIES.select { |dir| Dir.exist?(File.join(project_dir, dir)) }
207
+ end
208
+
209
+ def detect_config_files
210
+ CONFIG_FILES.select { |file| project_glob?(file) }
211
+ end
212
+
213
+ def detect_test_frameworks
214
+ results = []
215
+
216
+ TEST_FRAMEWORK_HINTS.each do |framework, hints|
217
+ has_directory = hints[:directories]&.any? { |dir| Dir.exist?(File.join(project_dir, dir)) }
218
+ has_dependency = hints[:dependencies]&.any? { |pattern| search_project_for_pattern(pattern, limit_files: ["Gemfile", "Gemfile.lock", "package.json", "pyproject.toml", "requirements.txt", "go.mod", "mix.exs", "composer.json", "Cargo.toml"]) }
219
+ has_files = hints[:files]&.any? { |glob| project_glob?(glob) }
220
+
221
+ results << framework if has_directory || has_dependency || has_files
222
+ end
223
+
224
+ results.uniq.sort
225
+ end
226
+
227
+ def detect_tooling
228
+ tooling = Hash.new { |hash, key| hash[key] = [] }
229
+
230
+ TOOLING_HINTS.each do |tool, indicators|
231
+ hit = indicators.any? do |indicator|
232
+ if indicator.include?("*")
233
+ end
234
+ project_glob?(indicator)
235
+ end
236
+ tooling[tool] << "config" if hit
237
+ end
238
+
239
+ # Post-process for package.json to extract scripts referencing linters
240
+ package_json_path = File.join(project_dir, "package.json")
241
+ if File.exist?(package_json_path)
242
+ begin
243
+ json = JSON.parse(File.read(package_json_path))
244
+ scripts = json.fetch("scripts", {})
245
+ tooling[:eslint] << "package.json scripts" if scripts.values.any? { |cmd| cmd.include?("eslint") }
246
+ tooling[:prettier] << "package.json scripts" if scripts.values.any? { |cmd| cmd.include?("prettier") }
247
+ tooling[:jest] << "package.json scripts" if scripts.values.any? { |cmd| cmd.include?("jest") }
248
+ rescue JSON::ParserError
249
+ # ignore malformed package.json
250
+ end
251
+ end
252
+
253
+ tooling.delete_if { |_tool, evidence| evidence.empty? }
254
+ end
255
+
256
+ def collect_repo_stats
257
+ {
258
+ total_files: counted_files.size,
259
+ total_directories: counted_directories.size,
260
+ docs_present: Dir.exist?(File.join(project_dir, "docs")),
261
+ has_ci_config: project_glob?(".github/workflows/*.yml") || project_glob?(".gitlab-ci.yml"),
262
+ has_containerization: project_glob?("Dockerfile") || project_glob?("docker-compose.yml")
263
+ }
264
+ end
265
+
266
+ def counted_files
267
+ @counted_files ||= begin
268
+ files = []
269
+ traverse_files { |path| files << path }
270
+ files
271
+ end
272
+ end
273
+
274
+ def counted_directories
275
+ @counted_directories ||= begin
276
+ dirs = Set.new
277
+ traverse_files do |path|
278
+ dirs << File.dirname(path)
279
+ end
280
+ dirs.to_a
281
+ end
282
+ end
283
+
284
+ def traverse_files
285
+ Find.find(project_dir) do |path|
286
+ next if path == project_dir
287
+
288
+ relative = path.sub("#{project_dir}/", "")
289
+ if File.directory?(path)
290
+ dirname = File.basename(path)
291
+ if IGNORED_DIRECTORIES.include?(dirname)
292
+ Find.prune
293
+ else
294
+ next
295
+ end
296
+ else
297
+ yield relative
298
+ end
299
+ end
300
+ end
301
+
302
+ def project_glob?(pattern)
303
+ Dir.glob(File.join(project_dir, pattern)).any?
304
+ end
305
+
306
+ def search_files_for_pattern(files, pattern)
307
+ files.any? do |file|
308
+ Dir.glob(File.join(project_dir, file)).any? do |path|
309
+ File.read(path).match?(pattern)
310
+ rescue Errno::ENOENT, Errno::EISDIR
311
+ false
312
+ end
313
+ end
314
+ end
315
+
316
+ def search_project_for_pattern(pattern, limit_files: nil)
317
+ if limit_files
318
+ limit_files.any? do |file|
319
+ path = File.join(project_dir, file)
320
+ next false unless File.exist?(path)
321
+
322
+ File.read(path).match?(pattern)
323
+ rescue Errno::ENOENT
324
+ false
325
+ end
326
+ else
327
+ traverse_files do |relative_path|
328
+ path = File.join(project_dir, relative_path)
329
+ begin
330
+ return true if File.read(path).match?(pattern)
331
+ rescue Errno::ENOENT
332
+ next
333
+ rescue ArgumentError, Encoding::InvalidByteSequenceError => e
334
+ warn "[AIDP] Skipping file with invalid encoding: #{path} (#{e.class})"
335
+ next
336
+ end
337
+ end
338
+ false
339
+ end
340
+ end
341
+ end
342
+ end
343
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-prompt"
4
+ require_relative "../message_display"
5
+ require_relative "project_analyzer"
6
+ require_relative "doc_generator"
7
+
8
+ module Aidp
9
+ module Init
10
+ # High-level coordinator for `aidp init`. Handles analysis, optional user
11
+ # preferences, and documentation generation.
12
+ class Runner
13
+ include Aidp::MessageDisplay
14
+
15
+ def initialize(project_dir = Dir.pwd, prompt: TTY::Prompt.new, analyzer: nil, doc_generator: nil)
16
+ @project_dir = project_dir
17
+ @prompt = prompt
18
+ @analyzer = analyzer || ProjectAnalyzer.new(project_dir)
19
+ @doc_generator = doc_generator || DocGenerator.new(project_dir)
20
+ end
21
+
22
+ def run
23
+ display_message("🔍 Running aidp init project analysis...", type: :info)
24
+ analysis = @analyzer.analyze
25
+ display_summary(analysis)
26
+
27
+ preferences = gather_preferences
28
+
29
+ @doc_generator.generate(analysis: analysis, preferences: preferences)
30
+
31
+ display_message("\n📄 Generated documentation:", type: :info)
32
+ display_message(" - docs/LLM_STYLE_GUIDE.md", type: :success)
33
+ display_message(" - docs/PROJECT_ANALYSIS.md", type: :success)
34
+ display_message(" - docs/CODE_QUALITY_PLAN.md", type: :success)
35
+ display_message("\n✅ aidp init complete.", type: :success)
36
+
37
+ {
38
+ analysis: analysis,
39
+ preferences: preferences,
40
+ generated_files: [
41
+ "docs/LLM_STYLE_GUIDE.md",
42
+ "docs/PROJECT_ANALYSIS.md",
43
+ "docs/CODE_QUALITY_PLAN.md"
44
+ ]
45
+ }
46
+ end
47
+
48
+ private
49
+
50
+ def display_summary(analysis)
51
+ languages = analysis[:languages].keys
52
+ frameworks = analysis[:frameworks]
53
+ tests = analysis[:test_frameworks]
54
+ config_files = analysis[:config_files]
55
+
56
+ display_message("\n📊 Repository Snapshot", type: :highlight)
57
+ display_message(" Languages: #{languages.empty? ? "Unknown" : languages.join(", ")}", type: :info)
58
+ display_message(" Frameworks: #{frameworks.empty? ? "None detected" : frameworks.join(", ")}", type: :info)
59
+ display_message(" Test suites: #{tests.empty? ? "Not found" : tests.join(", ")}", type: :info)
60
+ display_message(" Config files: #{config_files.empty? ? "None detected" : config_files.join(", ")}", type: :info)
61
+ end
62
+
63
+ def gather_preferences
64
+ display_message("\n⚙️ Customise bootstrap plans (press Enter to accept defaults):", type: :info)
65
+
66
+ {
67
+ adopt_new_conventions: ask_yes_no("Adopt the newly generated conventions as canonical defaults?", default: true),
68
+ stricter_linters: ask_yes_no("Enforce stricter linting based on detected tools?", default: false),
69
+ migrate_styles: ask_yes_no("Plan migrations to align legacy files with the new style guide?", default: false)
70
+ }
71
+ end
72
+
73
+ def ask_yes_no(question, default:)
74
+ @prompt.yes?(question) do |q|
75
+ q.default default ? "yes" : "no"
76
+ end
77
+ rescue NoMethodError
78
+ # Compatibility with simplified prompts in tests (e.g. TestPrompt)
79
+ default
80
+ end
81
+ end
82
+ end
83
+ end
data/lib/aidp/init.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "init/project_analyzer"
4
+ require_relative "init/doc_generator"
5
+ require_relative "init/runner"
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "json"
5
+ require "fileutils"
6
+
7
+ module Aidp
8
+ # Unified structured logger for all AIDP operations
9
+ # Supports:
10
+ # - Multiple log levels (info, error, debug)
11
+ # - Text and JSONL formats
12
+ # - Automatic rotation
13
+ # - Redaction of secrets
14
+ # - Consistent file layout in .aidp/logs/
15
+ #
16
+ # Usage:
17
+ # Aidp.setup_logger(project_dir, config)
18
+ # Aidp.logger.info("component", "message", key: "value")
19
+ class AidpLogger
20
+ LEVELS = {
21
+ debug: ::Logger::DEBUG,
22
+ info: ::Logger::INFO,
23
+ warn: ::Logger::WARN,
24
+ error: ::Logger::ERROR
25
+ }.freeze
26
+
27
+ LOG_DIR = ".aidp/logs"
28
+ INFO_LOG = "#{LOG_DIR}/aidp.log"
29
+ DEBUG_LOG = "#{LOG_DIR}/aidp_debug.log"
30
+
31
+ DEFAULT_MAX_SIZE = 10 * 1024 * 1024 # 10MB
32
+ DEFAULT_MAX_FILES = 5
33
+
34
+ attr_reader :level, :json_format
35
+
36
+ def initialize(project_dir = Dir.pwd, config = {})
37
+ @project_dir = project_dir
38
+ @config = config
39
+ @level = determine_log_level
40
+ @json_format = config[:json] || false
41
+ @max_size = config[:max_size_mb] ? config[:max_size_mb] * 1024 * 1024 : DEFAULT_MAX_SIZE
42
+ @max_files = config[:max_backups] || DEFAULT_MAX_FILES
43
+
44
+ ensure_log_directory
45
+ migrate_old_logs if should_migrate?
46
+ setup_loggers
47
+ end
48
+
49
+ # Log info level message
50
+ def info(component, message, **metadata)
51
+ log(:info, component, message, **metadata)
52
+ end
53
+
54
+ # Log error level message
55
+ def error(component, message, **metadata)
56
+ log(:error, component, message, **metadata)
57
+ end
58
+
59
+ # Log warn level message
60
+ def warn(component, message, **metadata)
61
+ log(:warn, component, message, **metadata)
62
+ end
63
+
64
+ # Log debug level message
65
+ def debug(component, message, **metadata)
66
+ log(:debug, component, message, **metadata)
67
+ end
68
+
69
+ # Log at specified level
70
+ def log(level, component, message, **metadata)
71
+ return unless should_log?(level)
72
+
73
+ # Redact sensitive data
74
+ safe_message = redact(message)
75
+ safe_metadata = redact_hash(metadata)
76
+
77
+ # Log to appropriate file(s)
78
+ if level == :debug
79
+ write_to_debug(level, component, safe_message, safe_metadata)
80
+ else
81
+ write_to_info(level, component, safe_message, safe_metadata)
82
+ end
83
+
84
+ # Always log errors to both files
85
+ if level == :error
86
+ write_to_debug(level, component, safe_message, safe_metadata)
87
+ end
88
+ end
89
+
90
+ # Close all loggers
91
+ def close
92
+ @info_logger&.close
93
+ @debug_logger&.close
94
+ end
95
+
96
+ private
97
+
98
+ def determine_log_level
99
+ # Priority: ENV > config > default
100
+ level_str = ENV["AIDP_LOG_LEVEL"] || @config[:level] || "info"
101
+ level_sym = level_str.to_sym
102
+ LEVELS.key?(level_sym) ? level_sym : :info
103
+ end
104
+
105
+ def should_log?(level)
106
+ LEVELS[level] >= LEVELS[@level]
107
+ end
108
+
109
+ def ensure_log_directory
110
+ log_dir = File.join(@project_dir, LOG_DIR)
111
+ FileUtils.mkdir_p(log_dir) unless Dir.exist?(log_dir)
112
+ end
113
+
114
+ def setup_loggers
115
+ info_path = File.join(@project_dir, INFO_LOG)
116
+ debug_path = File.join(@project_dir, DEBUG_LOG)
117
+
118
+ @info_logger = create_logger(info_path)
119
+ @debug_logger = create_logger(debug_path)
120
+ end
121
+
122
+ def create_logger(path)
123
+ logger = ::Logger.new(path, @max_files, @max_size)
124
+ logger.level = ::Logger::DEBUG # Control at write level instead
125
+ logger.formatter = proc { |severity, datetime, progname, msg| "#{msg}\n" }
126
+ logger
127
+ end
128
+
129
+ def write_to_info(level, component, message, metadata)
130
+ entry = format_entry(level, component, message, metadata)
131
+ @info_logger.send(logger_method(level), entry)
132
+ end
133
+
134
+ def write_to_debug(level, component, message, metadata)
135
+ entry = format_entry(level, component, message, metadata)
136
+ @debug_logger.send(logger_method(level), entry)
137
+ end
138
+
139
+ def logger_method(level)
140
+ case level
141
+ when :debug then :debug
142
+ when :info then :info
143
+ when :warn then :warn
144
+ when :error then :error
145
+ else :info
146
+ end
147
+ end
148
+
149
+ def format_entry(level, component, message, metadata)
150
+ if @json_format
151
+ format_json(level, component, message, metadata)
152
+ else
153
+ format_text(level, component, message, metadata)
154
+ end
155
+ end
156
+
157
+ def format_text(level, component, message, metadata)
158
+ timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
159
+ level_str = level.to_s.upcase
160
+ parts = ["#{timestamp} #{level_str} #{component} #{message}"]
161
+
162
+ unless metadata.empty?
163
+ metadata_str = metadata.map { |k, v| "#{k}=#{redact(v.to_s)}" }.join(" ")
164
+ parts << "(#{metadata_str})"
165
+ end
166
+
167
+ parts.join(" ")
168
+ end
169
+
170
+ def format_json(level, component, message, metadata)
171
+ entry = {
172
+ ts: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ"),
173
+ level: level.to_s,
174
+ component: component,
175
+ msg: message
176
+ }.merge(metadata)
177
+
178
+ JSON.generate(entry)
179
+ end
180
+
181
+ # Redaction patterns for common secrets
182
+ REDACTION_PATTERNS = [
183
+ # API keys and tokens (with capture groups)
184
+ [/\b(api[_-]?key|token|secret|password|passwd|pwd)[=:]\s*['"]?([^\s'")]+)['"]?/i, '\1=<REDACTED>'],
185
+ # Bearer tokens
186
+ [/Bearer\s+[A-Za-z0-9\-._~+\/]+=*/, "<REDACTED>"],
187
+ # GitHub tokens
188
+ [/\bgh[ps]_[A-Za-z0-9_]{36,}/, "<REDACTED>"],
189
+ # AWS keys
190
+ [/\bAKIA[0-9A-Z]{16}/, "<REDACTED>"],
191
+ # Generic secrets in key=value format
192
+ [/\b(secret|credentials?|auth)[=:]\s*['"]?([^\s'")]{8,})['"]?/i, '\1=<REDACTED>']
193
+ ].freeze
194
+
195
+ def redact(text)
196
+ return text unless text.is_a?(String)
197
+
198
+ redacted = text.dup
199
+ REDACTION_PATTERNS.each do |pattern, replacement|
200
+ redacted.gsub!(pattern, replacement)
201
+ end
202
+ redacted
203
+ end
204
+
205
+ def redact_hash(hash)
206
+ hash.transform_values { |v| v.is_a?(String) ? redact(v) : v }
207
+ end
208
+
209
+ # Migration from old debug_logs location
210
+ OLD_DEBUG_DIR = ".aidp/debug_logs"
211
+ OLD_DEBUG_LOG = "#{OLD_DEBUG_DIR}/aidp_debug.log"
212
+
213
+ def should_migrate?
214
+ old_path = File.join(@project_dir, OLD_DEBUG_LOG)
215
+ new_path = File.join(@project_dir, DEBUG_LOG)
216
+
217
+ # Migrate if old exists and new doesn't
218
+ File.exist?(old_path) && !File.exist?(new_path)
219
+ end
220
+
221
+ def migrate_old_logs
222
+ old_path = File.join(@project_dir, OLD_DEBUG_LOG)
223
+ new_path = File.join(@project_dir, DEBUG_LOG)
224
+
225
+ begin
226
+ FileUtils.mv(old_path, new_path)
227
+ log_migration_notice
228
+ rescue => e
229
+ # If migration fails, just continue (new logs will be created)
230
+ warn "Failed to migrate old logs: #{e.message}"
231
+ end
232
+ end
233
+
234
+ def log_migration_notice
235
+ notice = format_text(
236
+ :info,
237
+ "migration",
238
+ "Logs migrated from .aidp/debug_logs/ to .aidp/logs/",
239
+ timestamp: Time.now.utc.iso8601
240
+ )
241
+
242
+ # Write directly to avoid recursion
243
+ info_path = File.join(@project_dir, INFO_LOG)
244
+ File.open(info_path, "a") do |f|
245
+ f.puts notice
246
+ end
247
+ end
248
+ end
249
+
250
+ # Module-level logger accessor
251
+ class << self
252
+ # Set up global logger instance
253
+ def setup_logger(project_dir = Dir.pwd, config = {})
254
+ @logger = AidpLogger.new(project_dir, config)
255
+ end
256
+
257
+ # Get current logger instance (creates default if not set up)
258
+ def logger
259
+ @logger ||= AidpLogger.new
260
+ end
261
+
262
+ # Convenience logging methods
263
+ def log_info(component, message, **metadata)
264
+ logger.info(component, message, **metadata)
265
+ end
266
+
267
+ def log_error(component, message, **metadata)
268
+ logger.error(component, message, **metadata)
269
+ end
270
+
271
+ def log_warn(component, message, **metadata)
272
+ logger.warn(component, message, **metadata)
273
+ end
274
+
275
+ def log_debug(component, message, **metadata)
276
+ logger.debug(component, message, **metadata)
277
+ end
278
+ end
279
+ end