aidp 0.21.1 → 0.23.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
@@ -27,11 +27,12 @@ module Aidp
27
27
  waiting_for_rate_limit: "waiting_for_rate_limit",
28
28
  stopped: "stopped",
29
29
  completed: "completed",
30
- error: "error"
30
+ error: "error",
31
+ needs_clarification: "needs_clarification"
31
32
  }.freeze
32
33
 
33
34
  # Public accessors for testing and integration
34
- attr_reader :current_provider, :current_step, :user_input, :execution_log, :provider_manager
35
+ attr_reader :current_provider, :current_step, :user_input, :execution_log, :provider_manager, :clarification_questions
35
36
 
36
37
  def initialize(project_dir, mode = :analyze, options = {})
37
38
  @project_dir = project_dir
@@ -132,7 +133,9 @@ module Aidp
132
133
  cleanup
133
134
  end
134
135
 
135
- {status: @state, message: get_completion_message}
136
+ result = {status: @state, message: get_completion_message}
137
+ result[:clarification_questions] = @clarification_questions if @clarification_questions
138
+ result
136
139
  end
137
140
 
138
141
  # Pause the harness execution
@@ -248,12 +251,23 @@ module Aidp
248
251
  end
249
252
 
250
253
  def handle_user_feedback_request(result)
251
- @state = STATES[:waiting_for_user]
252
- log_execution("Waiting for user feedback")
253
-
254
254
  # Extract questions from result
255
255
  questions = @condition_detector.extract_questions(result)
256
256
 
257
+ # Check if we're in watch mode (non-interactive)
258
+ if @options[:workflow_type] == :watch_mode
259
+ # Store questions for later retrieval and set state to needs_clarification
260
+ @clarification_questions = questions
261
+ @state = STATES[:needs_clarification]
262
+ log_execution("Clarification needed in watch mode", {question_count: questions.size})
263
+ # Don't continue - exit the loop so we can return this status
264
+ return
265
+ end
266
+
267
+ # Interactive mode: collect feedback from user
268
+ @state = STATES[:waiting_for_user]
269
+ log_execution("Waiting for user feedback")
270
+
257
271
  # Collect user input
258
272
  user_responses = @user_interface.collect_feedback(questions)
259
273
 
