todo-agent 0.3.3__py3-none-any.whl → 0.3.6__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.
todo_agent/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.3.3'
32
- __version_tuple__ = version_tuple = (0, 3, 3)
31
+ __version__ = version = '0.3.6'
32
+ __version_tuple__ = version_tuple = (0, 3, 6)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -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,6 +139,86 @@ 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
 
@@ -107,50 +227,53 @@ class TodoManager:
107
227
  ) -> str:
108
228
  """List tasks with optional filtering."""
109
229
  result = self.todo_shell.list_tasks(filter, suppress_color=suppress_color)
110
- if not result.strip():
111
- return "No tasks found."
112
-
113
- # Return the raw todo.txt format for the LLM to format conversationally
114
- # The LLM will convert this into natural language in its response
115
- return result
230
+ return self._handle_empty_result(result, "No tasks found.")
116
231
 
117
232
  def complete_task(self, task_number: int) -> str:
118
233
  """Mark task complete by line number."""
119
234
  result = self.todo_shell.complete(task_number)
120
- return f"Completed task {task_number}: {result}"
235
+ return self._format_task_operation_response("Completed", task_number, result)
121
236
 
122
237
  def replace_task(self, task_number: int, new_description: str) -> str:
123
238
  """Replace entire task content."""
124
239
  result = self.todo_shell.replace(task_number, new_description)
125
- return f"Replaced task {task_number}: {result}"
240
+ return self._format_task_operation_response("Replaced", task_number, result)
126
241
 
127
242
  def append_to_task(self, task_number: int, text: str) -> str:
128
243
  """Add text to end of existing task."""
129
244
  result = self.todo_shell.append(task_number, text)
130
- return f"Appended to task {task_number}: {result}"
245
+ return self._format_task_operation_response("Appended to", task_number, result)
131
246
 
132
247
  def prepend_to_task(self, task_number: int, text: str) -> str:
133
248
  """Add text to beginning of existing task."""
134
249
  result = self.todo_shell.prepend(task_number, text)
135
- return f"Prepended to task {task_number}: {result}"
250
+ return self._format_task_operation_response("Prepended to", task_number, result)
136
251
 
137
252
  def delete_task(self, task_number: int, term: Optional[str] = None) -> str:
138
253
  """Delete entire task or specific term from task."""
139
254
  result = self.todo_shell.delete(task_number, term)
140
- if term:
141
- 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
+ )
142
259
  else:
143
- return f"Deleted task {task_number}: {result}"
260
+ return self._format_task_operation_response("Deleted", task_number, result)
144
261
 
145
262
  def set_priority(self, task_number: int, priority: str) -> str:
146
263
  """Set or change task priority (A-Z)."""
264
+ priority = self._normalize_empty_to_none(priority)
265
+ self._validate_priority(priority)
147
266
  result = self.todo_shell.set_priority(task_number, priority)
148
- 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
+ )
149
270
 
150
271
  def remove_priority(self, task_number: int) -> str:
151
272
  """Remove priority from task."""
152
273
  result = self.todo_shell.remove_priority(task_number)
153
- return f"Removed priority from task {task_number}: {result}"
274
+ return self._format_task_operation_response(
275
+ "Removed priority from", task_number, result
276
+ )
154
277
 
155
278
  def set_due_date(self, task_number: int, due_date: str) -> str:
156
279
  """
@@ -164,19 +287,17 @@ class TodoManager:
164
287
  Confirmation message with the updated task
165
288
  """
166
289
  # Validate due date format only if not empty
167
- if due_date.strip():
168
- try:
169
- datetime.strptime(due_date, "%Y-%m-%d")
170
- except ValueError:
171
- raise ValueError(
172
- f"Invalid due date format '{due_date}'. Must be YYYY-MM-DD."
173
- )
290
+ self._validate_date_format(due_date, "due date")
174
291
 
175
292
  result = self.todo_shell.set_due_date(task_number, due_date)
176
293
  if due_date.strip():
