aidp 0.21.1 → 0.22.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.
@@ -19,12 +19,16 @@ module Aidp
19
19
 
20
20
  def initialize(project_dir, config: nil)
21
21
  @project_dir = project_dir
22
- @prompt_path = File.join(project_dir, PROMPT_FILENAME)
22
+ @aidp_dir = File.join(project_dir, ".aidp")
23
+ @prompt_path = File.join(@aidp_dir, PROMPT_FILENAME)
23
24
  @archive_dir = File.join(project_dir, ARCHIVE_DIR)
24
25
  @config = config
25
26
  @optimizer = nil
26
27
  @last_optimization_stats = nil
27
28
 
29
+ # Ensure .aidp directory exists
30
+ FileUtils.mkdir_p(@aidp_dir)
31
+
28
32
  # Initialize optimizer if enabled
29
33
  if config&.respond_to?(:prompt_optimization_enabled?) && config.prompt_optimization_enabled?
30
34
  @optimizer = Aidp::PromptOptimization::Optimizer.new(
@@ -37,8 +41,15 @@ module Aidp
37
41
  # Write content to PROMPT.md
38
42
  # If optimization is enabled, stores the content but doesn't write yet
39
43
  # (use write_optimized instead)
40
- def write(content)
44
+ #
45
+ # @param content [String] The prompt content to write
46
+ # @param step_name [String, nil] Optional step name for immediate archiving
47
+ # @return [String, nil] Archive path if archived, nil otherwise
48
+ def write(content, step_name: nil)
41
49
  File.write(@prompt_path, content)
50
+
51
+ # Archive immediately if step_name provided (issue #224)
52
+ archive(step_name) if step_name
42
53
  end
43
54
 
44
55
  # Write optimized prompt using intelligent fragment selection
@@ -88,6 +99,9 @@ module Aidp
88
99
  budget_utilization: result.composition_result.budget_utilization
89
100
  )
90
101
 
102
+ # Archive immediately if step_name provided (issue #224)
103
+ archive(task_context[:step_name]) if task_context[:step_name]
104
+
91
105
  true
92
106
  rescue => e
93
107
  Aidp.logger.error("prompt_manager", "Optimization failed, using fallback", error: e.message)
@@ -308,12 +308,17 @@ module Aidp
308
308
  end
309
309
 
310
310
  def template_search_paths
311
+ gem_root = File.expand_path("../../../..", __dir__)
312
+ # We search:
313
+ # 1. Project root templates (supports entries like "planning/create_prd.md" since name carries subfolder)
314
+ # 2. Project COMMON templates (supports direct filename lookups like "00_PRD.md")
315
+ # 3. Gem root templates (fallback when project lacks local copies)
316
+ # 4. Gem COMMON templates (fallback for filename-only lookups)
311
317
  [
312
- File.join(@project_dir, "templates"), # Root templates folder
313
- File.join(@project_dir, "templates", "planning"),
314
- File.join(@project_dir, "templates", "analysis"),
315
- File.join(@project_dir, "templates", "implementation"),
316
- File.join(@project_dir, "templates", "COMMON")
318
+ File.join(@project_dir, "templates"),
319
+ File.join(@project_dir, "templates", "COMMON"),
320
+ File.join(gem_root, "templates"),
321
+ File.join(gem_root, "templates", "COMMON")
317
322
  ]
318
323
  end
319
324
 
@@ -375,7 +375,7 @@ module Aidp
375
375
  previous_agent_summary: previous_summary
376
376
  )
377
377
 
378
- @prompt_manager.write(initial_prompt)
378
+ @prompt_manager.write(initial_prompt, step_name: @step_name)
379
379
  display_message(" Created PROMPT.md (#{initial_prompt.length} chars)", type: :info)
380
380
  end
381
381
 
@@ -603,10 +603,10 @@ module Aidp
603
603
 
604
604
  return if test_results[:success] && lint_results[:success]
605
605
 
606
- # Append failures to PROMPT.md
606
+ # Append failures to PROMPT.md and archive immediately (issue #224)
607
607
  current_prompt = @prompt_manager.read
608
608
  updated_prompt = current_prompt + "\n\n---\n\n" + failures.join("\n")
