UNPKG

@stackmemoryai/stackmemory

Version:

Project-scoped memory for AI coding tools. Durable context across sessions with MCP integration, frames, smart retrieval, Claude Code skills, and automatic hooks.

546 lines (541 loc) 15.5 kB
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 }; //# sourceMappingURL=post-task-hooks.js.map