@@ -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,182 @@
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, clock: Time)
14
+ @project_dir = project_dir
15
+ @backup_dir = File.join(project_dir, ".aidp", "backups", "devcontainer")
16
+ @clock = clock
17
+ end
18
+
19
+ # Create a backup of the devcontainer file
20
+ # @param source_path [String] Path to devcontainer.json to backup
21
+ # @param metadata [Hash] Optional metadata to store with backup
22
+ # @return [String] Path to backup file
23
+ def create_backup(source_path, metadata = {})
24
+ unless File.exist?(source_path)
25
+ raise BackupError, "Source file does not exist: #{source_path}"
26
+ end
27
+
28
+ ensure_backup_directory_exists
29
+
30
+ timestamp = current_time.utc.strftime("%Y%m%d_%H%M%S")
31
+ backup_filename = "devcontainer-#{timestamp}.json"
32
+ backup_path = File.join(@backup_dir, backup_filename)
33
+
34
+ FileUtils.cp(source_path, backup_path)
35
+
36
+ # Create metadata file
37
+ if metadata.any?
38
+ metadata_path = "#{backup_path}.meta"
39
+ File.write(metadata_path, JSON.pretty_generate(metadata))
40
+ end
41
+
42
+ Aidp.log_info("backup_manager", "created backup",
43
+ source: source_path,
44
+ backup: backup_path)
45
+
46
+ backup_path
47
+ rescue => e
48
+ raise BackupError, "Failed to create backup: #{e.message}"
49
+ end
50
+
51
+ # List all available backups
52
+ # @return [Array<Hash>] Array of backup info hashes
53
+ def list_backups
54
+ return [] unless File.directory?(@backup_dir)
55
+
56
+ Dir.glob(File.join(@backup_dir, "devcontainer-*.json"))
57
+ .reject { |f| f.end_with?(".meta") }
58
+ .map { |path| backup_info(path) }
59
+ .sort_by { |info| info[:timestamp] }
60
+ .reverse
61
+ end
62
+
63
+ # Restore a backup to the specified location
64
+ # @param backup_path [String] Path to backup file
65
+ # @param target_path [String] Where to restore the backup
66
+ # @param create_backup [Boolean] Create backup of target before restoring
67
+ # @return [Boolean] true if successful
68
+ def restore_backup(backup_path, target_path, create_backup: true)
69
+ unless File.exist?(backup_path)
70
+ raise BackupError, "Backup file does not exist: #{backup_path}"
71
+ end
72
+
73
+ # Backup current file before restoring
74
+ if create_backup && File.exist?(target_path)
75
+ create_backup(target_path, {
76
+ reason: "pre_restore",
77
+ restoring_from: backup_path
78
+ })
79
+ end
80
+
81
+ FileUtils.mkdir_p(File.dirname(target_path))
82
+ FileUtils.cp(backup_path, target_path)
83
+
84
+ Aidp.log_info("backup_manager", "restored backup",
85
+ backup: backup_path,
86
+ target: target_path)
87
+
88
+ true
89
+ rescue => e
90
+ raise BackupError, "Failed to restore backup: #{e.message}"
91
+ end
92
+
93
+ # Delete old backups, keeping only the N most recent
94
+ # @param keep_count [Integer] Number of backups to keep
95
+ # @return [Integer] Number of backups deleted
96
+ def cleanup_old_backups(keep_count = 10)
97
+ backups = list_backups
98
+ return 0 if backups.size <= keep_count
99
+
100
+ to_delete = backups[keep_count..]
101
+ deleted_count = 0
102
+
103
+ to_delete.each do |backup|
104
+ File.delete(backup[:path]) if File.exist?(backup[:path])
105
+
106
+ metadata_path = "#{backup[:path]}.meta"
107
+ File.delete(metadata_path) if File.exist?(metadata_path)
108
+
109
+ deleted_count += 1
110
+ end
111
+
112
+ Aidp.log_info("backup_manager", "cleaned up old backups",
113
+ deleted: deleted_count,
114
+ kept: keep_count)
115
+
116
+ deleted_count
117
+ end
118
+
119
+ # Get the most recent backup
120
+ # @return [Hash, nil] Backup info or nil if no backups
121
+ def latest_backup
122
+ list_backups.first
123
+ end
124
+
125
+ # Calculate total size of all backups
126
+ # @return [Integer] Total size in bytes
127
+ def total_backup_size
128
+ return 0 unless File.directory?(@backup_dir)
129
+
130
+ Dir.glob(File.join(@backup_dir, "devcontainer-*.{json,meta}"))
131
+ .sum { |f| File.size(f) }
132
+ end
133
+
134
+ private
135
+
136
+ def ensure_backup_directory_exists
137
+ FileUtils.mkdir_p(@backup_dir) unless File.directory?(@backup_dir)
138
+ end
139
+
140
+ def backup_info(path)
141
+ filename = File.basename(path)
142
+ timestamp_str = filename[/\d{8}_\d{6}/]
143
+
144
+ info = {
145
+ path: path,
146
+ filename: filename,
147
+ size: File.size(path),
148
+ created_at: File.mtime(path),
149
+ timestamp: parse_timestamp(timestamp_str)
150
+ }
151
+
152
+ # Load metadata if available
153
+ metadata_path = "#{path}.meta"
154
+ if File.exist?(metadata_path)
155
+ begin
156
+ metadata = JSON.parse(File.read(metadata_path))
157
+ info[:metadata] = metadata
158
+ rescue JSON::ParserError
159
+ # Ignore invalid metadata
160
+ end
161
+ end
162
+
163
+ info
164
+ end
165
+
166
+ def parse_timestamp(timestamp_str)
167
+ return current_time if timestamp_str.nil?
168
+
169
+ Time.strptime(timestamp_str, "%Y%m%d_%H%M%S")
170
+ rescue ArgumentError
171
+ current_time
172
+ end
173
+
174
+ attr_reader :clock
175
+
176
+ def current_time
177
+ clock.respond_to?(:call) ? clock.call : clock.now
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end