todo-agent 0.3.2__py3-none-any.whl → 0.3.5__py3-none-any.whl

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.
@@ -12,86 +12,126 @@ class TodoManager:
12
12
  def __init__(self, todo_shell: Any) -> None:
13
13
  self.todo_shell = todo_shell
14
14
 
15
- def add_task(
16
- self,
17
- description: str,
18
- priority: Optional[str] = None,
19
- project: Optional[str] = None,
20
- context: Optional[str] = None,
21
- due: Optional[str] = None,
22
- duration: Optional[str] = None,
23
- ) -> str:
24
- """Add new task with explicit project/context parameters."""
25
- # Validate and sanitize inputs
26
- if priority and not (
27
- len(priority) == 1 and priority.isalpha() and priority.isupper()
28
- ):
15
+ # ============================================================================
16
+ # VALIDATION METHODS
17
+ # ============================================================================
18
+
19
+ def _normalize_empty_to_none(self, value: Optional[str]) -> Optional[str]:
20
+ """Convert empty strings to None for consistent handling."""
21
+ if value is None:
22
+ return None
23
+ if isinstance(value, str) and not value.strip():
24
+ return None
25
+ return value
26
+
27
+ def _validate_date_format(
28
+ self, date_str: Optional[str], field_name: str = "date"
29
+ ) -> None:
30
+ """Validate date format (YYYY-MM-DD)."""
31
+ if date_str is None or not date_str.strip():
32
+ return
33
+ try:
34
+ datetime.strptime(date_str, "%Y-%m-%d")
35
+ except ValueError:
36
+ raise ValueError(
37
+ f"Invalid {field_name} format '{date_str}'. Must be YYYY-MM-DD."
38
+ )
39
+
40
+ def _validate_priority(self, priority: Optional[str]) -> None:
41
+ """Validate priority format (single uppercase letter A-Z)."""
42
+ if priority is None:
43
+ return
44
+ if not (len(priority) == 1 and priority.isalpha() and priority.isupper()):
29
45
  raise ValueError(
30
46
  f"Invalid priority '{priority}'. Must be a single uppercase letter (A-Z)."
31
47
  )
32
48
 
33
- if project:
34
- # Remove any existing + symbols to prevent duplication
35
- project = project.strip().lstrip("+")
36
- if not project:
37
- raise ValueError(
38
- "Project name cannot be empty after removing + symbol."
39
- )
49
+ def _validate_parent_number(self, parent_number: Optional[int]) -> None:
50
+ """Validate parent task number."""
51
+ if parent_number is not None and (
52
+ not isinstance(parent_number, int) or parent_number <= 0
53
+ ):
54
+ raise ValueError(
55
+ f"Invalid parent_number '{parent_number}'. Must be a positive integer."
56
+ )
40
57
 
41
- if context:
42
- # Remove any existing @ symbols to prevent duplication
43
- context = context.strip().lstrip("@")
44
- if not context:
45
- raise ValueError(
46
- "Context name cannot be empty after removing @ symbol."
47
- )
58
+ def _validate_duration(self, duration: Optional[str]) -> None:
59
+ """Validate duration format (e.g., '30m', '2h', '1d')."""
60
+ if duration is None:
61
+ return
48
62
 
49
- if due:
50
- # Basic date format validation
51
- try:
52
- datetime.strptime(due, "%Y-%m-%d")
53
- except ValueError:
54
- raise ValueError(
55
- f"Invalid due date format '{due}'. Must be YYYY-MM-DD."
56
- )
63
+ if not isinstance(duration, str) or not duration.strip():
64
+ raise ValueError("Duration must be a non-empty string.")
57
65
 
58
- if duration is not None:
59
- # Validate duration format (e.g., "30m", "2h", "1d")
60
- if not duration or not isinstance(duration, str):
61
- raise ValueError("Duration must be a non-empty string.")
66
+ if not any(duration.endswith(unit) for unit in ["m", "h", "d"]):
67
+ raise ValueError(
68
+ f"Invalid duration format '{duration}'. Must end with m (minutes), h (hours), or d (days)."
69
+ )
62
70
 
