aidp 0.17.1 → 0.19.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +69 -0
  3. data/lib/aidp/cli/terminal_io.rb +5 -2
  4. data/lib/aidp/cli.rb +43 -2
  5. data/lib/aidp/config.rb +9 -14
  6. data/lib/aidp/execute/agent_signal_parser.rb +20 -0
  7. data/lib/aidp/execute/persistent_tasklist.rb +220 -0
  8. data/lib/aidp/execute/prompt_manager.rb +128 -1
  9. data/lib/aidp/execute/repl_macros.rb +719 -0
  10. data/lib/aidp/execute/work_loop_runner.rb +162 -1
  11. data/lib/aidp/harness/ai_decision_engine.rb +376 -0
  12. data/lib/aidp/harness/capability_registry.rb +273 -0
  13. data/lib/aidp/harness/config_schema.rb +305 -1
  14. data/lib/aidp/harness/configuration.rb +452 -0
  15. data/lib/aidp/harness/enhanced_runner.rb +7 -1
  16. data/lib/aidp/harness/provider_factory.rb +0 -2
  17. data/lib/aidp/harness/runner.rb +7 -1
  18. data/lib/aidp/harness/thinking_depth_manager.rb +335 -0
  19. data/lib/aidp/harness/zfc_condition_detector.rb +395 -0
  20. data/lib/aidp/init/devcontainer_generator.rb +274 -0
  21. data/lib/aidp/init/runner.rb +37 -10
  22. data/lib/aidp/init.rb +1 -0
  23. data/lib/aidp/prompt_optimization/context_composer.rb +286 -0
  24. data/lib/aidp/prompt_optimization/optimizer.rb +335 -0
  25. data/lib/aidp/prompt_optimization/prompt_builder.rb +309 -0
  26. data/lib/aidp/prompt_optimization/relevance_scorer.rb +256 -0
  27. data/lib/aidp/prompt_optimization/source_code_fragmenter.rb +308 -0
  28. data/lib/aidp/prompt_optimization/style_guide_indexer.rb +240 -0
  29. data/lib/aidp/prompt_optimization/template_indexer.rb +250 -0
  30. data/lib/aidp/provider_manager.rb +0 -2
  31. data/lib/aidp/providers/anthropic.rb +19 -0
  32. data/lib/aidp/setup/wizard.rb +299 -4
  33. data/lib/aidp/utils/devcontainer_detector.rb +166 -0
  34. data/lib/aidp/version.rb +1 -1
  35. data/lib/aidp/watch/build_processor.rb +72 -6
  36. data/lib/aidp/watch/repository_client.rb +2 -1
  37. data/lib/aidp.rb +1 -1
  38. data/templates/aidp.yml.example +128 -0
  39. metadata +15 -2
  40. data/lib/aidp/providers/macos_ui.rb +0 -102
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 949670fdea4721406643f4fd8da096e943740effbd836407311b812350919b14
4
- data.tar.gz: 8034a3c798571e4626b273c4a9abe9a5da38cef5dd4f25de8794a0ef63bf6022
3
+ metadata.gz: 220c03eda20360cfa8b954bc578b15fe766742e849037539f1a1f9c794310569
4
+ data.tar.gz: 0fa8cc7ff18ab1ee66c11f187df4554f84504d4713322f089427b9d921de5420
5
5
  SHA512:
6
- metadata.gz: cdabc6ba04d06a89dbc4ad19b27a64862e960daa350029db4fada5a4f6d32d3fba27cb32dda291871a79ad8f319d27e7dd1348ddcb7fdb8b0f0a1390b9493fc1
7
- data.tar.gz: 39c384f4c01bf00bef550ac9190d27ddd268437be92fc4cdfcdeac3dff09c79c055b2e5b5582e553af6fa838b3f904cce354e9db62ee3638f658eec05673e4f5
6
+ metadata.gz: 772f8bc2099fce5b1d8ff0c9d8567382281851b7d3a283eb0aa847ad92d5f420b9aff91c702424df89cf6570cc59cfe35dacc8baf7df60b0d019cd1dfc97ceea
7
+ data.tar.gz: 1bad64d70c87752e7416bbad5558ff9cab2e67902b2e461bbd1ad946a4659951949ea4f6c0642a2424d81a6119e405f9208647e5d7c229d13626fc9f0774bae7
data/README.md CHANGED
@@ -42,6 +42,75 @@ You can re-run the wizard manually:
42
42
  aidp --setup-config
