@stackmemoryai/stackmemory
Version:
Lossless, project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, conductor orchestrator, loop/watch monitoring, snapshot capture, pre-flight overlap checks, Claude/Codex/OpenCode wrappers, Linear sync, a
589 lines (581 loc) • 17.5 kB
JavaScript
import { fileURLToPath as __fileURLToPath } from 'url';
import { dirname as __pathDirname } from 'path';
const __filename = __fileURLToPath(import.meta.url);
const __dirname = __pathDirname(__filename);
import { LinearClient } from "./client.js";
import { LinearDuplicateDetector } from "./sync.js";
import { logger } from "../../core/monitoring/logger.js";
import { IntegrationError, ErrorCode } from "../../core/errors/index.js";
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
import { join, dirname } from "path";
import { EventEmitter } from "events";
const DEFAULT_UNIFIED_CONFIG = {
enabled: true,
direction: "bidirectional",
duplicateDetection: true,
duplicateSimilarityThreshold: 0.85,
mergeStrategy: "merge_content",
conflictResolution: "newest_wins",
taskPlanningEnabled: true,
taskPlanFile: ".stackmemory/task-plan.md",
autoCreateTaskPlan: true,
maxBatchSize: 50,
rateLimitDelay: 100,
maxRetries: 3,
autoSync: false,
autoSyncInterval: 15
};
class UnifiedLinearSync extends EventEmitter {
config;
linearClient;
taskStore;
authManager;
duplicateDetector;
projectRoot;
mappings = /* @__PURE__ */ new Map();
// task.id -> linear.id
lastSyncStats;
syncInProgress = false;
constructor(taskStore, authManager, projectRoot, config) {
super();
this.taskStore = taskStore;
this.authManager = authManager;
this.projectRoot = projectRoot;
this.config = { ...DEFAULT_UNIFIED_CONFIG, ...config };
this.linearClient = null;
this.duplicateDetector = null;
this.loadMappings();
}
/**
* Initialize the sync system
*/
async initialize() {
try {
const token = await this.authManager.getValidToken();
if (!token) {
throw new IntegrationError(
'Linear authentication required. Run "stackmemory linear auth" first.',
ErrorCode.LINEAR_AUTH_FAILED
);
}
const isOAuth = this.authManager.isOAuth();
this.linearClient = new LinearClient({
apiKey: token,
useBearer: isOAuth,
teamId: this.config.defaultTeamId,
onUnauthorized: isOAuth ? async () => {
const refreshed = await this.authManager.refreshAccessToken();
return refreshed.accessToken;
} : void 0
});
if (this.config.duplicateDetection) {
this.duplicateDetector = new LinearDuplicateDetector(this.linearClient);
}
if (this.config.taskPlanningEnabled) {
await this.initializeTaskPlanning();
}
logger.info("Unified Linear sync initialized", {
direction: this.config.direction,
duplicateDetection: this.config.duplicateDetection,
taskPlanning: this.config.taskPlanningEnabled
});
} catch (error) {
logger.error("Failed to initialize Linear sync:", error);
throw error;
}
}
/**
* Main sync method - orchestrates bidirectional sync
*/
async sync() {
if (this.syncInProgress) {
throw new IntegrationError(
"Sync already in progress",
ErrorCode.LINEAR_SYNC_FAILED
);
}
this.syncInProgress = true;
const startTime = Date.now();
const stats = {
toLinear: { created: 0, updated: 0, skipped: 0, duplicatesMerged: 0 },
fromLinear: { created: 0, updated: 0, skipped: 0 },
conflicts: [],
errors: [],
duration: 0,
timestamp: Date.now()
};
try {
this.emit("sync:started", { config: this.config });
switch (this.config.direction) {
case "bidirectional":
await this.syncFromLinear(stats);
await this.syncToLinear(stats);
break;
case "from_linear":
await this.syncFromLinear(stats);
break;
case "to_linear":
await this.syncToLinear(stats);
break;
}
if (this.config.taskPlanningEnabled) {
await this.updateTaskPlan(stats);
}
this.saveMappings();
stats.duration = Date.now() - startTime;
this.lastSyncStats = stats;
this.emit("sync:completed", { stats });
logger.info("Unified sync completed", {
duration: `${stats.duration}ms`,
toLinear: stats.toLinear,
fromLinear: stats.fromLinear,
conflicts: stats.conflicts.length
});
return stats;
} catch (error) {
stats.errors.push(error.message);
stats.duration = Date.now() - startTime;
this.emit("sync:failed", { stats, error });
logger.error("Unified sync failed:", error);
throw error;
} finally {
this.syncInProgress = false;
}
}
/**
* Sync from Linear to local tasks
*/
async syncFromLinear(stats) {
try {
logger.debug("Syncing from Linear...");
const teamId = this.config.defaultTeamId || await this.getDefaultTeamId();
const issues = await this.linearClient.getIssues({
teamId,
limit: this.config.maxBatchSize
});
for (const issue of issues) {
try {
await this.delay(this.config.rateLimitDelay);
const localTaskId = this.findLocalTaskByLinearId(issue.id);
if (localTaskId) {
const localTask = await this.taskStore.getTask(localTaskId);
if (localTask && this.hasChanges(localTask, issue)) {
await this.updateLocalTask(localTask, issue);
stats.fromLinear.updated++;
} else {
stats.fromLinear.skipped++;
}
} else {
await this.createLocalTask(issue);
stats.fromLinear.created++;
}
} catch (error) {
stats.errors.push(
`Failed to sync issue ${issue.identifier}: ${error.message}`
);
}
}
} catch (error) {
logger.error("Failed to sync from Linear:", error);
throw error;
}
}
/**
* Sync local tasks to Linear
*/
async syncToLinear(stats) {
try {
logger.debug("Syncing to Linear...");
const tasks = await this.taskStore.getAllTasks();
const teamId = this.config.defaultTeamId || await this.getDefaultTeamId();
for (const task of tasks) {
try {
await this.delay(this.config.rateLimitDelay);
const linearId = this.mappings.get(task.id);
if (linearId) {
const linearIssue = await this.linearClient.getIssue(linearId);
if (linearIssue && this.taskNeedsUpdate(task, linearIssue)) {
await this.updateLinearIssue(linearIssue, task);
stats.toLinear.updated++;
} else {
stats.toLinear.skipped++;
}
} else {
if (this.config.duplicateDetection) {
const duplicateCheck = await this.duplicateDetector.checkForDuplicate(
task.title,
teamId
);
if (duplicateCheck.isDuplicate && duplicateCheck.existingIssue) {
if (this.config.mergeStrategy === "merge_content") {
await this.mergeTaskIntoLinear(
task,
duplicateCheck.existingIssue
);
this.mappings.set(task.id, duplicateCheck.existingIssue.id);
stats.toLinear.duplicatesMerged++;
} else if (this.config.mergeStrategy === "skip") {
stats.toLinear.skipped++;
continue;
}
} else {
await this.createLinearIssue(task, teamId);
stats.toLinear.created++;
}
} else {
await this.createLinearIssue(task, teamId);
stats.toLinear.created++;
}
}
} catch (error) {
stats.errors.push(
`Failed to sync task ${task.id}: ${error.message}`
);
}
}
} catch (error) {
logger.error("Failed to sync to Linear:", error);
throw error;
}
}
/**
* Initialize task planning system
*/
async initializeTaskPlanning() {
const planFile = join(this.projectRoot, this.config.taskPlanFile);
const planDir = dirname(planFile);
if (!existsSync(planDir)) {
mkdirSync(planDir, { recursive: true });
}
if (!existsSync(planFile) && this.config.autoCreateTaskPlan) {
const defaultPlan = {
version: "1.0.0",
lastUpdated: /* @__PURE__ */ new Date(),
phases: [
{
name: "Backlog",
description: "Tasks to be prioritized",
tasks: []
},
{
name: "Current Sprint",
description: "Active work items",
tasks: []
},
{
name: "Completed",
description: "Finished tasks",
tasks: []
}
]
};
this.saveTaskPlan(defaultPlan);
logger.info("Created default task plan", { path: planFile });
}
}
/**
* Update task plan with sync results
*/
async updateTaskPlan(stats) {
if (!this.config.taskPlanningEnabled) return;
try {
const plan = this.loadTaskPlan();
const tasks = await this.taskStore.getAllTasks();
plan.phases = [
{
name: "Backlog",
description: "Tasks to be prioritized",
tasks: tasks.filter((t) => t.status === "todo").map((t) => ({
id: t.id,
title: t.title,
priority: t.priority || "medium",
status: t.status,
linearId: this.mappings.get(t.id)
}))
},
{
name: "In Progress",
description: "Active work items",
tasks: tasks.filter((t) => t.status === "in_progress").map((t) => ({
id: t.id,
title: t.title,
priority: t.priority || "medium",
status: t.status,
linearId: this.mappings.get(t.id)
}))
},
{
name: "Completed",
description: "Finished tasks",
tasks: tasks.filter((t) => t.status === "done").slice(-20).map((t) => ({
id: t.id,
title: t.title,
priority: t.priority || "medium",
status: t.status,
linearId: this.mappings.get(t.id)
}))
}
];
plan.lastUpdated = /* @__PURE__ */ new Date();
this.saveTaskPlan(plan);
this.generateTaskReport(plan, stats);
} catch (error) {
logger.error("Failed to update task plan:", error);
}
}
/**
* Generate markdown task report
*/
generateTaskReport(plan, stats) {
const reportFile = join(this.projectRoot, ".stackmemory", "task-report.md");
let content = `# Task Sync Report
`;
content += `**Last Updated:** ${plan.lastUpdated.toLocaleString()}
`;
content += `**Sync Duration:** ${stats.duration}ms
`;
content += `## Sync Statistics
`;
content += `### To Linear
`;
content += `- Created: ${stats.toLinear.created}
`;
content += `- Updated: ${stats.toLinear.updated}
`;
content += `- Duplicates Merged: ${stats.toLinear.duplicatesMerged}
`;
content += `- Skipped: ${stats.toLinear.skipped}
`;
content += `### From Linear
`;
content += `- Created: ${stats.fromLinear.created}
`;
content += `- Updated: ${stats.fromLinear.updated}
`;
content += `- Skipped: ${stats.fromLinear.skipped}
`;
if (stats.conflicts.length > 0) {
content += `### Conflicts
`;
stats.conflicts.forEach((c) => {
content += `- **${c.taskId}**: ${c.reason} (${c.resolution})
`;
});
content += "\n";
}
content += `## Task Overview
`;
plan.phases.forEach((phase) => {
content += `### ${phase.name} (${phase.tasks.length})
`;
content += `> ${phase.description}
`;
if (phase.tasks.length > 0) {
phase.tasks.slice(0, 10).forEach((task) => {
const linearLink = task.linearId ? ` [Linear]` : "";
content += `- **${task.title}**${linearLink}
`;
});
if (phase.tasks.length > 10) {
content += `- _...and ${phase.tasks.length - 10} more_
`;
}
}
content += "\n";
});
writeFileSync(reportFile, content);
logger.debug("Task report generated", { path: reportFile });
}
/**
* Helper methods
*/
async getDefaultTeamId() {
const teams = await this.linearClient.getTeams();
if (teams.length === 0) {
throw new IntegrationError(
"No Linear teams found",
ErrorCode.LINEAR_API_ERROR
);
}
return teams[0].id;
}
findLocalTaskByLinearId(linearId) {
for (const [taskId, linId] of this.mappings) {
if (linId === linearId) return taskId;
}
return void 0;
}
hasChanges(localTask, linearIssue) {
return localTask.title !== linearIssue.title || localTask.description !== (linearIssue.description || "") || this.mapLinearStateToStatus(linearIssue.state.type) !== localTask.status;
}
taskNeedsUpdate(task, linearIssue) {
return task.title !== linearIssue.title || task.description !== (linearIssue.description || "") || task.status !== this.mapLinearStateToStatus(linearIssue.state.type);
}
async createLocalTask(issue) {
const task = await this.taskStore.createTask({
title: issue.title,
description: issue.description || "",
status: this.mapLinearStateToStatus(issue.state.type),
priority: this.mapLinearPriorityToPriority(issue.priority),
metadata: {
linear: {
id: issue.id,
identifier: issue.identifier,
url: issue.url
}
}
});
this.mappings.set(task.id, issue.id);
}
async updateLocalTask(task, issue) {
await this.taskStore.updateTask(task.id, {
title: issue.title,
description: issue.description || "",
status: this.mapLinearStateToStatus(issue.state.type),
priority: this.mapLinearPriorityToPriority(issue.priority)
});
}
async createLinearIssue(task, teamId) {
const input = {
title: task.title,
description: task.description || "",
teamId,
priority: this.mapPriorityToLinearPriority(task.priority)
};
const issue = await this.linearClient.createIssue(input);
this.mappings.set(task.id, issue.id);
await this.taskStore.updateTask(task.id, {
metadata: {
...task.metadata,
linear: {
id: issue.id,
identifier: issue.identifier,
url: issue.url
}
}
});
}
async updateLinearIssue(issue, task) {
await this.linearClient.updateIssue(issue.id, {
title: task.title,
description: task.description,
priority: this.mapPriorityToLinearPriority(task.priority)
});
}
async mergeTaskIntoLinear(task, existingIssue) {
await this.duplicateDetector.mergeIntoExisting(
existingIssue,
task.title,
task.description,
`StackMemory Task: ${task.id}
Merged: ${(/* @__PURE__ */ new Date()).toISOString()}`
);
}
mapLinearStateToStatus(state) {
switch (state.toLowerCase()) {
case "backlog":
case "unstarted":
return "todo";
case "started":
return "in_progress";
case "completed":
return "done";
case "cancelled":
return "cancelled";
default:
return "todo";
}
}
mapLinearPriorityToPriority(priority) {
switch (priority) {
case 1:
return "urgent";
case 2:
return "high";
case 3:
return "medium";
case 4:
return "low";
default:
return void 0;
}
}
mapPriorityToLinearPriority(priority) {
switch (priority) {
case "urgent":
return 1;
case "high":
return 2;
case "medium":
return 3;
case "low":
return 4;
default:
return 0;
}
}
loadMappings() {
const mappingFile = join(
this.projectRoot,
".stackmemory",
"linear-mappings.json"
);
if (existsSync(mappingFile)) {
try {
const data = JSON.parse(readFileSync(mappingFile, "utf8"));
this.mappings = new Map(Object.entries(data));
} catch (error) {
logger.error("Failed to load mappings:", error);
}
}
}
saveMappings() {
const mappingFile = join(
this.projectRoot,
".stackmemory",
"linear-mappings.json"
);
const data = Object.fromEntries(this.mappings);
writeFileSync(mappingFile, JSON.stringify(data, null, 2));
}
loadTaskPlan() {
const planFile = join(this.projectRoot, this.config.taskPlanFile);
if (existsSync(planFile)) {
try {
return JSON.parse(readFileSync(planFile, "utf8"));
} catch (error) {
logger.error("Failed to load task plan:", error);
}
}
return {
version: "1.0.0",
lastUpdated: /* @__PURE__ */ new Date(),
phases: []
};
}
saveTaskPlan(plan) {
const planFile = join(this.projectRoot, this.config.taskPlanFile);
writeFileSync(planFile, JSON.stringify(plan, null, 2));
}
delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Get last sync statistics
*/
getLastSyncStats() {
return this.lastSyncStats;
}
/**
* Clear duplicate detector cache
*/
clearCache() {
if (this.duplicateDetector) {
this.duplicateDetector.clearCache();
}
}
}
export {
DEFAULT_UNIFIED_CONFIG,
UnifiedLinearSync
};