Skip to content

Commit e090155

Browse files
committed
feat(tasks): implement procedural task management script
1 parent 449293c commit e090155

4 files changed

Lines changed: 527 additions & 219 deletions

File tree

.gemini/commands/task.toml

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,43 @@
11
description = "Manage the project roadmap (TASKS.md) and implement tasks via a strict, agent-delegated TCR loop."
22

33
prompt = """
4-
You are an expert project manager. Your goal is to manage the project's roadmap in `TASKS.md`.
4+
You are an expert project manager. Your goal is to manage the project's roadmap in `TASKS.md` EXCLUSIVELY via the `.gemini/scripts/task.py` CLI.
5+
**CRITICAL: NEVER modify `TASKS.md` directly with file-editing tools. You MUST use the script.**
6+
57
Depending on the user's intent or arguments, perform one of the following actions:
68
79
### **Action: Create**
8-
Add a new task to `TASKS.md`.
9-
1. Read `TASKS.md` to understand the current structure and existing tasks.
10+
Add a new task using the script.
11+
1. Run `python .gemini/scripts/task.py --help` if you need to recall the `add` subcommand arguments.
1012
2. If the user provided a task description in the arguments, use it. Otherwise, identify the task from the recent conversation context.
11-
3. Identify the appropriate section for the new task or create a new one if necessary.
12-
4. Add the task with a clear description and status 'Todo' [ ].
13-
5. Ensure the task follows the existing formatting conventions and verify the file after the update.
13+
3. Determine an appropriate `label`, `description`, `category`, and `complexity`.
14+
4. Execute: `python .gemini/scripts/task.py add --label "<label>" --description "<description>" --category "<category>" --complexity <complexity>`.
15+
5. Verify the update by reading the new `TASKS.md`.
1416
1517
### **Action: Work**
1618
Implement a task using a strict, agent-delegated Test-Commit-Revert (TCR) protocol on a feature branch.
19+
1720
1. **Pre-flight Verification:**
1821
- Verify `git status --porcelain` is empty (clean tree).
1922
- Verify `git branch --show-current` is `main`.
2023
- Run `make test` and ensure all tests pass. If any fail, notify the user and stop.
24+
2125
2. **Task Setup:**
22-
- Identify the target task from arguments or context.
23-
- Mark it as `[/] In Progress (@apiad)` in `TASKS.md`.
26+
- Identify the target task ID from arguments or context.
27+
- Run `python .gemini/scripts/task.py start --task-id <ID>` to mark it in progress.
2428
- Generate a descriptive kebab-case branch name (e.g., `feature/task-description`).
2529
- Create and switch to the branch: `git checkout -b <branch-name>`.
30+
2631
3. **The TCR Loop (Delegation):**
2732
- Break the task into granular, testable steps based on the project plan (if one exists).
2833
- For each step, **delegate the implementation to the specialized `coder` subagent**:
2934
- Instruct the `coder` to follow its Red-Green-Verify mantra (write test, implement code, verify).
3035
- **If the `coder` reports success:** Run `git add . && git commit -m "Step: <description of changes>"`.
3136
- **If the `coder` reports failure:** Run `git checkout .` to revert the current step to the last green state.
3237
- Use `ask_user` to report each step's result and confirm before proceeding to the next one.
38+
39+
NOTE: The coder subagent is very dumb, only for grunt coding. Make sure to give it detailed instructions and to split the task into very small steps. If the coder keeps failing and the task explanation cannot be simplified further, then fix it yourself. If that doesn't work, ask the user for clarification and possibly update the task plan.
40+
3341
4. **Integration & Finalization:**
3442
- Once all steps are complete, run a full `make test`.
3543
- Use `ask_user` to request permission to merge back to `main`.
@@ -38,20 +46,20 @@ Implement a task using a strict, agent-delegated Test-Commit-Revert (TCR) protoc
3846
- Merge the branch: `git merge <branch-name>`.
3947
- Run `make test` on `main`.
4048
- Delete the feature branch: `git branch -d <branch-name>`.
41-
- Update `TASKS.md` to reflect the final state (mark as `[x] Done`).
49+
- Run `python .gemini/scripts/task.py archive --task-id <ID>` to mark it done.
4250
4351
### **Action: Report**
4452
Produce a strategic report of current tasks.
45-
1. Analyze `TASKS.md` and list all [ ] Todo or [/] In Progress tasks.
53+
1. Read `TASKS.md` and list all active tasks (Todo or In Progress).
4654
2. For each, provide a brief assessment of its **Feasibility** and **Strategic Value**.
4755
3. Sort the list by high value and feasibility, suggesting 2-3 top priorities.
4856
4957
### **Action: Update**
5058
Synchronize `TASKS.md` with the project's progress.
5159
1. Read `TASKS.md`, `CHANGELOG.md`, and recent `journal/` entries.
5260
2. Analyze uncommitted changes and conversation context.
53-
3. Update task statuses: mark completed as [x] Done, move current work to [/] In Progress, and add new high-level features if missing.
54-
4. If a task was implemented differently, update its description.
61+
3. Use the script subcommands (`start`, `cancel`, `archive`) to update task statuses as appropriate.
62+
4. If a task requires a linked plan, use `python .gemini/scripts/task.py attach-plan --task-id <ID> --plan-path <path>`.
5563
5. Verify the file after the update.
5664
5765
---

.gemini/scripts/task.py

Lines changed: 141 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ def __init__(self, id=None, label=None, description=None, category=None, complex
1313
self.category = category
1414
self.complexity = complexity
1515
self.dependencies = dependencies if dependencies is not None else []
16-
self.status = status # e.g., "todo", "in_progress", "done"
16+
self.status = status # e.g., "todo", "in_progress", "done", "cancelled"
1717
self.plan_path = plan_path
1818

1919
def __repr__(self):
@@ -34,13 +34,15 @@ def __eq__(self, other):
3434

3535
# --- Regex Patterns ---
3636
# New format: - [Status] **[ID]** Label: Description (Complexity: X) [Deps: Y] (See plan: Z)
37+
# Note: Label is optional in some contexts but the script expects it for new format
3738
NEW_TASK_REGEX = re.compile(
38-
r'^- \[(?P<status>[^\]]+)\] \*\*(?P<id>[^ ]+)\*\* (?P<label>[^:]+): (?P<description>.*?)(?: \(Complexity: (?P<complexity>\d+)\))?(?: \[Deps: (?P<dependencies>.*?)\])?(?: \(See plan: (?P<plan_path>.*?)\))?$'
39+
r'^- \[(?P<status>[^\]]*)\] \*\*(?P<id>[^ ]+)\*\*(?: (?P<label>[^:]+):)? (?P<description>.*?)(?: \(Complexity: (?P<complexity>\d+)\))?(?: \[Deps: (?P<dependencies>.*?)\])?(?: \(See plan: (?P<plan_path>.*?)\))?$'
3940
)
4041
# Old format: - [ ] Description (See plan: ...)
4142
OLD_TASK_REGEX = re.compile(
4243
r'^- \[(?P<status>[^\]]*)\] (?P<description>.*?)(?: \(See plan: (?P<plan_path>.*?)\))?$'
4344
)
45+
4446
HEADER_WARNING = """# Tasks
4547
4648
> **WARNING: NEVER MODIFY THIS FILE BY HAND. USE THE SCRIPT INSTEAD.**
@@ -51,7 +53,7 @@ def __eq__(self, other):
5153

