@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
222 lines (221 loc) • 5.62 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 * as fs from "fs";
import * as path from "path";
import * as os from "os";
const CANONICAL_HOOKS = [
{
scriptName: "session-rescue.sh",
eventType: "Stop",
timeout: 12,
required: true
},
{
scriptName: "stop-checkpoint.js",
eventType: "Stop",
timeout: 5,
commandPrefix: "node",
required: true
},
{
scriptName: "chime-on-stop.sh",
eventType: "Stop",
timeout: 2,
required: true
},
{
scriptName: "auto-checkpoint.js",
eventType: "PostToolUse",
timeout: 2,
commandPrefix: "node",
required: true
},
{
scriptName: "cord-trace.js",
eventType: "PostToolUse",
matcher: "mcp__.*__cord_(spawn|fork|complete|ask|tree)",
timeout: 2,
commandPrefix: "node",
required: true
},
{
scriptName: "theory-capture.js",
eventType: "PostToolUse",
matcher: "Edit|Write|MultiEdit",
timeout: 2,
commandPrefix: "node",
required: false
},
{
scriptName: "team-subagent-stop.js",
eventType: "SubagentStop",
timeout: 5,
commandPrefix: "node",
required: false
},
{
scriptName: "team-task-complete.js",
eventType: "TaskCompleted",
timeout: 5,
commandPrefix: "node",
required: false
},
{
scriptName: "team-teammate-idle.js",
eventType: "TeammateIdle",
timeout: 3,
commandPrefix: "node",
required: false
},
{
scriptName: "desire-path-trace.js",
eventType: "PostToolUse",
timeout: 2,
commandPrefix: "node",
required: false
},
{
scriptName: "daemon-auto-start.js",
eventType: "PostToolUse",
timeout: 2,
commandPrefix: "node",
required: false
},
{
scriptName: "wiki-update.js",
eventType: "Stop",
timeout: 10,
commandPrefix: "node",
required: false
},
{
scriptName: "doc-ingest.js",
eventType: "PostToolUse",
matcher: "WebFetch",
timeout: 15,
commandPrefix: "node",
required: false
}
];
const DEAD_HOOKS = ["sms-response-handler.js"];
function buildCommand(entry, hooksDir) {
const scriptPath = path.join(hooksDir, entry.scriptName);
if (entry.commandPrefix) {
return `${entry.commandPrefix} ${scriptPath}`;
}
return scriptPath;
}
function hookExists(settings, entry) {
const groups = settings.hooks?.[entry.eventType];
if (!groups) return false;
for (const group of groups) {
for (const hook of group.hooks) {
if (hook.command.includes(entry.scriptName)) {
return true;
}
}
}
return false;
}
function hasDeadHooks(settings) {
if (!settings.hooks) return false;
for (const groups of Object.values(settings.hooks)) {
for (const group of groups) {
for (const hook of group.hooks) {
for (const dead of DEAD_HOOKS) {
if (hook.command.includes(dead)) return true;
}
}
}
}
return false;
}
function removeDeadHooks(settings) {
if (!settings.hooks) return false;
let removed = false;
for (const eventType of Object.keys(settings.hooks)) {
const groups = settings.hooks[eventType];
for (const group of groups) {
const before = group.hooks.length;
group.hooks = group.hooks.filter((hook) => {
for (const dead of DEAD_HOOKS) {
if (hook.command.includes(dead)) return false;
}
return true;
});
if (group.hooks.length < before) removed = true;
}
settings.hooks[eventType] = groups.filter((g) => g.hooks.length > 0);
if (settings.hooks[eventType].length === 0) {
delete settings.hooks[eventType];
}
}
return removed;
}
function addHook(settings, entry, hooksDir) {
if (!settings.hooks) settings.hooks = {};
const eventGroups = settings.hooks[entry.eventType] || [];
const command = buildCommand(entry, hooksDir);
const hookCmd = { type: "command", command };
if (entry.timeout) hookCmd.timeout = entry.timeout;
const matcherValue = entry.matcher ?? void 0;
const targetGroup = eventGroups.find((g) => {
if (matcherValue) return g.matcher === matcherValue;
return !g.matcher;
});
if (targetGroup) {
targetGroup.hooks.push(hookCmd);
} else {
const newGroup = { hooks: [hookCmd] };
if (matcherValue) newGroup.matcher = matcherValue;
eventGroups.push(newGroup);
}
settings.hooks[entry.eventType] = eventGroups;
}
function mergeSettings(existing, hooksDir) {
const merged = JSON.parse(JSON.stringify(existing));
removeDeadHooks(merged);
for (const entry of CANONICAL_HOOKS) {
if (!hookExists(merged, entry)) {
addHook(merged, entry, hooksDir);
}
}
return merged;
}
function getSettingsPath() {
return path.join(os.homedir(), ".claude", "settings.json");
}
function readSettings(settingsPath) {
const p = settingsPath ?? getSettingsPath();
try {
if (fs.existsSync(p)) {
return JSON.parse(fs.readFileSync(p, "utf8"));
}
} catch {
}
return {};
}
function writeSettingsAtomic(settings, settingsPath) {
const p = settingsPath ?? getSettingsPath();
const dir = path.dirname(p);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const tmp = p + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
fs.renameSync(tmp, p);
}
export {
CANONICAL_HOOKS,
DEAD_HOOKS,
buildCommand,
getSettingsPath,
hasDeadHooks,
hookExists,
mergeSettings,
readSettings,
removeDeadHooks,
writeSettingsAtomic
};