63
- # Check if duration ends with a valid unit
64
- if not any(duration.endswith(unit) for unit in ["m", "h", "d"]):
65
- raise ValueError(
66
- f"Invalid duration format '{duration}'. Must end with m (minutes), h (hours), or d (days)."
67
- )
71
+ value = duration[:-1]
72
+ if not value:
73
+ raise ValueError("Duration value cannot be empty.")
68
74
 
69
- # Extract the numeric part and validate it
70
- value = duration[:-1]
71
- if not value:
72
- raise ValueError("Duration value cannot be empty.")
73
-
74
- try:
75
- # Check if the value is a valid positive number
76
- numeric_value = float(value)
77
- if numeric_value <= 0:
78
- raise ValueError("Duration value must be positive.")
79
- except ValueError:
80
- raise ValueError(
81
- f"Invalid duration value '{value}'. Must be a positive number."
82
- )
75
+ try:
76
+ numeric_value = float(value)
77
+ if numeric_value <= 0:
78
+ raise ValueError("Duration value must be positive.")
79
+ except ValueError:
80
+ raise ValueError(
81
+ f"Invalid duration value '{value}'. Must be a positive number."
82
+ )
83
83
 
84
- # Build the full task description with priority, project, and context
84
+ def _clean_project_name(self, project: Optional[str]) -> Optional[str]:
85
+ """Clean and validate project name."""
86
+ if project is None:
87
+ return None
88
+ if not project.strip():
89
+ return None
90
+ clean_project = project.strip().lstrip("+")
91
+ if not clean_project:
92
+ raise ValueError("Project name cannot be empty after removing + symbol.")
93
+ return clean_project
94
+
95
+ def _clean_context_name(self, context: Optional[str]) -> Optional[str]:
96
+ """Clean and validate context name."""
97
+ if context is None:
98
+ return None
99
+ if not context.strip():
100
+ return "" # Return empty string for empty input (used by set_context for removal)
101
+ clean_context = context.strip().lstrip("@")
102
+ if not clean_context:
103
+ raise ValueError("Context name cannot be empty after removing @ symbol.")
104
+ return clean_context
105
+
106
+ # ============================================================================
107
+ # TASK BUILDING AND UTILITY METHODS
108
+ # ============================================================================
109
+
110
+ def _build_task_description(
111
+ self,
112
+ description: str,
113
+ priority: Optional[str] = None,
114
+ project: Optional[str] = None,
115
+ context: Optional[str] = None,
116
+ due: Optional[str] = None,
117
+ duration: Optional[str] = None,
118
+ parent_number: Optional[int] = None,
119
+ ) -> str:
120
+ """Build full task description with all components."""
85
121
  full_description = description
86
122
 
87
123
  if priority:
88
124
  full_description = f"({priority}) {full_description}"
89
125
 
90
126
  if project:
91
- full_description = f"{full_description} +{project}"
127
+ clean_project = self._clean_project_name(project)
128
+ if clean_project: # Only add if not None/empty after cleaning
129
+ full_description = f"{full_description} +{clean_project}"
92
130
 
93
131
  if context:
94
- full_description = f"{full_description} @{context}"
132
+ clean_context = self._clean_context_name(context)
133
+ if clean_context: # Only add if not None/empty after cleaning
134
+ full_description = f"{full_description} @{clean_context}"
95
135
 
96
136
  if due:
97
137
  full_description = f"{full_description} due:{due}"
@@ -99,56 +139,141 @@ class TodoManager:
99
139
  if duration:
100
140
  full_description = f"{full_description} duration:{duration}"
101
141
 