609
- @prompt_manager.write(updated_prompt)
609
+ @prompt_manager.write(updated_prompt, step_name: @step_name)
610
610
 
611
611
  display_message(" [NEXT_PATCH] Added failure reports and diagnostic to PROMPT.md", type: :warning)
612
612
  end
@@ -2,12 +2,15 @@
2
2
 
3
3
  require "json"
4
4
  require "fileutils"
5
+ require_relative "../../safe_directory"
5
6
 
6
7
  module Aidp
7
8
  module Harness
8
9
  module State
9
10
  # Handles file I/O and persistence for state management
10
11
  class Persistence
12
+ include Aidp::SafeDirectory
13
+
11
14
  def initialize(project_dir, mode, skip_persistence: false)
12
15
  @project_dir = project_dir
13
16
  @mode = mode
@@ -83,7 +86,15 @@ module Aidp
83
86
  end
84
87
 
85
88
  def ensure_state_directory
86
- FileUtils.mkdir_p(@state_dir) unless Dir.exist?(@state_dir)
89
+ return if @skip_persistence # Don't create directories when persistence is disabled
90
+
91
+ original_dir = @state_dir
92
+ @state_dir = safe_mkdir_p(@state_dir, component_name: "State::Persistence")
93
+
94
+ # If fallback occurred, switch to in-memory mode
95
+ if @state_dir != original_dir
96
+ @skip_persistence = true
97
+ end
87
98
  end
88
99
 
89
100
  def with_lock(&block)
@@ -6,11 +6,14 @@ require_relative "../execute/progress"
6
6
  require_relative "../analyze/progress"
7
7
  require_relative "../execute/steps"
8
8
  require_relative "../analyze/steps"
9
+ require_relative "../safe_directory"
9
10
 
10
11
  module Aidp
11
12
  module Harness
12
13
  # Manages harness-specific state and persistence, extending existing progress tracking
13
14
  class StateManager
15
+ include Aidp::SafeDirectory
16
+
14
17
  def initialize(project_dir, mode, skip_persistence: false)
15
18
  @project_dir = project_dir
16
19
  @mode = mode
@@ -576,7 +579,16 @@ module Aidp
576
579
  end
577
580
 
578
581
  def ensure_state_directory
579
- FileUtils.mkdir_p(@state_dir) unless Dir.exist?(@state_dir)
582
+ return if @skip_persistence # Don't create directories when persistence is disabled
583
+
584
+ original_dir = @state_dir
585
+ @state_dir = safe_mkdir_p(@state_dir, component_name: "StateManager")
586
+
587
+ # If fallback occurred, switch to in-memory mode
588
+ if @state_dir != original_dir
589
+ @skip_persistence = true
590
+ @memory_state ||= {}
591
+ end
580
592
  end
581
593
 
582
594
  def with_lock(&_block)
@@ -6,6 +6,7 @@ require "fileutils"
6
6
  require "time"
7
7
  require_relative "../rescue_logging"
8
8
  require_relative "../concurrency"
9
+ require_relative "../safe_directory"
9
10
 
10
11
  module Aidp
11
12
  module Jobs
@@ -14,6 +15,7 @@ module Aidp
14
15
  class BackgroundRunner
15
16
  include Aidp::MessageDisplay
16
17
  include Aidp::RescueLogging
18
+ include Aidp::SafeDirectory
17
19
 
18
20
  attr_reader :project_dir, :jobs_dir
19
21
 
@@ -190,7 +192,7 @@ module Aidp
190
192
  private
191
193
 
192
194
  def ensure_jobs_directory
193
- FileUtils.mkdir_p(@jobs_dir) unless Dir.exist?(@jobs_dir)
195
+ @jobs_dir = safe_mkdir_p(@jobs_dir, component_name: "BackgroundRunner")
194
196
  end
195
197
 
196
198
  def generate_job_id
data/lib/aidp/logger.rb CHANGED
@@ -39,6 +39,7 @@ module Aidp
39
39
  @json_format = config[:json] || false
40
40
  @max_size = config[:max_size_mb] ? config[:max_size_mb] * 1024 * 1024 : DEFAULT_MAX_SIZE
41
41
  @max_files = config[:max_backups] || DEFAULT_MAX_FILES
42
+ @instrument_internal = config.key?(:instrument) ? config[:instrument] : (ENV["AIDP_LOG_INSTRUMENT"] == "1")
42
43
 