177
- 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
+ )
178
297
  else:
179
- 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
+ )
180
301
 
181
302
  def set_context(self, task_number: int, context: str) -> str:
182
303
  """
@@ -190,20 +311,17 @@ class TodoManager:
190
311
  Confirmation message with the updated task
191
312
  """
192
313
  # Validate context name if not empty
193
- if context.strip():
194
- # Remove any existing @ symbols to prevent duplication
195
- clean_context = context.strip().lstrip("@")
196
- if not clean_context:
197
- raise ValueError(
198
- "Context name cannot be empty after removing @ symbol."
199
- )
314
+ clean_context = self._clean_context_name(context) if context.strip() else ""
200
315
 
201
316
  result = self.todo_shell.set_context(task_number, context)
202
317
  if context.strip():
203
- clean_context = context.strip().lstrip("@")
204
- 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
+ )
205
321
  else:
206
- return f"Removed context from task {task_number}: {result}"
322
+ return self._format_task_operation_response(
323
+ "Removed context from", task_number, result
324
+ )
207
325
 
208
326
  def set_project(self, task_number: int, projects: list) -> str:
209
327
  """
@@ -223,12 +341,7 @@ class TodoManager:
223
341
  if projects:
224
342
  for project in projects:
225
343
  if project.strip() and not project.startswith("-"):
226
- # Remove any existing + symbols to prevent duplication
227
- clean_project = project.strip().lstrip("+")
228
- if not clean_project:
229
- raise ValueError(
230
- "Project name cannot be empty after removing + symbol."
231
- )
344
+ self._clean_project_name(project)
232
345
  elif project.startswith("-"):
233
346
  clean_project = project[1:].strip().lstrip("+")
234
347
  if not clean_project:
@@ -239,7 +352,9 @@ class TodoManager:
239
352
  result = self.todo_shell.set_project(task_number, projects)
240
353
 
241
354
  if not projects:
242
- 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
+ )
243
358
  else:
244
359
  # Build operation description
245
360
  operations = []
@@ -255,24 +370,52 @@ class TodoManager:
255
370
  operations.append(f"added +{clean_project}")
256
371
 
257
372
  if not operations:
258
- 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
+ )
259
376
  else:
260
377
  operation_desc = ", ".join(operations)
261
- 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)
395
+
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
+ # ============================================================================
262
409
 
263
410
  def list_projects(self, suppress_color: bool = True, **kwargs: Any) -> str:
264
411
  """List all available projects in todo.txt."""
265
412
  result = self.todo_shell.list_projects(suppress_color=suppress_color)
266
- if not result.strip():
267
- return "No projects found."
268
- return result
413
+ return self._handle_empty_result(result, "No projects found.")
269
414
 
270
415
  def list_contexts(self, suppress_color: bool = True, **kwargs: Any) -> str:
271
416
  """List all available contexts in todo.txt."""
272
417
  result = self.todo_shell.list_contexts(suppress_color=suppress_color)
273
- if not result.strip():
274
- return "No contexts found."
275
- return result
418
+ return self._handle_empty_result(result, "No contexts found.")
276
419
 
277
420
  def list_completed_tasks(
278
421
  self,
@@ -335,16 +478,22 @@ class TodoManager:
335
478
  result = self.todo_shell.list_completed(
336
479
  combined_filter, suppress_color=suppress_color
337
480
  )
338
- if not result.strip():
339
- return "No completed tasks found matching the criteria."
340
- return result
481
+ return self._handle_empty_result(
482
+ result, "No completed tasks found matching the criteria."
483
+ )
484
+
485
+ # ============================================================================
486
+ # UTILITY OPERATIONS
487
+ # ============================================================================
341
488
 
342
489
  def move_task(
343
490
  self, task_number: int, destination: str, source: Optional[str] = None
344
491
  ) -> str:
345
492
  """Move task from source to destination file."""
346
493
  result = self.todo_shell.move(task_number, destination, source)
347
- 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
+ )
348
497
 
349
498
  def archive_tasks(self, **kwargs: Any) -> str:
350
499
  """Archive completed tasks."""
@@ -363,12 +512,17 @@ class TodoManager:
363
512
  timezone = now.astimezone().tzinfo
364
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}"
365
514
 
366
- def created_completed_task(
515
+ # ============================================================================
516
+ # ADVANCED OPERATIONS
517
+ # ============================================================================
518
+
519
+ def create_completed_task(
367
520
  self,
368
521
  description: str,
369
522
  completion_date: Optional[str] = None,
370
523
  project: Optional[str] = None,
371
524
  context: Optional[str] = None,
525
+ parent_number: Optional[int] = None,
372
526
  ) -> str:
373
527
  """