142
+ if parent_number:
143
+ full_description = f"{full_description} parent:{parent_number}"
144
+
145
+ return full_description
146
+
147
+ def _format_task_operation_response(
148
+ self, operation: str, task_number: int, result: str, additional_info: str = ""
149
+ ) -> str:
150
+ """Format consistent task operation response messages."""
151
+ # Handle special cases for backward compatibility with tests
152
+ if operation == "Set due date" and additional_info:
153
+ return f"Set due date {additional_info} for task {task_number}: {result}"
154
+ elif operation == "Set context" and additional_info:
155
+ return f"Set context {additional_info} for task {task_number}: {result}"
156
+ elif operation == "Set priority" and additional_info:
157
+ return f"Set priority {additional_info} for task {task_number}: {result}"
158
+ else:
159
+ base_message = f"{operation} task {task_number}"
160
+ if additional_info:
161
+ base_message += f" ({additional_info})"
162
+ return f"{base_message}: {result}"
163
+
164
+ def _handle_empty_result(self, result: str, empty_message: str) -> str:
165
+ """Handle empty results with consistent messaging."""
166
+ return empty_message if not result.strip() else result
167
+
168
+ def _find_task_number_by_description(self, description: str) -> int:
169
+ """Find task number by description in the current task list."""
170
+ tasks = self.todo_shell.list_tasks()
171
+ task_lines = [line.strip() for line in tasks.split("\n") if line.strip()]
172
+
173
+ if not task_lines:
174
+ raise RuntimeError("Failed to add task - no tasks found after addition")
175
+
176
+ import re
177
+
178
+ for line in reversed(task_lines):
179
+ if description in line:
180
+ match = re.match(r"^(\d+)", line)
181
+ if match:
182
+ return int(match.group(1))
183
+
184
+ raise RuntimeError(
185
+ f"Could not find task with description '{description}' after adding it. "
186
+ f"This indicates a serious issue with task matching."
187
+ )
188
+
189
+ # ============================================================================
190
+ # CORE CRUD OPERATIONS
191
+ # ============================================================================
192
+
193
+ def add_task(
194
+ self,
195
+ description: str,
196
+ priority: Optional[str] = None,
197
+ project: Optional[str] = None,
198
+ context: Optional[str] = None,
199
+ due: Optional[str] = None,
200
+ duration: Optional[str] = None,
201
+ parent_number: Optional[int] = None,
202
+ ) -> str:
203
+ """Add new task with explicit project/context parameters."""
204
+ # Normalize empty strings to None for optional parameters
205
+ priority = self._normalize_empty_to_none(priority)
206
+ project = self._normalize_empty_to_none(project)
207
+ context = self._normalize_empty_to_none(context)
208
+ due = self._normalize_empty_to_none(due)
209
+ # Note: duration is not normalized - empty strings should raise validation errors
210
+
211
+ # Validate inputs
212
+ self._validate_priority(priority)
213
+ self._validate_date_format(due, "due date")
214
+ self._validate_duration(duration)
215
+ self._validate_parent_number(parent_number)
216
+
217
+ # Build the full task description
218
+ full_description = self._build_task_description(
219
+ description, priority, project, context, due, duration, parent_number
220
+ )
221
+
102
222
  self.todo_shell.add(full_description)
103
223
  return f"Added task: {full_description}"
104
224
 
105
- def list_tasks(self, filter: Optional[str] = None) -> str:
225
+ def list_tasks(
226
+ self, filter: Optional[str] = None, suppress_color: bool = True
227
+ ) -> str:
106
228
  """List tasks with optional filtering."""
107
- result = self.todo_shell.list_tasks(filter)
108
- if not result.strip():
109
- return "No tasks found."
110
-
111
- # Return the raw todo.txt format for the LLM to format conversationally
112
- # The LLM will convert this into natural language in its response
113
- return result
229
+ result = self.todo_shell.list_tasks(filter, suppress_color=suppress_color)
230
+ return self._handle_empty_result(result, "No tasks found.")
114
231
 
115
232
  def complete_task(self, task_number: int) -> str:
116
233
  """Mark task complete by line number."""
