@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
545 lines (540 loc) • 15.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 { EventEmitter } from "events";
import { execSync } from "child_process";
import * as fs from "fs/promises";
import * as path from "path";
class PostTaskHooks extends EventEmitter {
config;
frameManager;
dbManager;
isActive = false;
lastProcessedFrame;
constructor(frameManager, dbManager, config) {
super();
this.frameManager = frameManager;
this.dbManager = dbManager;
this.config = {
projectRoot: process.cwd(),
qualityGates: {
runTests: true,
requireTestCoverage: false,
runCodeReview: true,
runLinter: true,
blockOnFailure: false
},
testFrameworks: {
detected: []
},
reviewConfig: {
reviewOnEveryChange: false,
reviewOnTaskComplete: true,
focusAreas: [
"security",
"performance",
"maintainability",
"correctness"
],
skipPatterns: ["*.test.ts", "*.spec.js", "dist/", "node_modules/"]
},
...config
};
}
/**
* Initialize post-task hooks
*/
async initialize() {
if (this.isActive) return;
await this.detectTestFrameworks();
this.setupFrameListeners();
await this.setupFileWatchers();
this.isActive = true;
console.log("\u2705 Post-task hooks initialized");
this.emit("initialized", this.config);
}
/**
* Detect available test frameworks and commands
*/
async detectTestFrameworks() {
const packageJsonPath = path.join(this.config.projectRoot, "package.json");
try {
const packageJson = JSON.parse(
await fs.readFile(packageJsonPath, "utf-8")
);
const scripts = packageJson.scripts || {};
const dependencies = {
...packageJson.dependencies,
...packageJson.devDependencies
};
const frameworks = [];
if (dependencies.jest) frameworks.push("jest");
if (dependencies.vitest) frameworks.push("vitest");
if (dependencies.mocha) frameworks.push("mocha");
if (dependencies.playwright) frameworks.push("playwright");
if (dependencies.cypress) frameworks.push("cypress");
this.config.testFrameworks.detected = frameworks;
if (scripts.test) {
this.config.testFrameworks.testCommand = "npm test";
} else if (scripts["test:run"]) {
this.config.testFrameworks.testCommand = "npm run test:run";
}
if (scripts.coverage) {
this.config.testFrameworks.coverageCommand = "npm run coverage";
}
if (scripts.lint) {
this.config.testFrameworks.lintCommand = "npm run lint";
}
} catch (error) {
console.warn("Could not detect test frameworks:", error);
}
}
/**
* Set up frame event listeners
*/
setupFrameListeners() {
this.frameManager.on(
"frame:closed",
async (frameId, frameData) => {
if (frameData.type === "task" || frameData.type === "subtask") {
await this.handleTaskCompletion({
taskType: "task_complete",
frameId,
frameName: frameData.name || "Unnamed task",
files: this.extractFilesFromFrame(frameData),
changes: this.calculateChanges(frameData),
metadata: frameData.metadata || {}
});
}
}
);
this.frameManager.on(
"frame:event",
async (frameId, eventType, data) => {
if (eventType === "code_change" || eventType === "file_modified") {
await this.handleTaskCompletion({
taskType: "code_change",
frameId,
frameName: data.description || "Code change",
files: data.files || [],
changes: data.changes || { added: 0, removed: 0, modified: 1 },
metadata: data
});
}
}
);
}
/**
* Set up file watchers for real-time code change detection
*/
async setupFileWatchers() {
try {
const chokidar = await import("chokidar");
const watcher = chokidar.watch(
[
"**/*.{ts,js,tsx,jsx,py,go,rs,java,cpp,c}",
"!node_modules/**",
"!dist/**",
"!build/**"
],
{
cwd: this.config.projectRoot,
ignored: /node_modules/,
persistent: true
}
);
let changeQueue = [];
let debounceTimer = null;
watcher.on("change", (filePath) => {
changeQueue.push(filePath);
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
if (changeQueue.length > 0) {
await this.handleFileChanges([...changeQueue]);
changeQueue = [];
}
}, 2e3);
});
} catch (error) {
console.warn("File watching not available:", error);
}
}
/**
* Handle task completion events
*/
async handleTaskCompletion(event) {
if (this.lastProcessedFrame === event.frameId && event.taskType !== "code_change") {
return;
}
this.lastProcessedFrame = event.frameId;
console.log(
`\u{1F50D} Task completed: ${event.frameName} (${event.files.length} files changed)`
);
this.emit("task:completed", event);
const results = await this.runQualityGates(event);
const allPassed = results.every((r) => r.passed);
if (allPassed) {
console.log("\u2705 All quality gates passed");
this.emit("quality:passed", { event, results });
} else {
console.log("\u26A0\uFE0F Quality gate failures detected");
this.emit("quality:failed", { event, results });
if (this.config.qualityGates.blockOnFailure) {
await this.blockFurtherWork(results);
}
}
await this.recordQualityResults(event.frameId, results);
}
/**
* Handle file changes
*/
async handleFileChanges(files) {
if (!this.config.reviewConfig.reviewOnEveryChange) return;
const filteredFiles = files.filter((file) => {
return !this.config.reviewConfig.skipPatterns.some((pattern) => {
return file.includes(pattern.replace("*", ""));
});
});
if (filteredFiles.length === 0) return;
await this.handleTaskCompletion({
taskType: "file_modified",
frameId: "file-watcher",
frameName: "File changes detected",
files: filteredFiles,
changes: { added: 0, removed: 0, modified: filteredFiles.length },
metadata: { trigger: "file_watcher" }
});
}
/**
* Run all configured quality gates
*/
async runQualityGates(event) {
const results = [];
if (this.config.qualityGates.runLinter) {
results.push(await this.runLinter(event.files));
}
if (this.config.qualityGates.runTests) {
results.push(await this.runTests(event.files));
}
if (this.config.qualityGates.requireTestCoverage) {
results.push(await this.checkTestCoverage(event.files));
}
if (this.config.qualityGates.runCodeReview) {
results.push(await this.runCodeReview(event));
}
return results;
}
/**
* Run linter on changed files
*/
async runLinter(_files) {
const start = Date.now();
try {
if (!this.config.testFrameworks.lintCommand) {
return {
gate: "linter",
passed: true,
output: "No lint command configured",
duration: Date.now() - start
};
}
const output = execSync(this.config.testFrameworks.lintCommand, {
cwd: this.config.projectRoot,
encoding: "utf-8",
timeout: 3e4
// 30 second timeout
});
return {
gate: "linter",
passed: true,
output,
duration: Date.now() - start
};
} catch (error) {
return {
gate: "linter",
passed: false,
output: error.stdout || error.message,
duration: Date.now() - start,
issues: this.parseLintErrors(error.stdout || error.message)
};
}
}
/**
* Run tests
*/
async runTests(_files) {
const start = Date.now();
try {
if (!this.config.testFrameworks.testCommand) {
return {
gate: "tests",
passed: true,
output: "No test command configured",
duration: Date.now() - start
};
}
const output = execSync(this.config.testFrameworks.testCommand, {
cwd: this.config.projectRoot,
encoding: "utf-8",
timeout: 12e4
// 2 minute timeout
});
return {
gate: "tests",
passed: true,
output,
duration: Date.now() - start
};
} catch (error) {
return {
gate: "tests",
passed: false,
output: error.stdout || error.message,
duration: Date.now() - start,
issues: this.parseTestFailures(error.stdout || error.message)
};
}
}
/**
* Check test coverage
*/
async checkTestCoverage(_files) {
const start = Date.now();
try {
if (!this.config.testFrameworks.coverageCommand) {
return {
gate: "coverage",
passed: true,
output: "No coverage command configured",
duration: Date.now() - start
};
}
const output = execSync(this.config.testFrameworks.coverageCommand, {
cwd: this.config.projectRoot,
encoding: "utf-8",
timeout: 12e4
});
const coverageMatch = output.match(/(\d+\.?\d*)%/);
const coverage = coverageMatch ? parseFloat(coverageMatch[1]) : 0;
const threshold = 80;
return {
gate: "coverage",
passed: coverage >= threshold,
output,
duration: Date.now() - start,
issues: coverage < threshold ? [
{
type: "coverage_low",
file: "overall",
message: `Coverage ${coverage}% is below threshold ${threshold}%`,
severity: "warning"
}
] : void 0
};
} catch (error) {
return {
gate: "coverage",
passed: false,
output: error.stdout || error.message,
duration: Date.now() - start,
issues: [
{
type: "coverage_low",
file: "overall",
message: "Coverage check failed",
severity: "error"
}
]
};
}
}
/**
* Run code review using AI agent
*/
async runCodeReview(event) {
const start = Date.now();
try {
const reviewPrompt = this.generateCodeReviewPrompt(event);
const review = await this.callCodeReviewAgent(reviewPrompt, event.files);
return {
gate: "code_review",
passed: !review.issues || review.issues.length === 0,
output: review.summary,
duration: Date.now() - start,
issues: review.issues
};
} catch (error) {
return {
gate: "code_review",
passed: false,
output: `Code review failed: ${error.message}`,
duration: Date.now() - start
};
}
}
/**
* Generate code review prompt
*/
generateCodeReviewPrompt(event) {
return `
Please review the following code changes:
Task: ${event.frameName}
Files changed: ${event.files.join(", ")}
Changes: +${event.changes.added}, -${event.changes.removed}, ~${event.changes.modified}
Focus areas: ${this.config.reviewConfig.focusAreas.join(", ")}
Please check for:
1. Security vulnerabilities
2. Performance issues
3. Code maintainability
4. Correctness and logic errors
5. Best practices adherence
Provide specific, actionable feedback.
`;
}
/**
* Call code review agent (placeholder for actual implementation)
*/
async callCodeReviewAgent(prompt, files) {
return {
summary: `Reviewed ${files.length} files. Code quality looks good.`,
issues: []
};
}
/**
* Parse lint errors into structured issues
*/
parseLintErrors(output) {
const issues = [];
const lines = output.split("\n");
for (const line of lines) {
const match = line.match(
/^(.+?):(\d+):(\d+):\s*(error|warning):\s*(.+)$/
);
if (match) {
issues.push({
type: "lint_error",
file: match[1],
line: parseInt(match[2]),
message: match[5],
severity: match[4]
});
}
}
return issues;
}
/**
* Parse test failures into structured issues
*/
parseTestFailures(output) {
const issues = [];
const lines = output.split("\n");
for (const line of lines) {
if (line.includes("FAIL") || line.includes("\u2717")) {
issues.push({
type: "test_failure",
file: "unknown",
message: line.trim(),
severity: "error"
});
}
}
return issues;
}
/**
* Block further work when quality gates fail
*/
async blockFurtherWork(results) {
const failedGates = results.filter((r) => !r.passed);
console.log("\u{1F6AB} Quality gates failed - blocking further work:");
failedGates.forEach((gate) => {
console.log(` ${gate.gate}: ${gate.output}`);
if (gate.issues) {
gate.issues.forEach((issue) => {
console.log(
` - ${issue.severity}: ${issue.message} (${issue.file}:${issue.line || 0})`
);
});
}
});
console.log("\n\u{1F527} Fix these issues before continuing:");
const allIssues = failedGates.flatMap((g) => g.issues || []);
allIssues.forEach((issue, i) => {
console.log(`${i + 1}. ${issue.message}`);
});
}
/**
* Record quality results in frame metadata
*/
async recordQualityResults(frameId, results) {
try {
const frame = await this.frameManager.getFrame(frameId);
if (frame) {
frame.metadata = {
...frame.metadata,
qualityGates: {
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
results,
passed: results.every((r) => r.passed),
totalDuration: results.reduce((sum, r) => sum + r.duration, 0)
}
};
await this.frameManager.updateFrame(frameId, frame);
}
} catch (error) {
console.error("Failed to record quality results:", error);
}
}
/**
* Extract files from frame data
*/
extractFilesFromFrame(frameData) {
const files = [];
if (frameData.metadata?.files) {
files.push(...frameData.metadata.files);
}
if (frameData.events) {
frameData.events.forEach((event) => {
if (event.type === "file_change" && event.data?.file) {
files.push(event.data.file);
}
});
}
return [...new Set(files)];
}
/**
* Calculate changes from frame data
*/
calculateChanges(frameData) {
return {
added: frameData.metadata?.linesAdded || 0,
removed: frameData.metadata?.linesRemoved || 0,
modified: frameData.metadata?.filesModified || 1
};
}
/**
* Stop post-task hooks
*/
async stop() {
this.isActive = false;
this.removeAllListeners();
console.log("\u{1F6D1} Post-task hooks stopped");
}
/**
* Get current configuration
*/
getConfig() {
return { ...this.config };
}
/**
* Update configuration
*/
updateConfig(updates) {
this.config = { ...this.config, ...updates };
this.emit("config:updated", this.config);
}
}
export {
PostTaskHooks
};