clacky 0.5.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 +7 -0
- data/.clackyrules +80 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +74 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +272 -0
- data/Rakefile +12 -0
- data/lib/clacky/agent.rb +964 -0
- data/lib/clacky/agent_config.rb +47 -0
- data/lib/clacky/cli.rb +666 -0
- data/lib/clacky/client.rb +159 -0
- data/lib/clacky/config.rb +43 -0
- data/lib/clacky/conversation.rb +41 -0
- data/lib/clacky/hook_manager.rb +61 -0
- data/lib/clacky/progress_indicator.rb +53 -0
- data/lib/clacky/session_manager.rb +124 -0
- data/lib/clacky/thinking_verbs.rb +26 -0
- data/lib/clacky/tool_registry.rb +44 -0
- data/lib/clacky/tools/base.rb +64 -0
- data/lib/clacky/tools/edit.rb +100 -0
- data/lib/clacky/tools/file_reader.rb +79 -0
- data/lib/clacky/tools/glob.rb +93 -0
- data/lib/clacky/tools/grep.rb +169 -0
- data/lib/clacky/tools/run_project.rb +287 -0
- data/lib/clacky/tools/safe_shell.rb +397 -0
- data/lib/clacky/tools/shell.rb +305 -0
- data/lib/clacky/tools/todo_manager.rb +228 -0
- data/lib/clacky/tools/trash_manager.rb +367 -0
- data/lib/clacky/tools/web_fetch.rb +161 -0
- data/lib/clacky/tools/web_search.rb +138 -0
- data/lib/clacky/tools/write.rb +65 -0
- data/lib/clacky/utils/arguments_parser.rb +139 -0
- data/lib/clacky/utils/limit_stack.rb +80 -0
- data/lib/clacky/utils/path_helper.rb +15 -0
- data/lib/clacky/version.rb +5 -0
- data/lib/clacky.rb +38 -0
- data/sig/clacky.rbs +4 -0
- metadata +152 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Clacky
|
|
6
|
+
module Tools
|
|
7
|
+
class Shell < Base
|
|
8
|
+
self.tool_name = "shell"
|
|
9
|
+
self.tool_description = "Execute shell commands in the terminal"
|
|
10
|
+
self.tool_category = "system"
|
|
11
|
+
self.tool_parameters = {
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
command: {
|
|
15
|
+
type: "string",
|
|
16
|
+
description: "Shell command to execute"
|
|
17
|
+
},
|
|
18
|
+
soft_timeout: {
|
|
19
|
+
type: "integer",
|
|
20
|
+
description: "Soft timeout in seconds (for interaction detection)"
|
|
21
|
+
},
|
|
22
|
+
hard_timeout: {
|
|
23
|
+
type: "integer",
|
|
24
|
+
description: "Hard timeout in seconds (force kill)"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
required: ["command"]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
INTERACTION_PATTERNS = [
|
|
31
|
+
[/\[Y\/n\]|\[y\/N\]|\(yes\/no\)|\(Y\/n\)|\(y\/N\)/i, 'confirmation'],
|
|
32
|
+
[/[Pp]assword\s*:\s*$|Enter password|enter password/, 'password'],
|
|
33
|
+
[/^\s*>>>\s*$|^\s*>>?\s*$|^irb\(.*\):\d+:\d+[>*]\s*$|^>\s*$/, 'repl'],
|
|
34
|
+
[/^\s*:\s*$|\(END\)|--More--|Press .* to continue|lines \d+-\d+/, 'pager'],
|
|
35
|
+
[/Are you sure|Continue\?|Proceed\?|Confirm|Overwrite/i, 'question'],
|
|
36
|
+
[/Enter\s+\w+:|Input\s+\w+:|Please enter|please provide/i, 'input'],
|
|
37
|
+
[/Select an option|Choose|Which one|select one/i, 'selection']
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
40
|
+
SLOW_COMMANDS = [
|
|
41
|
+
'bundle install',
|
|
42
|
+
'npm install',
|
|
43
|
+
'yarn install',
|
|
44
|
+
'pnpm install',
|
|
45
|
+
'rspec',
|
|
46
|
+
'rake test',
|
|
47
|
+
'npm run build',
|
|
48
|
+
'npm run test',
|
|
49
|
+
'yarn build',
|
|
50
|
+
'cargo build',
|
|
51
|
+
'go build'
|
|
52
|
+
].freeze
|
|
53
|
+
|
|
54
|
+
def execute(command:, soft_timeout: nil, hard_timeout: nil)
|
|
55
|
+
require "open3"
|
|
56
|
+
require "stringio"
|
|
57
|
+
|
|
58
|
+
soft_timeout, hard_timeout = determine_timeouts(command, soft_timeout, hard_timeout)
|
|
59
|
+
|
|
60
|
+
stdout_buffer = StringIO.new
|
|
61
|
+
stderr_buffer = StringIO.new
|
|
62
|
+
soft_timeout_triggered = false
|
|
63
|
+
|
|
64
|
+
begin
|
|
65
|
+
Open3.popen3(command) do |stdin, stdout, stderr, wait_thr|
|
|
66
|
+
start_time = Time.now
|
|
67
|
+
|
|
68
|
+
stdout.sync = true
|
|
69
|
+
stderr.sync = true
|
|
70
|
+
|
|
71
|
+
loop do
|
|
72
|
+
elapsed = Time.now - start_time
|
|
73
|
+
|
|
74
|
+
if elapsed > hard_timeout
|
|
75
|
+
Process.kill('TERM', wait_thr.pid) rescue nil
|
|
76
|
+
sleep 0.5
|
|
77
|
+
Process.kill('KILL', wait_thr.pid) if wait_thr.alive? rescue nil
|
|
78
|
+
|
|
79
|
+
return format_timeout_result(
|
|
80
|
+
command,
|
|
81
|
+
stdout_buffer.string,
|
|
82
|
+
stderr_buffer.string,
|
|
83
|
+
elapsed,
|
|
84
|
+
:hard_timeout,
|
|
85
|
+
hard_timeout
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
if elapsed > soft_timeout && !soft_timeout_triggered
|
|
90
|
+
soft_timeout_triggered = true
|
|
91
|
+
|
|
92
|
+
# L1:
|
|
93
|
+
interaction = detect_interaction(stdout_buffer.string)
|
|
94
|
+
if interaction
|
|
95
|
+
Process.kill('TERM', wait_thr.pid) rescue nil
|
|
96
|
+
return format_waiting_input_result(
|
|
97
|
+
command,
|
|
98
|
+
stdout_buffer.string,
|
|
99
|
+
stderr_buffer.string,
|
|
100
|
+
interaction
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# L2:
|
|
105
|
+
last_size = stdout_buffer.size
|
|
106
|
+
stdin.puts("\n") rescue nil
|
|
107
|
+
sleep 2
|
|
108
|
+
|
|
109
|
+
if stdout_buffer.size > last_size
|
|
110
|
+
next
|
|
111
|
+
else
|
|
112
|
+
Process.kill('TERM', wait_thr.pid) rescue nil
|
|
113
|
+
return format_stuck_result(
|
|
114
|
+
command,
|
|
115
|
+
stdout_buffer.string,
|
|
116
|
+
stderr_buffer.string,
|
|
117
|
+
elapsed
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
break unless wait_thr.alive?
|
|
123
|
+
|
|
124
|
+
begin
|
|
125
|
+
ready = IO.select([stdout, stderr], nil, nil, 0.1)
|
|
126
|
+
if ready
|
|
127
|
+
ready[0].each do |io|
|
|
128
|
+
begin
|
|
129
|
+
data = io.read_nonblock(4096)
|
|
130
|
+
if io == stdout
|
|
131
|
+
stdout_buffer.write(data)
|
|
132
|
+
else
|
|
133
|
+
stderr_buffer.write(data)
|
|
134
|
+
end
|
|
135
|
+
rescue IO::WaitReadable, EOFError
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
rescue StandardError => e
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
sleep 0.1
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
begin
|
|
146
|
+
stdout_buffer.write(stdout.read)
|
|
147
|
+
rescue StandardError
|
|
148
|
+
end
|
|
149
|
+
begin
|
|
150
|
+
stderr_buffer.write(stderr.read)
|
|
151
|
+
rescue StandardError
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
{
|
|
155
|
+
command: command,
|
|
156
|
+
stdout: stdout_buffer.string,
|
|
157
|
+
stderr: stderr_buffer.string,
|
|
158
|
+
exit_code: wait_thr.value.exitstatus,
|
|
159
|
+
success: wait_thr.value.success?,
|
|
160
|
+
elapsed: Time.now - start_time
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
rescue StandardError => e
|
|
164
|
+
{
|
|
165
|
+
command: command,
|
|
166
|
+
stdout: stdout_buffer.string,
|
|
167
|
+
stderr: "Error executing command: #{e.message}\n#{e.backtrace.first(3).join("\n")}",
|
|
168
|
+
exit_code: -1,
|
|
169
|
+
success: false
|
|
170
|
+
}
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def determine_timeouts(command, soft_timeout, hard_timeout)
|
|
175
|
+
# 检查是否是慢命令
|
|
176
|
+
is_slow = SLOW_COMMANDS.any? { |slow_cmd| command.include?(slow_cmd) }
|
|
177
|
+
|
|
178
|
+
if is_slow
|
|
179
|
+
soft_timeout ||= 30
|
|
180
|
+
hard_timeout ||= 180 # 3分钟
|
|
181
|
+
else
|
|
182
|
+
soft_timeout ||= 7
|
|
183
|
+
hard_timeout ||= 60
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
[soft_timeout, hard_timeout]
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# L1: 规则检测
|
|
190
|
+
def detect_interaction(output)
|
|
191
|
+
return nil if output.empty?
|
|
192
|
+
|
|
193
|
+
lines = output.split("\n").last(10)
|
|
194
|
+
|
|
195
|
+
lines.reverse.each do |line|
|
|
196
|
+
line_stripped = line.strip
|
|
197
|
+
next if line_stripped.empty?
|
|
198
|
+
|
|
199
|
+
INTERACTION_PATTERNS.each do |pattern, type|
|
|
200
|
+
if line.match?(pattern)
|
|
201
|
+
return { type: type, line: line_stripped }
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def format_waiting_input_result(command, stdout, stderr, interaction)
|
|
210
|
+
{
|
|
211
|
+
command: command,
|
|
212
|
+
stdout: stdout,
|
|
213
|
+
stderr: stderr,
|
|
214
|
+
exit_code: -2,
|
|
215
|
+
success: false,
|
|
216
|
+
state: 'WAITING_INPUT',
|
|
217
|
+
interaction_type: interaction[:type],
|
|
218
|
+
message: format_waiting_message(stdout, interaction)
|
|
219
|
+
}
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def format_waiting_message(output, interaction)
|
|
223
|
+
<<~MSG
|
|
224
|
+
#{output}
|
|
225
|
+
|
|
226
|
+
#{'=' * 60}
|
|
227
|
+
[Terminal State: WAITING_INPUT]
|
|
228
|
+
#{'=' * 60}
|
|
229
|
+
|
|
230
|
+
The terminal is waiting for your input.
|
|
231
|
+
|
|
232
|
+
Detected pattern: #{interaction[:type]}
|
|
233
|
+
Last line: #{interaction[:line]}
|
|
234
|
+
|
|
235
|
+
Suggested actions:
|
|
236
|
+
• Provide answer: run shell with your response
|
|
237
|
+
• Cancel: send Ctrl+C (\\x03)
|
|
238
|
+
MSG
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def format_stuck_result(command, stdout, stderr, elapsed)
|
|
242
|
+
{
|
|
243
|
+
command: command,
|
|
244
|
+
stdout: stdout,
|
|
245
|
+
stderr: stderr,
|
|
246
|
+
exit_code: -3,
|
|
247
|
+
success: false,
|
|
248
|
+
state: 'STUCK',
|
|
249
|
+
elapsed: elapsed,
|
|
250
|
+
message: format_stuck_message(stdout, elapsed)
|
|
251
|
+
}
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def format_stuck_message(output, elapsed)
|
|
255
|
+
<<~MSG
|
|
256
|
+
#{output}
|
|
257
|
+
|
|
258
|
+
#{'=' * 60}
|
|
259
|
+
[Terminal State: STUCK]
|
|
260
|
+
#{'=' * 60}
|
|
261
|
+
|
|
262
|
+
The terminal is not responding after #{elapsed.round(1)}s.
|
|
263
|
+
|
|
264
|
+
Suggested actions:
|
|
265
|
+
• Try interrupting with Ctrl+C
|
|
266
|
+
• Check if command is frozen
|
|
267
|
+
MSG
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def format_timeout_result(command, stdout, stderr, elapsed, type, timeout)
|
|
271
|
+
{
|
|
272
|
+
command: command,
|
|
273
|
+
stdout: stdout,
|
|
274
|
+
stderr: stderr.empty? ? "Command timed out after #{elapsed.round(1)} seconds (#{type}=#{timeout}s)" : stderr,
|
|
275
|
+
exit_code: -1,
|
|
276
|
+
success: false,
|
|
277
|
+
state: 'TIMEOUT',
|
|
278
|
+
timeout_type: type
|
|
279
|
+
}
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def format_call(args)
|
|
283
|
+
cmd = args[:command] || args['command'] || ''
|
|
284
|
+
cmd_parts = cmd.split
|
|
285
|
+
cmd_short = cmd_parts.first(3).join(' ')
|
|
286
|
+
cmd_short += '...' if cmd_parts.size > 3
|
|
287
|
+
"Shell(#{cmd_short})"
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def format_result(result)
|
|
291
|
+
exit_code = result[:exit_code] || result['exit_code'] || 0
|
|
292
|
+
stdout = result[:stdout] || result['stdout'] || ""
|
|
293
|
+
stderr = result[:stderr] || result['stderr'] || ""
|
|
294
|
+
|
|
295
|
+
if exit_code == 0
|
|
296
|
+
lines = stdout.lines.size
|
|
297
|
+
"✓ Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
|
|
298
|
+
else
|
|
299
|
+
error_msg = stderr.lines.first&.strip || "Failed"
|
|
300
|
+
"✗ Exit #{exit_code}: #{error_msg[0..50]}"
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Clacky
|
|
7
|
+
module Tools
|
|
8
|
+
class TodoManager < Base
|
|
9
|
+
self.tool_name = "todo_manager"
|
|
10
|
+
self.tool_description = "Manage TODO items for task planning and tracking. IMPORTANT: This tool is ONLY for planning - after adding all TODOs, you MUST immediately start executing them using other tools (write, edit, shell, etc). DO NOT stop after adding TODOs!"
|
|
11
|
+
self.tool_category = "task_management"
|
|
12
|
+
self.tool_parameters = {
|
|
13
|
+
type: "object",
|
|
14
|
+
properties: {
|
|
15
|
+
action: {
|
|
16
|
+
type: "string",
|
|
17
|
+
enum: ["add", "list", "complete", "remove", "clear"],
|
|
18
|
+
description: "Action to perform: 'add' (add new todo(s)), 'list' (show all todos), 'complete' (mark as done), 'remove' (delete todo), 'clear' (remove all todos)"
|
|
19
|
+
},
|
|
20
|
+
tasks: {
|
|
21
|
+
type: "array",
|
|
22
|
+
items: { type: "string" },
|
|
23
|
+
description: "Array of task descriptions to add (for 'add' action). Example: ['Task 1', 'Task 2', 'Task 3']"
|
|
24
|
+
},
|
|
25
|
+
task: {
|
|
26
|
+
type: "string",
|
|
27
|
+
description: "Single task description (for 'add' action). Use 'tasks' array for adding multiple tasks at once."
|
|
28
|
+
},
|
|
29
|
+
id: {
|
|
30
|
+
type: "integer",
|
|
31
|
+
description: "The task ID (required for 'complete' and 'remove' actions)"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
required: ["action"]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
def execute(action:, task: nil, tasks: nil, id: nil, todos_storage: nil)
|
|
38
|
+
# todos_storage is injected by Agent, stores todos in memory
|
|
39
|
+
@todos = todos_storage || []
|
|
40
|
+
|
|
41
|
+
case action
|
|
42
|
+
when "add"
|
|
43
|
+
add_todos(task: task, tasks: tasks)
|
|
44
|
+
when "list"
|
|
45
|
+
list_todos
|
|
46
|
+
when "complete"
|
|
47
|
+
complete_todo(id)
|
|
48
|
+
when "remove"
|
|
49
|
+
remove_todo(id)
|
|
50
|
+
when "clear"
|
|
51
|
+
clear_todos
|
|
52
|
+
else
|
|
53
|
+
{ error: "Unknown action: #{action}" }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def format_call(args)
|
|
58
|
+
action = args[:action] || args['action']
|
|
59
|
+
case action
|
|
60
|
+
when 'add'
|
|
61
|
+
count = (args[:tasks] || args['tasks'])&.size || 1
|
|
62
|
+
"TodoManager(add #{count} task#{count > 1 ? 's' : ''})"
|
|
63
|
+
when 'complete'
|
|
64
|
+
"TodoManager(complete ##{args[:id] || args['id']})"
|
|
65
|
+
when 'list'
|
|
66
|
+
"TodoManager(list)"
|
|
67
|
+
when 'remove'
|
|
68
|
+
"TodoManager(remove ##{args[:id] || args['id']})"
|
|
69
|
+
when 'clear'
|
|
70
|
+
"TodoManager(clear all)"
|
|
71
|
+
else
|
|
72
|
+
"TodoManager(#{action})"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def format_result(result)
|
|
77
|
+
return result[:error] if result[:error]
|
|
78
|
+
|
|
79
|
+
if result[:message]
|
|
80
|
+
result[:message]
|
|
81
|
+
else
|
|
82
|
+
"Done"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def load_todos
|
|
89
|
+
@todos
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def save_todos(todos)
|
|
93
|
+
# Modify the array in-place so Agent's @todos is updated
|
|
94
|
+
# Important: Don't use @todos.clear first because todos might be @todos itself!
|
|
95
|
+
@todos.replace(todos)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def add_todos(task: nil, tasks: nil)
|
|
99
|
+
# Determine which tasks to add
|
|
100
|
+
tasks_to_add = []
|
|
101
|
+
|
|
102
|
+
if tasks && tasks.is_a?(Array) && !tasks.empty?
|
|
103
|
+
tasks_to_add = tasks.map(&:strip).reject(&:empty?)
|
|
104
|
+
elsif task && !task.strip.empty?
|
|
105
|
+
tasks_to_add = [task.strip]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
return { error: "At least one task description is required" } if tasks_to_add.empty?
|
|
109
|
+
|
|
110
|
+
existing_todos = load_todos
|
|
111
|
+
next_id = existing_todos.empty? ? 1 : existing_todos.map { |t| t[:id] }.max + 1
|
|
112
|
+
|
|
113
|
+
added_todos = []
|
|
114
|
+
tasks_to_add.each_with_index do |task_desc, index|
|
|
115
|
+
new_todo = {
|
|
116
|
+
id: next_id + index,
|
|
117
|
+
task: task_desc,
|
|
118
|
+
status: "pending",
|
|
119
|
+
created_at: Time.now.iso8601
|
|
120
|
+
}
|
|
121
|
+
existing_todos << new_todo
|
|
122
|
+
added_todos << new_todo
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
save_todos(existing_todos)
|
|
126
|
+
|
|
127
|
+
{
|
|
128
|
+
message: added_todos.size == 1 ? "TODO added successfully" : "#{added_todos.size} TODOs added successfully",
|
|
129
|
+
todos: added_todos,
|
|
130
|
+
total: existing_todos.size,
|
|
131
|
+
reminder: "⚠️ IMPORTANT: You have added TODO(s) but have NOT started working yet! You MUST now use other tools (write, edit, shell, etc.) to actually complete these tasks. DO NOT stop here!"
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def list_todos
|
|
136
|
+
todos = load_todos
|
|
137
|
+
|
|
138
|
+
if todos.empty?
|
|
139
|
+
return {
|
|
140
|
+
message: "No TODO items",
|
|
141
|
+
todos: [],
|
|
142
|
+
total: 0
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
{
|
|
147
|
+
message: "TODO list",
|
|
148
|
+
todos: todos,
|
|
149
|
+
total: todos.size,
|
|
150
|
+
pending: todos.count { |t| t[:status] == "pending" },
|
|
151
|
+
completed: todos.count { |t| t[:status] == "completed" }
|
|
152
|
+
}
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def complete_todo(id)
|
|
156
|
+
return { error: "Task ID is required" } if id.nil?
|
|
157
|
+
|
|
158
|
+
todos = load_todos
|
|
159
|
+
todo = todos.find { |t| t[:id] == id }
|
|
160
|
+
|
|
161
|
+
return { error: "Task not found: #{id}" } unless todo
|
|
162
|
+
|
|
163
|
+
if todo[:status] == "completed"
|
|
164
|
+
return { message: "Task already completed", todo: todo }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
todo[:status] = "completed"
|
|
168
|
+
todo[:completed_at] = Time.now.iso8601
|
|
169
|
+
save_todos(todos)
|
|
170
|
+
|
|
171
|
+
# Find the next pending task
|
|
172
|
+
next_pending = todos.find { |t| t[:status] == "pending" }
|
|
173
|
+
|
|
174
|
+
# Count statistics
|
|
175
|
+
completed_count = todos.count { |t| t[:status] == "completed" }
|
|
176
|
+
total_count = todos.size
|
|
177
|
+
|
|
178
|
+
result = {
|
|
179
|
+
message: "Task marked as completed",
|
|
180
|
+
todo: todo,
|
|
181
|
+
progress: "#{completed_count}/#{total_count}",
|
|
182
|
+
reminder: "⚠️ REMINDER: Check the PROJECT-SPECIFIC RULES section in your system prompt before continuing to the next task"
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if next_pending
|
|
186
|
+
result[:next_task] = next_pending
|
|
187
|
+
result[:next_task_info] = "✅ Progress: #{completed_count}/#{total_count}. Next task: ##{next_pending[:id]} - #{next_pending[:task]}"
|
|
188
|
+
else
|
|
189
|
+
result[:all_completed] = true
|
|
190
|
+
result[:completion_message] = "🎉 All tasks completed! (#{completed_count}/#{total_count})"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
result
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def remove_todo(id)
|
|
197
|
+
return { error: "Task ID is required" } if id.nil?
|
|
198
|
+
|
|
199
|
+
todos = load_todos
|
|
200
|
+
todo = todos.find { |t| t[:id] == id }
|
|
201
|
+
|
|
202
|
+
return { error: "Task not found: #{id}" } unless todo
|
|
203
|
+
|
|
204
|
+
todos.reject! { |t| t[:id] == id }
|
|
205
|
+
save_todos(todos)
|
|
206
|
+
|
|
207
|
+
{
|
|
208
|
+
message: "Task removed",
|
|
209
|
+
todo: todo,
|
|
210
|
+
remaining: todos.size
|
|
211
|
+
}
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def clear_todos
|
|
215
|
+
todos = load_todos
|
|
216
|
+
count = todos.size
|
|
217
|
+
|
|
218
|
+
# Clear the in-memory storage
|
|
219
|
+
save_todos([])
|
|
220
|
+
|
|
221
|
+
{
|
|
222
|
+
message: "All TODOs cleared",
|
|
223
|
+
cleared_count: count
|
|
224
|
+
}
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|