117
234
  result = self.todo_shell.complete(task_number)
118
- return f"Completed task {task_number}: {result}"
235
+ return self._format_task_operation_response("Completed", task_number, result)
119
236
 
120
237
  def replace_task(self, task_number: int, new_description: str) -> str:
121
238
  """Replace entire task content."""
122
239
  result = self.todo_shell.replace(task_number, new_description)
123
- return f"Replaced task {task_number}: {result}"
240
+ return self._format_task_operation_response("Replaced", task_number, result)
124
241
 
125
242
  def append_to_task(self, task_number: int, text: str) -> str:
126
243
  """Add text to end of existing task."""
127
244
  result = self.todo_shell.append(task_number, text)
128
- return f"Appended to task {task_number}: {result}"
245
+ return self._format_task_operation_response("Appended to", task_number, result)
129
246
 
130
247
  def prepend_to_task(self, task_number: int, text: str) -> str:
131
248
  """Add text to beginning of existing task."""
132
249
  result = self.todo_shell.prepend(task_number, text)
133
- return f"Prepended to task {task_number}: {result}"
250
+ return self._format_task_operation_response("Prepended to", task_number, result)
134
251
 
135
252
  def delete_task(self, task_number: int, term: Optional[str] = None) -> str:
136
253
  """Delete entire task or specific term from task."""
137
254
  result = self.todo_shell.delete(task_number, term)
138
- if term:
139
- return f"Removed '{term}' from task {task_number}: {result}"
255
+ if term is not None:
256
+ return self._format_task_operation_response(
257
+ "Removed", task_number, result, f"'{term}'"
258
+ )
140
259
  else:
141
- return f"Deleted task {task_number}: {result}"
260
+ return self._format_task_operation_response("Deleted", task_number, result)
142
261
 
143
262
  def set_priority(self, task_number: int, priority: str) -> str:
144
263
  """Set or change task priority (A-Z)."""
264
+ priority = self._normalize_empty_to_none(priority)
265
+ self._validate_priority(priority)
145
266
  result = self.todo_shell.set_priority(task_number, priority)
146
- return f"Set priority {priority} for task {task_number}: {result}"
267
+ return self._format_task_operation_response(
268
+ "Set priority", task_number, result, priority or ""
269
+ )
147
270
 
148
271
  def remove_priority(self, task_number: int) -> str:
149
272
  """Remove priority from task."""
150
273
  result = self.todo_shell.remove_priority(task_number)
151
- return f"Removed priority from task {task_number}: {result}"
274
+ return self._format_task_operation_response(
275
+ "Removed priority from", task_number, result
276
+ )
152
277
 
153
278
  def set_due_date(self, task_number: int, due_date: str) -> str:
154
279
  """
@@ -162,19 +287,17 @@ class TodoManager:
162
287
  Confirmation message with the updated task
163
288
  """
164
289
  # Validate due date format only if not empty
165
- if due_date.strip():
166
- try:
167
- datetime.strptime(due_date, "%Y-%m-%d")
168
- except ValueError:
169
- raise ValueError(
170
- f"Invalid due date format '{due_date}'. Must be YYYY-MM-DD."
171
- )
290
+ self._validate_date_format(due_date, "due date")
172
291
 
173
292
  result = self.todo_shell.set_due_date(task_number, due_date)
174
293
  if due_date.strip():
175
- return f"Set due date {due_date} for task {task_number}: {result}"
294
+ return self._format_task_operation_response(
295
+ "Set due date", task_number, result, due_date
296
+ )
176
297
  else:
177
- return f"Removed due date from task {task_number}: {result}"
298
+ return self._format_task_operation_response(
299
+ "Removed due date from", task_number, result
300
+ )
178
301
 
179
302
  def set_context(self, task_number: int, context: str) -> str:
180
303
  """
@@ -188,20 +311,17 @@ class TodoManager:
188
311
  Confirmation message with the updated task
189
312
  """
190
313
  # Validate context name if not empty
