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.
@@ -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
 
@@ -1091,6 +1094,7 @@ module Aidp
1091
1094
  # Execute with sanitized environment (secrets stripped) when security is enabled
1092
1095
  # This ensures agent processes cannot access registered secrets directly
1093
1096
  execute_block = lambda do
1097
+ @security_adapter.apply_provider_mcp_tool_risk!(provider_name)
1094
1098
  @provider_manager.execute_with_provider(
1095
1099
  provider_name,
1096
1100
  full_prompt,
@@ -1110,6 +1114,7 @@ module Aidp
1110
1114
  execute_block.call
1111
1115
  end
1112
1116
  end
1117
+ .tap { |result| record_agentic_execution_result(result) }
1113
1118
  end
1114
1119
 
1115
1120
  def display_iteration_overview(provider_name, model_name, prompt_length, checks_summary = nil)
@@ -1515,10 +1520,283 @@ module Aidp
1515
1520
  end
1516
1521
 
1517
1522
  def archive_and_cleanup
1523
+ sync_project_knowledge
1518
1524
  @prompt_manager.archive(@step_name)
1519
1525
  @prompt_manager.delete
1520
1526
  end
1521
1527
 
1528
+ def sync_project_knowledge
1529
+ affected_files = knowledge_affected_files
1530
+ return if affected_files.empty?
1531
+
1532
+ @project_knowledge_manager.sync!(
1533
+ step_name: @step_name,
1534
+ feature_identifier: knowledge_feature_identifier,
1535
+ task_description: build_task_description(format_user_input(@work_context[:user_input]), @work_context),
1536
+ affected_files: affected_files,
1537
+ tool_commands: knowledge_tool_commands
1538
+ )
1539
+ rescue => e
1540
+ Aidp.logger.warn("work_loop", "project_knowledge_sync_failed",
1541
+ step: @step_name,
1542
+ error: e.message)
1543
+ end
1544
+
1545
+ def knowledge_affected_files
1546
+ files = Array(@work_context[:affected_files])
1547
+ files.concat(extract_affected_files(@work_context, format_user_input(@work_context[:user_input])))
1548
+ files.concat(metadata_affected_files)
1549
+ files.concat(get_changed_files) if files.empty?
1550
+ files.map(&:to_s).select { |path| knowledge_path_candidate?(path) }.uniq
1551
+ end
1552
+
1553
+ def extract_paths_from_text(text)
1554
+ return [] if text.nil?
1555
+
1556
+ text.to_s.split.filter_map do |token|
1557
+ path = trim_path_token(token)
1558
+ path if valid_path_candidate?(path)
1559
+ end
1560
+ end
1561
+
1562
+ def extract_output_paths(outputs)
1563
+ Array(outputs).filter_map do |output|
1564
+ path = output[:output_path].to_s.strip
1565
+ path unless path.empty?
1566
+ end
1567
+ end
1568
+
1569
+ def knowledge_tool_commands
1570
+ metadata_tool_commands.concat(@executed_tool_commands).uniq
1571
+ end
1572
+
1573
+ def record_agentic_execution_result(result)
1574
+ return unless result.is_a?(Hash)
1575
+
1576
+ @agentic_execution_results << result
1577
+ end
1578
+
1579
+ def knowledge_feature_identifier
1580
+ user_input = @work_context[:user_input]
1581
+
1582
+ @work_context[:feature_identifier] ||
1583
+ @work_context[:feature_id] ||
1584
+ extract_feature_identifier(user_input)
1585
+ end
1586
+
1587
+ def record_phase_commands(phase, results)
1588
+ commands_by_name = Array(@config.commands_for_phase(phase)).each_with_object({}) do |entry, indexed|
1589
+ indexed[entry[:name]] = entry
1590
+ end
1591
+ executed = results.fetch(:results_by_command, {}).keys.filter_map do |name|
1592
+ commands_by_name[name]&.fetch(:command, nil)
1593
+ end
1594
+ executed = commands_by_name.values.map { |entry| entry[:command] } if executed.empty? && commands_by_name.any?
1595
+ @executed_tool_commands.concat(executed.compact)
1596
+ end
1597
+
1598
+ def record_legacy_category_commands(category)
1599
+ return unless @test_runner.respond_to?(:planned_commands)
1600
+
1601
+ commands = Array(@test_runner.planned_commands[category]).filter_map do |entry|
1602
+ entry.is_a?(String) ? entry : entry[:command]
1603
+ end
1604
+ @executed_tool_commands.concat(commands)
1605
+ end
1606
+
1607
+ def extract_feature_identifier(user_input)
1608
+ return unless user_input.is_a?(Hash)
1609
+
1610
+ user_input["feature_identifier"] ||
1611
+ user_input[:feature_identifier] ||
1612
+ user_input["feature_id"] ||
1613
+ user_input[:feature_id]
1614
+ end
1615
+
1616
+ def metadata_affected_files
1617
+ metadata_entries = metadata_value_objects
1618
+ files = extract_file_entries(metadata_entries, :affected_files)
1619
+ files.concat(extract_file_entries(metadata_entries, :changed_files))
1620
+ files.concat(extract_file_entries(metadata_entries, :edited_files))
1621
+ files.concat(extract_file_entries(metadata_entries, :files_changed))
1622
+ files.concat(extract_file_entries(metadata_entries, :modified_files))
1623
+ files.concat(extract_file_entries(metadata_entries, :touched_files))
1624
+ files.uniq
1625
+ end
1626
+
1627
+ def metadata_tool_commands
1628
+ metadata_entries = metadata_value_objects
1629
+ commands = extract_command_entries(metadata_entries, :tool_commands)
1630
+ commands.concat(extract_command_entries(metadata_entries, :tool_usage))
1631
+ commands.concat(extract_command_entries(metadata_entries, :tool_calls))
1632
+ commands.concat(extract_command_entries(metadata_entries, :tools_used))
1633
+ commands.concat(extract_command_entries(metadata_entries, :commands))
1634
+ commands.uniq
1635
+ end
1636
+
1637
+ def metadata_value_objects
1638
+ Array(@agentic_execution_results).flat_map do |result|
1639
+ [
1640
+ result,
1641
+ indifferent_hash_value(result, :metadata),
1642
+ indifferent_hash_value(result, :output)
1643
+ ]
1644
+ end.compact.select { |entry| entry.is_a?(Hash) }
1645
+ end
1646
+
1647
+ def extract_file_entries(entries, key)
1648
+ entries.flat_map do |entry|
1649
+ Array(indifferent_hash_value(entry, key)).flat_map do |value|
1650
+ case value
1651
+ when String
1652
+ [value]
1653
+ when Hash
1654
+ candidate = file_candidate_from_hash(value)
1655
+ candidate ? [candidate] : []
1656
+ else
1657
+ []
1658
+ end
1659
+ end
1660
+ end
1661
+ end
1662
+
1663
+ def extract_command_entries(entries, key)
1664
+ entries.flat_map do |entry|
1665
+ Array(indifferent_hash_value(entry, key)).flat_map do |value|
1666
+ case value
1667
+ when String
1668
+ [value]
1669
+ when Hash
1670
+ candidate = command_candidate_from_hash(value)
1671
+ candidate ? [candidate] : []
1672
+ else
1673
+ []
1674
+ end
1675
+ end
1676
+ end.reject(&:empty?)
1677
+ end
1678
+
1679
+ def indifferent_hash_value(hash, key)
1680
+ hash[key] || hash[key.to_s]
1681
+ end
1682
+
1683
+ def file_candidate_from_hash(hash)
1684
+ %i[path file file_path output_path relative_path].each do |key|
1685
+ candidate = indifferent_hash_value(hash, key).to_s.strip
1686
+ return candidate unless candidate.empty?
1687
+ end
1688
+
1689
+ nil
1690
+ end
1691
+
1692
+ def command_candidate_from_hash(hash)
1693
+ %i[command tool name identifier].each do |key|
1694
+ candidate = indifferent_hash_value(hash, key).to_s.strip
1695
+ return candidate unless candidate.empty?
1696
+ end
1697
+
1698
+ nil
1699
+ end
1700
+
1701
+ def trim_path_token(token)
1702
+ text = token.to_s
1703
+ start_index = 0
1704
+ end_index = text.length - 1
1705
+
1706
+ start_index += 1 while start_index <= end_index && !path_character?(text[start_index])
1707
+ end_index -= 1 while end_index >= start_index && !path_character?(text[end_index])
1708
+
1709
+ return "" if start_index > end_index
1710
+
1711
+ text[start_index..end_index]
1712
+ end
1713
+
1714
+ def knowledge_path_candidate?(path)
1715
+ return false if path.empty?
1716
+ return false if internal_generated_path?(path)
1717
+
1718
+ true
1719
+ end
1720
+
1721
+ def internal_generated_path?(path)
1722
+ path == ".aidp" || path.start_with?(".aidp/")
1723
+ end
1724
+
1725
+ def path_character?(char)
1726
+ alphanumeric_character?(char) || ["_", ".", "/", "-"].include?(char)
1727
+ end
1728
+
1729
+ def valid_path_candidate?(path)
1730
+ return false if path.empty?
1731
+ return false if standalone_version_token?(path)
1732
+
1733
+ segments = path.split("/")
1734
+ return false if segments.empty? || segments.any?(&:empty?)
1735
+
1736
+ filename = segments.last
1737
+ return false unless valid_filename_candidate?(filename, path.include?("/"))
1738
+
1739
+ segments.all? { |segment| valid_path_segment?(segment) }
1740
+ end
1741
+
1742
+ def standalone_version_token?(path)
1743
+ return false if path.include?("/")
1744
+
1745
+ components = path.split(".")
1746
+ return false if components.length < 2
1747
+
1748
+ components.all? { |component| version_component?(component) }
1749
+ end
1750
+
1751
+ def version_component?(component)
1752
+ component.match?(/\Av?\d+\z/i)
1753
+ end
1754
+
1755
+ def valid_path_segment?(segment)
1756
+ return false if segment == "." || segment == ".."
1757
+
1758
+ segment.each_char.all? do |char|
1759
+ alphanumeric_character?(char) || ["_", ".", "-"].include?(char)
1760
+ end
1761
+ end
1762
+
1763
+ def valid_filename_candidate?(filename, nested_path)
1764
+ return valid_extension_filename?(filename) if filename.include?(".")
1765
+
1766
+ return true if nested_path
1767
+
1768
+ known_extensionless_filename?(filename)
1769
+ end
1770
+
1771
+ def valid_extension_filename?(filename)
1772
+ name, _separator, extension = filename.rpartition(".")
1773
+ return false if name.to_s.empty? || extension.to_s.empty?
1774
+
1775
+ extension.each_char.all? { |char| alphanumeric_character?(char) }
1776
+ end
1777
+
1778
+ def known_extensionless_filename?(filename)
1779
+ %w[
1780
+ Dockerfile
1781
+ Gemfile
1782
+ Procfile
1783
+ Rakefile
1784
+ Makefile
1785
+ Brewfile
1786
+ Podfile
1787
+ Fastfile
1788
+ Appfile
1789
+ Guardfile
1790
+ Vagrantfile
1791
+ ].include?(filename)
1792
+ end
1793
+
1794
+ def alphanumeric_character?(char)
1795
+ char.between?("a", "z") ||
1796
+ char.between?("A", "Z") ||
1797
+ char.between?("0", "9")
1798
+ end
1799
+
1522
1800
  def load_template(template_name)
1523
1801
  return "" unless template_name
1524
1802
 
@@ -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