Skip to content

Commit fdbf38c

Browse files
author
StackMemory Bot (CLI)
committed
feat(tasks): add local-first master-tasks.md task management
Markdown table parser + CLI commands + MCP tools for local-first task steering. Tasks live in master-tasks.md, optionally sync to Linear/GH. - Parser: parse/serialize/update/add/getNext for pipe-delimited md tables - CLI: stackmemory tasks init/md list/md next/md add/md update - MCP: get_next_master_task, update_master_task, create_master_task - 19 tests covering parse, round-trip, priority sorting, file ops
1 parent 77f0105 commit fdbf38c

6 files changed

Lines changed: 981 additions & 1 deletion

File tree

src/cli/commands/tasks.ts

Lines changed: 198 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,24 @@
66
import { Command } from 'commander';
77
import Database from 'better-sqlite3';
88
import { join } from 'path';
9-
import { existsSync } from 'fs';
9+
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
1010
import {
1111
LinearTaskManager,
1212
TaskPriority,
1313
} from '../../features/tasks/linear-task-manager.js';
14+
import {
15+
parseMasterTasks,
16+
getNextTask,
17+
addTaskToFile,
18+
updateTaskInFile,
19+
type TaskPriority as MdPriority,
20+
type TaskStatus as MdStatus,
21+
type TaskSync,
22+
} from '../../core/tasks/md-task-parser.js';
23+
import {
24+
MASTER_TASKS_TEMPLATE,
25+
TASKS_CONFIG_TEMPLATE,
26+
} from '../../core/tasks/master-tasks-template.js';
1427

1528
/** Raw task row from task_cache table */
1629
interface TaskCacheRow {
@@ -263,9 +276,193 @@ export function createTaskCommands(): Command {
263276
}
264277
});
265278

