aidp 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 95269030b5c3fe91c2cfb6e29066a4f37afbcf1903e930a377ec02dd1e8a738c
4
- data.tar.gz: 6c343d45e804a73751c99e0e63491703c1eb9ba18fb2acf4e828198e774c2c92
3
+ metadata.gz: 220c03eda20360cfa8b954bc578b15fe766742e849037539f1a1f9c794310569
4
+ data.tar.gz: 0fa8cc7ff18ab1ee66c11f187df4554f84504d4713322f089427b9d921de5420
5
5
  SHA512:
6
- metadata.gz: dc54a6f0b9424c18ebdd8e6df75e77f0f233db98dd59543a9dd6152eacd835d8820c437ccd220c3dbcfb43cf91bfeb47e40ce415773e8cd0e24783770b2ac033
7
- data.tar.gz: f89cf387121e7edc3c384cead9369bc6ae5be97e639969fa036978a249779152ccd7a50832897be97d7fd04f4083ccf744e23e93c1117c6ed5d1cc7d8529615d
6
+ metadata.gz: 772f8bc2099fce5b1d8ff0c9d8567382281851b7d3a283eb0aa847ad92d5f420b9aff91c702424df89cf6570cc59cfe35dacc8baf7df60b0d019cd1dfc97ceea
7
+ data.tar.gz: 1bad64d70c87752e7416bbad5558ff9cab2e67902b2e461bbd1ad946a4659951949ea4f6c0642a2424d81a6119e405f9208647e5d7c229d13626fc9f0774bae7
@@ -51,8 +51,11 @@ module Aidp
51
51
  interrupt: :exit
52
52
  )
53
53
 
54
- # Read line with full readline support (Ctrl-A, Ctrl-E, Ctrl-W, etc.)
55
- result = reader.read_line(prompt, default: default || "")
54
+ # TTY::Reader#read_line does not support a :default keyword; we emulate fallback
55
+ result = reader.read_line(prompt)
56
+ if (result.nil? || result.chomp.empty?) && !default.nil?
57
+ return default
58
+ end
56
59
  result&.chomp
57
60
  rescue TTY::Reader::InputInterrupt
58
61
  raise Interrupt
@@ -16,6 +16,26 @@ module Aidp
16
16
  nil
17
17
  end
18
18
 
19
+ # Parse task filing signals from agent output
20
+ # Returns array of task hashes with description, priority, and tags
21
+ def self.parse_task_filing(output)
22
+ return [] unless output
23
+
24
+ tasks = []
25
+ # Pattern: File task: "description" [priority: high|medium|low] [tags: tag1,tag2]
26
+ pattern = /File\s+task:\s*"([^"]+)"(?:\s+priority:\s*(high|medium|low))?(?:\s+tags:\s*([^\s]+))?/i
27
+
28
+ output.to_s.scan(pattern).each do |description, priority, tags|
29
+ tasks << {
30
+ description: description.strip,
31
+ priority: (priority || "medium").downcase.to_sym,
32
+ tags: tags ? tags.split(",").map(&:strip) : []
33
+ }
34
+ end
35
+
36
+ tasks
37
+ end
38
+
19
39
  def self.normalize_token(raw)
20
40
  return nil if raw.nil? || raw.empty?
