aidp 0.40.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 +4 -4
- data/lib/aidp/execute/project_knowledge_manager.rb +300 -0
- data/lib/aidp/execute/work_loop_runner.rb +356 -205
- data/lib/aidp/prompt_optimization/context_composer.rb +5 -0
- data/lib/aidp/prompt_optimization/optimizer.rb +11 -0
- data/lib/aidp/prompt_optimization/project_knowledge_indexer.rb +98 -0
- data/lib/aidp/prompt_optimization/prompt_builder.rb +22 -0
- data/lib/aidp/prompt_optimization/relevance_scorer.rb +17 -0
- data/lib/aidp/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f44b47ad0d31015794ec7b2286dc659e85061eb3e6dad8c89ae018976e0d9b1d
|
|
4
|
+
data.tar.gz: 6d8b28a431c3d55e037e32d0e54e583313af4d495982578b72ab3374155323c5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,9 +8,11 @@ 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"
|
|
15
|
+
require_relative "../prompts/prompt_template_manager"
|
|
14
16
|
require_relative "../style_guide/selector"
|
|
15
17
|
require_relative "../security"
|
|
16
18
|
|
|
@@ -44,7 +46,7 @@ module Aidp
|
|
|
44
46
|
# Expose state for testability
|
|
45
47
|
attr_accessor :iteration_count, :step_name, :options, :persistent_tasklist
|
|
46
48
|
attr_reader :project_dir, :current_state, :state_history, :test_runner, :prompt_manager, :checkpoint
|
|
47
|
-
attr_writer :guard_policy, :prompt_manager, :style_guide_selector
|
|
49
|
+
attr_writer :guard_policy, :prompt_manager, :prompt_template_manager, :style_guide_selector
|
|
48
50
|
|
|
49
51
|
MAX_ITERATIONS = 50 # Safety limit
|
|
50
52
|
CHECKPOINT_INTERVAL = 5 # Record checkpoint every N iterations
|
|
@@ -60,7 +62,10 @@ module Aidp
|
|
|
60
62
|
@checkpoint = Checkpoint.new(project_dir)
|
|
61
63
|
@checkpoint_display = CheckpointDisplay.new(prompt: @prompt)
|
|
62
64
|
@guard_policy = GuardPolicy.new(project_dir, config.guards_config)
|
|
65
|
+
@project_knowledge_manager = options[:project_knowledge_manager] || ProjectKnowledgeManager.new(project_dir)
|
|
63
66
|
@work_context = {}
|
|
67
|
+
@executed_tool_commands = []
|
|
68
|
+
@agentic_execution_results = []
|
|
64
69
|
@persistent_tasklist = PersistentTasklist.new(project_dir)
|
|
65
70
|
@iteration_count = 0
|
|
66
71
|
@step_name = nil
|
|
@@ -87,6 +92,7 @@ module Aidp
|
|
|
87
92
|
|
|
88
93
|
# FIX for issue #391: Initialize prompt evaluator for iteration threshold assessment
|
|
89
94
|
@prompt_evaluator = options[:prompt_evaluator] || PromptEvaluator.new(config)
|
|
95
|
+
@prompt_template_manager = options[:prompt_template_manager] || Prompts::PromptTemplateManager.new(project_dir: project_dir)
|
|
90
96
|
|
|
91
97
|
# Initialize security adapter for Rule of Two enforcement
|
|
92
98
|
@security_adapter = options[:security_adapter] || Aidp::Security::WorkLoopAdapter.new(project_dir: project_dir)
|
|
@@ -98,6 +104,8 @@ module Aidp
|
|
|
98
104
|
def execute_step(step_name, step_spec, context = {})
|
|
99
105
|
@step_name = step_name
|
|
100
106
|
@work_context = context
|
|
107
|
+
@executed_tool_commands = []
|
|
108
|
+
@agentic_execution_results = []
|
|
101
109
|
@iteration_count = 0
|
|
102
110
|
transition_to(:ready)
|
|
103
111
|
|
|
@@ -570,6 +578,7 @@ module Aidp
|
|
|
570
578
|
tier: @thinking_depth_manager.current_tier
|
|
571
579
|
}
|
|
572
580
|
)
|
|
581
|
+
record_agentic_execution_result(agent_result)
|
|
573
582
|
|
|
574
583
|
requested = AgentSignalParser.extract_next_unit(agent_result[:output])
|
|
575
584
|
|
|
@@ -603,6 +612,7 @@ module Aidp
|
|
|
603
612
|
tier: @thinking_depth_manager.current_tier
|
|
604
613
|
}
|
|
605
614
|
)
|
|
615
|
+
record_agentic_execution_result(agent_result)
|
|
606
616
|
|
|
607
617
|
requested = AgentSignalParser.extract_next_unit(agent_result[:output])
|
|
608
618
|
|
|
@@ -776,11 +786,13 @@ module Aidp
|
|
|
776
786
|
if @config.respond_to?(:commands) && @config.commands.any?
|
|
777
787
|
# New phase-based execution
|
|
778
788
|
each_unit_results = @test_runner.run_commands_for_phase(:each_unit)
|
|
789
|
+
record_phase_commands(:each_unit, each_unit_results)
|
|
779
790
|
all_results[:each_unit] = each_unit_results
|
|
780
791
|
|
|
781
792
|
# Run on_completion commands only if agent marked work complete
|
|
782
793
|
if agent_marked_complete?(agent_result)
|
|
783
794
|
on_completion_results = @test_runner.run_commands_for_phase(:on_completion)
|
|
795
|
+
record_phase_commands(:on_completion, on_completion_results)
|
|
784
796
|
all_results[:on_completion] = on_completion_results
|
|
785
797
|
else
|
|
786
798
|
all_results[:on_completion] = {
|
|
@@ -808,6 +820,7 @@ module Aidp
|
|
|
808
820
|
return {success: true, output: "", failures: [], required_failures: []} unless @config.respond_to?(:commands)
|
|
809
821
|
|
|
810
822
|
full_loop_results = @test_runner.run_commands_for_phase(:full_loop)
|
|
823
|
+
record_phase_commands(:full_loop, full_loop_results)
|
|
811
824
|
|
|
812
825
|
Aidp.log_debug("work_loop", "ran_full_loop_commands",
|
|
813
826
|
success: full_loop_results[:success],
|
|
@@ -819,13 +832,17 @@ module Aidp
|
|
|
819
832
|
# Run commands using legacy category-based approach (backwards compatibility)
|
|
820
833
|
def run_legacy_category_commands(agent_result)
|
|
821
834
|
test_results = @test_runner.run_tests
|
|
835
|
+
record_legacy_category_commands(:tests)
|
|
822
836
|
lint_results = @test_runner.run_linters
|
|
837
|
+
record_legacy_category_commands(:lints)
|
|
823
838
|
build_results = @test_runner.run_builds
|
|
839
|
+
record_legacy_category_commands(:builds)
|
|
824
840
|
doc_results = @test_runner.run_documentation
|
|
841
|
+
record_legacy_category_commands(:docs)
|
|
825
842
|
|
|
826
843
|
# Run formatters only if agent marked work complete (per issue #234)
|
|
827
844
|
formatter_results = if agent_marked_complete?(agent_result)
|
|
828
|
-
@test_runner.run_formatters
|
|
845
|
+
@test_runner.run_formatters.tap { record_legacy_category_commands(:formatters) }
|
|
829
846
|
else
|
|
830
847
|
{success: true, output: "Formatters: Skipped (work not complete)", failures: [], required_failures: []}
|
|
831
848
|
end
|
|
@@ -957,20 +974,8 @@ module Aidp
|
|
|
957
974
|
|
|
958
975
|
# Extract files that will be affected by this work
|
|
959
976
|
def extract_affected_files(context, user_input)
|
|
960
|
-
files =
|
|
961
|
-
|
|
962
|
-
# From user input (e.g., "update lib/user.rb")
|
|
963
|
-
user_input&.scan(/[\w\/]+\.rb/)&.each do |file|
|
|
964
|
-
files << file
|
|
965
|
-
end
|
|
966
|
-
|
|
967
|
-
# From deterministic outputs
|
|
968
|
-
context[:deterministic_outputs]&.each do |output|
|
|
969
|
-
if output[:output_path]&.end_with?(".rb")
|
|
970
|
-
files << output[:output_path]
|
|
971
|
-
end
|
|
972
|
-
end
|
|
973
|
-
|
|
977
|
+
files = extract_paths_from_text(user_input)
|
|
978
|
+
files.concat(extract_output_paths(context[:deterministic_outputs]))
|
|
974
979
|
files.uniq
|
|
975
980
|
end
|
|
976
981
|
|
|
@@ -1039,71 +1044,20 @@ module Aidp
|
|
|
1039
1044
|
end
|
|
1040
1045
|
|
|
1041
1046
|
def build_initial_prompt_content(template:, prd:, style_guide:, user_input:, step_name:, deterministic_outputs:, previous_agent_summary:, task_description:, additional_context:)
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
parts << "4. After you finish, tests and linters will run automatically"
|
|
1056
|
-
parts << "5. If tests/linters fail, you'll see the errors in the next iteration"
|
|
1057
|
-
parts << ""
|
|
1058
|
-
parts << "## Completion Criteria"
|
|
1059
|
-
parts << "Mark this step COMPLETE by adding this line to `.aidp/PROMPT.md`:"
|
|
1060
|
-
parts << "```"
|
|
1061
|
-
parts << "STATUS: COMPLETE"
|
|
1062
|
-
parts << "```"
|
|
1063
|
-
parts << ""
|
|
1064
|
-
|
|
1065
|
-
if user_input && !user_input.empty?
|
|
1066
|
-
parts << "## User Input"
|
|
1067
|
-
parts << user_input
|
|
1068
|
-
parts << ""
|
|
1069
|
-
end
|
|
1070
|
-
|
|
1071
|
-
if previous_agent_summary && !previous_agent_summary.empty?
|
|
1072
|
-
parts << "## Previous Agent Summary"
|
|
1073
|
-
parts << previous_agent_summary
|
|
1074
|
-
parts << ""
|
|
1075
|
-
end
|
|
1076
|
-
|
|
1077
|
-
unless deterministic_outputs.empty?
|
|
1078
|
-
parts << "## Recent Deterministic Outputs"
|
|
1079
|
-
deterministic_outputs.each do |entry|
|
|
1080
|
-
parts << "- #{entry[:name]} (status: #{entry[:status]})"
|
|
1081
|
-
parts << " Output: #{entry[:output_path] || "n/a"}"
|
|
1082
|
-
end
|
|
1083
|
-
parts << ""
|
|
1084
|
-
end
|
|
1085
|
-
|
|
1086
|
-
if style_guide
|
|
1087
|
-
parts << "## LLM Style Guide"
|
|
1088
|
-
parts << style_guide
|
|
1089
|
-
parts << ""
|
|
1090
|
-
end
|
|
1091
|
-
|
|
1092
|
-
if prd
|
|
1093
|
-
parts << "## Product Requirements (PRD)"
|
|
1094
|
-
parts << prd
|
|
1095
|
-
parts << ""
|
|
1096
|
-
end
|
|
1097
|
-
|
|
1098
|
-
parts << "## Task Template"
|
|
1099
|
-
parts << interpolate_task_template(
|
|
1100
|
-
template,
|
|
1101
|
-
task_description: task_description,
|
|
1102
|
-
additional_context: additional_context
|
|
1047
|
+
@prompt_template_manager.render(
|
|
1048
|
+
"work_loop/initial_prompt",
|
|
1049
|
+
STEP_NAME: step_name,
|
|
1050
|
+
USER_INPUT_SECTION: optional_section("## User Input", user_input),
|
|
1051
|
+
PREVIOUS_AGENT_SUMMARY_SECTION: optional_section("## Previous Agent Summary", previous_agent_summary),
|
|
1052
|
+
DETERMINISTIC_OUTPUTS_SECTION: deterministic_outputs_section(deterministic_outputs),
|
|
1053
|
+
STYLE_GUIDE_SECTION: optional_section("## LLM Style Guide", style_guide),
|
|
1054
|
+
PRD_SECTION: optional_section("## Product Requirements (PRD)", prd),
|
|
1055
|
+
TASK_TEMPLATE: interpolate_task_template(
|
|
1056
|
+
template,
|
|
1057
|
+
task_description: task_description,
|
|
1058
|
+
additional_context: additional_context
|
|
1059
|
+
)
|
|
1103
1060
|
)
|
|
1104
|
-
parts << ""
|
|
1105
|
-
|
|
1106
|
-
parts.join("\n")
|
|
1107
1061
|
end
|
|
1108
1062
|
|
|
1109
1063
|
def send_to_agent(selected_provider: nil, selected_model: nil)
|
|
@@ -1159,6 +1113,7 @@ module Aidp
|
|
|
1159
1113
|
execute_block.call
|
|
1160
1114
|
end
|
|
1161
1115
|
end
|
|
1116
|
+
.tap { |result| record_agentic_execution_result(result) }
|
|
1162
1117
|
end
|
|
1163
1118
|
|
|
1164
1119
|
def display_iteration_overview(provider_name, model_name, prompt_length, checks_summary = nil)
|
|
@@ -1325,88 +1280,14 @@ module Aidp
|
|
|
1325
1280
|
|
|
1326
1281
|
# FIX for issue #391: Enhanced work loop header with upfront task filing requirements
|
|
1327
1282
|
def build_work_loop_header(step_name, iteration)
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
parts << "4. Run tests to verify your changes work correctly"
|
|
1337
|
-
parts << "5. Update task status as you complete items"
|
|
1338
|
-
parts << "6. When ALL tasks are complete and tests pass, mark the step COMPLETE"
|
|
1339
|
-
parts << ""
|
|
1340
|
-
parts << "## Important Notes"
|
|
1341
|
-
parts << "- You have full file system access - create and edit files as needed"
|
|
1342
|
-
parts << "- The working directory is: #{@project_dir}"
|
|
1343
|
-
parts << "- After you finish, tests and linters will run automatically"
|
|
1344
|
-
parts << "- If tests/linters fail, you'll see the errors in the next iteration and can fix them"
|
|
1345
|
-
parts << ""
|
|
1346
|
-
parts << "## ⚠️ Code Changes Required"
|
|
1347
|
-
parts << "**IMPORTANT**: This implementation requires actual code changes."
|
|
1348
|
-
parts << "- Documentation-only changes will NOT be accepted as complete"
|
|
1349
|
-
parts << "- Configuration-only changes will NOT be accepted as complete"
|
|
1350
|
-
parts << "- You must modify/create code files (.rb, .py, .js, etc.) to implement the feature/fix"
|
|
1351
|
-
parts << "- Tests should accompany code changes"
|
|
1352
|
-
parts << ""
|
|
1353
|
-
|
|
1354
|
-
if @config.task_completion_required?
|
|
1355
|
-
parts << "## Task Filing (REQUIRED - DO THIS FIRST)"
|
|
1356
|
-
parts << "**CRITICAL**: This work loop requires task tracking. You MUST file tasks before implementation."
|
|
1357
|
-
parts << ""
|
|
1358
|
-
parts << "### Step 1: File Tasks Immediately"
|
|
1359
|
-
parts << "In your FIRST iteration, analyze the requirements and file tasks for ALL work:"
|
|
1360
|
-
parts << ""
|
|
1361
|
-
parts << "```text"
|
|
1362
|
-
parts << "File task: \"Implement [feature/fix description]\" priority: high tags: implementation"
|
|
1363
|
-
parts << "File task: \"Add unit tests for [feature]\" priority: high tags: testing"
|
|
1364
|
-
parts << "File task: \"Add integration tests if needed\" priority: medium tags: testing"
|
|
1365
|
-
parts << "```"
|
|
1366
|
-
parts << ""
|
|
1367
|
-
parts << "### Step 2: Work Through Tasks"
|
|
1368
|
-
parts << "- Pick the highest priority pending task"
|
|
1369
|
-
parts << "- Implement it completely"
|
|
1370
|
-
parts << "- Mark it done: `Update task: task_id status: done`"
|
|
1371
|
-
parts << "- Repeat until all tasks are complete"
|
|
1372
|
-
parts << ""
|
|
1373
|
-
parts << "### Step 3: Complete the Work Loop"
|
|
1374
|
-
parts << "Only after ALL tasks are done:"
|
|
1375
|
-
parts << "- Verify tests pass"
|
|
1376
|
-
parts << "- Add STATUS: COMPLETE to `.aidp/PROMPT.md`"
|
|
1377
|
-
parts << ""
|
|
1378
|
-
parts << "### Task Rules"
|
|
1379
|
-
parts << "- **At least ONE task must be filed** - completion blocked without tasks"
|
|
1380
|
-
parts << "- **At least ONE task must be DONE** - completion blocked if all abandoned"
|
|
1381
|
-
parts << "- **Substantive work required** - doc-only changes rejected"
|
|
1382
|
-
parts << ""
|
|
1383
|
-
parts << "**Important**: Tasks exist due to careful planning. Do NOT abandon tasks due to"
|
|
1384
|
-
parts << "perceived complexity - these factors were considered during planning. Only abandon"
|
|
1385
|
-
parts << "when truly obsolete (requirements changed, duplicate, external blockers)."
|
|
1386
|
-
parts << ""
|
|
1387
|
-
parts << "### Task Filing Examples"
|
|
1388
|
-
parts << "- `File task: \"Implement user authentication\" priority: high tags: security,auth`"
|
|
1389
|
-
parts << "- `File task: \"Add tests for login flow\" priority: medium tags: testing`"
|
|
1390
|
-
parts << "- `File task: \"Update documentation\" priority: low tags: docs`"
|
|
1391
|
-
parts << ""
|
|
1392
|
-
parts << "### Task Status Update Examples"
|
|
1393
|
-
parts << "- `Update task: task_123_abc status: in_progress`"
|
|
1394
|
-
parts << "- `Update task: task_456_def status: done`"
|
|
1395
|
-
parts << "- `Update task: task_789_ghi status: abandoned reason: \"Requirements changed\"`"
|
|
1396
|
-
parts << ""
|
|
1397
|
-
end
|
|
1398
|
-
|
|
1399
|
-
parts << "## Completion Criteria"
|
|
1400
|
-
parts << "Mark this step COMPLETE by adding these lines to `.aidp/PROMPT.md`:"
|
|
1401
|
-
parts << "```"
|
|
1402
|
-
parts << "STATUS: COMPLETE"
|
|
1403
|
-
if @config.task_completion_required?
|
|
1404
|
-
parts << ""
|
|
1405
|
-
parts << "Update task: task_xxx_yyy status: done # Mark ALL your tasks as done"
|
|
1406
|
-
end
|
|
1407
|
-
parts << "```"
|
|
1408
|
-
parts << ""
|
|
1409
|
-
parts.join("\n")
|
|
1283
|
+
@prompt_template_manager.render(
|
|
1284
|
+
"work_loop/header",
|
|
1285
|
+
STEP_NAME: step_name,
|
|
1286
|
+
ITERATION: iteration,
|
|
1287
|
+
PROJECT_DIR: @project_dir,
|
|
1288
|
+
TASK_FILING_SECTION: task_filing_section,
|
|
1289
|
+
TASK_COMPLETION_LINE: task_completion_line
|
|
1290
|
+
)
|
|
1410
1291
|
end
|
|
1411
1292
|
|
|
1412
1293
|
def iteration_context_metadata
|
|
@@ -1638,10 +1519,283 @@ module Aidp
|
|
|
1638
1519
|
end
|
|
1639
1520
|
|
|
1640
1521
|
def archive_and_cleanup
|
|
1522
|
+
sync_project_knowledge
|
|
1641
1523
|
@prompt_manager.archive(@step_name)
|
|
1642
1524
|
@prompt_manager.delete
|
|
1643
1525
|
end
|
|
1644
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
|
+
|
|
1645
1799
|
def load_template(template_name)
|
|
1646
1800
|
return "" unless template_name
|
|
1647
1801
|
|
|
@@ -2131,51 +2285,48 @@ module Aidp
|
|
|
2131
2285
|
|
|
2132
2286
|
# Append task completion requirement to PROMPT.md
|
|
2133
2287
|
def append_task_requirement_to_prompt(message)
|
|
2134
|
-
task_requirement = []
|
|
2135
|
-
|
|
2136
|
-
task_requirement << "## Task Completion Requirement"
|
|
2137
|
-
task_requirement << ""
|
|
2138
|
-
task_requirement << "**CRITICAL**: #{message}"
|
|
2139
|
-
task_requirement << ""
|
|
2140
|
-
task_requirement << "### How to Complete Tasks"
|
|
2141
|
-
task_requirement << ""
|
|
2142
|
-
task_requirement << "Update task status using these signals in your output:"
|
|
2143
|
-
task_requirement << ""
|
|
2144
|
-
task_requirement << "**Creating tasks:**"
|
|
2145
|
-
task_requirement << "```"
|
|
2146
|
-
task_requirement << 'File task: "Implement feature X" priority: high tags: feature,backend'
|
|
2147
|
-
task_requirement << 'File task: "Add tests for feature X" priority: medium tags: testing'
|
|
2148
|
-
task_requirement << "```"
|
|
2149
|
-
task_requirement << ""
|
|
2150
|
-
task_requirement << "**Updating task status:**"
|
|
2151
|
-
task_requirement << "```"
|
|
2152
|
-
task_requirement << "Update task: task_123_abc status: in_progress"
|
|
2153
|
-
task_requirement << "Update task: task_123_abc status: done"
|
|
2154
|
-
task_requirement << 'Update task: task_456_def status: abandoned reason: "Requirements changed"'
|
|
2155
|
-
task_requirement << "```"
|
|
2156
|
-
task_requirement << ""
|
|
2157
|
-
task_requirement << "**Task states:**"
|
|
2158
|
-
task_requirement << "- ⏳ **pending** - Not started yet"
|
|
2159
|
-
task_requirement << "- 🚧 **in_progress** - Currently working on it"
|
|
2160
|
-
task_requirement << "- ✅ **done** - Completed successfully"
|
|
2161
|
-
task_requirement << "- ❌ **abandoned** - Not doing this (requires reason)"
|
|
2162
|
-
task_requirement << ""
|
|
2163
|
-
task_requirement << "**Completion requirement:**"
|
|
2164
|
-
task_requirement << "All tasks for this session must be marked as DONE or ABANDONED (with reason) before the work loop can complete."
|
|
2165
|
-
task_requirement << ""
|
|
2166
|
-
task_requirement << "**Action Required**: Review the current task list and update status for all tasks."
|
|
2167
|
-
task_requirement << ""
|
|
2168
|
-
|
|
2169
2288
|
# Append to PROMPT.md - ensure directory exists
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2289
|
+
current_prompt = @prompt_manager.read || ""
|
|
2290
|
+
task_requirement = @prompt_template_manager.render(
|
|
2291
|
+
"work_loop/task_completion_requirement",
|
|
2292
|
+
MESSAGE: message
|
|
2293
|
+
)
|
|
2294
|
+
updated_prompt = current_prompt + "\n\n---\n\n" + task_requirement
|
|
2295
|
+
@prompt_manager.write(updated_prompt, step_name: @step_name)
|
|
2296
|
+
display_message(" [TASK_REQ] Added task completion requirement to PROMPT.md", type: :warning)
|
|
2297
|
+
rescue => e
|
|
2298
|
+
Aidp.log_warn("work_loop", "Failed to append task requirement to PROMPT.md", error: e.message)
|
|
2299
|
+
display_message(" [TASK_REQ] Warning: Could not update PROMPT.md: #{e.message}", type: :warning)
|
|
2300
|
+
end
|
|
2301
|
+
|
|
2302
|
+
def optional_section(title, body)
|
|
2303
|
+
return "" if body.nil? || body.empty?
|
|
2304
|
+
|
|
2305
|
+
[title, body, ""].join("\n")
|
|
2306
|
+
end
|
|
2307
|
+
|
|
2308
|
+
def deterministic_outputs_section(deterministic_outputs)
|
|
2309
|
+
return "" if deterministic_outputs.empty?
|
|
2310
|
+
|
|
2311
|
+
lines = ["## Recent Deterministic Outputs"]
|
|
2312
|
+
deterministic_outputs.each do |entry|
|
|
2313
|
+
lines << "- #{entry[:name]} (status: #{entry[:status]})"
|
|
2314
|
+
lines << " Output: #{entry[:output_path] || "n/a"}"
|
|
2178
2315
|
end
|
|
2316
|
+
lines << ""
|
|
2317
|
+
lines.join("\n")
|
|
2318
|
+
end
|
|
2319
|
+
|
|
2320
|
+
def task_filing_section
|
|
2321
|
+
return "" unless @config.task_completion_required?
|
|
2322
|
+
|
|
2323
|
+
@prompt_template_manager.render("work_loop/task_filing")
|
|
2324
|
+
end
|
|
2325
|
+
|
|
2326
|
+
def task_completion_line
|
|
2327
|
+
return "" unless @config.task_completion_required?
|
|
2328
|
+
|
|
2329
|
+
"Update task: task_xxx_yyy status: done # Mark ALL your tasks as done\n"
|
|
2179
2330
|
end
|
|
2180
2331
|
|
|
2181
2332
|
# Validate changes against guard policy
|
|
@@ -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
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.
|
|
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
|