43
44
  ensure_log_directory
44
45
  setup_logger
@@ -95,12 +96,24 @@ module Aidp
95
96
 
96
97
  def ensure_log_directory
97
98
  log_dir = File.join(@project_dir, LOG_DIR)
98
- FileUtils.mkdir_p(log_dir) unless Dir.exist?(log_dir)
99
+ return if Dir.exist?(log_dir)
100
+ begin
101
+ FileUtils.mkdir_p(log_dir)
102
+ rescue SystemCallError => e
103
+ Kernel.warn "[AIDP Logger] Cannot create log directory #{log_dir}: #{e.class}: #{e.message}. Falling back to STDERR logging."
104
+ # We intentionally do not re-raise; file logger setup will attempt and then fall back itself.
105
+ end
99
106
  end
100
107
 
101
108
  def setup_logger
102
109
  info_path = File.join(@project_dir, INFO_LOG)
103
110
  @logger = create_logger(info_path)
111
+ # Emit instrumentation after logger is available (avoid recursive Aidp.log_* calls during bootstrap)
112
+ return unless @instrument_internal
113
+ if defined?(@root_fallback) && @root_fallback
114
+ debug("logger", "root_fallback_applied", effective_dir: @root_fallback)
115
+ end
116
+ debug("logger", "logger_initialized", path: info_path, project_dir: @project_dir)
104
117
  end
105
118
 
106
119
  def create_logger(path)
@@ -200,12 +213,35 @@ module Aidp
200
213
  # that would create odd top-level directories like "<STDERR>".
201
214
  def sanitize_project_dir(dir)
202
215
  return Dir.pwd if dir.nil?
203
- str = dir.to_s
204
- if str.empty? || str.match?(/[<>|]/) || str.match?(/[\x00-\x1F]/)
205
- Kernel.warn "[AIDP Logger] Invalid project_dir '#{str}' - falling back to #{Dir.pwd}"
216
+ raw_input = dir.to_s
217
+ raw_invalid = raw_input.empty? || raw_input.match?(/[<>|]/) || raw_input.match?(/[\x00-\x1F]/)
218
+ if raw_invalid
219
+ Kernel.warn "[AIDP Logger] Invalid project_dir '#{raw_input}' - falling back to #{Dir.pwd}"
220
+ if Dir.pwd == File::SEPARATOR
221
+ fallback = begin
222
+ home = Dir.home
223
+ (home && !home.empty?) ? home : Dir.tmpdir
224
+ rescue
225
+ Dir.tmpdir
226
+ end
227
+ @root_fallback = fallback
228
+ Kernel.warn "[AIDP Logger] Root directory detected - using #{fallback} for logging instead of '#{Dir.pwd}'"
229
+ return fallback
230
+ end
206
231
  return Dir.pwd
207
232
  end
208
- str
233
+ if raw_input == File::SEPARATOR
234
+ fallback = begin
235
+ home = Dir.home
236
+ (home && !home.empty?) ? home : Dir.tmpdir
237
+ rescue
238
+ Dir.tmpdir
239
+ end
240
+ @root_fallback = fallback
241
+ Kernel.warn "[AIDP Logger] Root directory detected - using #{fallback} for logging instead of '#{raw_input}'"
242
+ return fallback
243
+ end
244
+ raw_input
209
245
  end
210
246
  end