191
- if context.strip():
192
- # Remove any existing @ symbols to prevent duplication
193
- clean_context = context.strip().lstrip("@")
194
- if not clean_context:
195
- raise ValueError(
196
- "Context name cannot be empty after removing @ symbol."
197
- )
314
+ clean_context = self._clean_context_name(context) if context.strip() else ""
198
315
 
199
316
  result = self.todo_shell.set_context(task_number, context)
200
317
  if context.strip():
201
- clean_context = context.strip().lstrip("@")
202
- return f"Set context @{clean_context} for task {task_number}: {result}"
318
+ return self._format_task_operation_response(
319
+ "Set context", task_number, result, f"@{clean_context}"
320
+ )
203
321
  else:
204
- return f"Removed context from task {task_number}: {result}"
322
+ return self._format_task_operation_response(
323
+ "Removed context from", task_number, result
324
+ )
205
325
 
206
326
  def set_project(self, task_number: int, projects: list) -> str:
207
327
  """
@@ -221,12 +341,7 @@ class TodoManager:
221
341
  if projects:
222
342
  for project in projects:
223
343
  if project.strip() and not project.startswith("-"):
224
- # Remove any existing + symbols to prevent duplication
225
- clean_project = project.strip().lstrip("+")
226
- if not clean_project:
227
- raise ValueError(
228
- "Project name cannot be empty after removing + symbol."
229
- )
344
+ self._clean_project_name(project)
230
345
  elif project.startswith("-"):
231
346
  clean_project = project[1:].strip().lstrip("+")
232
347
  if not clean_project:
@@ -237,7 +352,9 @@ class TodoManager:
237
352
  result = self.todo_shell.set_project(task_number, projects)
238
353
 
239
354
  if not projects:
240
- return f"No project changes made to task {task_number}: {result}"
355
+ return self._format_task_operation_response(
356
+ "No project changes made to", task_number, result
357
+ )
241
358
  else:
242
359
  # Build operation description
243
360
  operations = []
@@ -253,24 +370,52 @@ class TodoManager:
253
370
  operations.append(f"added +{clean_project}")
254
371
 
255
372
  if not operations:
256
- return f"No project changes made to task {task_number}: {result}"
373
+ return self._format_task_operation_response(
374
+ "No project changes made to", task_number, result
375
+ )
257
376
  else:
258
377
  operation_desc = ", ".join(operations)
259
- return f"Updated projects for task {task_number} ({operation_desc}): {result}"
378
+ return self._format_task_operation_response(
379
+ "Updated projects for", task_number, result, operation_desc
380
+ )
381
+
382
+ def set_parent(self, task_number: int, parent_number: Optional[int]) -> str:
383
+ """
384
+ Set or update parent task number for a task by intelligently rewriting it.
385
+
386
+ Args:
387
+ task_number: The task number to modify
388
+ parent_number: Parent task number, or None to remove parent
389
+
390
+ Returns:
391
+ Confirmation message with the updated task
392
+ """
393
+ # Validate parent_number if provided
394
+ self._validate_parent_number(parent_number)
260
395
 
261
- def list_projects(self, **kwargs: Any) -> str:
396
+ result = self.todo_shell.set_parent(task_number, parent_number)
397
+ if parent_number is not None:
398
+ return self._format_task_operation_response(
399
+ "Set parent task", task_number, result, str(parent_number)
400
+ )
401
+ else:
402
+ return self._format_task_operation_response(
403
+ "Removed parent from", task_number, result
404
+ )
405
+
406
+ # ============================================================================
407
+ # LISTING AND QUERY METHODS
408
+ # ============================================================================
409
+
410
+ def list_projects(self, suppress_color: bool = True, **kwargs: Any) -> str:
262
411
  """List all available projects in todo.txt."""
263
- result = self.todo_shell.list_projects()
264
- if not result.strip():
265
- return "No projects found."
266
- return result
412
+ result = self.todo_shell.list_projects(suppress_color=suppress_color)
413
+ return self._handle_empty_result(result, "No projects found.")
267
414
 
268
- def list_contexts(self, **kwargs: Any) -> str:
415
+ def list_contexts(self, suppress_color: bool = True, **kwargs: Any) -> str:
269
416
  """List all available contexts in todo.txt."""
270
- result = self.todo_shell.list_contexts()
271
- if not result.strip():
272
- return "No contexts found."
273
- return result
417
+ result = self.todo_shell.list_contexts(suppress_color=suppress_color)
418
+ return self._handle_empty_result(result, "No contexts found.")
274
419
 
275
420
  def list_completed_tasks(
276
421
  self,
@@ -280,6 +425,7 @@ class TodoManager:
280
425
  text_search: Optional[str] = None,
281
426
  date_from: Optional[str] = None,
282
427
  date_to: Optional[str] = None,
428
+ suppress_color: bool = True,
283
429
  **kwargs: Any,
284
430
  ) -> str:
285
431
  """List completed tasks with optional filtering.
