Skip to content

Commit 2ff3c97

Browse files
committed
Add priority-based task ordering and auto time tracking (v1.4.1)
- task_list now sorts by priority (critical first) by default, with sort_by parameter for created, due_date, and status ordering - Auto-compute actual_hours when tasks move to done, based on time since last in_progress status change in activity log - Works in both task_update and task_batch_update Closes #7, closes #10
1 parent f501a30 commit 2ff3c97

4 files changed

Lines changed: 73 additions & 3 deletions

File tree

manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"manifest_version": "0.3",
33
"name": "saga-mcp",
44
"display_name": "Saga — Project Tracker for AI Agents",
5-
"version": "1.4.0",
5+
"version": "1.4.1",
66
"description": "A Jira-like project tracker MCP server for AI agents. SQLite-backed, per-project scoped, with full hierarchy and activity logging — so LLMs never lose track.",
77
"long_description": "Saga gives your AI assistant a structured SQLite database to track projects, epics, tasks, subtasks, notes, and decisions across sessions. No more scattered markdown files — one `tracker_dashboard` call gives full project context to resume work.\n\n**Key features:**\n- Full hierarchy: Projects > Epics > Tasks > Subtasks\n- SQLite: Self-contained `.tracker.db` file per project — zero setup\n- Activity log: Every mutation is automatically tracked\n- Dashboard: One tool call gives full project overview\n- Notes system: Decisions, context, meeting notes, blockers\n- Batch operations: Create multiple subtasks or update multiple tasks in one call\n- 25 focused tools with safety annotations\n- Import/export: Full project backup and migration as JSON",
88
"author": {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "saga-mcp",
3-
"version": "1.4.0",
3+
"version": "1.4.1",
44
"description": "A Jira-like project tracker MCP server for AI agents. SQLite-backed, per-project scoped, with full hierarchy (Projects > Epics > Tasks > Subtasks), activity logging, and a dashboard — so LLMs never lose track.",
55
"type": "module",
66
"main": "dist/index.js",

src/tools/activity.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,28 @@ function handleTaskBatchUpdate(args: Record<string, unknown>) {
203203
);
204204
}
205205

206+
// Auto time tracking
207+
if (status === 'done' && oldRow.status !== 'done' && !newRow.actual_hours) {
208+
const startEntry = db.prepare(
209+
`SELECT created_at FROM activity_log
210+
WHERE entity_type = 'task' AND entity_id = ? AND action = 'status_changed'
211+
AND field_name = 'status' AND new_value = 'in_progress'
212+
ORDER BY created_at DESC LIMIT 1`
213+
).get(id) as { created_at: string } | undefined;
214+
215+
if (startEntry) {
216+
const startMs = new Date(startEntry.created_at + 'Z').getTime();
217+
const nowMs = Date.now();
218+
const hours = Math.round(((nowMs - startMs) / 3_600_000) * 10) / 10;
219+
if (hours > 0) {
220+
db.prepare('UPDATE tasks SET actual_hours = ? WHERE id = ?').run(hours, id);
221+
newRow.actual_hours = hours;
222+
logActivity(db, 'task', id, 'updated', 'actual_hours', null, String(hours),
223+
`Task '${newRow.title}' auto-tracked: ${hours}h`);
224+
}
225+
}
226+
}
227+
206228
return newRow;
207229
});
208230
})();

src/tools/tasks.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ export const definitions: Tool[] = [
5858
priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },
5959
assigned_to: { type: 'string', description: 'Filter by assignee' },
6060
tag: { type: 'string', description: 'Filter by tag' },
61+
sort_by: {
62+
type: 'string',
63+
enum: ['priority', 'created', 'due_date', 'status'],
64+
default: 'priority',
65+
description: 'Sort order: priority (critical first), created (newest first), due_date (earliest first), status (actionable first)',
66+
},
6167
limit: { type: 'integer', default: 50, description: 'Max results' },
6268
},
6369
},
@@ -137,13 +143,32 @@ function handleTaskCreate(args: Record<string, unknown>) {
137143
return task;
138144
}
139145

146+
const PRIORITY_ORDER = "CASE t.priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END";
147+
const STATUS_ORDER = "CASE t.status WHEN 'blocked' THEN 0 WHEN 'in_progress' THEN 1 WHEN 'review' THEN 2 WHEN 'todo' THEN 3 WHEN 'done' THEN 4 END";
148+
149+
function getTaskOrderClause(sortBy: string): string {
150+
switch (sortBy) {
151+
case 'priority':
152+
return `${PRIORITY_ORDER}, ${STATUS_ORDER}, t.sort_order, t.created_at`;
153+
case 'status':
154+
return `${STATUS_ORDER}, ${PRIORITY_ORDER}, t.sort_order, t.created_at`;
155+
case 'due_date':
156+
return `t.due_date IS NULL, t.due_date ASC, ${PRIORITY_ORDER}, t.created_at`;
157+
case 'created':
158+
return `t.created_at DESC`;
159+
default:
160+
return `${PRIORITY_ORDER}, ${STATUS_ORDER}, t.sort_order, t.created_at`;
161+
}
162+
}
163+
140164
function handleTaskList(args: Record<string, unknown>) {
141165
const db = getDb();
142166
const epicId = args.epic_id as number | undefined;
143167
const status = args.status as string | undefined;
144168
const priority = args.priority as string | undefined;
145169
const assignedTo = args.assigned_to as string | undefined;
146170
const tag = args.tag as string | undefined;
171+
const sortBy = (args.sort_by as string) ?? 'priority';
147172
const limit = (args.limit as number) ?? 50;
148173

149174
const whereClauses: string[] = [];
@@ -181,7 +206,7 @@ function handleTaskList(args: Record<string, unknown>) {
181206
LEFT JOIN subtasks s ON s.task_id = t.id
182207
${whereStr}
183208
GROUP BY t.id
184-
ORDER BY t.sort_order, t.created_at
209+
ORDER BY ${getTaskOrderClause(sortBy)}
185210
LIMIT ?
186211
`;
187212

@@ -237,6 +262,29 @@ function handleTaskUpdate(args: Record<string, unknown>) {
237262
'status', 'priority', 'assigned_to', 'title',
238263
]);
239264

265+
// Auto time tracking: when status changes to done and actual_hours wasn't manually set
266+
const statusChanged = args.status && oldRow.status !== args.status;
267+
if (statusChanged && args.status === 'done' && !args.actual_hours && !newRow.actual_hours) {
268+
const startEntry = db.prepare(
269+
`SELECT created_at FROM activity_log
270+
WHERE entity_type = 'task' AND entity_id = ? AND action = 'status_changed'
271+
AND field_name = 'status' AND new_value = 'in_progress'
272+
ORDER BY created_at DESC LIMIT 1`
273+
).get(id) as { created_at: string } | undefined;
274+
275+
if (startEntry) {
276+
const startMs = new Date(startEntry.created_at + 'Z').getTime();
277+
const nowMs = Date.now();
278+
const hours = Math.round(((nowMs - startMs) / 3_600_000) * 10) / 10; // 1 decimal
279+
if (hours > 0) {
280+
db.prepare('UPDATE tasks SET actual_hours = ? WHERE id = ?').run(hours, id);
281+
(newRow as Record<string, unknown>).actual_hours = hours;
282+
logActivity(db, 'task', id, 'updated', 'actual_hours', null, String(hours),
283+
`Task '${newRow.title}' auto-tracked: ${hours}h`);
284+
}
285+
}
286+
}
287+
240288
return newRow;
241289
}
242290

0 commit comments

Comments
 (0)