aidp 0.41.0 → 0.42.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: f44b47ad0d31015794ec7b2286dc659e85061eb3e6dad8c89ae018976e0d9b1d
4
+ data.tar.gz: 6d8b28a431c3d55e037e32d0e54e583313af4d495982578b72ab3374155323c5
5
5
  SHA512:
6
- metadata.gz: d8f4bd6b9900a418406f72e34c52daecca793284f137ea28227503d723495cf9c7a3f03910474bce6d460c37f51773c16e96a012612680342791b41e0cbd076f
7
- data.tar.gz: b797ddd64998481040f02a10a830fab2f7f739fd83e0d7d69b97cbc3a77816e3d1781a97910d57cebcabf2f6fe76c214a82650fec575f8a412c9a831e68e6602
6
+ metadata.gz: d2f838a10c7789f69a253858d0d71aee754a24020722a845eba38cd29eb764be247fee615427afe67f13618cec384565c04cf0f25a25ddd2493962df0df3e0af
7
+ data.tar.gz: 616bf1a6910461b907c5ab99bccb8dddc3312ee7816550d22d31c395b9faecb90e97bd4e5a032ba594a5d78538d9199a0ffbee203a8fe5c08ae8f784ae47b84e
@@ -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
@@ -8,6 +8,7 @@ require_relative "guard_policy"
8
8
  require_relative "work_loop_unit_scheduler"
9
9
  require_relative "deterministic_unit"
10
10
  require_relative "agent_signal_parser"
11
+ require_relative "project_knowledge_manager"
11
12
  require_relative "steps"
12
13
  require_relative "../harness/test_runner"
13
14
  require_relative "../errors"
@@ -61,7 +62,10 @@ module Aidp
61
62
  @checkpoint = Checkpoint.new(project_dir)
62
63
  @checkpoint_display = CheckpointDisplay.new(prompt: @prompt)
63
64
  @guard_policy = GuardPolicy.new(project_dir, config.guards_config)
65
+ @project_knowledge_manager = options[:project_knowledge_manager] || ProjectKnowledgeManager.new(project_dir)
64
66
  @work_context = {}
67
+ @executed_tool_commands = []
68
+ @agentic_execution_results = []
65
69
  @persistent_tasklist = PersistentTasklist.new(project_dir)
66
70
  @iteration_count = 0
67
71
  @step_name = nil
@@ -100,6 +104,8 @@ module Aidp
100
104
  def execute_step(step_name, step_spec, context = {})
101
105
  @step_name = step_name
102
106
  @work_context = context
107
+ @executed_tool_commands = []
108
+ @agentic_execution_results = []
103
109
  @iteration_count = 0
104
110
  transition_to(:ready)
105
111
 
@@ -572,6 +578,7 @@ module Aidp
572
578
  tier: @thinking_depth_manager.current_tier
573
579
  }
574
580
  )
581
+ record_agentic_execution_result(agent_result)
575
582
 
576
583
  requested = AgentSignalParser.extract_next_unit(agent_result[:output])
577
584
 
@@ -605,6 +612,7 @@ module Aidp
605
612
  tier: @thinking_depth_manager.current_tier
606
613
  }
607
614
  )
615
+ record_agentic_execution_result(agent_result)
608
616
 
609
617
  requested = AgentSignalParser.extract_next_unit(agent_result[:output])
610
618
 
@@ -778,11 +786,13 @@ module Aidp
778
786
  if @config.respond_to?(:commands) && @config.commands.any?
779
787
  # New phase-based execution
780
788
  each_unit_results = @test_runner.run_commands_for_phase(:each_unit)
789
+ record_phase_commands(:each_unit, each_unit_results)
781
790
  all_results[:each_unit] = each_unit_results
782
791
 
783
792
  # Run on_completion commands only if agent marked work complete
784
793
  if agent_marked_complete?(agent_result)
785
794
  on_completion_results = @test_runner.run_commands_for_phase(:on_completion)
795
+ record_phase_commands(:on_completion, on_completion_results)
786
796
  all_results[:on_completion] = on_completion_results
787
797
  else