374
528
  Create a task and immediately mark it as completed.
@@ -381,74 +535,48 @@ class TodoManager:
381
535
  completion_date: Completion date in YYYY-MM-DD format (defaults to today)
382
536
  project: Optional project name (without the + symbol)
383
537
  context: Optional context name (without the @ symbol)
538
+ parent_number: Optional parent task number (required for subtasks)
384
539
 
385
540
  Returns:
386
541
  Confirmation message with the completed task details
387
542
  """
543
+ # Normalize empty strings to None
544
+ project = self._normalize_empty_to_none(project)
545
+ context = self._normalize_empty_to_none(context)
546
+
388
547
  # Set default completion date to today if not provided
389
548
  if not completion_date:
390
549
  completion_date = datetime.now().strftime("%Y-%m-%d")
391
550
 
392
- # Validate completion date format
393
- try:
394
- datetime.strptime(completion_date, "%Y-%m-%d")
395
- except ValueError:
396
- raise ValueError(
397
- f"Invalid completion date format '{completion_date}'. Must be YYYY-MM-DD."
398
- )
399
-
400
- # Build the task description with project and context
401
- full_description = description
551
+ # Validate inputs
552
+ self._validate_date_format(completion_date, "completion date")
553
+ self._validate_parent_number(parent_number)
402
554
 
403
- if project:
404
- # Remove any existing + symbols to prevent duplication
405
- clean_project = project.strip().lstrip("+")
406
- if not clean_project:
407
- raise ValueError(
408
- "Project name cannot be empty after removing + symbol."
409
- )
410
- full_description = f"{full_description} +{clean_project}"
411
-
412
- if context:
413
- # Remove any existing @ symbols to prevent duplication
414
- clean_context = context.strip().lstrip("@")
415
- if not clean_context:
416
- raise ValueError(
417
- "Context name cannot be empty after removing @ symbol."
418
- )
419
- full_description = f"{full_description} @{clean_context}"
420
-
421
- # Add the task first
422
- self.todo_shell.add(full_description)
423
-
424
- # Get the task number by finding the newly added task
425
- tasks = self.todo_shell.list_tasks()
426
- task_lines = [line.strip() for line in tasks.split("\n") if line.strip()]
427
- if not task_lines:
428
- raise RuntimeError("Failed to add task - no tasks found after addition")
429
-
430
- # Find the task that matches our description (it should be the last one added)
431
- # Look for the task that contains our description
432
- task_number = None
433
- for i, line in enumerate(task_lines, 1): # Start from 1 for todo.sh numbering
434
- if description in line:
435
- task_number = i
436
- break
555
+ # Build the task description
556
+ full_description = self._build_task_description(
557
+ description, project=project, context=context, parent_number=parent_number
558
+ )
437
559
 
438
- if task_number is None:
439
- # Fallback: use the last task number if we can't find a match
440
- task_number = len(task_lines)
441
- # Log a warning that we're using fallback logic
442
- import logging
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
443
563
 
444
- logging.warning(
445
- f"Could not find exact match for '{description}', using fallback task number {task_number}"
446
- )
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)
447
574
 
448
- # Mark it as complete
449
- 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)
450
578
 
451
- 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})"
452
580
 
453
581
  def restore_completed_task(self, task_number: int) -> str:
454
582
  """