md2do Development Skill
When to Use This Skill
Use this skill when:
- Building or modifying the md2do CLI tool
- Working on markdown task parsing
- Implementing filters, sorting, or context extraction
- Adding new CLI commands or features
- Writing tests for md2do functionality
- Questions about md2do architecture or patterns
Overview
md2do is a CLI tool for scanning and managing TODO items in markdown files. It provides intelligent parsing with context awareness and is architected to support future MCP server integration.
Key Reference: Always consult /path/to/md2do-project-plan.md for complete specifications, data models, and implementation phases.
Library-First Development Philosophy
Critical: Always check for established libraries before implementing custom solutions.
Discovery Process
Before writing custom code, follow this checklist:
- Search npm - Search for the problem domain (e.g., "node config file", "cli argument parser")
- Verify quality - Check weekly downloads (>100k/week indicates well-maintained)
- Check TypeScript support - Look for native types or @types packages
- Review recent activity - Last update within 6 months preferred
- Read the README - If it solves 80%+ of the problem, use it
- Check bundle size - Use bundlephobia.com to avoid bloat
Recommended Libraries by Category
CLI & Terminal:
- commander or yargs - Argument parsing (don't write custom argv parsing)
- chalk - Terminal colors (don't use console color codes)
- ora - Spinners and loading indicators
- cli-table3 - Tables (don't manually format with spaces)
- inquirer or prompts - Interactive prompts
- boxen - Draw boxes around text
Configuration:
- cosmiconfig - Config file discovery and loading (CRITICAL - don't write custom)
- dotenv - Environment variables
- zod or joi - Configuration validation
File System:
- fast-glob or glob - File pattern matching (don't use manual readdir recursion)
- fs-extra - Enhanced fs with promises (better than native fs for CLI apps)
- chokidar - File watching (for future watch mode)
Date/Time:
- date-fns - Date manipulation (don't use native Date math)
- date-fns-tz - Timezone support if needed
Validation & Parsing:
- zod - Schema validation with TypeScript inference (preferred)
- joi - Alternative, more established but worse TypeScript
Markdown & Text:
- remark / unified - Full AST (for complex parsing)
- marked - Simple, fast parser (if AST not needed)
- gray-matter - Frontmatter parsing (if you add YAML support)
- string-width - Terminal string width (handles emoji/unicode)
Utilities:
- execa - Run shell commands (better than child_process)
- which - Find executables in PATH
- update-notifier - Notify users of new versions
Build & Dev:
- tsx - Run TypeScript directly (better than ts-node)
- tsup - Bundle for npm publishing
- concurrently - Run multiple commands in parallel
Common Anti-Patterns to Avoid
Never reinvent these wheels:
// ❌ DON'T: Custom config file parsing
function loadConfig() {
const configPath = path.join(os.homedir(), '.md2dorc');
if (fs.existsSync(configPath)) {
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
}
}
// ✅ DO: Use cosmiconfig
import { cosmiconfig } from 'cosmiconfig';
const explorer = cosmiconfig('md2do');
const result = await explorer.search();
// ❌ DON'T: Manual date math
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1); // Mutates!
// ✅ DO: Use date-fns
import { addDays } from 'date-fns';
const tomorrow = addDays(new Date(), 1); // Immutable
// ❌ DON'T: Manual argument parsing
const args = process.argv.slice(2);
const command = args[0];
const flags = {};
for (let i = 1; i < args.length; i++) {
if (args[i].startsWith('--')) {
flags[args[i].slice(2)] = args[i + 1];
}
}
// ✅ DO: Use commander
import { Command } from 'commander';
const program = new Command();
program.command('list').option('-a, --assignee <name>').action(handleList);
// ❌ DON'T: String concatenation for colors
console.log('\x1b[31m' + error + '\x1b[0m');
// ✅ DO: Use chalk
import chalk from 'chalk';
console.log(chalk.red(error));
// ❌ DON'T: Manual glob recursion
function findMarkdownFiles(dir) {
const results = [];
const files = fs.readdirSync(dir);
for (const file of files) {
const fullPath = path.join(dir, file);
if (fs.statSync(fullPath).isDirectory()) {
results.push(...findMarkdownFiles(fullPath));
} else if (file.endsWith('.md')) {
results.push(fullPath);
}
}
return results;
}
// ✅ DO: Use fast-glob
import fg from 'fast-glob';
const files = await fg('**/*.md', { cwd: dir, ignore: ['node_modules'] });
When to Build Custom
Only implement custom solutions when:
- No library exists for your specific use case
- Poor maintenance - Library hasn't been updated in 2+ years
- Excessive dependencies - Library pulls in 50+ packages for simple task
- Performance critical - Library is measurably too slow for your needs
- Massive overkill - Library is 10x more complex than what you need
- Security concerns - Known vulnerabilities with no fixes
Example: For md2do task parsing
- ✅ Custom regex parser is appropriate (no library for this specific syntax)
- ✅ Custom filter logic is appropriate (domain-specific)
- ❌ Custom config loading would be inappropriate (cosmiconfig exists)
- ❌ Custom CLI framework would be inappropriate (commander exists)
Library Selection for md2do
Core dependencies:
{
"dependencies": {
"commander": "^11.0.0", // CLI framework
"chalk": "^5.0.0", // Terminal colors
"cosmiconfig": "^9.0.0", // Config loading
"date-fns": "^3.0.0", // Date utilities
"fast-glob": "^3.3.0", // File pattern matching
"zod": "^3.22.0" // Validation
},
"devDependencies": {
"vitest": "^1.0.0", // Testing
"@vitest/ui": "^1.0.0", // Test UI
"tsx": "^4.0.0" // Run TypeScript
}
}
Optional enhancements:
{
"dependencies": {
"ora": "^7.0.0", // Loading spinners
"cli-table3": "^0.6.0", // Tables for stats
"inquirer": "^9.0.0", // Interactive prompts (for md2do done)
"update-notifier": "^7.0.0" // Version check
}
}
Project Architecture
Monorepo Structure
md2do/
├── packages/
│ ├── core/ # Pure TypeScript (no I/O, 100% testable)
│ ├── cli/ # Command-line interface
│ └── mcp/ # MCP server (future)
├── package.json # Workspace root
└── pnpm-workspace.yaml
Critical: Keep core package pure (no file I/O, no console.log). All I/O happens in cli package.
Core Package Responsibilities
The core package contains pure business logic:
- Type definitions (
types/) - Markdown parsing (
parser/) - File scanning (
scanner/) - Filtering logic (
filters/) - Sorting logic (
sorters/) - Utilities (
utils/) - date parsing, ID generation
CLI Package Responsibilities
The cli package handles I/O and user interaction:
- Command definitions (
commands/) - Output formatting (
formatters/) - Configuration management (
config/) - File system operations
- User input/output
TypeScript Conventions
Code Style
Follow Nick's preferences:
- Use 2-space indentation
- Always use semicolons
- Prefer
constoverlet - Use trailing commas in multiline structures
- Prefer functional patterns (map, filter, reduce) over loops
- Use descriptive variable names (no single letters except in short lambdas)
- Extract magic numbers to named constants
- Prefer pure functions with explicit inputs/outputs
Example:
// Good
const incompleteTasks = tasks.filter((task) => !task.completed);
const sortedByDue = incompleteTasks.sort((a, b) =>
compareDates(a.dueDate, b.dueDate),
);
// Avoid
let result = [];
for (let i = 0; i < tasks.length; i++) {
if (!tasks[i].completed) {
result.push(tasks[i]);
}
}
Type Safety
- Use strict TypeScript settings
- Avoid
any- useunknownif type is truly unknown - Define explicit interfaces for all data structures
- Use discriminated unions for variant types
- Prefer
typefor unions/primitives,interfacefor objects
Example:
// Core task type
interface Task {
id: string;
text: string;
completed: boolean;
// ... other fields
}
// Filter result type
interface FilterResult {
tasks: Task[];
metadata: {
total: number;
filtered: number;
};
}
// Priority as discriminated union
type Priority = 'urgent' | 'high' | 'normal' | 'low';
Error Handling
- Always handle promise rejections
- Use custom Error classes for domain errors
- Provide helpful error messages with context
- Never swallow errors silently
Example:
class TaskParsingError extends Error {
constructor(
message: string,
public file: string,
public line: number,
) {
super(`${message} at ${file}:${line}`);
this.name = 'TaskParsingError';
}
}
// Usage
if (!isValidTaskSyntax(line)) {
throw new TaskParsingError('Invalid task syntax', currentFile, lineNumber);
}
Parser Implementation
Regex Patterns
Store all regex patterns in constants with clear names:
// parser/patterns.ts
export const PATTERNS = {
TASK_CHECKBOX: /^(\s*)-\s+\[([ xX])\]\s+/,
ASSIGNEE: /@([\w-]+)/g,
PRIORITY_URGENT: /!!!/,
PRIORITY_HIGH: /!!/,
PRIORITY_NORMAL: /(?<!!)!(?!!)/, // Single ! not part of !! or !!!
DUE_DATE_ABSOLUTE: /\[due:\s*(\d{4}-\d{2}-\d{2})\]/,
DUE_DATE_RELATIVE: /\[due:\s*(tomorrow|today|next\s+week)\]/i,
TAG: /#([\w-]+)/g,
TODOIST_ID: /\[todoist:\s*(\d+)\]/,
COMPLETED_DATE: /\[completed:\s*(\d{4}-\d{2}-\d{2})\]/,
HEADING_DATE: /^#{1,6}\s+.*?(\d{1,2}\/\d{1,2}\/\d{2,4})/,
} as const;
Document each regex with examples:
/**
* Matches GFM task checkbox syntax
*
* Examples:
* "- [ ] Task" → match, incomplete
* "- [x] Task" → match, complete
* " - [X] Task" → match, complete (case-insensitive)
* "* [ ] Task" → no match (not supported)
*/
export const TASK_CHECKBOX = /^(\s*)-\s+\[([ xX])\]\s+/;
Parsing Pipeline
Follow this pattern for parsing tasks:
export function parseTask(
line: string,
lineNumber: number,
file: string,
context: ParsingContext,
): Task | null {
// 1. Check if line is a task
const taskMatch = line.match(PATTERNS.TASK_CHECKBOX);
if (!taskMatch) return null;
// 2. Extract completion status
const completed = taskMatch[2].toLowerCase() === 'x';
// 3. Extract text (everything after checkbox)
const fullText = line.substring(taskMatch[0].length);
// 4. Parse metadata from text
const assignee = extractAssignee(fullText);
const priority = extractPriority(fullText);
const dueDate = extractDueDate(fullText, context);
const tags = extractTags(fullText);
const todoistId = extractTodoistId(fullText);
const completedDate = extractCompletedDate(fullText);
// 5. Clean text (remove metadata markers)
const cleanText = cleanTaskText(fullText);
// 6. Generate stable ID
const id = generateTaskId(file, lineNumber, cleanText);
return {
id,
text: cleanText,
completed,
file,
line: lineNumber,
assignee,
priority,
dueDate,
tags,
todoistId,
completedDate,
// Context from file structure and headings
project: context.project,
person: context.person,
contextDate: context.currentDate,
contextHeading: context.currentHeading,
};
}
Context Tracking
Track context while scanning files:
interface ParsingContext {
project?: string; // From folder structure
person?: string; // From filename
currentDate?: Date; // From most recent heading
currentHeading?: string; // The heading text itself
}
export class MarkdownScanner {
private context: ParsingContext = {};
scanFile(filePath: string, content: string): Task[] {
// Extract project from path: "projects/divvy/notes.md" → "divvy"
this.context.project = extractProjectFromPath(filePath);
// Extract person from filename: "1-1s/jane-doe.md" → "jane-doe"
this.context.person = extractPersonFromFilename(filePath);
const tasks: Task[] = [];
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Update context date from headings
const headingDate = extractDateFromHeading(line);
if (headingDate) {
this.context.currentDate = headingDate;
this.context.currentHeading = line.trim();
}
// Parse task with current context
const task = parseTask(line, i + 1, filePath, this.context);
if (task) {
tasks.push(task);
}
}
return tasks;
}
}
Date Handling
Absolute Dates
import { parse, isValid } from 'date-fns';
export function parseAbsoluteDate(dateStr: string): Date | null {
// Try ISO format: 2026-01-25
let date = parse(dateStr, 'yyyy-MM-dd', new Date());
if (isValid(date)) return date;
// Try US format: 1/25/26
date = parse(dateStr, 'M/d/yy', new Date());
if (isValid(date)) return date;
// Try full year: 1/25/2026
date = parse(dateStr, 'M/d/yyyy', new Date());
if (isValid(date)) return date;
return null;
}
Relative Dates
import { addDays, addWeeks, startOfWeek } from 'date-fns';
export function resolveRelativeDate(
relative: string,
baseDate: Date,
): Date | null {
const normalized = relative.toLowerCase().trim();
switch (normalized) {
case 'today':
return baseDate;
case 'tomorrow':
return addDays(baseDate, 1);
case 'next week':
// Next Monday
return addWeeks(startOfWeek(baseDate, { weekStartsOn: 1 }), 1);
case 'next month':
return addMonths(baseDate, 1);
default:
return null;
}
}
Date Warnings
When relative dates lack context:
interface DateResolutionWarning {
file: string;
line: number;
text: string;
reason: string;
}
export function extractDueDate(
text: string,
context: ParsingContext,
): { date: Date | null; warning?: DateResolutionWarning } {
const absoluteMatch = text.match(PATTERNS.DUE_DATE_ABSOLUTE);
if (absoluteMatch) {
return { date: parseAbsoluteDate(absoluteMatch[1]) };
}
const relativeMatch = text.match(PATTERNS.DUE_DATE_RELATIVE);
if (relativeMatch) {
if (!context.currentDate) {
return {
date: null,
warning: {
file: context.file,
line: context.line,
text: text,
reason: 'Relative due date without context date from heading',
},
};
}
return {
date: resolveRelativeDate(relativeMatch[1], context.currentDate),
};
}
return { date: null };
}
Filtering Implementation
Filter Interface
interface TaskFilter {
assignee?: string | string[];
completed?: boolean;
overdue?: boolean;
dueDate?: {
before?: Date;
after?: Date;
exact?: Date;
};
priority?: Priority | Priority[];
project?: string | string[];
person?: string | string[];
tags?: string | string[];
hasTag?: boolean;
path?: string;
}
export function filterTasks(tasks: Task[], filter: TaskFilter): Task[] {
return tasks.filter((task) => {
// Assignee filter
if (filter.assignee) {
const assignees = Array.isArray(filter.assignee)
? filter.assignee
: [filter.assignee];
if (!task.assignee || !assignees.includes(task.assignee)) {
return false;
}
}
// Completion filter
if (filter.completed !== undefined) {
if (task.completed !== filter.completed) {
return false;
}
}
// Overdue filter
if (filter.overdue && task.dueDate) {
const now = new Date();
if (task.dueDate >= now) {
return false;
}
}
// Priority filter
if (filter.priority) {
const priorities = Array.isArray(filter.priority)
? filter.priority
: [filter.priority];
if (!task.priority || !priorities.includes(task.priority)) {
return false;
}
}
// Tags filter
if (filter.tags) {
const requiredTags = Array.isArray(filter.tags)
? filter.tags
: [filter.tags];
const hasAllTags = requiredTags.every((tag) => task.tags.includes(tag));
if (!hasAllTags) {
return false;
}
}
return true;
});
}
CLI Implementation
Command Structure
Use commander.js pattern:
import { Command } from 'commander';
const program = new Command();
program
.name('md2do')
.description('Scan and manage TODOs in markdown files')
.version('1.0.0');
// List command
program
.command('list')
.description('List tasks with optional filters')
.option('-a, --assignee <name>', 'filter by assignee')
.option('--overdue', 'show only overdue tasks')
.option(
'-p, --priority <level>',
'filter by priority (urgent|high|normal|low)',
)
.option('--project <name>', 'filter by project')
.option('--tag <tag>', 'filter by tag')
.option('-s, --sort <field>', 'sort by field (due|priority|created|file)')
.option('-f, --format <format>', 'output format (pretty|json|csv)', 'pretty')
.action(async (options) => {
await handleListCommand(options);
});
// Stats command
program
.command('stats')
.description('Show task statistics')
.option('-a, --assignee <name>', 'filter by assignee')
.option('--by <field>', 'group by field (assignee|project|priority)')
.action(async (options) => {
await handleStatsCommand(options);
});
program.parse();
Output Formatting
Pretty format with colors:
import chalk from 'chalk';
export function formatTasksPretty(tasks: Task[], groupBy?: string): string {
const lines: string[] = [];
// Group tasks
const groups = groupTasks(tasks, groupBy);
for (const [groupName, groupTasks] of Object.entries(groups)) {
// Group header
lines.push('');
lines.push(chalk.bold.cyan(groupName));
lines.push(chalk.gray('─'.repeat(60)));
// Tasks in group
for (const task of groupTasks) {
lines.push(formatSingleTask(task));
lines.push(''); // Blank line between tasks
}
}
return lines.join('\n');
}
function formatSingleTask(task: Task): string {
const parts: string[] = [];
// Priority indicator
const priorityIcon = {
urgent: '🔴',
high: '🟠',
normal: '🟡',
low: '⚪',
}[task.priority || 'low'];
// Priority markers + text
const priorityMarkers = {
urgent: '!!!',
high: '!!',
normal: '!',
low: '',
}[task.priority || 'low'];
parts.push(`${priorityIcon} ${priorityMarkers} ${task.text}`);
// Due date with overdue indicator
if (task.dueDate) {
const isOverdue = task.dueDate < new Date();
const dateStr = formatDate(task.dueDate);
const dueLine = isOverdue
? chalk.red(
` Due: ${dateStr} (${getDaysOverdue(task.dueDate)} days ago)`,
)
: chalk.yellow(` Due: ${dateStr}`);
parts.push(dueLine);
}
// Assignee and tags
const metadata: string[] = [];
if (task.assignee) metadata.push(chalk.blue(`@${task.assignee}`));
if (task.tags.length > 0) {
metadata.push(task.tags.map((t) => chalk.cyan(`#${t}`)).join(' '));
}
if (metadata.length > 0) {
parts.push(` ${metadata.join(' ')}`);
}
// File path (command-clickable in VSCode)
const filePath = makeClickablePath(task.file, task.line);
parts.push(chalk.gray(` ${filePath}`));
// Context
if (task.contextHeading) {
parts.push(chalk.dim(` Context: ${task.contextHeading}`));
}
return parts.join('\n');
}
function makeClickablePath(file: string, line: number): string {
const absolutePath = path.resolve(getMarkdownRoot(), file);
return `file://${absolutePath}:${line}`;
}
JSON format:
export function formatTasksJSON(tasks: Task[]): string {
const output = {
tasks: tasks.map((task) => ({
id: task.id,
text: task.text,
completed: task.completed,
file: task.file,
line: task.line,
assignee: task.assignee,
dueDate: task.dueDate?.toISOString(),
priority: task.priority,
tags: task.tags,
project: task.project,
person: task.person,
contextDate: task.contextDate?.toISOString(),
contextHeading: task.contextHeading,
})),
metadata: {
total: tasks.length,
completed: tasks.filter((t) => t.completed).length,
overdue: tasks.filter((t) => isOverdue(t)).length,
},
};
return JSON.stringify(output, null, 2);
}
Testing Strategy
Unit Tests
Test parser functions in isolation:
import { describe, it, expect } from 'vitest';
import { parseTask, PATTERNS } from '../parser';
describe('parseTask', () => {
it('should parse basic incomplete task', () => {
const line = '- [ ] Review PR';
const task = parseTask(line, 1, 'test.md', {});
expect(task).toMatchObject({
text: 'Review PR',
completed: false,
line: 1,
file: 'test.md',
});
});
it('should extract assignee', () => {
const line = '- [ ] @nick Review PR';
const task = parseTask(line, 1, 'test.md', {});
expect(task?.assignee).toBe('nick');
expect(task?.text).toBe('Review PR'); // Assignee removed from text
});
it('should parse priority levels', () => {
expect(parseTask('- [ ] Task !!!', 1, 'test.md', {})?.priority).toBe(
'urgent',
);
expect(parseTask('- [ ] Task !!', 1, 'test.md', {})?.priority).toBe('high');
expect(parseTask('- [ ] Task !', 1, 'test.md', {})?.priority).toBe(
'normal',
);
expect(parseTask('- [ ] Task', 1, 'test.md', {})?.priority).toBeUndefined();
});
it('should handle relative dates with context', () => {
const context = { currentDate: new Date('2026-01-13') };
const line = '- [ ] Task [due: tomorrow]';
const task = parseTask(line, 1, 'test.md', context);
expect(task?.dueDate).toEqual(new Date('2026-01-14'));
});
it('should warn about relative dates without context', () => {
const line = '- [ ] Task [due: tomorrow]';
const result = parseTaskWithWarnings(line, 1, 'test.md', {});
expect(result.task?.dueDate).toBeNull();
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0].reason).toContain('without context date');
});
});
Integration Tests
Test full scanning workflow:
describe('MarkdownScanner integration', () => {
it('should scan directory and extract all tasks', async () => {
const scanner = new MarkdownScanner({
root: 'tests/fixtures/sample-markdown',
});
const tasks = await scanner.scanAll();
expect(tasks).toHaveLength(15); // Known count in fixtures
// Verify context extraction
const divvyTasks = tasks.filter((t) => t.project === 'divvy');
expect(divvyTasks.length).toBeGreaterThan(0);
const janeTasks = tasks.filter((t) => t.person === 'jane-doe');
expect(janeTasks.length).toBeGreaterThan(0);
});
});
Test Fixtures
Create realistic test data:
tests/fixtures/
└── sample-markdown/
├── projects/
│ └── divvy/
│ └── sprint-notes.md
├── 1-1s/
│ └── jane-doe.md
└── personal/
└── tasks.md
sprint-notes.md:
# Divvy Sprint Planning
## Sprint 23 - 1/13/26
- [ ] @nick Review API design !! #backend
- [ ] @jonathan Update documentation [due: 1/15/26]
- [x] @greg Fix deployment bug [completed: 1/12/26]
## Backlog
- [ ] Refactor authentication #technical-debt
Configuration Management
Loading Config
import { cosmiconfig } from 'cosmiconfig';
export async function loadConfig(): Promise<Config> {
const explorer = cosmiconfig('md2do');
const result = await explorer.search();
if (!result) {
return getDefaultConfig();
}
return validateConfig(result.config);
}
function validateConfig(config: unknown): Config {
// Use zod or similar for validation
const schema = z.object({
markdown: z.object({
root: z.string(),
excludePaths: z.array(z.string()).optional(),
}),
defaultAssignee: z.string(),
// ... other fields
});
return schema.parse(config);
}
Performance Considerations
Large Repository Optimization
// Use async iteration for large files
export async function* scanFilesAsync(
root: string,
options: ScanOptions,
): AsyncGenerator<Task[]> {
const files = await findMarkdownFiles(root, options);
for (const file of files) {
const content = await fs.readFile(file, 'utf-8');
const tasks = parseFile(file, content);
yield tasks;
}
}
// Consume incrementally
const allTasks: Task[] = [];
for await (const tasks of scanFilesAsync(markdownRoot, options)) {
allTasks.push(...tasks);
}
Caching (Future)
// Cache parsed results with file hash
interface CacheEntry {
fileHash: string;
tasks: Task[];
timestamp: number;
}
export class TaskCache {
async getCachedTasks(filePath: string): Promise<Task[] | null> {
const fileHash = await hashFile(filePath);
const cached = await this.store.get(filePath);
if (cached && cached.fileHash === fileHash) {
return cached.tasks;
}
return null;
}
}
Error Messages
Be helpful and specific:
// Bad
throw new Error('Invalid date');
// Good
throw new Error(
`Invalid due date format "[due: ${dateStr}]" in ${file}:${line}\n` +
`Expected format: [due: YYYY-MM-DD] or [due: tomorrow|next week]`,
);
// Bad
console.error('Config error');
// Good
console.error(
chalk.red('Error: ') +
`Invalid config file at ${configPath}\n` +
` ${error.message}\n\n` +
`Run "md2do config reset" to restore defaults.`,
);
Documentation
Code Comments
/**
* Extracts project name from file path.
*
* Looks for "projects/" directory in path and returns the
* immediate subdirectory name.
*
* @example
* extractProjectFromPath('projects/divvy/notes.md') // => 'divvy'
* extractProjectFromPath('1-1s/jane.md') // => undefined
*/
export function extractProjectFromPath(filePath: string): string | undefined {
const match = filePath.match(/projects\/([^/]+)/);
return match?.[1];
}
README Sections
Include in README:
- Installation instructions
- Quick start guide
- All CLI commands with examples
- Configuration file format
- Syntax guide (how to write tasks)
- Examples of common workflows
- Troubleshooting section
- Contributing guide
Common Patterns
ID Generation
import { createHash } from 'crypto';
export function generateTaskId(
file: string,
line: number,
text: string,
): string {
// Hash file+line+text for stable ID
// ID stays same unless task moves or text changes
const content = `${file}:${line}:${text}`;
return createHash('md5').update(content).digest('hex').substring(0, 8);
}
Path Handling
import path from 'path';
// Always work with relative paths internally
export function makeRelativePath(absolutePath: string, root: string): string {
return path.relative(root, absolutePath);
}
// Convert to absolute for file operations
export function makeAbsolutePath(relativePath: string, root: string): string {
return path.resolve(root, relativePath);
}
Future MCP Integration Points
Design core with MCP in mind:
// Core functions return structured data (MCP-friendly)
export interface ScanResult {
tasks: Task[];
warnings: Warning[];
metadata: {
filesScanned: number;
totalTasks: number;
parseErrors: number;
};
}
// MCP server will expose these as tools:
// - scan_todos(filters) → ScanResult
// - get_todo(id) → Task
// - stats() → Statistics
// - search_tasks(query) → Task[]
Summary
When building md2do:
- Search for libraries first - Don't reinvent config loading, date math, CLI parsing
- Consult the project plan for specifications
- Keep core pure (no I/O in core package)
- Write tests first for complex parsing logic
- Use clear regex patterns with documentation
- Provide helpful errors with context
- Follow Nick's style (functional, typed, clean)
- Think MCP-first (structured data, pure functions)
- Document thoroughly (code comments, README, examples)
Essential Libraries to Use
- ✅ cosmiconfig for config loading
- ✅ commander for CLI framework
- ✅ chalk for terminal colors
- ✅ date-fns for date operations
- ✅ fast-glob for file pattern matching
- ✅ zod for validation
- ✅ vitest for testing
The goal is a reliable, well-tested tool that Nick can trust for daily task management, with an architecture that supports future MCP server integration for Claude.ai conversations.