211
247
 
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+
6
+ module Aidp
7
+ # Provides safe directory creation with automatic fallback for permission errors
8
+ # Used across AIDP components to handle CI environments and restricted filesystems
9
+ module SafeDirectory
10
+ # Safely create a directory with fallback to temp/home on permission errors
11
+ #
12
+ # @param path [String] The directory path to create
13
+ # @param component_name [String] Name of the component for logging (default: "AIDP")
14
+ # @param skip_creation [Boolean] If true, skip directory creation entirely (default: false)
15
+ # @return [String] The actual directory path (may differ from input if fallback occurred)
16
+ def safe_mkdir_p(path, component_name: "AIDP", skip_creation: false)
17
+ return path if skip_creation
18
+ return path if Dir.exist?(path)
19
+
20
+ begin
21
+ FileUtils.mkdir_p(path)
22
+ path
23
+ rescue SystemCallError => e
24
+ fallback = determine_fallback_path(path)
25
+ Kernel.warn "[#{component_name}] Cannot create directory #{path}: #{e.class}: #{e.message}"
26
+ Kernel.warn "[#{component_name}] Using fallback directory: #{fallback}"
27
+
28
+ # Try to create fallback directory
29
+ begin
30
+ FileUtils.mkdir_p(fallback) unless Dir.exist?(fallback)
31
+ rescue SystemCallError => e2
32
+ Kernel.warn "[#{component_name}] Fallback directory creation also failed: #{e2.class}: #{e2.message}"
33
+ end
34
+
35
+ fallback
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ # Determine a fallback directory path when primary creation fails
42
+ # Tries: $HOME/.aidp -> /tmp/aidp_<basename>
43
+ #
44
+ # @param original_path [String] The original path that failed
45
+ # @return [String] A fallback directory path
46
+ def determine_fallback_path(original_path)
47
+ # Extract meaningful name from path (e.g., ".aidp/jobs" -> "aidp_jobs")
48
+ base_name = extract_base_name(original_path)
49
+
50
+ # Try home directory first
51
+ begin
52
+ home = Dir.home
53
+ if home && !home.empty? && File.writable?(home)
54
+ return File.join(home, base_name)
55
+ end
56
+ rescue
57
+ # Ignore home directory errors, fall through to temp
58
+ end
59
+
60
+ # Fall back to temp directory
61
+ File.join(Dir.tmpdir, base_name)
62
+ end
63
+
64
+ # Extract a meaningful base name from a path for fallback naming
65
+ #
66
+ # @param path [String] The original path
67
+ # @return [String] A sanitized base name
68
+ def extract_base_name(path)
69
+ # Handle paths like "/project/.aidp/jobs" -> "aidp_jobs"
70
+ # or "/.aidp" -> ".aidp"
71
+ parts = path.split(File::SEPARATOR).reject(&:empty?)
72
+
73
+ if parts.include?(".aidp")
74
+ # If path contains .aidp, use .aidp and subdirectory
75
+ idx = parts.index(".aidp")
76
+ if idx && parts[idx + 1]
77
+ return "aidp_#{parts[idx + 1]}"
78
+ else
79
+ return ".aidp"
80
+ end
81
+ end
82
+
83
+ # Fallback: use last directory name
84
+ parts.last || "aidp_storage"
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "time"
5
+
6
+ module Aidp
7
+ module Setup
8
+ module Devcontainer
9
+ # Manages backups of devcontainer.json before modifications
10
+ class BackupManager
11
+ class BackupError < StandardError; end
12
+
13
+ def initialize(project_dir)
14
+ @project_dir = project_dir
15
+ @backup_dir = File.join(project_dir, ".aidp", "backups", "devcontainer")
16
+ end
17
+
18
+ # Create a backup of the devcontainer file
19
+ # @param source_path [String] Path to devcontainer.json to backup
20
+ # @param metadata [Hash] Optional metadata to store with backup
21
+ # @return [String] Path to backup file
22
+ def create_backup(source_path, metadata = {})
23
+ unless File.exist?(source_path)
24
+ raise BackupError, "Source file does not exist: #{source_path}"
25
+ end
26
+
27
+ ensure_backup_directory_exists
28
+
29
+ timestamp = Time.now.utc.strftime("%Y%m%d_%H%M%S")
30
+ backup_filename = "devcontainer-#{timestamp}.json"
31
+ backup_path = File.join(@backup_dir, backup_filename)
32
+
33
+ FileUtils.cp(source_path, backup_path)
34
+
35
+ # Create metadata file
36
+ if metadata.any?
37
+ metadata_path = "#{backup_path}.meta"
38
+ File.write(metadata_path, JSON.pretty_generate(metadata))
39
+ end
40
+
41
+ Aidp.log_info("backup_manager", "created backup",
42
+ source: source_path,
43
+ backup: backup_path)
44
+
45
+ backup_path
46
+ rescue => e
47
+ raise BackupError, "Failed to create backup: #{e.message}"
48
+ end
49
+
50
+ # List all available backups
51
+ # @return [Array<Hash>] Array of backup info hashes
52
+ def list_backups
53
+ return [] unless File.directory?(@backup_dir)
54
+
55
+ Dir.glob(File.join(@backup_dir, "devcontainer-*.json"))
56
+ .reject { |f| f.end_with?(".meta") }
57
+ .map { |path| backup_info(path) }
58
+ .sort_by { |info| info[:timestamp] }
59
+ .reverse
60
+ end
61
+
62
+ # Restore a backup to the specified location
63
+ # @param backup_path [String] Path to backup file
64
+ # @param target_path [String] Where to restore the backup
65
+ # @param create_backup [Boolean] Create backup of target before restoring
66
+ # @return [Boolean] true if successful
67
+ def restore_backup(backup_path, target_path, create_backup: true)
68
+ unless File.exist?(backup_path)
69
+ raise BackupError, "Backup file does not exist: #{backup_path}"
70
+ end
71
+
72
+ # Backup current file before restoring
73
+ if create_backup && File.exist?(target_path)
74
+ create_backup(target_path, {
75
+ reason: "pre_restore",
76
+ restoring_from: backup_path
77
+ })
78
+ end
79
+
80
+ FileUtils.mkdir_p(File.dirname(target_path))
81
+ FileUtils.cp(backup_path, target_path)
82
+
83
+ Aidp.log_info("backup_manager", "restored backup",
84
+ backup: backup_path,
85
+ target: target_path)
86
+
87
+ true
88
+ rescue => e
89
+ raise BackupError, "Failed to restore backup: #{e.message}"
90
+ end
91
+
92
+ # Delete old backups, keeping only the N most recent
93
+ # @param keep_count [Integer] Number of backups to keep
94
+ # @return [Integer] Number of backups deleted
95
+ def cleanup_old_backups(keep_count = 10)
96
+ backups = list_backups
97
+ return 0 if backups.size <= keep_count
98
+
99
+ to_delete = backups[keep_count..]
100
+ deleted_count = 0
101
+
102
+ to_delete.each do |backup|
103
+ File.delete(backup[:path]) if File.exist?(backup[:path])
104
+
105
+ metadata_path = "#{backup[:path]}.meta"
106
+ File.delete(metadata_path) if File.exist?(metadata_path)
107
+
108
+ deleted_count += 1
109
+ end
110
+
111
+ Aidp.log_info("backup_manager", "cleaned up old backups",
112
+ deleted: deleted_count,
113
+ kept: keep_count)
114
+
115
+ deleted_count
116
+ end
117
+
118
+ # Get the most recent backup
119
+ # @return [Hash, nil] Backup info or nil if no backups
120
+ def latest_backup
121
+ list_backups.first
122
+ end
123
+
124
+ # Calculate total size of all backups
125
+ # @return [Integer] Total size in bytes
126
+ def total_backup_size
127
+ return 0 unless File.directory?(@backup_dir)
128
+
129
+ Dir.glob(File.join(@backup_dir, "devcontainer-*.{json,meta}"))
130
+ .sum { |f| File.size(f) }
131
+ end
132
+
133
+ private
134
+
135
+ def ensure_backup_directory_exists
136
+ FileUtils.mkdir_p(@backup_dir) unless File.directory?(@backup_dir)
137
+ end
138
+
139
+ def backup_info(path)
140
+ filename = File.basename(path)
141
+ timestamp_str = filename[/\d{8}_\d{6}/]
142
+
143
+ info = {
144
+ path: path,
145
+ filename: filename,
146
+ size: File.size(path),
147
+ created_at: File.mtime(path),
148
+ timestamp: parse_timestamp(timestamp_str)
149
+ }
150
+
151
+ # Load metadata if available
152
+ metadata_path = "#{path}.meta"
153
+ if File.exist?(metadata_path)
154
+ begin
155
+ metadata = JSON.parse(File.read(metadata_path))
156
+ info[:metadata] = metadata
157
+ rescue JSON::ParserError
158
+ # Ignore invalid metadata
159
+ end
160
+ end
161
+
162
+ info
163
+ end
164
+
165
+ def parse_timestamp(timestamp_str)
166
+ return Time.now if timestamp_str.nil?
167
+
168
+ Time.strptime(timestamp_str, "%Y%m%d_%H%M%S")
169
+ rescue ArgumentError
170
+ Time.now
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end