21
41
 
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require "securerandom"
6
+ require "fileutils"
7
+
8
+ module Aidp
9
+ module Execute
10
+ # Task struct for persistent tasklist entries
11
+ Task = Struct.new(
12
+ :id,
13
+ :description,
14
+ :status,
15
+ :priority,
16
+ :created_at,
17
+ :updated_at,
18
+ :session,
19
+ :discovered_during,
20
+ :started_at,
21
+ :completed_at,
22
+ :abandoned_at,
23
+ :abandoned_reason,
24
+ :tags,
25
+ keyword_init: true
26
+ ) do
27
+ def to_h
28
+ super.compact
29
+ end
30
+ end
31
+
32
+ # Persistent tasklist for tracking tasks across sessions
33
+ # Uses append-only JSONL format for git-friendly storage
34
+ class PersistentTasklist
35
+ attr_reader :project_dir, :file_path
36
+
37
+ class TaskNotFoundError < StandardError; end
38
+ class InvalidTaskError < StandardError; end
39
+
40
+ def initialize(project_dir)
41
+ @project_dir = project_dir
42
+ @file_path = File.join(project_dir, ".aidp", "tasklist.jsonl")
43
+ ensure_file_exists
44
+ end
45
+
46
+ # Create a new task
47
+ def create(description, priority: :medium, session: nil, discovered_during: nil, tags: [])
48
+ validate_description!(description)
49
+ validate_priority!(priority)
50
+
51
+ task = Task.new(
52
+ id: generate_id,
53
+ description: description.strip,
54
+ status: :pending,
55
+ priority: priority,
56
+ created_at: Time.now,
57
+ updated_at: Time.now,
58
+ session: session,
59
+ discovered_during: discovered_during,
60
+ tags: Array(tags)
61
+ )
62
+
63
+ append_task(task)
64
+ Aidp.log_debug("tasklist", "Created task", task_id: task.id, description: task.description)
65
+ task
66
+ end
67
+
68
+ # Update task status
69
+ def update_status(task_id, new_status, reason: nil)
70
+ validate_status!(new_status)
71
+ task = find(task_id)
72
+ raise TaskNotFoundError, "Task not found: #{task_id}" unless task
73
+
74
+ task.status = new_status
75
+ task.updated_at = Time.now
76
+
77
+ case new_status
78
+ when :in_progress
79
+ task.started_at ||= Time.now
80
+ when :done
81
+ task.completed_at = Time.now
82
+ when :abandoned
83
+ task.abandoned_at = Time.now
84
+ task.abandoned_reason = reason
85
+ end
86
+
87
+ append_task(task)
88
+ Aidp.log_debug("tasklist", "Updated task status", task_id: task.id, status: new_status)
89
+ task
90
+ end
91
+
92
+ # Query tasks with optional filters
93
+ def all(status: nil, priority: nil, since: nil, tags: nil)
94
+ tasks = load_latest_tasks
95
+
96
+ tasks = tasks.select { |t| t.status == status } if status
97
+ tasks = tasks.select { |t| t.priority == priority } if priority
98
+ tasks = tasks.select { |t| t.created_at >= since } if since
99
+ tasks = tasks.select { |t| (Array(t.tags) & Array(tags)).any? } if tags && !tags.empty?
100
+
101
+ tasks.sort_by(&:created_at).reverse
102
+ end
103
+
104
+ # Find single task by ID
105
+ def find(task_id)
106
+ all.find { |t| t.id == task_id }
107
+ end
108
+
109
+ # Query pending tasks (common operation)
110
+ def pending
111
+ all(status: :pending)
112
+ end
113
+
114
+ # Query in-progress tasks
115
+ def in_progress
116
+ all(status: :in_progress)
117
+ end
118
+
119
+ # Count tasks by status
120
+ def counts
121
+ tasks = load_latest_tasks
122
+ {
123
+ total: tasks.size,
124
+ pending: tasks.count { |t| t.status == :pending },
125
+ in_progress: tasks.count { |t| t.status == :in_progress },
126
+ done: tasks.count { |t| t.status == :done },
127
+ abandoned: tasks.count { |t| t.status == :abandoned }
128
+ }
129
+ end
130
+
131
+ private
132
+
133
+ VALID_STATUSES = [:pending, :in_progress, :done, :abandoned].freeze
134
+ VALID_PRIORITIES = [:high, :medium, :low].freeze
135
+
136
+ def append_task(task)
137
+ File.open(@file_path, "a") do |f|
138
+ f.puts serialize_task(task)
139
+ end
140
+ end
141
+
142
+ def load_latest_tasks
143
+ return [] unless File.exist?(@file_path)
144
+
145
+ tasks_by_id = {}
146
+
147
+ File.readlines(@file_path).each_with_index do |line, index|
148
+ next if line.strip.empty?
149
+
150
+ begin
151
+ data = JSON.parse(line.strip, symbolize_names: true)
152
+ task = deserialize_task(data)
153
+ tasks_by_id[task.id] = task
154
+ rescue JSON::ParserError => e
155
+ Aidp.log_warn("tasklist", "Skipping malformed JSONL line", line_number: index + 1, error: e.message)
156
+ next
157
+ rescue => e
158
+ Aidp.log_warn("tasklist", "Error loading task", line_number: index + 1, error: e.message)
159
+ next
160
+ end
161
+ end
162
+
163
+ tasks_by_id.values
164
+ end
165
+
166
+ def serialize_task(task)
167
+ hash = task.to_h
168
+ # Convert Time objects to ISO8601 strings
169
+ hash[:created_at] = hash[:created_at].iso8601 if hash[:created_at]
170
+ hash[:updated_at] = hash[:updated_at].iso8601 if hash[:updated_at]
171
+ hash[:started_at] = hash[:started_at].iso8601 if hash[:started_at]
172
+ hash[:completed_at] = hash[:completed_at].iso8601 if hash[:completed_at]
173
+ hash[:abandoned_at] = hash[:abandoned_at].iso8601 if hash[:abandoned_at]
174
+ JSON.generate(hash)
175
+ end
176
+
177
+ def deserialize_task(data)
178
+ Task.new(**data.merge(
179
+ status: data[:status]&.to_sym,
180
+ priority: data[:priority]&.to_sym,
181
+ created_at: parse_time(data[:created_at]),
182
+ updated_at: parse_time(data[:updated_at]),
183
+ started_at: parse_time(data[:started_at]),
184
+ completed_at: parse_time(data[:completed_at]),
185
+ abandoned_at: parse_time(data[:abandoned_at]),
186
+ tags: Array(data[:tags])
187
+ ))
188
+ end
189
+
190
+ def parse_time(time_string)
191
+ return nil if time_string.nil?
192
+ Time.parse(time_string)
193
+ rescue ArgumentError
194
+ nil
195
+ end
196
+
197
+ def generate_id
198
+ "task_#{Time.now.to_i}_#{SecureRandom.hex(4)}"
199
+ end
200
+
201
+ def ensure_file_exists
202
+ FileUtils.mkdir_p(File.dirname(@file_path))
203
+ FileUtils.touch(@file_path) unless File.exist?(@file_path)
204
+ end
205
+
206
+ def validate_description!(description)
207
+ raise InvalidTaskError, "Description cannot be empty" if description.nil? || description.strip.empty?
208
+ raise InvalidTaskError, "Description too long (max 200 chars)" if description.length > 200
209
+ end
210
+
211
+ def validate_priority!(priority)
212
+ raise InvalidTaskError, "Invalid priority: #{priority}" unless VALID_PRIORITIES.include?(priority)
213
+ end
214
+
215
+ def validate_status!(status)
216
+ raise InvalidTaskError, "Invalid status: #{status}" unless VALID_STATUSES.include?(status)
217
+ end
218
+ end
219
+ end
220
+ end
@@ -293,6 +293,12 @@ module Aidp
293
293
  usage: "/prompt <explain|stats|expand|reset>",