279+
// ── Init: scaffold master-tasks.md ─────────────────────────
280+
tasks
281+
.command('init')
282+
.description('Scaffold .stackmemory/tasks/master-tasks.md')
283+
.action(() => {
284+
const projectRoot = process.cwd();
285+
const tasksDir = join(projectRoot, '.stackmemory', 'tasks');
286+
const mdPath = join(tasksDir, 'master-tasks.md');
287+
const configPath = join(tasksDir, 'config.json');
288+
289+
if (existsSync(mdPath)) {
290+
console.log(`Already exists: ${mdPath}`);
291+
return;
292+
}
293+
294+
mkdirSync(tasksDir, { recursive: true });
295+
writeFileSync(mdPath, MASTER_TASKS_TEMPLATE, 'utf-8');
296+
writeFileSync(
297+
configPath,
298+
JSON.stringify(TASKS_CONFIG_TEMPLATE, null, 2),
299+
'utf-8'
300+
);
301+
console.log(`Created: ${mdPath}`);
302+
console.log(`Created: ${configPath}`);
303+
});
304+
305+
// ── MD subcommands (local-first master-tasks.md) ──────────
306+
const md = new Command('md').description(
307+
'Local-first task management via master-tasks.md'
308+
);
309+
310+
md.command('list')
311+
.alias('ls')
312+
.description('List tasks from master-tasks.md')
313+
.option('-p, --priority <P>', 'Filter by priority (P0, P1, P2, P3)')
314+
.option(
315+
'-s, --status <status>',
316+
'Filter by status (todo, active, done, blocked, cut)'
317+
)
318+
.option('-o, --owner <owner>', 'Filter by owner (@me, @agent, @defer)')
319+
.option('--json', 'Output as JSON')
320+
.action((options) => {
321+
const mdPath = resolveMdPath();
322+
if (!mdPath) return;
323+
324+
let tasks = parseMasterTasks(readFileSync(mdPath, 'utf-8'));
325+
326+
if (options.priority)
327+
tasks = tasks.filter((t) => t.priority === options.priority);
328+
if (options.status)
329+
tasks = tasks.filter((t) => t.status === options.status);
330+
if (options.owner) tasks = tasks.filter((t) => t.owner === options.owner);
331+
332+
if (options.json) {
333+
console.log(JSON.stringify(tasks, null, 2));
334+
return;
335+
}
336+
337+
if (tasks.length === 0) {
338+
console.log('No tasks found');
339+
return;
340+
}
341+
342+
console.log(`\nTasks (${tasks.length})\n`);
343+
for (const t of tasks) {
344+
const pColor =
345+
t.priority === 'P0'
346+
? '\x1b[31m'
347+
: t.priority === 'P1'
348+
? '\x1b[33m'
349+
: '\x1b[90m';
350+
const sIcon =
351+
t.status === 'done'
352+
? '[x]'
353+
: t.status === 'active'
354+
? '[>]'
355+
: t.status === 'blocked'
356+
? '[!]'
357+
: '[ ]';
358+
console.log(
359+
`${sIcon} ${pColor}${t.priority}\x1b[0m ${t.id} ${t.task} ${t.owner} ${t.branchPr ? `(${t.branchPr})` : ''}`
360+
);
361+
}
362+
console.log('');
363+
});
364+
365+
md.command('next')
366+
.description('Show the next task to work on')
367+
.option('--json', 'Output as JSON')
368+
.action((options) => {
369+
const mdPath = resolveMdPath();
370+
if (!mdPath) return;
371+
372+
const tasks = parseMasterTasks(readFileSync(mdPath, 'utf-8'));
373+
const next = getNextTask(tasks);
374+
375+
if (!next) {
376+
console.log('No actionable tasks');
377+
return;
378+
}
379+
380+
if (options.json) {
381+
console.log(JSON.stringify(next));
382+
return;
383+
}
384+
385+
console.log(`\nNext: ${next.id} [${next.priority}] ${next.task}`);
386+
console.log(` Owner: ${next.owner} | Sync: ${next.sync}`);
387+
if (next.notes) console.log(` Notes: ${next.notes}`);
388+
console.log('');
389+
});
390+
391+
md.command('add <description>')
392+
.description('Add a task to master-tasks.md')
393+
.option('-p, --priority <P>', 'Priority (P0-P3)', 'P1')
394+
.option('-o, --owner <owner>', 'Owner (@me, @agent, @defer)', '@me')
395+
.option('-s, --sync <sync>', 'Sync target (local, linear, gh)', 'local')
396+
.option('-b, --branch <branch>', 'Branch or PR')
397+
.option('-n, --notes <notes>', 'Notes')
398+
.action((description, options) => {
399+
const mdPath = resolveMdPath();
400+
if (!mdPath) return;
401+
402+
const id = addTaskToFile(mdPath, {
403+
priority: options.priority as MdPriority,
404+
status: 'todo',
405+
owner: options.owner,
406+
sync: options.sync as TaskSync,
407+
task: description,
408+
branchPr: options.branch || '',
409+
notes: options.notes || '',
410+
});
411+
412+
console.log(`Added: ${id} ${description}`);
413+
});
414+
415+
md.command('update <taskId>')
416+
.description('Update a task in master-tasks.md')
417+
.option(
418+
'-s, --status <status>',
419+
'New status (todo, active, done, blocked, cut)'
420+
)
421+
.option('-p, --priority <P>', 'New priority (P0-P3)')
422+
.option('-o, --owner <owner>', 'New owner')
423+
.option('-b, --branch <branch>', 'Branch or PR')
424+
.option('-n, --notes <notes>', 'Notes')
425+
.option('--sync <sync>', 'Sync target (local, linear, gh)')
426+
.action((taskId, options) => {
427+
const mdPath = resolveMdPath();
428+
if (!mdPath) return;
429+
430+
try {
431+
const updates: Record<string, string> = {};
432+
if (options.status) updates.status = options.status;
433+
if (options.priority) updates.priority = options.priority;
434+
if (options.owner) updates.owner = options.owner;
435+
if (options.branch) updates.branchPr = options.branch;
436+
if (options.notes) updates.notes = options.notes;
437+
if (options.sync) updates.sync = options.sync;
438+
439+
updateTaskInFile(mdPath, taskId.toUpperCase(), updates);
440+
console.log(`Updated: ${taskId.toUpperCase()}`);
441+
} catch (err) {
442+
console.error(`Error: ${(err as Error).message}`);
443+
}
444+
});
445+
446+
tasks.addCommand(md);
447+
266448
return tasks;
267449
}
268450

451+
/** Resolve master-tasks.md path — check .stackmemory/tasks/ then project root */
452+
function resolveMdPath(): string | null {
453+
const projectRoot = process.cwd();
454+
const smPath = join(projectRoot, '.stackmemory', 'tasks', 'master-tasks.md');
455+
if (existsSync(smPath)) return smPath;
456+
457+
const rootPath = join(projectRoot, 'master-tasks.md');
458+
if (existsSync(rootPath)) return rootPath;
459+
460+
console.error(
461+
'No master-tasks.md found. Run "stackmemory tasks init" first.'
462+
);
463+
return null;
464+
}
465+
269466
function findTaskByPartialId(
270467
projectRoot: string,
271468
partialId: string

0 commit comments

Comments
 (0)