mcp-ai-agent-guidelines
Version:
A comprehensive Model Context Protocol server providing advanced tools, resources, and prompts for implementing AI agent best practices
436 lines • 15.3 kB
JavaScript
/**
* Progress tracking service for Spec-Kit artifacts
*
* @module strategies/speckit/progress-tracker
*/
import { execFileSync } from "node:child_process";
import { promises as fs } from "node:fs";
import { logger } from "../../tools/shared/logger.js";
/**
* Service for tracking specification progress
*
* Manages task completion state and generates progress.md content.
*
* @example
* ```typescript
* const tracker = createProgressTracker(tasks);
* tracker.updateProgress({ taskId: "TASK-001", status: "completed" });
* const metrics = tracker.calculateCompletion();
* const markdown = tracker.generateProgressMarkdown();
* ```
*/
export class ProgressTracker {
tasks;
taskStatuses = new Map();
constructor(tasks) {
this.tasks = tasks;
this.initializeFromTasks();
}
initializeFromTasks() {
if (!this.tasks?.items)
return;
for (const task of this.tasks.items) {
this.taskStatuses.set(task.id, {
id: task.id,
completed: false,
});
}
}
/**
* Load progress from existing progress.md content
*
* Parses markdown checkboxes to determine task completion status.
*
* @param content - The progress.md markdown content
*
* @example
* ```typescript
* const content = `
* ## Tasks
* - [x] **TASK-001**: Setup database
* - [ ] **TASK-002**: Create API
* `;
* tracker.loadProgress(content);
* ```
*/
loadProgress(content) {
// Parse progress.md format
const completedPattern = /- \[x\] (.+)/gi;
const matches = content.matchAll(completedPattern);
for (const match of matches) {
const taskRef = this.extractTaskId(match[1]);
if (taskRef && this.taskStatuses.has(taskRef)) {
const status = this.taskStatuses.get(taskRef);
if (status) {
status.completed = true;
status.completedAt = new Date().toISOString();
}
}
}
}
/**
* Load progress from file
*
* Reads and parses progress.md file from disk.
*
* @param path - Path to progress.md file
* @throws Error if file cannot be read
*
* @example
* ```typescript
* await tracker.loadProgressFromFile('./progress.md');
* ```
*/
async loadProgressFromFile(path) {
const content = await fs.readFile(path, "utf-8");
this.loadProgress(content);
}
/**
* Update progress for a specific task
*
* @param update - Progress update information
* @throws Error if task ID is unknown
*
* @example
* ```typescript
* tracker.updateProgress({
* taskId: "TASK-001",
* status: "completed",
* notes: "All tests passing"
* });
* ```
*/
updateProgress(update) {
const status = this.taskStatuses.get(update.taskId);
if (!status) {
throw new Error(`Unknown task: ${update.taskId}`);
}
status.completed = update.status === "completed";
status.completedAt =
update.status === "completed"
? (update.timestamp ?? new Date().toISOString())
: undefined;
status.notes = update.notes;
}
/**
* Batch update multiple tasks
*
* @param updates - Array of progress updates
*
* @example
* ```typescript
* tracker.updateMultiple([
* { taskId: "TASK-001", status: "completed" },
* { taskId: "TASK-002", status: "in-progress" }
* ]);
* ```
*/
updateMultiple(updates) {
for (const update of updates) {
this.updateProgress(update);
}
}
/**
* Calculate completion metrics
*
* @returns Metrics including total, completed, remaining, and percentage
*
* @example
* ```typescript
* const metrics = tracker.calculateCompletion();
* console.log(`Progress: ${metrics.percentComplete}%`);
* ```
*/
calculateCompletion() {
const total = this.taskStatuses.size;
const completed = Array.from(this.taskStatuses.values()).filter((s) => s.completed).length;
return {
total,
completed,
remaining: total - completed,
percentComplete: total > 0 ? Math.round((completed / total) * 100) : 0,
};
}
/**
* Generate progress.md content
*
* Creates formatted markdown with summary table and task checklist.
*
* @returns Markdown content for progress.md
*
* @example
* ```typescript
* const markdown = tracker.generateProgressMarkdown();
* await fs.writeFile('progress.md', markdown);
* ```
*/
generateProgressMarkdown() {
const metrics = this.calculateCompletion();
const lines = [];
lines.push("# Progress\n");
lines.push(`**Last Updated**: ${new Date().toISOString()}\n`);
lines.push(`**Status**: ${this.getStatusIndicator(metrics)}\n\n`);
lines.push("## Summary\n\n");
lines.push("| Metric | Value |\n");
lines.push("|--------|-------|\n");
lines.push(`| Total Tasks | ${metrics.total} |\n`);
lines.push(`| Completed | ${metrics.completed} |\n`);
lines.push(`| Remaining | ${metrics.remaining} |\n`);
lines.push(`| Progress | ${metrics.percentComplete}% |\n\n`);
lines.push("## Tasks\n\n");
for (const [id, status] of this.taskStatuses) {
const checkbox = status.completed ? "[x]" : "[ ]";
const taskInfo = this.tasks?.items?.find((t) => t.id === id);
const title = taskInfo?.title ?? id;
lines.push(`- ${checkbox} **${id}**: ${title}\n`);
if (status.notes) {
lines.push(` - Note: ${status.notes}\n`);
}
}
return lines.join("");
}
/**
* Sync progress from git commit history
*
* Scans git commit messages for task references and automatically updates
* task status based on commit keywords like "closes", "fixes", etc.
*
* @param options - Options for git sync
* @returns Array of progress updates that were applied
*
* @example
* ```typescript
* const updates = tracker.syncFromGit({
* since: "2026-01-01",
* branch: "main"
* });
* console.log(`Updated ${updates.length} tasks from git history`);
* ```
*/
syncFromGit(options = {}) {
const commits = this.fetchCommits(options);
const updates = [];
for (const commit of commits) {
const taskRefs = this.extractTaskReferences(commit.message, options.taskIdPattern);
for (const taskRef of taskRefs) {
if (this.taskStatuses.has(taskRef.taskId)) {
// Actions that indicate completion: close, fix, resolve, complete
const completionActions = ["close", "fix", "resolve", "complete"];
const isCompleted = completionActions.includes(taskRef.action);
const update = {
taskId: taskRef.taskId,
status: isCompleted ? "completed" : "in-progress",
notes: `${taskRef.action} via commit ${commit.hash.substring(0, 7)}`,
timestamp: commit.date,
};
this.updateProgress(update);
updates.push(update);
}
}
}
return updates;
}
/**
* Fetch commits from git repository
*
* @param options - Git sync options
* @returns Array of git commits
* @private
*/
fetchCommits(options) {
const cwd = options.repoPath ?? process.cwd();
const branch = options.branch ?? "HEAD";
try {
// Build git log arguments safely to prevent command injection
const args = ["log", branch, "--format=%H|%s|%aI|%an", "--no-merges"];
// Add since parameter if provided
if (options.since) {
args.push(`--since=${options.since}`);
}
const output = execFileSync("git", args, {
cwd,
encoding: "utf-8",
});
return output
.trim()
.split("\n")
.filter(Boolean)
.map((line) => {
// Split with limit to handle pipe characters in commit messages
const parts = line.split("|");
if (parts.length < 4) {
// Malformed line, skip it
return null;
}
// Format: hash|message|date|author
// If message contains pipes, we need to reassemble it
const hash = parts[0];
const author = parts[parts.length - 1];
const date = parts[parts.length - 2];
// Everything between hash and date is the message
const messageParts = parts.slice(1, parts.length - 2);
const message = messageParts.join("|");
return {
hash,
message,
date,
author,
};
})
.filter((commit) => commit !== null);
}
catch (_error) {
// Git not available or not a repo - graceful degradation
return [];
}
}
/**
* Extract task references from commit message
*
* Parses commit messages for standard patterns like "closes #123" or
* "fixes TASK-001" as well as standalone task ID mentions.
*
* @param message - Commit message to parse
* @param customPattern - Optional custom regex pattern for task IDs
* @returns Array of task references with action and task ID
* @private
*/
extractTaskReferences(message, customPattern) {
const results = [];
// Standard patterns: closes #X, fixes #X, resolves #X
// Each pattern captures the action word and the task ID separately
const patterns = [
{
pattern: /(?:closes?|close)\s+#?([A-Z0-9]+-\d+)\b/gi,
action: "close",
},
{ pattern: /(?:fixes?|fix)\s+#?([A-Z0-9]+-\d+)\b/gi, action: "fix" },
{
pattern: /(?:resolves?|resolve)\s+#?([A-Z0-9]+-\d+)\b/gi,
action: "resolve",
},
{
pattern: /(?:completes?|complete)\s+#?([A-Z0-9]+-\d+)\b/gi,
action: "complete",
},
];
// Process standard patterns
for (const { pattern, action } of patterns) {
const matches = message.matchAll(pattern);
for (const match of matches) {
results.push({
taskId: match[1],
action,
});
}
}
// Add custom pattern if provided
// Expected shape for customPattern matches:
// - match[0]: full text starting with an action word (e.g., "closes P4-001")
// - match[1]: captured task ID (e.g., "P4-001")
if (customPattern) {
const matches = message.matchAll(customPattern);
for (const match of matches) {
// Validate that we have a captured task ID
if (!match[1]) {
continue;
}
// Derive action from the first whitespace-separated token in the full match
const rawMatchText = typeof match[0] === "string" ? match[0].trim() : "";
const firstToken = rawMatchText.split(/\s+/)[0] ?? "";
const normalizedAction = firstToken.toLowerCase().replace(/s$/, "");
// Validate that we have a plausible action word
if (!normalizedAction || !/^[a-z]+$/.test(normalizedAction)) {
// If the custom pattern doesn't start with an action word, use generic action
results.push({
taskId: match[1],
action: "custom",
});
continue;
}
results.push({
taskId: match[1],
action: normalizedAction,
});
}
}
// Also check for task ID mentions like "P4-001" or "TASK-123"
const taskIdPattern = /\b([A-Z0-9]+-\d+)\b/g;
const taskIdMatches = message.matchAll(taskIdPattern);
for (const match of taskIdMatches) {
if (!results.some((r) => r.taskId === match[1])) {
results.push({
taskId: match[1],
action: "mention",
});
}
}
return results;
}
/**
* Watch for new commits and update progress automatically
*
* Starts a periodic sync process that checks for new commits and updates
* task progress. Returns a cleanup function to stop watching.
*
* @param options - Git sync options with optional interval
* @returns Cleanup function to stop watching
*
* @example
* ```typescript
* const stopWatching = tracker.watchAndSync({
* intervalMs: 60000, // Check every minute
* branch: "main"
* });
*
* // Later, to stop watching:
* stopWatching();
* ```
*/
watchAndSync(options) {
const interval = options.intervalMs ?? 60000; // Default 1 minute
let lastSync = new Date().toISOString();
const intervalId = setInterval(() => {
const updates = this.syncFromGit({ ...options, since: lastSync });
lastSync = new Date().toISOString();
if (updates.length > 0) {
logger.info("Progress updated from git commits", {
taskCount: updates.length,
taskIds: updates.map((u) => u.taskId),
});
}
}, interval);
// Return cleanup function
return () => clearInterval(intervalId);
}
getStatusIndicator(metrics) {
if (metrics.percentComplete === 100)
return "✅ Complete";
if (metrics.percentComplete >= 75)
return "🟢 On Track";
if (metrics.percentComplete >= 50)
return "🟡 In Progress";
if (metrics.percentComplete >= 25)
return "🟠 Early Stage";
return "🔴 Starting";
}
extractTaskId(text) {
const match = text.match(/\*\*([^*]+)\*\*/);
return match ? match[1] : null;
}
}
/**
* Factory function to create a ProgressTracker instance
*
* @param tasks - Optional task collection to initialize with
* @returns New ProgressTracker instance
*
* @example
* ```typescript
* const tracker = createProgressTracker(tasks);
* ```
*/
export function createProgressTracker(tasks) {
return new ProgressTracker(tasks);
}
//# sourceMappingURL=progress-tracker.js.map