294
294
  example: "/prompt explain",
295
295
  handler: method(:cmd_prompt)
296
+ },
297
+ "/tasks" => {
298
+ description: "Manage persistent tasklist (cross-session task tracking)",
299
+ usage: "/tasks <list|show|done|abandon|stats> [args]",
300
+ example: "/tasks list pending",
301
+ handler: method(:cmd_tasks)
296
302
  }
297
303
  }
298
304
  end
@@ -2037,6 +2043,164 @@ module Aidp
2037
2043
  }
2038
2044
  end
2039
2045
 
2046
+ # Manage persistent tasklist
2047
+ def cmd_tasks(args)
2048
+ tasklist = PersistentTasklist.new(@project_dir)
2049
+ subcommand = args[0]
2050
+
2051
+ case subcommand
2052
+ when "list", nil
2053
+ cmd_tasks_list(tasklist, args[1])
2054
+ when "show"
2055
+ cmd_tasks_show(tasklist, args[1])
2056
+ when "done"
2057
+ cmd_tasks_done(tasklist, args[1])
2058
+ when "abandon"
2059
+ cmd_tasks_abandon(tasklist, args[1], args[2..]&.join(" "))
2060
+ when "stats"
2061
+ cmd_tasks_stats(tasklist)
2062
+ else
2063
+ {
2064
+ success: false,
2065
+ message: "Unknown subcommand: #{subcommand}. Use: list, show, done, abandon, stats",
2066
+ action: :none
2067
+ }
2068
+ end
2069
+ rescue => e
2070
+ Aidp.log_error("repl_macros", "Tasks command failed", error: e.message)
2071
+ {success: false, message: "Error: #{e.message}", action: :none}
2072
+ end
2073
+
2074
+ private
2075
+
2076
+ # List tasks with optional status filter
2077
+ def cmd_tasks_list(tasklist, status_filter = nil)
2078
+ status = status_filter&.to_sym
2079
+ tasks = status ? tasklist.all(status: status) : tasklist.all
2080
+
2081
+ if tasks.empty?
2082
+ message = status ? "No #{status} tasks found." : "No tasks found."
2083
+ return {success: true, message: message, action: :display}
2084
+ end
2085
+
2086
+ # Group by status
2087
+ by_status = tasks.group_by(&:status)
2088
+ output = []
2089
+
2090
+ [:pending, :in_progress, :done, :abandoned].each do |st|
2091
+ next unless by_status[st]
2092
+ next if status && st != status # Skip if filtering by specific status
2093
+
2094
+ output << ""
2095
+ output << "#{st.to_s.upcase.tr("_", " ")} (#{by_status[st].size})"
2096
+ output << "=" * 50
2097
+
2098
+ by_status[st].each do |task|
2099
+ priority_icon = case task.priority
2100
+ when :high then "⚠️ "
2101
+ when :medium then "○ "
2102
+ when :low then "· "
2103
+ end
2104
+
2105
+ age = ((Time.now - task.created_at) / 86400).to_i
2106
+ age_str = (age > 0) ? " (#{age}d ago)" : " (today)"
2107
+
2108
+ output << " #{priority_icon}[#{task.id}] #{task.description}#{age_str}"
2109
+ end
2110
+ end
2111
+
2112
+ {
2113
+ success: true,
2114
+ message: output.join("\n"),
2115
+ action: :display
2116
+ }
2117
+ end
2118
+
2119
+ # Show detailed information about a specific task
2120
+ def cmd_tasks_show(tasklist, task_id)
2121
+ return {success: false, message: "Usage: /tasks show <task_id>", action: :none} unless task_id
2122
+
2123
+ task = tasklist.find(task_id)
2124
+ unless task
2125
+ return {success: false, message: "Task not found: #{task_id}", action: :none}
2126
+ end
2127
+
2128
+ output = []
2129
+ output << ""
2130
+ output << "Task Details:"
2131
+ output << "=" * 50
2132
+ output << "ID: #{task.id}"
2133
+ output << "Description: #{task.description}"
2134
+ output << "Status: #{task.status}"
2135
+ output << "Priority: #{task.priority}"
2136
+ output << "Created: #{task.created_at}"
2137
+ output << "Updated: #{task.updated_at}"
2138
+ output << "Session: #{task.session}" if task.session
2139
+ output << "Context: #{task.discovered_during}" if task.discovered_during
2140
+ output << "Started: #{task.started_at}" if task.started_at
2141
+ output << "Completed: #{task.completed_at}" if task.completed_at
2142
+ output << "Abandoned: #{task.abandoned_at} (#{task.abandoned_reason})" if task.abandoned_at
2143
+ output << "Tags: #{task.tags.join(", ")}" if task.tags&.any?
2144
+
2145
+ {
2146
+ success: true,
2147
+ message: output.join("\n"),
2148
+ action: :display
2149
+ }
2150
+ end
2151
+
2152
+ # Mark a task as done
2153
+ def cmd_tasks_done(tasklist, task_id)
2154
+ return {success: false, message: "Usage: /tasks done <task_id>", action: :none} unless task_id
2155
+
2156
+ task = tasklist.update_status(task_id, :done)
2157
+ {
2158
+ success: true,
2159
+ message: "✓ Task marked as done: #{task.description}",
2160
+ action: :display
2161
+ }
2162
+ rescue PersistentTasklist::TaskNotFoundError
2163
+ {success: false, message: "Task not found: #{task_id}", action: :none}
2164
+ end
2165
+
2166
+ # Abandon a task with optional reason
2167
+ def cmd_tasks_abandon(tasklist, task_id, reason = nil)
2168
+ return {success: false, message: "Usage: /tasks abandon <task_id> [reason]", action: :none} unless task_id
2169
+
2170
+ task = tasklist.update_status(task_id, :abandoned, reason: reason)
2171
+ message = "✗ Task abandoned: #{task.description}"
2172
+ message += " (Reason: #{reason})" if reason
2173
+
2174
+ {
2175
+ success: true,
2176
+ message: message,
2177
+ action: :display
2178
+ }
2179
+ rescue PersistentTasklist::TaskNotFoundError
2180
+ {success: false, message: "Task not found: #{task_id}", action: :none}
2181
+ end
2182
+
2183
+ # Show task statistics
2184
+ def cmd_tasks_stats(tasklist)
2185
+ counts = tasklist.counts
2186
+
2187
+ output = []
2188
+ output << ""
2189
+ output << "Task Statistics:"
2190
+ output << "=" * 50
2191
+ output << "Total: #{counts[:total]}"
2192
+ output << "Pending: #{counts[:pending]}"
2193
+ output << "In Progress: #{counts[:in_progress]}"
2194
+ output << "Done: #{counts[:done]}"
2195
+ output << "Abandoned: #{counts[:abandoned]}"
2196
+
2197
+ {
2198
+ success: true,
2199
+ message: output.join("\n"),
2200
+ action: :display
2201
+ }
2202
+ end
2203
+
2040
2204
  private