@@ -329,17 +475,25 @@ class TodoManager:
329
475
  # Combine all filters
330
476
  combined_filter = " ".join(filter_parts) if filter_parts else None
331
477
 
332
- result = self.todo_shell.list_completed(combined_filter)
333
- if not result.strip():
334
- return "No completed tasks found matching the criteria."
335
- return result
478
+ result = self.todo_shell.list_completed(
479
+ combined_filter, suppress_color=suppress_color
480
+ )
481
+ return self._handle_empty_result(
482
+ result, "No completed tasks found matching the criteria."
483
+ )
484
+
485
+ # ============================================================================
486
+ # UTILITY OPERATIONS
487
+ # ============================================================================
336
488
 
337
489
  def move_task(
338
490
  self, task_number: int, destination: str, source: Optional[str] = None
339
491
  ) -> str:
340
492
  """Move task from source to destination file."""
341
493
  result = self.todo_shell.move(task_number, destination, source)
342
- return f"Moved task {task_number} to {destination}: {result}"
494
+ return self._format_task_operation_response(
495
+ "Moved", task_number, result, f"to {destination}"
496
+ )
343
497
 
344
498
  def archive_tasks(self, **kwargs: Any) -> str:
345
499
  """Archive completed tasks."""
@@ -358,12 +512,17 @@ class TodoManager:
358
512
  timezone = now.astimezone().tzinfo
359
513
  return f"Current date and time: {now.strftime('%Y-%m-%d %H:%M:%S')} {timezone} ({now.strftime('%A, %B %d, %Y at %I:%M %p')}) - Week {week_number}"
360
514
 
