@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
314 lines (313 loc) • 10.1 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 { execFileSync } from "child_process";
import { extname } from "path";
import { extractKeywords as extractKeywordsShared } from "../utils/text.js";
class PreflightChecker {
repoPath;
gitLogCache = /* @__PURE__ */ new Map();
constructor(repoPath) {
this.repoPath = repoPath || process.cwd();
}
/**
* Run pre-flight check on a set of tasks.
* Returns parallel-safe groupings and sequential recommendations.
*/
check(tasks) {
if (tasks.length < 2) {
return {
parallelSafe: [tasks],
sequential: [],
allOverlaps: [],
summary: "Single task - no overlap check needed."
};
}
const taskFiles = /* @__PURE__ */ new Map();
const taskKeywords = /* @__PURE__ */ new Map();
for (const task of tasks) {
const files = this.predictFiles(task);
taskFiles.set(task.name, files);
taskKeywords.set(
task.name,
task.keywords || this.extractKeywords(task.description)
);
}
const allOverlaps = [];
const overlapPairs = /* @__PURE__ */ new Map();
for (let i = 0; i < tasks.length; i++) {
for (let j = i + 1; j < tasks.length; j++) {
const a = tasks[i];
const b = tasks[j];
const filesA = taskFiles.get(a.name);
const filesB = taskFiles.get(b.name);
const shared = [...filesA].filter((f) => filesB.has(f));
if (shared.length > 0) {
for (const file of shared) {
allOverlaps.push({
file,
tasks: [a.name, b.name],
confidence: this.estimateConfidenceCached(
file,
taskKeywords.get(a.name),
taskKeywords.get(b.name),
a,
b
),
source: this.getSourceCached(
file,
taskKeywords.get(a.name),
taskKeywords.get(b.name),
a,
b
)
});
}
if (!overlapPairs.has(a.name)) overlapPairs.set(a.name, /* @__PURE__ */ new Set());
if (!overlapPairs.has(b.name)) overlapPairs.set(b.name, /* @__PURE__ */ new Set());
overlapPairs.get(a.name).add(b.name);
overlapPairs.get(b.name).add(a.name);
}
}
}
const parallelSafe = this.buildParallelGroups(tasks, overlapPairs);
const sequential = [];
for (const task of tasks) {
const conflicts = overlapPairs.get(task.name);
if (conflicts && conflicts.size > 0) {
const overlaps = allOverlaps.filter((o) => o.tasks.includes(task.name));
const largestConflict = [...conflicts].sort((a, b) => {
return (taskFiles.get(b)?.size || 0) - (taskFiles.get(a)?.size || 0);
})[0];
sequential.push({
task,
after: largestConflict,
overlaps
});
}
}
const deduped = this.deduplicateSequential(sequential, taskFiles);
const summary = this.formatSummary(parallelSafe, deduped, allOverlaps);
return {
parallelSafe,
sequential: deduped,
allOverlaps,
summary
};
}
/**
* Predict which files a task will touch based on multiple signals.
*/
predictFiles(task) {
const files = /* @__PURE__ */ new Set();
if (task.files) {
task.files.forEach((f) => files.add(f));
}
const keywords = task.keywords || this.extractKeywords(task.description);
for (const keyword of keywords) {
const historyFiles = this.searchGitHistory(keyword);
historyFiles.forEach((f) => files.add(f));
}
if (task.files && task.files.length > 0) {
for (const file of task.files) {
const dependents = this.findDependents(file);
dependents.forEach((f) => files.add(f));
}
}
for (const keyword of keywords) {
const matched = this.searchFilePaths(keyword);
matched.forEach((f) => files.add(f));
}
return files;
}
/**
* Search git log for files changed in commits matching a keyword.
*/
searchGitHistory(keyword, maxCommits = 50) {
const cacheKey = keyword.toLowerCase();
if (this.gitLogCache.has(cacheKey)) {
return this.gitLogCache.get(cacheKey);
}
try {
const output = execFileSync(
"git",
[
"log",
`--max-count=${maxCommits}`,
"--name-only",
"--pretty=format:",
"--grep",
keyword,
"-i"
],
{ cwd: this.repoPath, encoding: "utf-8", timeout: 1e4 }
);
const files = output.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
const freq = /* @__PURE__ */ new Map();
for (const f of files) {
freq.set(f, (freq.get(f) || 0) + 1);
}
const result = [...freq.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20).map(([f]) => f);
this.gitLogCache.set(cacheKey, result);
return result;
} catch {
return [];
}
}
/**
* Find files that import/depend on a given file (shallow, grep-based).
*/
findDependents(filePath) {
const ext = extname(filePath);
if (![".ts", ".tsx", ".js", ".jsx", ".mjs"].includes(ext)) return [];
const baseName = filePath.replace(extname(filePath), "").replace(/\/index$/, "");
try {
const output = execFileSync(
"git",
["grep", "-l", baseName, "--", "*.ts", "*.tsx", "*.js", "*.jsx"],
{ cwd: this.repoPath, encoding: "utf-8", timeout: 1e4 }
);
return output.split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && l !== filePath);
} catch {
return [];
}
}
/**
* Search file paths for keyword matches using git ls-files.
*/
searchFilePaths(keyword) {
try {
const output = execFileSync("git", ["ls-files", `*${keyword}*`], {
cwd: this.repoPath,
encoding: "utf-8",
timeout: 5e3
});
return output.split("\n").map((l) => l.trim()).filter((l) => l.length > 0).slice(0, 10);
} catch {
return [];
}
}
extractKeywords(description) {
return extractKeywordsShared(description);
}
isInHistory(keywords, file) {
return keywords.some(
(k) => (this.gitLogCache.get(k.toLowerCase()) || []).includes(file)
);
}
estimateConfidenceCached(file, keywordsA, keywordsB, taskA, taskB) {
if (taskA.files?.includes(file) || taskB.files?.includes(file)) {
return 0.9;
}
if (this.isInHistory(keywordsA, file) && this.isInHistory(keywordsB, file)) {
return 0.7;
}
return 0.3;
}
getSourceCached(file, keywordsA, keywordsB, taskA, taskB) {
if (taskA.files?.includes(file) || taskB.files?.includes(file)) {
return "explicit";
}
if (this.isInHistory(keywordsA, file) || this.isInHistory(keywordsB, file)) {
return "git-history";
}
return "keyword-match";
}
/**
* Build parallel-safe groups via greedy graph coloring.
* Tasks that overlap go in different groups.
*/
buildParallelGroups(tasks, overlapPairs) {
const groups = [];
const assigned = /* @__PURE__ */ new Set();
const sorted = [...tasks].sort((a, b) => {
const conflictsA = overlapPairs.get(a.name)?.size || 0;
const conflictsB = overlapPairs.get(b.name)?.size || 0;
return conflictsA - conflictsB;
});
for (const task of sorted) {
if (assigned.has(task.name)) continue;
let placed = false;
for (const group of groups) {
const conflicts = overlapPairs.get(task.name) || /* @__PURE__ */ new Set();
const groupHasConflict = group.some((t) => conflicts.has(t.name));
if (!groupHasConflict) {
group.push(task);
assigned.add(task.name);
placed = true;
break;
}
}
if (!placed) {
groups.push([task]);
assigned.add(task.name);
}
}
return groups;
}
/**
* Deduplicate sequential recommendations — keep only the smaller task.
*/
deduplicateSequential(sequential, taskFiles) {
const seen = /* @__PURE__ */ new Set();
const result = [];
for (const entry of sequential) {
const key = [entry.task.name, entry.after].sort().join("|");
if (seen.has(key)) continue;
seen.add(key);
const mySize = taskFiles.get(entry.task.name)?.size || 0;
const otherSize = taskFiles.get(entry.after)?.size || 0;
if (mySize <= otherSize) {
result.push(entry);
} else {
const otherTask = sequential.find((s) => s.task.name === entry.after);
if (otherTask) {
result.push({
task: otherTask.task,
after: entry.task.name,
overlaps: entry.overlaps
});
}
}
}
return result;
}
/**
* Format human-readable summary.
*/
formatSummary(parallelSafe, sequential, overlaps) {
const lines = [];
if (overlaps.length === 0) {
lines.push("All tasks are parallel-safe. No file overlaps detected.");
return lines.join("\n");
}
lines.push(`Found ${overlaps.length} file overlap(s).
`);
if (parallelSafe.length === 1) {
lines.push(
"All tasks can run in parallel (overlaps are low-confidence)."
);
} else {
lines.push(`Parallel groups (${parallelSafe.length}):`);
parallelSafe.forEach((group, i) => {
lines.push(` Group ${i + 1}: ${group.map((t) => t.name).join(", ")}`);
});
}
if (sequential.length > 0) {
lines.push("\nSequential recommendations:");
for (const entry of sequential) {
lines.push(` "${entry.task.name}" should run after "${entry.after}"`);
for (const overlap of entry.overlaps.slice(0, 5)) {
lines.push(
` - ${overlap.file} (${overlap.source}, ${Math.round(overlap.confidence * 100)}%)`
);
}
}
}
return lines.join("\n");
}
}
export {
PreflightChecker
};