2041
2205
 
2042
2206
  # Load configuration for prompt commands
@@ -51,6 +51,7 @@ module Aidp
51
51
  @checkpoint = Checkpoint.new(project_dir)
52
52
  @checkpoint_display = CheckpointDisplay.new
53
53
  @guard_policy = GuardPolicy.new(project_dir, config.guards_config)
54
+ @persistent_tasklist = PersistentTasklist.new(project_dir)
54
55
  @iteration_count = 0
55
56
  @step_name = nil
56
57
  @options = options
@@ -74,6 +75,7 @@ module Aidp
74
75
  display_message(" Flow: Deterministic ↔ Agentic with fix-forward core", type: :info)
75
76
 
76
77
  display_guard_policy_status
78
+ display_pending_tasks
77
79
 
78
80
  @unit_scheduler = WorkLoopUnitScheduler.new(units_config)
79
81
  base_context = context.dup
@@ -148,6 +150,9 @@ module Aidp
148
150
  transition_to(:apply_patch)
149
151
  agent_result = apply_patch
150
152
 
153
+ # Process agent output for task filing signals
154
+ process_task_filing(agent_result)
155
+
151
156
  transition_to(:test)
152
157
  test_results = @test_runner.run_tests
153
158
  lint_results = @test_runner.run_linters