361
- def created_completed_task(
515
+ # ============================================================================
516
+ # ADVANCED OPERATIONS
517
+ # ============================================================================
518
+
519
+ def create_completed_task(
362
520
  self,
363
521
  description: str,
364
522
  completion_date: Optional[str] = None,
365
523
  project: Optional[str] = None,
366
524
  context: Optional[str] = None,
525
+ parent_number: Optional[int] = None,
367
526
  ) -> str:
368
527
  """
369
528
  Create a task and immediately mark it as completed.
@@ -376,74 +535,48 @@ class TodoManager:
376
535
  completion_date: Completion date in YYYY-MM-DD format (defaults to today)
377
536
  project: Optional project name (without the + symbol)
378
537
  context: Optional context name (without the @ symbol)
538
+ parent_number: Optional parent task number (required for subtasks)
379
539
 
380
540
  Returns:
381
541
  Confirmation message with the completed task details
382
542
  """
543
+ # Normalize empty strings to None
544
+ project = self._normalize_empty_to_none(project)
545
+ context = self._normalize_empty_to_none(context)
546
+
383
547
  # Set default completion date to today if not provided
384
548
  if not completion_date:
385
549
  completion_date = datetime.now().strftime("%Y-%m-%d")
386
550
 
387
- # Validate completion date format
388
- try:
389
- datetime.strptime(completion_date, "%Y-%m-%d")
390
- except ValueError:
391
- raise ValueError(
392
- f"Invalid completion date format '{completion_date}'. Must be YYYY-MM-DD."
393
- )
394
-
395
- # Build the task description with project and context
396
- full_description = description
397
-
398
- if project:
399
- # Remove any existing + symbols to prevent duplication
400
- clean_project = project.strip().lstrip("+")
401
- if not clean_project:
402
- raise ValueError(
403
- "Project name cannot be empty after removing + symbol."
404
- )
405
- full_description = f"{full_description} +{clean_project}"
406
-
407
- if context:
408
- # Remove any existing @ symbols to prevent duplication
409
- clean_context = context.strip().lstrip("@")
410
- if not clean_context:
411
- raise ValueError(
412
- "Context name cannot be empty after removing @ symbol."
413
- )
414
- full_description = f"{full_description} @{clean_context}"
415
-
416
- # Add the task first
417
- self.todo_shell.add(full_description)
418
-
419
- # Get the task number by finding the newly added task
420
- tasks = self.todo_shell.list_tasks()
421
- task_lines = [line.strip() for line in tasks.split("\n") if line.strip()]
422
- if not task_lines:
423
- raise RuntimeError("Failed to add task - no tasks found after addition")
424
-
425
- # Find the task that matches our description (it should be the last one added)
426
- # Look for the task that contains our description
427
- task_number = None
428
- for i, line in enumerate(task_lines, 1): # Start from 1 for todo.sh numbering
429
- if description in line:
430
- task_number = i
431
- break
432
-
433
- if task_number is None:
434
- # Fallback: use the last task number if we can't find a match
435
- task_number = len(task_lines)
436
- # Log a warning that we're using fallback logic
437
- import logging
438
-
439
- logging.warning(
440
- f"Could not find exact match for '{description}', using fallback task number {task_number}"
441
- )
551
+ # Validate inputs
552
+ self._validate_date_format(completion_date, "completion date")
553
+ self._validate_parent_number(parent_number)
554
+
555
+ # Build the task description
556
+ full_description = self._build_task_description(
557
+ description, project=project, context=context, parent_number=parent_number
558
+ )
559
+
560
+ # Check if we need to use a specific completion date
561
+ current_date = datetime.now().strftime("%Y-%m-%d")
562
+ use_specific_date = completion_date != current_date
563
+
564
+ if use_specific_date:
565
+ # When using a specific completion date, add directly to done.txt
566
+ # Format: "x YYYY-MM-DD [task description]"
567
+ completed_task_line = f"x {completion_date} {full_description}"
568
+ self.todo_shell.addto("done.txt", completed_task_line)
569
+ return f"Created and completed task: {full_description} (completed on {completion_date})"
570
+ else:
571
+ # When using current date, use the standard add + complete workflow
572
+ # Add the task first
573
+ self.todo_shell.add(full_description)
442
574
 
443
- # Mark it as complete
444
- self.todo_shell.complete(task_number)
575
+ # Find the task number and mark it complete
576
+ task_number = self._find_task_number_by_description(description)
577
+ self.todo_shell.complete(task_number)
445
578
 
446
- return f"Created and completed task: {full_description} (completed on {completion_date})"
579
+ return f"Created and completed task: {full_description} (completed on {completion_date})"
447
580
 
448
581
  def restore_completed_task(self, task_number: int) -> str:
449
582
  """
@@ -464,7 +597,7 @@ class TodoManager:
464
597
 
465
598
  # Use the move command to restore the task from done.txt to todo.txt
466
599
  result = self.todo_shell.move(task_number, "todo.txt", "done.txt")
467
-
600
+
468
601
  # Extract the task description from the result for confirmation
469
602
  # The result format is typically: "TODO: X moved from '.../done.txt' to '.../todo.txt'."
470
603
  if "moved from" in result and "to" in result: