Skip to content

Commit 449293c

Browse files
committed
Step 1: implement core Task model and parser/formatter logic
1 parent be376b5 commit 449293c

3 files changed

Lines changed: 386 additions & 0 deletions

File tree

.gemini/scripts/task.py

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
#!/usr/bin/env python3
2+
3+
import re
4+
import sys
5+
import argparse
6+
from collections import defaultdict, deque
7+
8+
class Task:
9+
def __init__(self, id=None, label=None, description=None, category=None, complexity=0, dependencies=None, status="todo", plan_path=None):
10+
self.id = id
11+
self.label = label
12+
self.description = description
13+
self.category = category
14+
self.complexity = complexity
15+
self.dependencies = dependencies if dependencies is not None else []
16+
self.status = status # e.g., "todo", "in_progress", "done"
17+
self.plan_path = plan_path
18+
19+
def __repr__(self):
20+
return (f"Task(id={self.id!r}, label={self.label!r}, description={self.description!r}, category={self.category!r}, "
21+
f"complexity={self.complexity!r}, dependencies={self.dependencies!r}, status={self.status!r}, plan_path={self.plan_path!r})")
22+
23+
def __eq__(self, other):
24+
if not isinstance(other, Task):
25+
return NotImplemented
26+
return (self.id == other.id and
27+
self.label == other.label and
28+
self.description == other.description and
29+
self.category == other.category and
30+
self.complexity == other.complexity and
31+
self.dependencies == other.dependencies and
32+
self.status == other.status and
33+
self.plan_path == other.plan_path)
34+
35+
# --- Regex Patterns ---
36+
# New format: - [Status] **[ID]** Label: Description (Complexity: X) [Deps: Y] (See plan: Z)
37+
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+
)
40+
# Old format: - [ ] Description (See plan: ...)
41+
OLD_TASK_REGEX = re.compile(
42+
r'^- \[(?P<status>[^\]]*)\] (?P<description>.*?)(?: \(See plan: (?P<plan_path>.*?)\))?$'
43+
)
44+
HEADER_WARNING = """# Tasks
45+
46+
> **WARNING: NEVER MODIFY THIS FILE BY HAND. USE THE SCRIPT INSTEAD.**
47+
> Run `python .gemini/scripts/task.py --help` for usage.
48+
"""
49+
50+
# --- Parser Functions ---
51+
52+
def parse_task_line(line):
53+
line = line.strip()
54+
if not line or line.startswith('#') or line.startswith('>'):
55+
return None
56+
57+
new_match = NEW_TASK_REGEX.match(line)
58+
if new_match:
59+
data = new_match.groupdict()
60+
deps_str = data.get('dependencies')
61+
dependencies = []
62+
if deps_str:
63+
dependencies = [dep.strip() for dep in deps_str.split(',') if dep.strip()]
64+
65+
complexity = int(data.get('complexity')) if data.get('complexity') else 0
66+
return Task(
67+
id=data.get('id'),
68+
label=data.get('label'),
69+
description=data.get('description'),
70+
category=None,
71+
complexity=complexity,
72+
dependencies=dependencies,
73+
status=data.get('status'),
74+
plan_path=data.get('plan_path')
75+
)
76+
77+
old_match = OLD_TASK_REGEX.match(line)
78+
if old_match:
79+
data = old_match.groupdict()
80+
status = data.get('status').strip()
81+
if not status: status = "todo"
82+
return Task(
83+
id=None,
84+
label=None,
85+
description=data.get('description'),
86+
category=None,
87+
complexity=0,
88+
dependencies=[],
89+
status=status,
90+
plan_path=data.get('plan_path')
91+
)
92+
return None
93+
94+
def parse_tasks_file(file_content):
95+
tasks = []
96+
current_category = None
97+
lines = file_content.splitlines()
98+
99+
for line in lines:
100+
stripped_line = line.strip()
101+
if stripped_line.startswith("## Active Tasks"):
102+
current_category = None
103+
continue
104+
elif stripped_line.startswith("## Archive"):
105+
current_category = None
106+
continue
107+
elif stripped_line.startswith("### "):
108+
current_category = stripped_line[4:].strip()
109+
continue
110+
111+
task = parse_task_line(line)
112+
if task:
113+
task.category = current_category
114+
tasks.append(task)
115+
return tasks
116+
117+
# --- Formatter Functions ---
118+
119+
def format_task_to_line(task):
120+
if task.id is not None:
121+
complexity_str = f" (Complexity: {task.complexity})" if task.complexity != 0 else ""
122+
deps_str = f" [Deps: {', '.join(task.dependencies)}]"
123+
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}"
125+
else:
126+
plan_path_str = f" (See plan: {task.plan_path})" if task.plan_path else ""
127+
return f"- [{task.status}] {task.description}{plan_path_str}"
128+
129+
def topological_sort(tasks):
130+
adj = defaultdict(list)
131+
in_degree = defaultdict(int)
132+
task_map = {task.id: task for task in tasks if task.id}
133+
134+
for tid in task_map:
135+
in_degree[tid] = 0
136+
137+
for task in tasks:
138+
if task.id:
139+
for dep_id in task.dependencies:
140+
if dep_id in task_map:
141+
adj[dep_id].append(task.id)
142+
in_degree[task.id] += 1
143+
144+
queue = deque(sorted([tid for tid in in_degree if in_degree[tid] == 0]))
145+
sorted_tasks_ids = []
146+
147+
while queue:
148+
u = queue.popleft()
149+
sorted_tasks_ids.append(u)
150+
for v in sorted(adj[u]):
151+
in_degree[v] -= 1
152+
if in_degree[v] == 0:
153+
queue.append(v)
154+
155+
sorted_tasks = [task_map[tid] for tid in sorted_tasks_ids]
156+
# Add tasks with IDs that were not in the sort (cycles)
157+
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
159+
without_ids = sorted([t for t in tasks if not t.id], key=lambda t: (t.complexity, t.description))
160+
161+
return sorted_tasks + remaining_with_ids + without_ids
162+
163+
def format_tasks_to_markdown(tasks):
164+
active_tasks = [t for t in tasks if t.status != "done"]
165+
archive_tasks = [t for t in tasks if t.status == "done"]
166+
167+
def group_by_category(task_list):
168+
grouped = defaultdict(list)
169+
for t in task_list:
170+
cat = t.category if t.category else "Uncategorized"
171+
grouped[cat].append(t)
172+
return grouped
173+
174+
active_grouped = group_by_category(active_tasks)
175+
archive_grouped = group_by_category(archive_tasks)
176+
177+
lines = [HEADER_WARNING.strip(), ""]
178+
179+
lines.append("## Active Tasks")
180+
if not active_tasks:
181+
lines.append("No active tasks.")
182+
else:
183+
for cat in sorted(active_grouped.keys()):
184+
lines.append(f"### {cat}")
185+
for t in topological_sort(active_grouped[cat]):
186+
lines.append(format_task_to_line(t))
187+
188+
lines.append("## Archive")
189+
if not archive_tasks:
190+
lines.append("No archived tasks.")
191+
else:
192+
for cat in sorted(archive_grouped.keys()):
193+
lines.append(f"### {cat}")
194+
# Archive sorted by complexity then id/description
195+
cat_tasks = sorted(archive_grouped[cat], key=lambda t: (t.complexity, t.id if t.id else t.description))
196+
for t in cat_tasks:
197+
lines.append(format_task_to_line(t))
198+
199+
return "\n".join(lines) + "\n"
200+
201+
def main():
202+
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"
205+
try:
206+
with open(tasks_file_path, 'r') as f:
207+
content = f.read()
208+
except FileNotFoundError:
209+
sys.exit(1)
210+
211+
tasks = parse_tasks_file(content)
212+
formatted = format_tasks_to_markdown(tasks)
213+
with open(tasks_file_path, 'w') as f:
214+
f.write(formatted)
215+
216+
if __name__ == "__main__":
217+
main()

journal/2026-03-24.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
[2026-03-24T13:16:16] - Added Tier Protocol RCA for 2026-03-24
44
[2026-03-24T13:27:32] - Starting implementation of procedural task management
55
[2026-03-24T13:27:52] - Started procedural task management implementation
6+
[2026-03-24T13:36:58] - Implemented core Task model and parser/formatter logic

tests/test_task_script.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import unittest
2+
import sys
3+
import os
4+
5+
# Add the script directory to the path so we can import from it
6+
sys.path.append(os.path.join(os.path.dirname(__file__), '..', '.gemini', 'scripts'))
7+
from task import Task, parse_task_line, parse_tasks_file, format_task_to_line, format_tasks_to_markdown, topological_sort
8+
9+
class TestTaskScript(unittest.TestCase):
10+
11+
# --- Parser Tests ---
12+
13+
def test_parse_new_task_format(self):
14+
line = '- [todo] **B.1** Setup Database: Initialize the PostgreSQL schema (Complexity: 3) [Deps: ] (See plan: plans/db.md)'
15+
task = parse_task_line(line)
16+
self.assertIsNotNone(task)
17+
self.assertEqual(task.id, 'B.1')
18+
self.assertEqual(task.label, 'Setup Database')
19+
self.assertEqual(task.description, 'Initialize the PostgreSQL schema')
20+
self.assertEqual(task.complexity, 3)
21+
self.assertEqual(task.dependencies, [])
22+
self.assertEqual(task.status, 'todo')
23+
self.assertEqual(task.plan_path, 'plans/db.md')
24+
25+
def test_parse_new_task_format_multiple_deps(self):
26+
line = '- [in_progress] **U.2** UI Components: Create shared buttons (Complexity: 1) [Deps: U.1, G.5]'
27+
task = parse_task_line(line)
28+
self.assertIsNotNone(task)
29+
self.assertEqual(task.dependencies, ['U.1', 'G.5'])
30+
self.assertEqual(task.status, 'in_progress')
31+
32+
def test_parse_old_task_format(self):
33+
line = '- [ ] Implement auth system (See plan: plans/auth.md)'
34+
task = parse_task_line(line)
35+
self.assertIsNotNone(task)
36+
self.assertIsNone(task.id)
37+
self.assertEqual(task.description, 'Implement auth system')
38+
self.assertEqual(task.status, 'todo')
39+
self.assertEqual(task.plan_path, 'plans/auth.md')
40+
41+
def test_parse_tasks_file_structure(self):
42+
content = """# Tasks
43+
44+
> WARNING: NEVER MODIFY BY HAND
45+
46+
## Active Tasks
47+
### Backend
48+
- [todo] **B.1** Task 1: Desc 1
49+
- [todo] **B.2** Task 2: Desc 2
50+
51+
### Frontend
52+
- [in_progress] **F.1** Task 3: Desc 3
53+
54+
## Archive
55+
### Backend
56+
- [done] **B.3** Task 4: Desc 4
57+
"""
58+
tasks = parse_tasks_file(content)
59+
self.assertEqual(len(tasks), 4)
60+
self.assertEqual(tasks[0].category, 'Backend')
61+
self.assertEqual(tasks[0].id, 'B.1')
62+
self.assertEqual(tasks[2].category, 'Frontend')
63+
self.assertEqual(tasks[3].category, 'Backend')
64+
self.assertEqual(tasks[3].status, 'done')
65+
66+
# --- Formatting Tests ---
67+
68+
def test_format_task_to_markdown_line(self):
69+
task = Task(
70+
id="T1",
71+
label="Task 1",
72+
description="Desc 1",
73+
category="Backend",
74+
complexity=1,
75+
dependencies=[],
76+
status="todo",
77+
plan_path="plans/t1.md"
78+
)
79+
expected_line = '- [todo] **T1** Task 1: Desc 1 (Complexity: 1) [Deps: ] (See plan: plans/t1.md)'
80+
formatted_line = format_task_to_line(task)
81+
self.assertEqual(formatted_line, expected_line)
82+
83+
def test_format_task_to_markdown_line_with_zero_complexity(self):
84+
task = Task(
85+
id="T1",
86+
label="Task 1",
87+
description="Desc 1",
88+
category="Backend",
89+
complexity=0,
90+
dependencies=[],
91+
status="todo",
92+
plan_path="plans/t1.md"
93+
)
94+
expected_line = '- [todo] **T1** Task 1: Desc 1 [Deps: ] (See plan: plans/t1.md)'
95+
formatted_line = format_task_to_line(task)
96+
self.assertEqual(formatted_line, expected_line)
97+
98+
def test_format_tasks_to_markdown_with_sorting_and_grouping(self):
99+
tasks = [
100+
Task(id="T3", label="Task C", description="Desc C", category="Frontend", complexity=1, status="done"),
101+
Task(id="T1", label="Task A", description="Desc A", category="Backend", complexity=2, dependencies=["T2"], status="todo"),
102+
Task(id="T2", label="Task B", description="Desc B", category="Backend", complexity=1, dependencies=[], status="todo"),
103+
Task(id="T4", label="Task D", description="Desc D", category="Frontend", complexity=2, status="in_progress"),
104+
Task(id="T5", label="Task E", description="Desc E", category="Backend", complexity=1, dependencies=["T1"], status="todo"),
105+
]
106+
107+
expected_markdown = """# Tasks
108+
109+
> **WARNING: NEVER MODIFY THIS FILE BY HAND. USE THE SCRIPT INSTEAD.**
110+
> Run `python .gemini/scripts/task.py --help` for usage.
111+
112+
## Active Tasks
113+
### Backend
114+
- [todo] **T2** Task B: Desc B (Complexity: 1) [Deps: ]
115+
- [todo] **T1** Task A: Desc A (Complexity: 2) [Deps: T2]
116+
- [todo] **T5** Task E: Desc E (Complexity: 1) [Deps: T1]
117+
### Frontend
118+
- [in_progress] **T4** Task D: Desc D (Complexity: 2) [Deps: ]
119+
## Archive
120+
### Frontend
121+
- [done] **T3** Task C: Desc C (Complexity: 1) [Deps: ]
122+
"""
123+
markdown_output = format_tasks_to_markdown(tasks)
124+
self.assertEqual(markdown_output, expected_markdown)
125+
126+
def test_format_tasks_to_markdown_with_unsorted_categories_and_mixed_statuses(self):
127+
tasks = [
128+
Task(id="T1", label="Task Alpha", description="Desc Alpha", category="Zebra", complexity=1, status="todo"),
129+
Task(id="T2", label="Task Beta", description="Desc Beta", category="Apple", complexity=2, dependencies=["T1"], status="in_progress"),
130+
Task(id="T3", label="Task Gamma", description="Desc Gamma", category="Zebra", complexity=3, status="done"),
131+
Task(id="T4", label="Task Delta", description="Desc Delta", category="Apple", complexity=1, status="todo"),
132+
]
133+
134+
expected_markdown = """# Tasks
135+
136+
> **WARNING: NEVER MODIFY THIS FILE BY HAND. USE THE SCRIPT INSTEAD.**
137+
> Run `python .gemini/scripts/task.py --help` for usage.
138+
139+
## Active Tasks
140+
### Apple
141+
- [in_progress] **T2** Task Beta: Desc Beta (Complexity: 2) [Deps: T1]
142+
- [todo] **T4** Task Delta: Desc Delta (Complexity: 1) [Deps: ]
143+
### Zebra
144+
- [todo] **T1** Task Alpha: Desc Alpha (Complexity: 1) [Deps: ]
145+
## Archive
146+
### Zebra
147+
- [done] **T3** Task Gamma: Desc Gamma (Complexity: 3) [Deps: ]
148+
"""
149+
markdown_output = format_tasks_to_markdown(tasks)
150+
self.assertEqual(markdown_output, expected_markdown)
151+
152+
def test_format_tasks_to_markdown_with_no_tasks(self):
153+
tasks = []
154+
expected_markdown = """# Tasks
155+
156+
> **WARNING: NEVER MODIFY THIS FILE BY HAND. USE THE SCRIPT INSTEAD.**
157+
> Run `python .gemini/scripts/task.py --help` for usage.
158+
159+
## Active Tasks
160+
No active tasks.
161+
## Archive
162+
No archived tasks.
163+
"""
164+
markdown_output = format_tasks_to_markdown(tasks)
165+
self.assertEqual(markdown_output, expected_markdown)
166+
167+
if __name__ == '__main__':
168+
unittest.main()

0 commit comments

Comments
 (0)