@@ -776,6 +781,55 @@ module Aidp
776
781
  display_message("")
777
782
  end
778
783
 
784
+ # Display pending tasks from persistent tasklist
785
+ def display_pending_tasks
786
+ pending_tasks = @persistent_tasklist.pending
787
+ return if pending_tasks.empty?
788
+
789
+ display_message("\n📋 Pending Tasks from Previous Sessions:", type: :info)
790
+
791
+ # Show up to 5 most recent pending tasks
792
+ pending_tasks.take(5).each do |task|
793
+ priority_icon = case task.priority
794
+ when :high then "⚠️ "
795
+ when :medium then "○ "
796
+ when :low then "· "
797
+ end
798
+
799
+ age = ((Time.now - task.created_at) / 86400).to_i
800
+ age_str = (age > 0) ? " (#{age}d ago)" : " (today)"
801
+
802
+ display_message(" #{priority_icon}#{task.description}#{age_str}", type: :info)
803
+ end
804
+
805
+ if pending_tasks.size > 5
806
+ display_message(" ... and #{pending_tasks.size - 5} more. Use /tasks list to see all", type: :info)
807
+ end
808
+
809
+ display_message("")
810
+ end
811
+
812
+ # Process agent output for task filing signals
813
+ def process_task_filing(agent_result)
814
+ return unless agent_result && agent_result[:output]
815
+
816
+ filed_tasks = AgentSignalParser.parse_task_filing(agent_result[:output])
817
+ return if filed_tasks.empty?
818
+
819
+ filed_tasks.each do |task_data|
820
+ task = @persistent_tasklist.create(
821
+ task_data[:description],
822
+ priority: task_data[:priority],
823
+ session: @step_name,
824
+ discovered_during: "#{@step_name} iteration #{@iteration_count}",
825
+ tags: task_data[:tags]
826
+ )
827
+
828
+ Aidp.log_info("tasklist", "Filed new task from agent", task_id: task.id, description: task.description)
829
+ display_message("📋 Filed task: #{task.description} (#{task.id})", type: :info)
830
+ end
831
+ end
832
+
779
833
  # Validate changes against guard policy
780
834
  # Returns validation result with errors if any
781
835
  def validate_guard_policy(changed_files = [])
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.18.0"
4
+ VERSION = "0.19.0"
5
5
  end
data/lib/aidp.rb CHANGED
@@ -59,6 +59,7 @@ require_relative "aidp/execute/checkpoint"
59
59
  require_relative "aidp/execute/checkpoint_display"
60
60
  require_relative "aidp/execute/work_loop_state"
61
61
  require_relative "aidp/execute/instruction_queue"
62
+ require_relative "aidp/execute/persistent_tasklist"
62
63
  require_relative "aidp/execute/async_work_loop_runner"
63
64
  require_relative "aidp/execute/interactive_repl"
64
65
 
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.18.0
4
+ version: 0.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan
@@ -271,6 +271,7 @@ files:
271
271
  - lib/aidp/execute/guard_policy.rb
272
272
  - lib/aidp/execute/instruction_queue.rb
273
273
  - lib/aidp/execute/interactive_repl.rb
274
+ - lib/aidp/execute/persistent_tasklist.rb
274
275
  - lib/aidp/execute/progress.rb
275
276
  - lib/aidp/execute/prompt_manager.rb
276
277
  - lib/aidp/execute/repl_macros.rb