aidp 0.41.0 → 0.43.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3f3d8cc88b8d264095f965487244c597be15336e889a57babebf8bc6da70cb17
4
- data.tar.gz: c1cb36afa4ae243874087152715829f72f72a8a530d8168c872cbe36367a7659
3
+ metadata.gz: 013dc044889cc320404776d0691d2091f3cddda0d9ba854dc0afb92312396336
4
+ data.tar.gz: bb1c57bf8e7d87dfa7d7b169d69646ac76c0cb14fe2f72c70ef02b8543b43494
5
5
  SHA512:
6
- metadata.gz: d8f4bd6b9900a418406f72e34c52daecca793284f137ea28227503d723495cf9c7a3f03910474bce6d460c37f51773c16e96a012612680342791b41e0cbd076f
7
- data.tar.gz: b797ddd64998481040f02a10a830fab2f7f739fd83e0d7d69b97cbc3a77816e3d1781a97910d57cebcabf2f6fe76c214a82650fec575f8a412c9a831e68e6602
6
+ metadata.gz: c7ea760cd1d62424ada60f64acc356cdf4a82db6e2166c203d9d41c489fed936a704afaee17e9a20d588fbf0ead8ebd816a991a3fa01d87644939c872b104b12
7
+ data.tar.gz: 9c3b34d7474a2f9e899bf50d9b61d1edcb67a1b4a7b41d78abf810517db8cb2082fe889ec7b1fda5266276b6f1406e577644f2ddca16142bcf66e66e79b1e338
@@ -6,6 +6,7 @@ require "tty-spinner"
6
6
  require_relative "../harness/provider_info"
7
7
  require_relative "../harness/capability_registry"
8
8
  require_relative "../harness/config_manager"
9
+ require_relative "../security"
9
10
 
10
11
  module Aidp
11
12
  class CLI
@@ -21,12 +22,13 @@ module Aidp
21
22
  class ProvidersCommand
22
23
  include Aidp::MessageDisplay
23
24
 
24
- def initialize(prompt: TTY::Prompt.new, provider_info_class: nil, capability_registry_class: nil, config_manager_class: nil, project_dir: nil)
25
+ def initialize(prompt: TTY::Prompt.new, provider_info_class: nil, capability_registry_class: nil, config_manager_class: nil, project_dir: nil, mcp_tool_risk_classifier_class: nil)
25
26
  @prompt = prompt
26
27
  @provider_info_class = provider_info_class || Aidp::Harness::ProviderInfo
27
28
  @capability_registry_class = capability_registry_class || Aidp::Harness::CapabilityRegistry
28
29
  @config_manager_class = config_manager_class || Aidp::Harness::ConfigManager
29
30
  @project_dir = project_dir || Dir.pwd
31
+ @mcp_tool_risk_classifier_class = mcp_tool_risk_classifier_class || Aidp::Security::McpToolRiskClassifier
30
32
  end
31
33
 
32
34
  # Main entry point for providers info/refresh subcommands
@@ -198,10 +200,23 @@ module Aidp
198
200
  end
199
201
  end
200
202
 
203
+ refresh_mcp_risk_profile(config_manager, providers_to_refresh)
204
+
201
205
  display_message("\n" + "=" * 60, type: :muted)
202
206
  display_message("Refresh complete", type: :highlight)
203
207
  end
204
208
 
209
+ def refresh_mcp_risk_profile(config_manager, providers_to_refresh)
210
+ classifier = @mcp_tool_risk_classifier_class.new(config_manager, project_dir: @project_dir)
211
+ classifier.generate!(
212
+ providers: config_manager.provider_names,
213
+ force_refresh_providers: providers_to_refresh
214
+ )
215
+ display_message("✓ MCP risk profile refreshed", type: :success)
216
+ rescue => e
217
+ display_message("⚠ MCP risk profile refresh failed: #{e.message}", type: :warning)
218
+ end
219
+
205
220
  def display_help
206
221
  display_message("\nUsage: aidp providers <subcommand> [options]", type: :info)
207
222
  display_message("\nSubcommands:", type: :info)