5254
def parse_task_line(line):
5355
line = line.strip()
54-
if not line or line.startswith('#') or line.startswith('>'):
56+
if not line or not line.startswith('- ['):
5557
return None
5658

5759
new_match = NEW_TASK_REGEX.match(line)
@@ -63,22 +65,35 @@ def parse_task_line(line):
6365
dependencies = [dep.strip() for dep in deps_str.split(',') if dep.strip()]
6466

6567
complexity = int(data.get('complexity')) if data.get('complexity') else 0
68+
69+
status_char = data.get('status')
70+
status_map = {" ": "todo", "/": "in_progress", "x": "done", "-": "cancelled"}
71+
status = status_map.get(status_char, "todo")
72+
73+
label = data.get('label')
74+
if label:
75+
label = label.strip()
76+
6677
return Task(
6778
id=data.get('id'),
68-
label=data.get('label'),
69-
description=data.get('description'),
79+
label=label,
80+
description=data.get('description').strip(),
7081
category=None,
7182
complexity=complexity,
7283
dependencies=dependencies,
73-
status=data.get('status'),
84+
status=status,
7485
plan_path=data.get('plan_path')
7586
)
7687

7788
old_match = OLD_TASK_REGEX.match(line)
7889
if old_match:
7990
data = old_match.groupdict()
80-
status = data.get('status').strip()
81-
if not status: status = "todo"
91+
status_char = data.get('status').strip()
92+
if status_char == 'x': status = "done"
93+
elif status_char == '/': status = "in_progress"
94+
elif status_char == '-': status = "cancelled"
95+
else: status = "todo"
96+
8297
return Task(
8398
id=None,
8499
label=None,
@@ -93,16 +108,16 @@ def parse_task_line(line):
93108

94109
def parse_tasks_file(file_content):
95110
tasks = []
96-
current_category = None
111+
current_category = "General"
97112
lines = file_content.splitlines()
98113

99114
for line in lines:
100115
stripped_line = line.strip()
116+
if not stripped_line:
117+
continue
101118
if stripped_line.startswith("## Active Tasks"):
102-
current_category = None
103119
continue
104120
elif stripped_line.startswith("## Archive"):
105-
current_category = None
106121
continue
107122
elif stripped_line.startswith("### "):
108123
current_category = stripped_line[4:].strip()
@@ -117,14 +132,20 @@ def parse_tasks_file(file_content):
117132
# --- Formatter Functions ---
118133

119134
def format_task_to_line(task):
135+
status_map = {"todo": " ", "in_progress": "/", "done": "x", "cancelled": "-"}
136+
status_str = status_map.get(task.status, task.status)
137+
120138
if task.id is not None:
121139
complexity_str = f" (Complexity: {task.complexity})" if task.complexity != 0 else ""
122-
deps_str = f" [Deps: {', '.join(task.dependencies)}]"
140+
deps_str = ""
141+
if task.dependencies: # Only add [Deps: ...] if there are dependencies
142+
deps_str = f" [Deps: {', '.join(task.dependencies)}]"
123143
plan_path_str = f" (See plan: {task.plan_path})" if task.plan_path else ""
124-
return f"- [{task.status}] **{task.id}** {task.label}: {task.description}{complexity_str}{deps_str}{plan_path_str}"
144+
label_str = f" {task.label}:" if task.label else ""
145+
return f"- [{status_str}] **{task.id}**{label_str} {task.description}{complexity_str}{deps_str}{plan_path_str}"
125146
else:
126147
plan_path_str = f" (See plan: {task.plan_path})" if task.plan_path else ""
127-
return f"- [{task.status}] {task.description}{plan_path_str}"
148+
return f"- [{status_str}] {task.description}{plan_path_str}"
128149

129150
def topological_sort(tasks):
130151
adj = defaultdict(list)
@@ -153,9 +174,7 @@ def topological_sort(tasks):
153174
queue.append(v)
154175

155176
sorted_tasks = [task_map[tid] for tid in sorted_tasks_ids]
156-
# Add tasks with IDs that were not in the sort (cycles)
157177
remaining_with_ids = sorted([t for t in tasks if t.id and t.id not in sorted_tasks_ids], key=lambda t: (t.complexity, t.id))
158-
# Add tasks without IDs
159178
without_ids = sorted([t for t in tasks if not t.id], key=lambda t: (t.complexity, t.description))
160179

161180
return sorted_tasks + remaining_with_ids + without_ids
@@ -167,7 +186,7 @@ def format_tasks_to_markdown(tasks):
167186
def group_by_category(task_list):
168187
grouped = defaultdict(list)
169188
for t in task_list:
170-
cat = t.category if t.category else "Uncategorized"
189+
cat = t.category if t.category else "General"
171190
grouped[cat].append(t)
172191
return grouped
173192

@@ -180,35 +199,136 @@ def group_by_category(task_list):
180199
if not active_tasks:
181200
lines.append("No active tasks.")
182201
else:
202+
# Sort categories alphabetically for consistent output
183203
for cat in sorted(active_grouped.keys()):
204+
lines.append("")
184205
lines.append(f"### {cat}")
185206
for t in topological_sort(active_grouped[cat]):
186207
lines.append(format_task_to_line(t))
187208

209+
lines.append("")
188210
lines.append("## Archive")
189211
if not archive_tasks:
190212
lines.append("No archived tasks.")
191213
else:
214+
# Sort categories alphabetically for consistent output
192215
for cat in sorted(archive_grouped.keys()):
216+
lines.append("")
193217
lines.append(f"### {cat}")
194-
# Archive sorted by complexity then id/description
195218
cat_tasks = sorted(archive_grouped[cat], key=lambda t: (t.complexity, t.id if t.id else t.description))
196219
for t in cat_tasks:
197220
lines.append(format_task_to_line(t))
198221

199222
return "\n".join(lines) + "\n"
200223

224+
def generate_next_task_id(tasks, category):
225+
prefix = category[0].upper()
226+
max_num = 0
227+
for task in tasks:
228+
if task.id and task.id.startswith(prefix + "."):
229+
try:
230+
num_part = task.id.split('.')[-1]
231+
current_num = int(num_part)
232+
if current_num > max_num:
233+
max_num = current_num
234+
except ValueError:
235+
continue
236+
return f"{prefix}.{max_num + 1}"
237+
238+
def add_task(tasks, label, description, category, complexity, dependencies, plan_path):
239+
new_task_id = generate_next_task_id(tasks, category)
240+
new_task = Task(
241+
id=new_task_id,
242+
label=label,
243+
description=description,
244+
category=category,
245+
complexity=complexity,
246+
dependencies=dependencies,
247+
status='todo',
248+
plan_path=plan_path
249+
)
250+
tasks.append(new_task)
251+
return tasks
252+
253+
# --- New Functionality ---
254+
255+
def update_task(tasks, task_id, **kwargs):
256+
"""
257+
Searches for a task by its ID and updates the specified attributes.
258+
If the task is not found, prints a warning to sys.stderr.
259+
Returns the potentially modified list of tasks.
260+
"""
261+
task_found = False
262+
for task in tasks:
263+
if task.id == task_id:
264+
for key, value in kwargs.items():
265+
setattr(task, key, value)
266+
task_found = True
267+
break # Assuming task IDs are unique
268+
if not task_found:
269+
print(f"Warning: Task with ID {task_id} not found.", file=sys.stderr)
270+
return tasks
271+
201272
def main():
202273
parser = argparse.ArgumentParser(description="Task management script for TASKS.md.")
203-
# For now we just implement the reformat on call
204-
tasks_file_path = "TASKS.md"
274+
subparsers = parser.add_subparsers(dest='command', help='Available commands')
275+
276+
# Add command
277+
parser_add = subparsers.add_parser('add', help='Add a new task')
278+
parser_add.add_argument('--label', required=True)
279+
parser_add.add_argument('--description', required=True)
280+
parser_add.add_argument('--category', required=True)
281+
parser_add.add_argument('--complexity', type=int, default=0)
282+
parser_add.add_argument('--dependencies', type=str, default="")
283+
parser_add.add_argument('--plan-path', type=str, default=None)
284+
285+
# New commands: start, cancel, archive, attach-plan
286+
parser_start = subparsers.add_parser('start', help='Set task status to in_progress')
287+
parser_start.add_argument('--task-id', required=True)
288+
289+
parser_cancel = subparsers.add_parser('cancel', help='Set task status to cancelled')
290+
parser_cancel.add_argument('--task-id', required=True)
291+
292+
parser_archive = subparsers.add_parser('archive', help='Set task status to done')
293+
parser_archive.add_argument('--task-id', required=True)
294+
295+
parser_attach_plan = subparsers.add_parser('attach-plan', help='Set the task\'s plan path')
296+
parser_attach_plan.add_argument('--task-id', required=True)
297+
parser_attach_plan.add_argument('--plan-path', required=True)
298+
299+
args = parser.parse_args()
300+
301+
# Use environment variable to override TASKS.md for testing
302+
import os
303+
tasks_file_path = os.environ.get("GEMINI_TASKS_FILE", "TASKS.md")
304+
205305
try:
206306
with open(tasks_file_path, 'r') as f:
207307
content = f.read()
208308
except FileNotFoundError:
209-
sys.exit(1)
309+
content = HEADER_WARNING + "\n"
210310

211311
tasks = parse_tasks_file(content)
312+
313+
# Migration: Assign IDs to tasks that don't have them
314+
for task in tasks:
315+
if task.id is None:
316+
task.id = generate_next_task_id(tasks, task.category if task.category else "General")
317+
318+
if args.command == 'add':
319+
deps = [d.strip() for d in args.dependencies.split(',') if d.strip()]
320+
tasks = add_task(tasks, args.label, args.description, args.category, args.complexity, deps, args.plan_path)
321+
322+
# Handle new commands
323+
elif args.command == 'start':
324+
tasks = update_task(tasks, args.task_id, status="in_progress")
325+
elif args.command == 'cancel':
326+
tasks = update_task(tasks, args.task_id, status="cancelled")
327+
elif args.command == 'archive':
328+
tasks = update_task(tasks, args.task_id, status="done")
329+
elif args.command == 'attach-plan':
330+
tasks = update_task(tasks, args.task_id, plan_path=args.plan_path)
331+
212332
formatted = format_tasks_to_markdown(tasks)
213333
with open(tasks_file_path, 'w') as f:
214334
f.write(formatted)

0 commit comments

Comments
 (0)