788
798
  all_results[:on_completion] = {
@@ -810,6 +820,7 @@ module Aidp
810
820
  return {success: true, output: "", failures: [], required_failures: []} unless @config.respond_to?(:commands)
811
821
 
812
822
  full_loop_results = @test_runner.run_commands_for_phase(:full_loop)
823
+ record_phase_commands(:full_loop, full_loop_results)
813
824
 
814
825
  Aidp.log_debug("work_loop", "ran_full_loop_commands",
815
826
  success: full_loop_results[:success],
@@ -821,13 +832,17 @@ module Aidp
821
832
  # Run commands using legacy category-based approach (backwards compatibility)
822
833
  def run_legacy_category_commands(agent_result)
823
834
  test_results = @test_runner.run_tests
835
+ record_legacy_category_commands(:tests)
824
836
  lint_results = @test_runner.run_linters
837
+ record_legacy_category_commands(:lints)
825
838
  build_results = @test_runner.run_builds
839
+ record_legacy_category_commands(:builds)
826
840
  doc_results = @test_runner.run_documentation
841
+ record_legacy_category_commands(:docs)
827
842
 
828
843
  # Run formatters only if agent marked work complete (per issue #234)
829
844
  formatter_results = if agent_marked_complete?(agent_result)
830
- @test_runner.run_formatters
845
+ @test_runner.run_formatters.tap { record_legacy_category_commands(:formatters) }
831
846
  else
832
847
  {success: true, output: "Formatters: Skipped (work not complete)", failures: [], required_failures: []}
833
848
  end
@@ -959,20 +974,8 @@ module Aidp
959
974
 
960
975
  # Extract files that will be affected by this work
961
976
  def extract_affected_files(context, user_input)
962
- files = []
963
-
964
- # From user input (e.g., "update lib/user.rb")
965
- user_input&.scan(/[\w\/]+\.rb/)&.each do |file|
966
- files << file
967
- end
968
-
969
- # From deterministic outputs
970
- context[:deterministic_outputs]&.each do |output|
971
- if output[:output_path]&.end_with?(".rb")
972
- files << output[:output_path]
973
- end
974
- end
975
-
977
+ files = extract_paths_from_text(user_input)
978
+ files.concat(extract_output_paths(context[:deterministic_outputs]))
976
979
  files.uniq
977
980
  end
978
981
 
@@ -1110,6 +1113,7 @@ module Aidp
1110
1113
  execute_block.call
1111
1114
  end
1112
1115
  end
1116
+ .tap { |result| record_agentic_execution_result(result) }
1113
1117
  end
1114
1118
 
1115
1119
  def display_iteration_overview(provider_name, model_name, prompt_length, checks_summary = nil)
@@ -1515,10 +1519,283 @@ module Aidp
1515
1519
  end
1516
1520
 
1517
1521
  def archive_and_cleanup
1522
+ sync_project_knowledge
1518
1523
  @prompt_manager.archive(@step_name)
1519
1524
  @prompt_manager.delete
1520
1525
  end
1521
1526
 
1527
+ def sync_project_knowledge
1528
+ affected_files = knowledge_affected_files
1529
+ return if affected_files.empty?
1530
+
1531
+ @project_knowledge_manager.sync!(
1532
+ step_name: @step_name,
1533
+ feature_identifier: knowledge_feature_identifier,
1534
+ task_description: build_task_description(format_user_input(@work_context[:user_input]), @work_context),
1535
+ affected_files: affected_files,
1536
+ tool_commands: knowledge_tool_commands
1537
+ )
1538
+ rescue => e
1539
+ Aidp.logger.warn("work_loop", "project_knowledge_sync_failed",
1540
+ step: @step_name,
1541
+ error: e.message)
1542
+ end
1543
+
1544
+ def knowledge_affected_files
1545
+ files = Array(@work_context[:affected_files])
1546
+ files.concat(extract_affected_files(@work_context, format_user_input(@work_context[:user_input])))
1547
+ files.concat(metadata_affected_files)
1548
+ files.concat(get_changed_files) if files.empty?
1549
+ files.map(&:to_s).select { |path| knowledge_path_candidate?(path) }.uniq
1550
+ end
1551
+
1552
+ def extract_paths_from_text(text)
1553
+ return [] if text.nil?
1554
+
1555
+ text.to_s.split.filter_map do |token|
1556
+ path = trim_path_token(token)
1557
+ path if valid_path_candidate?(path)
1558
+ end
1559
+ end
1560
+
1561
+ def extract_output_paths(outputs)
1562
+ Array(outputs).filter_map do |output|
1563
+ path = output[:output_path].to_s.strip
1564
+ path unless path.empty?
1565
+ end
1566
+ end
1567
+
1568
+ def knowledge_tool_commands
1569
+ metadata_tool_commands.concat(@executed_tool_commands).uniq
1570
+ end
1571
+
1572
+ def record_agentic_execution_result(result)
1573
+ return unless result.is_a?(Hash)
1574
+
1575
+ @agentic_execution_results << result
1576
+ end
1577
+
1578
+ def knowledge_feature_identifier
1579
+ user_input = @work_context[:user_input]
1580
+
1581
+ @work_context[:feature_identifier] ||
1582
+ @work_context[:feature_id] ||
1583
+ extract_feature_identifier(user_input)
1584
+ end
1585
+
1586
+ def record_phase_commands(phase, results)
1587
+ commands_by_name = Array(@config.commands_for_phase(phase)).each_with_object({}) do |entry, indexed|
1588
+ indexed[entry[:name]] = entry
1589
+ end
1590
+ executed = results.fetch(:results_by_command, {}).keys.filter_map do |name|
1591
+ commands_by_name[name]&.fetch(:command, nil)
1592
+ end
1593
+ executed = commands_by_name.values.map { |entry| entry[:command] } if executed.empty? && commands_by_name.any?
1594
+ @executed_tool_commands.concat(executed.compact)
1595
+ end
1596
+
1597
+ def record_legacy_category_commands(category)
1598
+ return unless @test_runner.respond_to?(:planned_commands)
1599
+
1600
+ commands = Array(@test_runner.planned_commands[category]).filter_map do |entry|
1601
+ entry.is_a?(String) ? entry : entry[:command]
1602
+ end
1603
+ @executed_tool_commands.concat(commands)
1604
+ end
1605
+
1606
+ def extract_feature_identifier(user_input)
1607
+ return unless user_input.is_a?(Hash)
1608
+
1609
+ user_input["feature_identifier"] ||
1610
+ user_input[:feature_identifier] ||
1611
+ user_input["feature_id"] ||
1612
+ user_input[:feature_id]
1613
+ end
1614
+
1615
+ def metadata_affected_files
1616
+ metadata_entries = metadata_value_objects
1617
+ files = extract_file_entries(metadata_entries, :affected_files)
1618
+ files.concat(extract_file_entries(metadata_entries, :changed_files))
1619
+ files.concat(extract_file_entries(metadata_entries, :edited_files))
1620
+ files.concat(extract_file_entries(metadata_entries, :files_changed))
1621
+ files.concat(extract_file_entries(metadata_entries, :modified_files))
1622
+ files.concat(extract_file_entries(metadata_entries, :touched_files))
1623
+ files.uniq
1624
+ end
1625
+
1626
+ def metadata_tool_commands
1627
+ metadata_entries = metadata_value_objects
1628
+ commands = extract_command_entries(metadata_entries, :tool_commands)
1629
+ commands.concat(extract_command_entries(metadata_entries, :tool_usage))
1630
+ commands.concat(extract_command_entries(metadata_entries, :tool_calls))
1631
+ commands.concat(extract_command_entries(metadata_entries, :tools_used))
1632
+ commands.concat(extract_command_entries(metadata_entries, :commands))
1633
+ commands.uniq
1634
+ end
1635
+
1636
+ def metadata_value_objects
1637
+ Array(@agentic_execution_results).flat_map do |result|
1638
+ [
1639
+ result,
1640
+ indifferent_hash_value(result, :metadata),
1641
+ indifferent_hash_value(result, :output)
1642
+ ]
1643
+ end.compact.select { |entry| entry.is_a?(Hash) }
1644
+ end
1645
+
1646
+ def extract_file_entries(entries, key)
1647
+ entries.flat_map do |entry|
1648
+ Array(indifferent_hash_value(entry, key)).flat_map do |value|
1649
+ case value
1650
+ when String
1651
+ [value]
1652
+ when Hash
1653
+ candidate = file_candidate_from_hash(value)
1654
+ candidate ? [candidate] : []
1655
+ else
1656
+ []
1657
+ end
1658
+ end
1659
+ end
1660
+ end
1661
+
1662
+ def extract_command_entries(entries, key)
1663
+ entries.flat_map do |entry|
1664
+ Array(indifferent_hash_value(entry, key)).flat_map do |value|
1665
+ case value
1666
+ when String
1667
+ [value]
1668
+ when Hash
1669
+ candidate = command_candidate_from_hash(value)
1670
+ candidate ? [candidate] : []
1671
+ else
1672
+ []
1673
+ end
1674
+ end
1675
+ end.reject(&:empty?)
1676
+ end
1677
+
1678
+ def indifferent_hash_value(hash, key)
1679
+ hash[key] || hash[key.to_s]
1680
+ end
1681
+
1682
+ def file_candidate_from_hash(hash)
1683
+ %i[path file file_path output_path relative_path].each do |key|
1684
+ candidate = indifferent_hash_value(hash, key).to_s.strip
1685
+ return candidate unless candidate.empty?
1686
+ end
1687
+
1688
+ nil
1689
+ end
1690
+
1691
+ def command_candidate_from_hash(hash)
1692
+ %i[command tool name identifier].each do |key|
1693
+ candidate = indifferent_hash_value(hash, key).to_s.strip
1694
+ return candidate unless candidate.empty?
1695
+ end
1696
+
1697
+ nil
1698
+ end
1699
+
1700
+ def trim_path_token(token)
1701
+ text = token.to_s
1702
+ start_index = 0
1703
+ end_index = text.length - 1
1704
+
1705
+ start_index += 1 while start_index <= end_index && !path_character?(text[start_index])
1706
+ end_index -= 1 while end_index >= start_index && !path_character?(text[end_index])
1707
+
1708
+ return "" if start_index > end_index
1709
+
1710
+ text[start_index..end_index]
1711
+ end
1712
+
1713
+ def knowledge_path_candidate?(path)
1714
+ return false if path.empty?
1715
+ return false if internal_generated_path?(path)
1716
+
1717
+ true
1718
+ end
1719
+
1720
+ def internal_generated_path?(path)
1721
+ path == ".aidp" || path.start_with?(".aidp/")
1722
+ end
1723
+
1724
+ def path_character?(char)
1725
+ alphanumeric_character?(char) || ["_", ".", "/", "-"].include?(char)
1726
+ end
1727
+
1728
+ def valid_path_candidate?(path)
1729
+ return false if path.empty?
1730
+ return false if standalone_version_token?(path)
1731
+
1732
+ segments = path.split("/")
1733
+ return false if segments.empty? || segments.any?(&:empty?)
1734
+
1735
+ filename = segments.last
1736
+ return false unless valid_filename_candidate?(filename, path.include?("/"))
1737
+
1738
+ segments.all? { |segment| valid_path_segment?(segment) }
1739
+ end
1740
+
1741
+ def standalone_version_token?(path)
1742
+ return false if path.include?("/")
1743
+
1744
+ components = path.split(".")
1745
+ return false if components.length < 2
1746
+
1747
+ components.all? { |component| version_component?(component) }
1748
+ end
1749
+
1750
+ def version_component?(component)
1751
+ component.match?(/\Av?\d+\z/i)
1752
+ end
1753
+
1754
+ def valid_path_segment?(segment)
1755
+ return false if segment == "." || segment == ".."
1756
+
1757
+ segment.each_char.all? do |char|
1758
+ alphanumeric_character?(char) || ["_", ".", "-"].include?(char)
1759
+ end
1760
+ end
1761
+
1762
+ def valid_filename_candidate?(filename, nested_path)
1763
+ return valid_extension_filename?(filename) if filename.include?(".")
1764
+
1765
+ return true if nested_path
1766
+
1767
+ known_extensionless_filename?(filename)
1768
+ end
1769
+
1770
+ def valid_extension_filename?(filename)
1771
+ name, _separator, extension = filename.rpartition(".")
1772
+ return false if name.to_s.empty? || extension.to_s.empty?
1773
+
1774
+ extension.each_char.all? { |char| alphanumeric_character?(char) }
1775
+ end
1776
+
1777
+ def known_extensionless_filename?(filename)
1778
+ %w[
1779
+ Dockerfile
1780
+ Gemfile
1781
+ Procfile
1782
+ Rakefile
1783
+ Makefile
1784
+ Brewfile
1785
+ Podfile
1786
+ Fastfile
1787
+ Appfile
1788
+ Guardfile
1789
+ Vagrantfile
1790
+ ].include?(filename)
1791
+ end
1792
+
1793
+ def alphanumeric_character?(char)
1794
+ char.between?("a", "z") ||
1795
+ char.between?("A", "Z") ||
1796
+ char.between?("0", "9")
1797
+ end
1798
+
1522
1799
  def load_template(template_name)
1523
1800
  return "" unless template_name
1524
1801
 
@@ -115,6 +115,8 @@ module Aidp
115
115
  def meets_threshold?(fragment, score, thresholds)
116
116
  threshold = if fragment.class.name.include?("Fragment") && fragment.respond_to?(:heading)
117
117
  thresholds[:style_guide] || 0.75
118
+ elsif fragment.respond_to?(:linked_paths)
119
+ thresholds[:knowledge] || 0.72
118
120
  elsif fragment.respond_to?(:category)
119
121
  thresholds[:templates] || 0.8
120
122
  elsif fragment.respond_to?(:file_path) && fragment.respond_to?(:type)
@@ -248,6 +250,8 @@ module Aidp
248
250
  case type
249
251
  when :style_guide
250
252
  item[:fragment].class.name.include?("Fragment") && item[:fragment].respond_to?(:heading)
253
+ when :project_knowledge
254
+ item[:fragment].respond_to?(:linked_paths)
251
255
  when :template
252
256
  item[:fragment].respond_to?(:category) && !item[:fragment].respond_to?(:type)
253
257
  when :code
@@ -272,6 +276,7 @@ module Aidp
272
276
  over_budget: over_budget?,
273
277
  by_type: {
274
278
  style_guide: fragments_by_type(:style_guide).count,
279
+ project_knowledge: fragments_by_type(:project_knowledge).count,
275
280
  templates: fragments_by_type(:template).count,
276
281
  code: fragments_by_type(:code).count
277
282
  }
@@ -6,6 +6,7 @@ require_relative "source_code_fragmenter"
6
6
  require_relative "relevance_scorer"
7
7
  require_relative "context_composer"
8
8
  require_relative "prompt_builder"
9
+ require_relative "project_knowledge_indexer"
9
10
 
10
11
  module Aidp
11
12
  module PromptOptimization
@@ -38,6 +39,7 @@ module Aidp
38
39
  @style_guide_indexer = nil
39
40
  @template_indexer = nil
40
41
  @fragmenter = nil
42
+ @project_knowledge_indexer = nil
41
43
  @scorer = nil
42
44
  @composer = nil
43
45
  @builder = nil
@@ -100,6 +102,7 @@ module Aidp
100
102
  @style_guide_indexer = nil
101
103
  @template_indexer = nil
102
104
  @fragmenter = nil
105
+ @project_knowledge_indexer = nil
103
106
  @stats.reset!
104
107
  end
105
108
 
@@ -127,6 +130,10 @@ module Aidp
127
130
  template_indexer.index!
128
131
  fragments.concat(template_indexer.templates)
129
132
 
133
+ # Project knowledge fragments
134
+ project_knowledge_indexer.index!
135
+ fragments.concat(project_knowledge_indexer.fragments)
136
+
130
137
  # Source code fragments (only for affected files)
131
138
  if affected_files && !affected_files.empty?
132
139
  code_fragments = fragmenter.fragment_files(affected_files)
@@ -180,6 +187,10 @@ module Aidp
180
187
  @fragmenter ||= SourceCodeFragmenter.new(project_dir: @project_dir)
181
188
  end
182
189
 
190
+ def project_knowledge_indexer
191
+ @project_knowledge_indexer ||= ProjectKnowledgeIndexer.new(project_dir: @project_dir)
192
+ end
193
+
183
194
  # Get or create relevance scorer (cached)
184
195
  def scorer
185
196
  @scorer ||= RelevanceScorer.new
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aidp
4
+ module PromptOptimization
5
+ # Indexes concise feature and tool notes stored under docs/
6
+ #
7
+ # These notes use inline path markers like <feature_id:lib/file.rb> so
8
+ # they can be found quickly by ripgrep and loaded for matching files.
9
+ class ProjectKnowledgeIndexer
10
+ KNOWLEDGE_DIRS = {
11
+ feature: File.join("docs", "features"),
12
+ tool: File.join("docs", "tools")
13
+ }.freeze
14
+
15
+ MARKER_PATTERN = /<([a-z0-9_]+):([^>\n]+)>/
16
+
17
+ attr_reader :fragments, :project_dir
18
+
19
+ def initialize(project_dir:)
20
+ @project_dir = project_dir
21
+ @fragments = []
22
+ end
23
+
24
+ def index!
25
+ @fragments = []
26
+
27
+ KNOWLEDGE_DIRS.each do |note_type, relative_dir|
28
+ index_directory(note_type, File.join(project_dir, relative_dir))
29
+ end
30
+
31
+ @fragments
32
+ end
33
+
34
+ private
35
+
36
+ def index_directory(note_type, directory)
37
+ return unless Dir.exist?(directory)
38
+
39
+ Dir.glob(File.join(directory, "**", "*.md")).sort.each do |file_path|
40
+ @fragments << parse_file(file_path, note_type)
41
+ end
42
+ end
43
+
44
+ def parse_file(file_path, note_type)
45
+ content = File.read(file_path, encoding: "UTF-8")
46
+ title = extract_title(content, file_path)
47
+ markers = content.scan(MARKER_PATTERN)
48
+ note_id = markers.first&.first || File.basename(file_path, ".md")
49
+
50
+ ProjectKnowledgeFragment.new(
51
+ id: note_id,
52
+ title: title,
53
+ note_type: note_type,
54
+ file_path: file_path,
55
+ content: content,
56
+ linked_paths: markers.map { |(_, path)| path.strip }.uniq,
57
+ tags: extract_tags(note_type, content)
58
+ )
59
+ end
60
+
61
+ def extract_title(content, file_path)
62
+ heading = content.lines.find { |line| line.start_with?("# ") }
63
+ return heading.sub(/^#\s+/, "").strip if heading
64
+
65
+ File.basename(file_path, ".md").split("_").map(&:capitalize).join(" ")
66
+ end
67
+
68
+ def extract_tags(note_type, content)
69
+ text = content.downcase
70
+ tags = [note_type.to_s, "project_knowledge"]
71
+ tags << "testing" if text.match?(/rspec|rubocop|test|lint|spec/)
72
+ tags << "documentation" if text.match?(/doc|guide|markdown/)
73
+ tags << "implementation" if text.match?(/implement|feature|behavior/)
74
+ tags << "filesystem" if text.match?(/file|path|directory|workspace/)
75
+ tags << "external_service" if text.match?(/api|provider|service|github|slack/)
76
+ tags.uniq
77
+ end
78
+ end
79
+
80
+ class ProjectKnowledgeFragment
81
+ attr_reader :id, :title, :note_type, :file_path, :content, :linked_paths, :tags
82
+
83
+ def initialize(id:, title:, note_type:, file_path:, content:, linked_paths:, tags:)
84
+ @id = id
85
+ @title = title
86
+ @note_type = note_type
87
+ @file_path = file_path
88
+ @content = content
89
+ @linked_paths = linked_paths
90
+ @tags = tags
91
+ end
92
+
93
+ def estimated_tokens
94
+ (content.length / 4.0).ceil
95
+ end
96
+ end
97
+ end
98
+ end
@@ -31,11 +31,13 @@ module Aidp
31
31
 
32
32
  # Group fragments by type
33
33
  style_guide_fragments = composition_result.fragments_by_type(:style_guide)
34
+ project_knowledge_fragments = composition_result.fragments_by_type(:project_knowledge)
34
35
  template_fragments = composition_result.fragments_by_type(:template)
35
36
  code_fragments = composition_result.fragments_by_type(:code)
36
37
 
37
38
  # Add relevant sections if they have content
38
39
  sections << build_style_guide_section(style_guide_fragments) unless style_guide_fragments.empty?
40
+ sections << build_project_knowledge_section(project_knowledge_fragments) unless project_knowledge_fragments.empty?
39
41
  sections << build_template_section(template_fragments) unless template_fragments.empty?
40
42
  sections << build_code_section(code_fragments) unless code_fragments.empty?
41
43
 
@@ -139,6 +141,25 @@ module Aidp
139
141
  lines.join("\n")
140
142
  end
141
143
 
144
+ def build_project_knowledge_section(fragments)
145
+ lines = ["# Project Knowledge"]
146
+ lines << ""
147
+ lines << "Concise notes associated with the affected files:"
148
+ lines << ""
149
+
150
+ fragments.each do |item|
151
+ fragment = item[:fragment]
152
+ lines << "## #{fragment.title}"
153
+ lines << ""
154
+ lines << "**Type**: #{fragment.note_type}"
155
+ lines << ""
156
+ lines << fragment.content
157
+ lines << ""
158
+ end
159
+
160
+ lines.join("\n")
161
+ end
162
+
142
163
  # Build code context section
143
164
  #
144
165
  # @param fragments [Array<Hash>] Code fragments
@@ -198,6 +219,7 @@ module Aidp
198
219
  lines << "## Fragments by Type"
199
220
  lines << ""
200
221
  lines << "- **Style Guide Sections**: #{summary[:by_type][:style_guide]}"
222
+ lines << "- **Project Knowledge Notes**: #{summary[:by_type][:project_knowledge]}"
201
223
  lines << "- **Templates**: #{summary[:by_type][:templates]}"
202
224
  lines << "- **Code Fragments**: #{summary[:by_type][:code]}"
203
225
  lines << ""
@@ -105,6 +105,13 @@ module Aidp
105
105
  def score_file_location_match(fragment, context)
106
106
  return 0.5 unless context.affected_files && !context.affected_files.empty?
107
107
 
108
+ if fragment.respond_to?(:linked_paths) && fragment.linked_paths.any?
109
+ matches_affected_file = context.affected_files.any? do |affected_file|
110
+ knowledge_path_match?(fragment.linked_paths, affected_file)
111
+ end
112
+ return matches_affected_file ? 1.0 : 0.1
113
+ end
114
+
108
115
  # Only code fragments have file_path
109
116
  return 0.5 unless fragment.respond_to?(:file_path)
110
117
 
@@ -114,6 +121,16 @@ module Aidp
114
121
  end) ? 1.0 : 0.1
115
122
  end
116
123
 
124
+ def knowledge_path_match?(linked_paths, affected_file)
125
+ linked_paths.any? do |linked_path|
126
+ normalized_link = linked_path.sub(%r{\A\./}, "")
127
+ normalized_file = affected_file.to_s.sub(%r{\A\./}, "")
128
+ normalized_file == normalized_link ||
129
+ normalized_file.start_with?("#{normalized_link}/") ||
130
+ normalized_link.start_with?("#{normalized_file}/")
131
+ end
132
+ end
133
+
117
134
  # Score based on work loop step
118
135
  #
119
136
  # @param fragment [Fragment] Fragment to score
data/lib/aidp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Aidp
4
- VERSION = "0.41.0"
4
+ VERSION = "0.42.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aidp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.41.0
4
+ version: 0.42.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan
@@ -407,6 +407,7 @@ files:
407
407
  - lib/aidp/execute/interactive_repl.rb
408
408
  - lib/aidp/execute/persistent_tasklist.rb
409
409
  - lib/aidp/execute/progress.rb
410
+ - lib/aidp/execute/project_knowledge_manager.rb
410
411
  - lib/aidp/execute/prompt_evaluator.rb
411
412
  - lib/aidp/execute/prompt_manager.rb
412
413
  - lib/aidp/execute/repl_macros.rb
@@ -526,6 +527,7 @@ files:
526
527
  - lib/aidp/pr_worktree_manager.rb
527
528
  - lib/aidp/prompt_optimization/context_composer.rb
528
529
  - lib/aidp/prompt_optimization/optimizer.rb
530
+ - lib/aidp/prompt_optimization/project_knowledge_indexer.rb
529
531
  - lib/aidp/prompt_optimization/prompt_builder.rb
530
532
  - lib/aidp/prompt_optimization/relevance_scorer.rb
531
533
  - lib/aidp/prompt_optimization/source_code_fragmenter.rb