@@ -32,6 +32,7 @@ module Aidp
32
32
  # Security module paths
33
33
  def self.security_dir(project_dir = Dir.pwd) = File.join(aidp_dir(project_dir), "security")
34
34
  def self.secrets_registry_file(project_dir = Dir.pwd) = File.join(security_dir(project_dir), "secrets_registry.json")
35
+ def self.mcp_risk_profile_file(project_dir = Dir.pwd) = File.join(security_dir(project_dir), "mcp_risk_profile.yml")
35
36
 
36
37
  # Database path
37
38
  def self.database_file(project_dir = Dir.pwd) = File.join(aidp_dir(project_dir), "aidp.db")
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "shellwords"
5
+
6
+ module Aidp
7
+ module Execute
8
+ # Creates and refreshes concise feature/tool notes under docs/
9
+ class ProjectKnowledgeManager
10
+ MAX_TOOL_NOTES = 5
11
+ FEATURE_ROOT_SEGMENTS = %w[app client lib packages server src].freeze
12
+
13
+ def initialize(project_dir)
14
+ @project_dir = project_dir
15
+ end
16
+
17
+ def sync!(step_name:, task_description:, affected_files:, tool_commands:, feature_identifier: nil)
18
+ sync_feature_note(
19
+ feature_identifier: feature_identifier,
20
+ task_description: task_description,
21
+ affected_files: affected_files
22
+ )
23
+ sync_tool_notes(step_name: step_name, affected_files: affected_files, tool_commands: tool_commands)
24
+ end
25
+
26
+ private
27
+
28
+ def sync_feature_note(feature_identifier:, task_description:, affected_files:)
29
+ return if affected_files.empty?
30
+
31
+ feature_id = resolve_feature_id(
32
+ feature_identifier: feature_identifier,
33
+ affected_files: affected_files
34
+ )
35
+ return if feature_id.empty?
36
+
37
+ path = File.join(@project_dir, "docs", "features", "#{feature_id}.md")
38
+ markers = affected_files.map { |file| "<#{feature_id}:#{file}>" }
39
+ learning = bullet_line("#{Time.now.utc.iso8601}: #{learning_summary(task_description)}")
40
+
41
+ content = if File.exist?(path)
42
+ update_existing_note(File.read(path, encoding: "UTF-8"), markers: markers, learning: learning)
43
+ else
44
+ build_feature_note(feature_id: feature_id, markers: markers, learning: learning)
45
+ end
46
+
47
+ write_note(path, content)
48
+ end
49
+
50
+ def sync_tool_notes(step_name:, affected_files:, tool_commands:)
51
+ tool_commands.first(MAX_TOOL_NOTES).each do |command|
52
+ tool_id = tool_identifier(command)
53
+ next if tool_id.empty?
54
+
55
+ path = File.join(@project_dir, "docs", "tools", "#{tool_id}.md")
56
+ commands = [bullet_line("`#{command}`")]
57
+ markers = affected_files.map { |file| "<#{tool_id}:#{file}>" }
58
+ learning = bullet_line("#{Time.now.utc.iso8601}: Used `#{command}` during `#{step_name}`.")
59
+
60
+ content = if File.exist?(path)
61
+ update_existing_note(File.read(path, encoding: "UTF-8"), markers: markers, learning: learning, commands: commands)
62
+ else
63
+ build_tool_note(commands: commands, tool_id: tool_id, markers: markers, learning: learning)
64
+ end
65
+
66
+ write_note(path, content)
67
+ end
68
+ end
69
+
70
+ def build_feature_note(feature_id:, markers:, learning:)
71
+ <<~MARKDOWN
72
+ # #{humanize(feature_id)}
73
+
74
+ ## Paths
75
+ #{markers.join("\n")}
76
+
77
+ ## Purpose
78
+ - Concise feature context for work touching these paths.
79
+
80
+ ## Users
81
+ - Add the end users or personas when they become clear.
82
+
83
+ ## Known Gaps
84
+ - Add concrete gaps when discovered.
85
+
86
+ ## Key Decisions
87
+ - Add behavior-affecting decisions when they become clear.
88
+
89
+ ## Recent Learnings
90
+ #{learning}
91
+ MARKDOWN
92
+ end
93
+
94
+ def build_tool_note(commands:, tool_id:, markers:, learning:)
95
+ paths_section = markers.empty? ? "- Add affected paths as they become known." : markers.join("\n")
96
+
97
+ <<~MARKDOWN
98
+ # #{humanize(tool_id)}
99
+
100
+ ## Commands
101
+ #{commands.join("\n")}
102
+
103
+ ## Paths
104
+ #{paths_section}
105
+
106
+ ## Intended Use
107
+ - Run this tool while validating or updating related paths.
108
+
109
+ ## Known Gaps
110
+ - Add environment quirks or failure modes when discovered.
111
+
112
+ ## Recent Learnings
113
+ #{learning}
114
+ MARKDOWN
115
+ end
116
+
117
+ def update_existing_note(content, markers:, learning:, commands: [])
118
+ updated = ensure_section_entries(content, "Commands", commands)
119
+ updated = ensure_section_entries(updated, "Paths", markers)
120
+ ensure_section_entries(updated, "Recent Learnings", [learning], normalizer: method(:normalize_learning_entry))
121
+ end
122
+
123
+ def ensure_section_entries(content, section_name, entries, normalizer: nil)
124
+ return content if entries.empty?
125
+
126
+ existing_section = content.match(/(## #{Regexp.escape(section_name)}\n)(.*?)(?=\n## |\z)/m)
127
+ return append_new_section(content, section_name, entries) unless existing_section
128
+
129
+ body = existing_section[2].to_s
130
+ existing_entries = body.lines.map(&:strip).reject(&:empty?)
131
+ normalized_existing = normalize_section_entries(existing_entries, normalizer)
132
+ missing = entries.reject do |entry|
133
+ normalized_existing.include?(normalize_section_entry(entry, normalizer))
134
+ end
135
+ return content if missing.empty?
136
+
137
+ replacement = "#{existing_section[1]}#{body.rstrip}\n#{missing.join("\n")}\n"
138
+ content.sub(existing_section[0], replacement)
139
+ end
140
+
141
+ def append_new_section(content, section_name, entries)
142
+ [content.rstrip, "", "## #{section_name}", entries.join("\n"), ""].join("\n")
143
+ end
144
+
145
+ def write_note(path, content)
146
+ FileUtils.mkdir_p(File.dirname(path))
147
+ File.write(path, content)
148
+ end
149
+
150
+ def resolve_feature_id(feature_identifier:, affected_files:)
151
+ explicit_id = normalize_id(feature_identifier)
152
+ return explicit_id unless explicit_id.empty?
153
+
154
+ derived_id = normalize_id(feature_path_identifier(affected_files))
155
+ return derived_id unless derived_id.empty?
156
+
157
+ ""
158
+ end
159
+
160
+ def feature_path_identifier(affected_files)
161
+ feature_files = affected_files.select { |file| feature_path?(file) }
162
+ segments = feature_files.map { |file| trimmed_feature_segments(file) }
163
+ return "" if segments.empty?
164
+
165
+ shared_segments = meaningful_segments(common_path_segments(segments))
166
+ return shared_segments.join("_") unless shared_segments.empty?
167
+
168
+ meaningful_segments(segments.first).join("_")
169
+ end
170
+
171
+ def feature_path?(file)
172
+ path_segments = file.to_s.split("/")
173
+ FEATURE_ROOT_SEGMENTS.include?(path_segments.first) && path_segments.length > 1
174
+ end
175
+
176
+ def common_path_segments(all_segments)
177
+ shared = []
178
+ max_length = all_segments.map(&:length).min
179
+
180
+ max_length.times do |index|
181
+ segment = all_segments.first[index]
182
+ break unless all_segments.all? { |segments| segments[index] == segment }
183
+
184
+ shared << segment
185
+ end
186
+
187
+ shared
188
+ end
189
+
190
+ def meaningful_segments(path_segments)
191
+ path_segments.map { |segment| segment.sub(/\.[^.]+\z/, "") }
192
+ .reject { |segment| generic_path_segment?(segment) }
193
+ .reject(&:empty?)
194
+ end
195
+
196
+ def trimmed_feature_segments(file)
197
+ segments = file.to_s.split("/")
198
+ return segments unless generic_path_segment?(segments.first)
199
+
200
+ segments.drop(1)
201
+ end
202
+
203
+ def generic_path_segment?(segment)
204
+ %w[app client docs lib packages server spec src test].include?(segment)
205
+ end
206
+
207
+ def normalize_id(value)
208
+ value.to_s.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/\A_|_\z/, "")
209
+ end
210
+
211
+ def learning_summary(task_description)
212
+ single_line = task_description.to_s.encode("UTF-8", invalid: :replace, undef: :replace)
213
+ .lines
214
+ .map(&:strip)
215
+ .reject(&:empty?)
216
+ .join(" ")
217
+ .gsub(/\s+/, " ")
218
+
219
+ return "Updated feature context." if single_line.empty?
220
+ return single_line if single_line.length <= 200
221
+
222
+ "#{single_line[0, 197].rstrip}..."
223
+ end
224
+
225
+ def normalize_section_entries(entries, normalizer)
226
+ entries.map { |entry| normalize_section_entry(entry, normalizer) }
227
+ end
228
+
229
+ def normalize_section_entry(entry, normalizer)
230
+ return entry unless normalizer
231
+
232
+ normalizer.call(entry)
233
+ end
234
+
235
+ def normalize_learning_entry(entry)
236
+ entry.to_s.sub(/\A-\s*/, "").sub(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z:\s*/, "")
237
+ end
238
+
239
+ def tool_identifier(command)
240
+ raw_command = command.to_s.strip
241
+ tokens = Shellwords.split(raw_command)
242
+ executable = unwrap_tool_command(strip_env_assignments(tokens))
243
+ normalize_id(executable)
244
+ rescue ArgumentError
245
+ normalize_id(raw_command.split(/\s+/).first)
246
+ end
247
+
248
+ def unwrap_tool_command(tokens)
249
+ command_tokens = tokens.dup
250
+
251
+ loop do
252
+ return "" if command_tokens.empty?
253
+
254
+ case command_tokens.first
255
+ when "mise"
256
+ command_tokens = unwrap_mise_exec(command_tokens)
257
+ when "env", "/usr/bin/env"
258
+ command_tokens = unwrap_env(command_tokens)
259
+ when "bundle"
260
+ return File.basename(command_tokens.first, ".rb") unless command_tokens[1] == "exec"
261
+
262
+ command_tokens = command_tokens.drop(2)
263
+ when "ruby"
264
+ return File.basename(command_tokens[1], ".rb") if command_tokens[1]
265
+
266
+ command_tokens = []
267
+ else
268
+ return File.basename(command_tokens.first, ".rb")
269
+ end
270
+ end
271
+ end
272
+
273
+ def strip_env_assignments(tokens)
274
+ tokens.drop_while { |token| token.include?("=") && !token.start_with?("/", "./", "../") }
275
+ end
276
+
277
+ def unwrap_mise_exec(tokens)
278
+ return tokens unless tokens[1] == "exec"
279
+
280
+ separator_index = tokens.index("--")
281
+ remaining = separator_index ? tokens.drop(separator_index + 1) : tokens.drop(2)
282
+ strip_env_assignments(remaining)
283
+ end
284
+
285
+ def unwrap_env(tokens)
286
+ remaining = tokens.drop(1)
287
+ remaining = remaining.drop_while { |token| token.start_with?("-") }
288
+ strip_env_assignments(remaining)
289
+ end
290
+
291
+ def humanize(value)
292
+ value.to_s.split(/[_\s]+/).map(&:capitalize).join(" ")
293
+ end
294
+
295
+ def bullet_line(value)
296
+ "- #{value}"
297
+ end
298
+ end
299
+ end
300
+ end