43
43
  ```
44
44
 
45
+ ## Devcontainer Support
46
+
47
+ AIDP provides first-class devcontainer support for sandboxed, secure AI agent execution. Devcontainers offer:
48
+
49
+ - **Network Security**: Strict firewall with allowlisted domains only
50
+ - **Sandboxed Environment**: Isolated from your host system
51
+ - **Elevated Permissions**: AI agents can run with full permissions inside the container
52
+ - **Consistent Setup**: Same environment across all developers
53
+
54
+ ### For AIDP Development
55
+
56
+ This repository includes a `.devcontainer/` setup for developing AIDP itself:
57
+
58
+ ```bash
59
+ # Open in VS Code
60
+ code .
61
+
62
+ # Press F1 → "Dev Containers: Reopen in Container"
63
+ # Container builds automatically with Ruby 3.4.5, all tools, and firewall
64
+
65
+ # Run tests inside container
66
+ bundle exec rspec
67
+
68
+ # Run AIDP inside container
69
+ bundle exec aidp
70
+ ```
71
+
72
+ See [.devcontainer/README.md](.devcontainer/README.md) for complete documentation.
73
+
74
+ ### Generating Devcontainers for Your Projects
75
+
76
+ Use `aidp init` to generate a devcontainer for any project:
77
+
78
+ ```bash
79
+ # Initialize project with devcontainer
80
+ aidp init
81
+
82
+ # When prompted:
83
+ # "Generate devcontainer configuration for sandboxed development?" → Yes
84
+
85
+ # Or use the flag directly
86
+ aidp init --with-devcontainer
87
+ ```
88
+
89
+ This creates:
90
+
91
+ - `.devcontainer/Dockerfile` - Customized for your project's language/framework
92
+ - `.devcontainer/devcontainer.json` - VS Code configuration and extensions
93
+ - `.devcontainer/init-firewall.sh` - Network security rules
94
+ - `.devcontainer/README.md` - Setup and usage documentation
95
+
96
+ ### Elevated Permissions in Devcontainers
97
+
98
+ When running inside a devcontainer, you can enable elevated permissions for AI agents:
99
+
100
+ ```yaml
101
+ # aidp.yml
102
+ devcontainer:
103
+ enabled: true
104
+ full_permissions_when_in_devcontainer: true # Run all providers with full permissions
105
+
106
+ # Or enable per-provider
107
+ permissions:
108
+ skip_permission_checks:
109
+ - claude # Adds --dangerously-skip-permissions for Claude Code
110
+ ```
111
+
112
+ AIDP automatically detects when it's running in a devcontainer and adjusts agent permissions accordingly. This is safe because the container is sandboxed from your host system.
113
+
45
114
  ## Core Features
46
115
 
47
116
  ### Work Loops
@@ -51,8 +51,11 @@ module Aidp
51
51
  interrupt: :exit
52
52
  )
53
53
 
54
- # Read line with full readline support (Ctrl-A, Ctrl-E, Ctrl-W, etc.)
55
- result = reader.read_line(prompt, default: default || "")
54
+ # TTY::Reader#read_line does not support a :default keyword; we emulate fallback
55
+ result = reader.read_line(prompt)
56
+ if (result.nil? || result.chomp.empty?) && !default.nil?
57
+ return default
58
+ end
56
59
  result&.chomp
57
60
  rescue TTY::Reader::InputInterrupt
58
61
  raise Interrupt
data/lib/aidp/cli.rb CHANGED
@@ -759,9 +759,10 @@ module Aidp
759
759
  require_relative "harness/provider_info"
760
760
 
761
761
  provider_name = args.shift
762
+
763
+ # If no provider specified, show models catalog table
762
764
  unless provider_name
763
- display_message("Usage: aidp providers info <provider_name>", type: :info)
764
- display_message("Example: aidp providers info claude", type: :info)
765
+ run_providers_models_catalog
765
766
  return
766
767
  end
767
768
 
@@ -839,6 +840,46 @@ module Aidp
839
840
  display_message("Tip: Use --refresh to update this information", type: :muted)
840
841
  end
841
842
 
843
+ def run_providers_models_catalog
844
+ require_relative "harness/capability_registry"
845
+ require "tty-table"
846
+
847
+ display_message("Models Catalog - Thinking Depth Tiers", type: :highlight)
848
+ display_message("=" * 80, type: :muted)
849
+
850
+ registry = Aidp::Harness::CapabilityRegistry.new
851
+ unless registry.load_catalog
852
+ display_message("No models catalog found. Create .aidp/models_catalog.yml first.", type: :error)
853
+ return
854
+ end
855
+
856
+ rows = []
857
+ registry.provider_names.sort.each do |provider|
858
+ models = registry.models_for_provider(provider)
859
+ models.each do |model_name, model_data|
860
+ tier = model_data["tier"] || "-"
861
+ context = model_data["context_window"] ? "#{model_data["context_window"] / 1000}k" : "-"
862
+ tools = model_data["supports_tools"] ? "yes" : "no"
863
+ cost_input = model_data["cost_per_mtok_input"]
864
+ cost = cost_input ? "$#{cost_input}/MTok" : "-"
865
+
866
+ rows << [provider, model_name, tier, context, tools, cost]
867
+ end
868
+ end
869
+
870
+ if rows.empty?
871
+ display_message("No models found in catalog", type: :info)
872
+ return
873
+ end
874
+
875
+ header = ["Provider", "Model", "Tier", "Context", "Tools", "Cost"]
876
+ table = TTY::Table.new(header, rows)
877
+ display_message(table.render(:basic), type: :info)
878
+
879
+ display_message("\n" + "=" * 80, type: :muted)
880
+ display_message("Use '/thinking show' in REPL to see current tier configuration", type: :muted)
881
+ end
882
+
842
883
  def run_providers_refresh_command(args)
843
884
  require_relative "harness/provider_info"
844
885
  require "tty-spinner"
data/lib/aidp/config.rb CHANGED
@@ -15,8 +15,7 @@ module Aidp
15
15
  no_api_keys_required: false,
16
16
  provider_weights: {
17
17
  "cursor" => 3,
18
- "anthropic" => 2,
19
- "macos" => 1
18
+ "anthropic" => 2
20
19
  },
21
20
  circuit_breaker: {
22
21
  enabled: true,
@@ -74,6 +73,7 @@ module Aidp
74
73
  cursor: {
75
74
  type: "subscription",
76
75
  priority: 1,
76
+ model_family: "auto",
77
77
  default_flags: [],
78
78
  models: ["cursor-default", "cursor-fast", "cursor-precise"],
79
79
  model_weights: {
@@ -108,6 +108,7 @@ module Aidp
108
108
  anthropic: {
109
109
  type: "usage_based",
110
110
  priority: 2,
111
+ model_family: "claude",
111
112
  max_tokens: 100_000,
112
113
  default_flags: ["--dangerously-skip-permissions"],
113
114
  models: ["claude-3-5-sonnet-20241022", "claude-3-5-haiku-20241022", "claude-3-opus-20240229"],
@@ -149,18 +150,6 @@ module Aidp
149
150
  enabled: true,
150
151
  metrics_interval: 60
151
152
  }
152
- },
153
- macos: {
154
- type: "passthrough",
155
- priority: 4,
156
- underlying_service: "cursor",
157
- models: ["cursor-chat"],
158
- features: {
159
- file_upload: false,
160
- code_generation: true,
161
- analysis: true,
162
- interactive: true
163
- }
164
153
  }
165
154
  },
166
155
  skills: {
@@ -342,6 +331,12 @@ module Aidp
342
331
  merged[:skills] = merged[:skills].merge(symbolize_keys(skills_section))
343
332
  end
344
333
 
334
+ # Deep merge thinking config
335
+ if config[:thinking] || config["thinking"]
336
+ thinking_section = config[:thinking] || config["thinking"]
337
+ merged[:thinking] = symbolize_keys(thinking_section)
338
+ end
339
+
345
340
  merged
346
341
  end
347
342
 
@@ -16,6 +16,26 @@ module Aidp
16
16
  nil
17
17
  end
18
18
 
19
+ # Parse task filing signals from agent output
20
+ # Returns array of task hashes with description, priority, and tags
21
+ def self.parse_task_filing(output)
22
+ return [] unless output
23
+
24
+ tasks = []
25
+ # Pattern: File task: "description" [priority: high|medium|low] [tags: tag1,tag2]
26
+ pattern = /File\s+task:\s*"([^"]+)"(?:\s+priority:\s*(high|medium|low))?(?:\s+tags:\s*([^\s]+))?/i
27
+
28
+ output.to_s.scan(pattern).each do |description, priority, tags|
29
+ tasks << {
30
+ description: description.strip,
31
+ priority: (priority || "medium").downcase.to_sym,
32
+ tags: tags ? tags.split(",").map(&:strip) : []
33
+ }
34
+ end
35
+
36
+ tasks
37
+ end
38
+
19
39
  def self.normalize_token(raw)
20
40
  return nil if raw.nil? || raw.empty?
21
41
 
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require "securerandom"
6
+ require "fileutils"
7
+
8
+ module Aidp
9
+ module Execute
10
+ # Task struct for persistent tasklist entries
11
+ Task = Struct.new(
12
+ :id,
13
+ :description,
14
+ :status,
15
+ :priority,
16
+ :created_at,
17
+ :updated_at,
18
+ :session,
19
+ :discovered_during,
20
+ :started_at,
21
+ :completed_at,
22
+ :abandoned_at,
23
+ :abandoned_reason,
24
+ :tags,
25
+ keyword_init: true
26
+ ) do
27
+ def to_h
28
+ super.compact
29
+ end
30
+ end
31
+
32
+ # Persistent tasklist for tracking tasks across sessions
33
+ # Uses append-only JSONL format for git-friendly storage
34
+ class PersistentTasklist
35
+ attr_reader :project_dir, :file_path
36
+
37
+ class TaskNotFoundError < StandardError; end
38
+ class InvalidTaskError < StandardError; end
39
+
40
+ def initialize(project_dir)
41
+ @project_dir = project_dir
42
+ @file_path = File.join(project_dir, ".aidp", "tasklist.jsonl")
43
+ ensure_file_exists
44
+ end
45
+
46
+ # Create a new task
47
+ def create(description, priority: :medium, session: nil, discovered_during: nil, tags: [])
48
+ validate_description!(description)
49
+ validate_priority!(priority)
50
+
51
+ task = Task.new(
52
+ id: generate_id,
53
+ description: description.strip,
54
+ status: :pending,
55
+ priority: priority,
56
+ created_at: Time.now,
57
+ updated_at: Time.now,
58
+ session: session,
59
+ discovered_during: discovered_during,
60
+ tags: Array(tags)
61
+ )
62
+
63
+ append_task(task)
64
+ Aidp.log_debug("tasklist", "Created task", task_id: task.id, description: task.description)
65
+ task
66
+ end
67
+
68
+ # Update task status
69
+ def update_status(task_id, new_status, reason: nil)
70
+ validate_status!(new_status)
71
+ task = find(task_id)
72
+ raise TaskNotFoundError, "Task not found: #{task_id}" unless task
73
+
74
+ task.status = new_status
75
+ task.updated_at = Time.now
76
+
77
+ case new_status
78
+ when :in_progress
79
+ task.started_at ||= Time.now
80
+ when :done
81
+ task.completed_at = Time.now
82
+ when :abandoned
83
+ task.abandoned_at = Time.now
84
+ task.abandoned_reason = reason
85
+ end
86
+
87
+ append_task(task)
88
+ Aidp.log_debug("tasklist", "Updated task status", task_id: task.id, status: new_status)
89
+ task
90
+ end
91
+
92
+ # Query tasks with optional filters
93
+ def all(status: nil, priority: nil, since: nil, tags: nil)
94
+ tasks = load_latest_tasks
95
+
96
+ tasks = tasks.select { |t| t.status == status } if status
97
+ tasks = tasks.select { |t| t.priority == priority } if priority
98
+ tasks = tasks.select { |t| t.created_at >= since } if since
99
+ tasks = tasks.select { |t| (Array(t.tags) & Array(tags)).any? } if tags && !tags.empty?
100
+
101
+ tasks.sort_by(&:created_at).reverse
102
+ end
103
+
104
+ # Find single task by ID
105
+ def find(task_id)
106
+ all.find { |t| t.id == task_id }
107
+ end
108
+
109
+ # Query pending tasks (common operation)
110
+ def pending
111
+ all(status: :pending)
112
+ end
113
+
114
+ # Query in-progress tasks
115
+ def in_progress
116
+ all(status: :in_progress)
117
+ end
118
+
119
+ # Count tasks by status
120
+ def counts
121
+ tasks = load_latest_tasks
122
+ {
123
+ total: tasks.size,
124
+ pending: tasks.count { |t| t.status == :pending },
125
+ in_progress: tasks.count { |t| t.status == :in_progress },
126
+ done: tasks.count { |t| t.status == :done },
127
+ abandoned: tasks.count { |t| t.status == :abandoned }
128
+ }
129
+ end
130
+
131
+ private
132
+
133
+ VALID_STATUSES = [:pending, :in_progress, :done, :abandoned].freeze
134
+ VALID_PRIORITIES = [:high, :medium, :low].freeze
135
+
136
+ def append_task(task)
137
+ File.open(@file_path, "a") do |f|
138
+ f.puts serialize_task(task)
139
+ end
140
+ end
141
+
142
+ def load_latest_tasks
143
+ return [] unless File.exist?(@file_path)
144
+
145
+ tasks_by_id = {}
146
+
147
+ File.readlines(@file_path).each_with_index do |line, index|
148
+ next if line.strip.empty?
149
+
150
+ begin
151
+ data = JSON.parse(line.strip, symbolize_names: true)
152
+ task = deserialize_task(data)
153
+ tasks_by_id[task.id] = task
154
+ rescue JSON::ParserError => e
155
+ Aidp.log_warn("tasklist", "Skipping malformed JSONL line", line_number: index + 1, error: e.message)
156
+ next
157
+ rescue => e
158
+ Aidp.log_warn("tasklist", "Error loading task", line_number: index + 1, error: e.message)
159
+ next
160
+ end
161
+ end
162
+
163
+ tasks_by_id.values
164
+ end
165
+
166
+ def serialize_task(task)
167
+ hash = task.to_h
168
+ # Convert Time objects to ISO8601 strings
169
+ hash[:created_at] = hash[:created_at].iso8601 if hash[:created_at]
170
+ hash[:updated_at] = hash[:updated_at].iso8601 if hash[:updated_at]
171
+ hash[:started_at] = hash[:started_at].iso8601 if hash[:started_at]
172
+ hash[:completed_at] = hash[:completed_at].iso8601 if hash[:completed_at]
173
+ hash[:abandoned_at] = hash[:abandoned_at].iso8601 if hash[:abandoned_at]
174
+ JSON.generate(hash)
175
+ end
176
+
177
+ def deserialize_task(data)
178
+ Task.new(**data.merge(
179
+ status: data[:status]&.to_sym,
180
+ priority: data[:priority]&.to_sym,
181
+ created_at: parse_time(data[:created_at]),
182
+ updated_at: parse_time(data[:updated_at]),
183
+ started_at: parse_time(data[:started_at]),
184
+ completed_at: parse_time(data[:completed_at]),
185
+ abandoned_at: parse_time(data[:abandoned_at]),
186
+ tags: Array(data[:tags])
187
+ ))
188
+ end
189
+
190
+ def parse_time(time_string)
191
+ return nil if time_string.nil?
192
+ Time.parse(time_string)
193
+ rescue ArgumentError
194
+ nil
195
+ end
196
+
197
+ def generate_id
198
+ "task_#{Time.now.to_i}_#{SecureRandom.hex(4)}"
199
+ end
200
+
201
+ def ensure_file_exists
202
+ FileUtils.mkdir_p(File.dirname(@file_path))
203
+ FileUtils.touch(@file_path) unless File.exist?(@file_path)
204
+ end
205
+
206
+ def validate_description!(description)
207
+ raise InvalidTaskError, "Description cannot be empty" if description.nil? || description.strip.empty?
208
+ raise InvalidTaskError, "Description too long (max 200 chars)" if description.length > 200
209
+ end
210
+
211
+ def validate_priority!(priority)
212
+ raise InvalidTaskError, "Invalid priority: #{priority}" unless VALID_PRIORITIES.include?(priority)
213
+ end
214
+
215
+ def validate_status!(status)
216
+ raise InvalidTaskError, "Invalid status: #{status}" unless VALID_STATUSES.include?(status)
217
+ end
218
+ end
219
+ end
220
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require_relative "../prompt_optimization/optimizer"
4
5
 
5
6
  module Aidp
6
7
  module Execute
@@ -9,21 +10,91 @@ module Aidp
9
10
  # - Read/write PROMPT.md
10
11
  # - Check existence
11
12
  # - Archive completed prompts
13
+ # - Optionally optimize prompts using intelligent fragment selection (ZFC)
12
14
  class PromptManager
13
15
  PROMPT_FILENAME = "PROMPT.md"
14
16
  ARCHIVE_DIR = ".aidp/prompt_archive"
15
17
 
16
- def initialize(project_dir)
18
+ attr_reader :optimizer, :last_optimization_stats
19
+
20
+ def initialize(project_dir, config: nil)
17
21
  @project_dir = project_dir
18
22
  @prompt_path = File.join(project_dir, PROMPT_FILENAME)
19
23
  @archive_dir = File.join(project_dir, ARCHIVE_DIR)
24
+ @config = config
25
+ @optimizer = nil
26
+ @last_optimization_stats = nil
27
+
28
+ # Initialize optimizer if enabled
29
+ if config&.respond_to?(:prompt_optimization_enabled?) && config.prompt_optimization_enabled?
30
+ @optimizer = Aidp::PromptOptimization::Optimizer.new(
31
+ project_dir: project_dir,
32
+ config: config.prompt_optimization_config
33
+ )
34
+ end
20
35
  end
21
36
 
22
37
  # Write content to PROMPT.md
38
+ # If optimization is enabled, stores the content but doesn't write yet
39
+ # (use write_optimized instead)
23
40
  def write(content)
24
41
  File.write(@prompt_path, content)
25
42
  end
26
43
 
44
+ # Write optimized prompt using intelligent fragment selection
45
+ #
46
+ # Uses Zero Framework Cognition to select only the most relevant fragments
47
+ # from style guides, templates, and source code based on task context.
48
+ #
49
+ # @param task_context [Hash] Context about the current task
50
+ # @option task_context [Symbol] :task_type Type of task (:feature, :bugfix, etc.)
51
+ # @option task_context [String] :description Task description
52
+ # @option task_context [Array<String>] :affected_files Files being modified
53
+ # @option task_context [String] :step_name Current work loop step
54
+ # @option task_context [Array<String>] :tags Additional context tags
55
+ # @param options [Hash] Optimization options
56
+ # @option options [Boolean] :include_metadata Include debug metadata
57
+ # @return [Boolean] True if optimization was used, false if fallback to regular write
58
+ def write_optimized(task_context, options = {})
59
+ unless @optimizer
60
+ Aidp.logger.warn("prompt_manager", "Optimization requested but not enabled")
61
+ return false
62
+ end
63
+
64
+ begin
65
+ # Use optimizer to build intelligent prompt
66
+ result = @optimizer.optimize_prompt(
67
+ task_type: task_context[:task_type],
68
+ description: task_context[:description],
69
+ affected_files: task_context[:affected_files] || [],
70
+ step_name: task_context[:step_name],
71
+ tags: task_context[:tags] || [],
72
+ options: options
73
+ )
74
+
75
+ # Write optimized prompt
76
+ result.write_to_file(@prompt_path)
77
+
78
+ # Store statistics for inspection
79
+ @last_optimization_stats = result.composition_result
80
+
81
+ # Log optimization results
82
+ Aidp.logger.info(
83
+ "prompt_manager",
84
+ "Optimized prompt written",
85
+ selected_fragments: result.composition_result.selected_count,
86
+ excluded_fragments: result.composition_result.excluded_count,
87
+ tokens: result.estimated_tokens,
88
+ budget_utilization: result.composition_result.budget_utilization
89
+ )
90
+
91
+ true
92
+ rescue => e
93
+ Aidp.logger.error("prompt_manager", "Optimization failed, using fallback", error: e.message)
94
+ false
95
+ end
96
+ end
97
+
27
98
  # Read content from PROMPT.md
28
99
  def read
29
100
  return nil unless exists?
@@ -57,6 +128,62 @@ module Aidp
57
128
  def path
58
129
  @prompt_path
59
130
  end
131
+
132
+ # Get optimization report for last optimization
133
+ #
134
+ # @return [String, nil] Markdown report or nil if no optimization performed
135
+ def optimization_report
136
+ return nil unless @last_optimization_stats
137
+
138
+ # Build report from composition result
139
+ lines = []
140
+ lines << "# Prompt Optimization Report"
141
+ lines << ""
142
+ lines << "## Statistics"
143
+ lines << "- **Selected Fragments**: #{@last_optimization_stats.selected_count}"
144
+ lines << "- **Excluded Fragments**: #{@last_optimization_stats.excluded_count}"
145
+ lines << "- **Total Tokens**: #{@last_optimization_stats.total_tokens} / #{@last_optimization_stats.budget}"
146
+ lines << "- **Budget Utilization**: #{@last_optimization_stats.budget_utilization.round(1)}%"
147
+ lines << "- **Average Relevance Score**: #{(@last_optimization_stats.average_score * 100).round(1)}%"
148
+ lines << ""
149
+ lines << "## Selected Fragments"
150
+ @last_optimization_stats.selected_fragments.each do |scored|
151
+ fragment = scored[:fragment]
152
+ score = scored[:score]
153
+ lines << "- #{fragment_name(fragment)} (#{(score * 100).round(0)}%)"
154
+ end
155
+
156
+ lines.join("\n")
157
+ end
158
+
159
+ # Check if optimization is enabled
160
+ #
161
+ # @return [Boolean] True if optimizer is available
162
+ def optimization_enabled?
163
+ !@optimizer.nil?
164
+ end
165
+
166
+ # Get optimizer statistics
167
+ #
168
+ # @return [Hash, nil] Statistics hash or nil if optimizer not available
169
+ def optimizer_stats
170
+ @optimizer&.statistics
171
+ end
172
+
173
+ private
174
+
175
+ # Get human-readable name for a fragment
176
+ def fragment_name(fragment)
177
+ if fragment.respond_to?(:heading)
178
+ fragment.heading
179
+ elsif fragment.respond_to?(:name)
180
+ fragment.name
181
+ elsif fragment.respond_to?(:id)
182
+ fragment.id
183
+ else
184
+ "Unknown fragment"
185
+ end
186
+ end
60
187
  end
61
188
  end
62
189
  end