@stackmemoryai/stackmemory 1.3.2 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/cli/commands/{symphony.js → orchestrate.js} +19 -17
- package/dist/src/cli/commands/{symphony-orchestrator.js → orchestrator.js} +125 -28
- package/dist/src/cli/commands/preflight.js +87 -0
- package/dist/src/cli/commands/snapshot.js +89 -0
- package/dist/src/cli/index.js +6 -2
- package/dist/src/core/retrieval/summary-generator.js +2 -14
- package/dist/src/core/utils/fs.js +18 -0
- package/dist/src/core/utils/git.js +91 -0
- package/dist/src/core/utils/text.js +63 -0
- package/dist/src/core/worktree/capture.js +178 -0
- package/dist/src/core/worktree/preflight.js +313 -0
- package/package.json +1 -1
- package/scripts/git-hooks/post-commit-stackmemory.sh +27 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { fileURLToPath as __fileURLToPath } from 'url';
|
|
2
|
+
import { dirname as __pathDirname } from 'path';
|
|
3
|
+
const __filename = __fileURLToPath(import.meta.url);
|
|
4
|
+
const __dirname = __pathDirname(__filename);
|
|
5
|
+
import { execFileSync } from "child_process";
|
|
6
|
+
function getCurrentBranch(cwd) {
|
|
7
|
+
try {
|
|
8
|
+
return execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
9
|
+
cwd: cwd || process.cwd(),
|
|
10
|
+
encoding: "utf-8",
|
|
11
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
12
|
+
}).trim();
|
|
13
|
+
} catch {
|
|
14
|
+
return "unknown";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function detectBaseBranch(cwd) {
|
|
18
|
+
const dir = cwd || process.cwd();
|
|
19
|
+
for (const base of ["main", "master", "develop"]) {
|
|
20
|
+
try {
|
|
21
|
+
execFileSync("git", ["rev-parse", "--verify", base], {
|
|
22
|
+
cwd: dir,
|
|
23
|
+
encoding: "utf-8",
|
|
24
|
+
stdio: "pipe"
|
|
25
|
+
});
|
|
26
|
+
return base;
|
|
27
|
+
} catch {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return "main";
|
|
32
|
+
}
|
|
33
|
+
function getDiffStats(baseBranch, cwd) {
|
|
34
|
+
try {
|
|
35
|
+
const output = execFileSync(
|
|
36
|
+
"git",
|
|
37
|
+
["diff", "--name-status", `${baseBranch}...HEAD`],
|
|
38
|
+
{ cwd: cwd || process.cwd(), encoding: "utf-8", timeout: 1e4 }
|
|
39
|
+
);
|
|
40
|
+
const changed = [];
|
|
41
|
+
const created = [];
|
|
42
|
+
const deleted = [];
|
|
43
|
+
for (const line of output.split("\n").filter((l) => l.trim())) {
|
|
44
|
+
const [status, ...pathParts] = line.split(" ");
|
|
45
|
+
const filePath = pathParts.join(" ");
|
|
46
|
+
if (!filePath) continue;
|
|
47
|
+
switch (status.charAt(0)) {
|
|
48
|
+
case "A":
|
|
49
|
+
created.push(filePath);
|
|
50
|
+
break;
|
|
51
|
+
case "D":
|
|
52
|
+
deleted.push(filePath);
|
|
53
|
+
break;
|
|
54
|
+
case "M":
|
|
55
|
+
case "R":
|
|
56
|
+
case "C":
|
|
57
|
+
changed.push(filePath);
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { changed, created, deleted };
|
|
62
|
+
} catch {
|
|
63
|
+
return { changed: [], created: [], deleted: [] };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function getCommitsSince(baseBranch, cwd) {
|
|
67
|
+
try {
|
|
68
|
+
const output = execFileSync(
|
|
69
|
+
"git",
|
|
70
|
+
[
|
|
71
|
+
"log",
|
|
72
|
+
`${baseBranch}..HEAD`,
|
|
73
|
+
"--pretty=format:%H%x00%s%x00%an%x00%aI",
|
|
74
|
+
"--no-merges"
|
|
75
|
+
],
|
|
76
|
+
{ cwd: cwd || process.cwd(), encoding: "utf-8", timeout: 1e4 }
|
|
77
|
+
);
|
|
78
|
+
return output.split("\n").filter((l) => l.trim()).map((line) => {
|
|
79
|
+
const [hash, message, author, date] = line.split("\0");
|
|
80
|
+
return { hash, message, author, date };
|
|
81
|
+
});
|
|
82
|
+
} catch {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export {
|
|
87
|
+
detectBaseBranch,
|
|
88
|
+
getCommitsSince,
|
|
89
|
+
getCurrentBranch,
|
|
90
|
+
getDiffStats
|
|
91
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { fileURLToPath as __fileURLToPath } from 'url';
|
|
2
|
+
import { dirname as __pathDirname } from 'path';
|
|
3
|
+
const __filename = __fileURLToPath(import.meta.url);
|
|
4
|
+
const __dirname = __pathDirname(__filename);
|
|
5
|
+
const DEFAULT_STOP_WORDS = /* @__PURE__ */ new Set([
|
|
6
|
+
"the",
|
|
7
|
+
"a",
|
|
8
|
+
"an",
|
|
9
|
+
"is",
|
|
10
|
+
"are",
|
|
11
|
+
"was",
|
|
12
|
+
"were",
|
|
13
|
+
"be",
|
|
14
|
+
"been",
|
|
15
|
+
"to",
|
|
16
|
+
"of",
|
|
17
|
+
"in",
|
|
18
|
+
"for",
|
|
19
|
+
"on",
|
|
20
|
+
"with",
|
|
21
|
+
"at",
|
|
22
|
+
"by",
|
|
23
|
+
"from",
|
|
24
|
+
"and",
|
|
25
|
+
"or",
|
|
26
|
+
"but",
|
|
27
|
+
"not",
|
|
28
|
+
"this",
|
|
29
|
+
"that",
|
|
30
|
+
"it",
|
|
31
|
+
"its",
|
|
32
|
+
"all",
|
|
33
|
+
"any",
|
|
34
|
+
"add",
|
|
35
|
+
"update",
|
|
36
|
+
"fix",
|
|
37
|
+
"change",
|
|
38
|
+
"make",
|
|
39
|
+
"create",
|
|
40
|
+
"new",
|
|
41
|
+
"use",
|
|
42
|
+
"get",
|
|
43
|
+
"set",
|
|
44
|
+
"has",
|
|
45
|
+
"have",
|
|
46
|
+
"had",
|
|
47
|
+
"will",
|
|
48
|
+
"would",
|
|
49
|
+
"should",
|
|
50
|
+
"could",
|
|
51
|
+
"can",
|
|
52
|
+
"may",
|
|
53
|
+
"might"
|
|
54
|
+
]);
|
|
55
|
+
function extractKeywords(text, opts) {
|
|
56
|
+
const minLength = opts?.minLength ?? 3;
|
|
57
|
+
const maxCount = opts?.maxCount ?? 5;
|
|
58
|
+
const stopWords = opts?.stopWords ?? DEFAULT_STOP_WORDS;
|
|
59
|
+
return text.toLowerCase().replace(/[^a-z0-9\s\-_]/g, " ").split(/\s+/).filter((w) => w.length >= minLength && !stopWords.has(w)).slice(0, maxCount);
|
|
60
|
+
}
|
|
61
|
+
export {
|
|
62
|
+
extractKeywords
|
|
63
|
+
};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { fileURLToPath as __fileURLToPath } from 'url';
|
|
2
|
+
import { dirname as __pathDirname } from 'path';
|
|
3
|
+
const __filename = __fileURLToPath(import.meta.url);
|
|
4
|
+
const __dirname = __pathDirname(__filename);
|
|
5
|
+
import {
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
writeFileSync
|
|
11
|
+
} from "fs";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
import { homedir } from "os";
|
|
14
|
+
import { formatDuration } from "../../utils/formatting.js";
|
|
15
|
+
import { logger } from "../monitoring/logger.js";
|
|
16
|
+
import {
|
|
17
|
+
getCurrentBranch,
|
|
18
|
+
detectBaseBranch,
|
|
19
|
+
getDiffStats,
|
|
20
|
+
getCommitsSince
|
|
21
|
+
} from "../utils/git.js";
|
|
22
|
+
import { pruneOldFiles } from "../utils/fs.js";
|
|
23
|
+
const MAX_CAPTURES = 50;
|
|
24
|
+
class ContextCapture {
|
|
25
|
+
repoPath;
|
|
26
|
+
capturesDir;
|
|
27
|
+
constructor(repoPath) {
|
|
28
|
+
this.repoPath = repoPath || process.cwd();
|
|
29
|
+
const localDir = join(this.repoPath, ".stackmemory", "captures");
|
|
30
|
+
const globalDir = join(homedir(), ".stackmemory", "captures");
|
|
31
|
+
this.capturesDir = existsSync(join(this.repoPath, ".stackmemory")) ? localDir : globalDir;
|
|
32
|
+
if (!existsSync(this.capturesDir)) {
|
|
33
|
+
mkdirSync(this.capturesDir, { recursive: true });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Capture current state after task completion.
|
|
38
|
+
* Compares current branch against base (default: main).
|
|
39
|
+
*/
|
|
40
|
+
capture(options) {
|
|
41
|
+
const branch = getCurrentBranch(this.repoPath);
|
|
42
|
+
const baseBranch = options?.baseBranch || detectBaseBranch(this.repoPath);
|
|
43
|
+
const task = options?.task || branch;
|
|
44
|
+
const { changed, created, deleted } = getDiffStats(
|
|
45
|
+
baseBranch,
|
|
46
|
+
this.repoPath
|
|
47
|
+
);
|
|
48
|
+
const commits = getCommitsSince(baseBranch, this.repoPath);
|
|
49
|
+
const commitDecisions = this.extractDecisions(commits);
|
|
50
|
+
const decisions = [...options?.decisions || [], ...commitDecisions];
|
|
51
|
+
const duration = this.estimateDuration(commits);
|
|
52
|
+
const result = {
|
|
53
|
+
id: `${Date.now()}-${branch.replace(/[^a-zA-Z0-9]/g, "-")}`,
|
|
54
|
+
task,
|
|
55
|
+
branch,
|
|
56
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
57
|
+
filesChanged: changed,
|
|
58
|
+
filesCreated: created,
|
|
59
|
+
filesDeleted: deleted,
|
|
60
|
+
commits,
|
|
61
|
+
decisions,
|
|
62
|
+
duration,
|
|
63
|
+
baseBranch
|
|
64
|
+
};
|
|
65
|
+
this.save(result);
|
|
66
|
+
logger.info("Context captured", {
|
|
67
|
+
task,
|
|
68
|
+
branch,
|
|
69
|
+
filesChanged: changed.length,
|
|
70
|
+
filesCreated: created.length,
|
|
71
|
+
commits: commits.length
|
|
72
|
+
});
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* List all captures, newest first.
|
|
77
|
+
*/
|
|
78
|
+
list(limit = 20) {
|
|
79
|
+
if (!existsSync(this.capturesDir)) return [];
|
|
80
|
+
const files = readdirSync(this.capturesDir).filter((f) => f.endsWith(".json")).sort().reverse().slice(0, limit);
|
|
81
|
+
return files.map((f) => {
|
|
82
|
+
const content = readFileSync(join(this.capturesDir, f), "utf-8");
|
|
83
|
+
return JSON.parse(content);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Get the most recent capture for a branch.
|
|
88
|
+
*/
|
|
89
|
+
getLatest(branch) {
|
|
90
|
+
const captures = this.list();
|
|
91
|
+
if (branch) {
|
|
92
|
+
return captures.find((c) => c.branch === branch);
|
|
93
|
+
}
|
|
94
|
+
return captures[0];
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Format a capture as a human-readable summary for session restore.
|
|
98
|
+
*/
|
|
99
|
+
format(capture) {
|
|
100
|
+
const lines = [];
|
|
101
|
+
lines.push(`# Capture: ${capture.task}`);
|
|
102
|
+
lines.push(`Branch: ${capture.branch} (base: ${capture.baseBranch})`);
|
|
103
|
+
lines.push(
|
|
104
|
+
`Time: ${capture.timestamp}${capture.duration ? ` (${capture.duration})` : ""}`
|
|
105
|
+
);
|
|
106
|
+
lines.push("");
|
|
107
|
+
if (capture.filesChanged.length > 0) {
|
|
108
|
+
lines.push(`## Files Changed (${capture.filesChanged.length})`);
|
|
109
|
+
capture.filesChanged.forEach((f) => lines.push(` - ${f}`));
|
|
110
|
+
lines.push("");
|
|
111
|
+
}
|
|
112
|
+
if (capture.filesCreated.length > 0) {
|
|
113
|
+
lines.push(`## Files Created (${capture.filesCreated.length})`);
|
|
114
|
+
capture.filesCreated.forEach((f) => lines.push(` + ${f}`));
|
|
115
|
+
lines.push("");
|
|
116
|
+
}
|
|
117
|
+
if (capture.filesDeleted.length > 0) {
|
|
118
|
+
lines.push(`## Files Deleted (${capture.filesDeleted.length})`);
|
|
119
|
+
capture.filesDeleted.forEach((f) => lines.push(` - ${f}`));
|
|
120
|
+
lines.push("");
|
|
121
|
+
}
|
|
122
|
+
if (capture.commits.length > 0) {
|
|
123
|
+
lines.push(`## Commits (${capture.commits.length})`);
|
|
124
|
+
capture.commits.forEach(
|
|
125
|
+
(c) => lines.push(` ${c.hash.slice(0, 7)} ${c.message}`)
|
|
126
|
+
);
|
|
127
|
+
lines.push("");
|
|
128
|
+
}
|
|
129
|
+
if (capture.decisions.length > 0) {
|
|
130
|
+
lines.push("## Decisions");
|
|
131
|
+
capture.decisions.forEach((d) => lines.push(` - ${d}`));
|
|
132
|
+
lines.push("");
|
|
133
|
+
}
|
|
134
|
+
return lines.join("\n");
|
|
135
|
+
}
|
|
136
|
+
// --- Private ---
|
|
137
|
+
/**
|
|
138
|
+
* Extract decision-like statements from commit messages.
|
|
139
|
+
* Looks for patterns like "chose X over Y", "switched to", "decided", etc.
|
|
140
|
+
*/
|
|
141
|
+
extractDecisions(commits) {
|
|
142
|
+
const decisionPatterns = [
|
|
143
|
+
/chose\s+.+\s+over\s+/i,
|
|
144
|
+
/switched\s+(to|from)\s+/i,
|
|
145
|
+
/decided\s+/i,
|
|
146
|
+
/replaced\s+.+\s+with\s+/i,
|
|
147
|
+
/migrated?\s+(to|from)\s+/i,
|
|
148
|
+
/refactor/i,
|
|
149
|
+
/breaking\s+change/i
|
|
150
|
+
];
|
|
151
|
+
const decisions = [];
|
|
152
|
+
for (const commit of commits) {
|
|
153
|
+
for (const pattern of decisionPatterns) {
|
|
154
|
+
if (pattern.test(commit.message)) {
|
|
155
|
+
decisions.push(commit.message);
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return decisions;
|
|
161
|
+
}
|
|
162
|
+
estimateDuration(commits) {
|
|
163
|
+
if (commits.length < 2) return void 0;
|
|
164
|
+
const first = new Date(commits[commits.length - 1].date);
|
|
165
|
+
const last = new Date(commits[0].date);
|
|
166
|
+
const diffMs = last.getTime() - first.getTime();
|
|
167
|
+
return formatDuration(diffMs);
|
|
168
|
+
}
|
|
169
|
+
save(result) {
|
|
170
|
+
const filename = `${result.timestamp.replace(/[:.]/g, "-")}-${result.branch.replace(/[^a-zA-Z0-9-]/g, "-").slice(0, 30)}.json`;
|
|
171
|
+
const filePath = join(this.capturesDir, filename);
|
|
172
|
+
writeFileSync(filePath, JSON.stringify(result, null, 2));
|
|
173
|
+
pruneOldFiles(this.capturesDir, ".json", MAX_CAPTURES);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
export {
|
|
177
|
+
ContextCapture
|
|
178
|
+
};
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { fileURLToPath as __fileURLToPath } from 'url';
|
|
2
|
+
import { dirname as __pathDirname } from 'path';
|
|
3
|
+
const __filename = __fileURLToPath(import.meta.url);
|
|
4
|
+
const __dirname = __pathDirname(__filename);
|
|
5
|
+
import { execFileSync } from "child_process";
|
|
6
|
+
import { extname } from "path";
|
|
7
|
+
import { extractKeywords as extractKeywordsShared } from "../utils/text.js";
|
|
8
|
+
class PreflightChecker {
|
|
9
|
+
repoPath;
|
|
10
|
+
gitLogCache = /* @__PURE__ */ new Map();
|
|
11
|
+
constructor(repoPath) {
|
|
12
|
+
this.repoPath = repoPath || process.cwd();
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Run pre-flight check on a set of tasks.
|
|
16
|
+
* Returns parallel-safe groupings and sequential recommendations.
|
|
17
|
+
*/
|
|
18
|
+
check(tasks) {
|
|
19
|
+
if (tasks.length < 2) {
|
|
20
|
+
return {
|
|
21
|
+
parallelSafe: [tasks],
|
|
22
|
+
sequential: [],
|
|
23
|
+
allOverlaps: [],
|
|
24
|
+
summary: "Single task - no overlap check needed."
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const taskFiles = /* @__PURE__ */ new Map();
|
|
28
|
+
const taskKeywords = /* @__PURE__ */ new Map();
|
|
29
|
+
for (const task of tasks) {
|
|
30
|
+
const files = this.predictFiles(task);
|
|
31
|
+
taskFiles.set(task.name, files);
|
|
32
|
+
taskKeywords.set(
|
|
33
|
+
task.name,
|
|
34
|
+
task.keywords || this.extractKeywords(task.description)
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
const allOverlaps = [];
|
|
38
|
+
const overlapPairs = /* @__PURE__ */ new Map();
|
|
39
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
40
|
+
for (let j = i + 1; j < tasks.length; j++) {
|
|
41
|
+
const a = tasks[i];
|
|
42
|
+
const b = tasks[j];
|
|
43
|
+
const filesA = taskFiles.get(a.name);
|
|
44
|
+
const filesB = taskFiles.get(b.name);
|
|
45
|
+
const shared = [...filesA].filter((f) => filesB.has(f));
|
|
46
|
+
if (shared.length > 0) {
|
|
47
|
+
for (const file of shared) {
|
|
48
|
+
allOverlaps.push({
|
|
49
|
+
file,
|
|
50
|
+
tasks: [a.name, b.name],
|
|
51
|
+
confidence: this.estimateConfidenceCached(
|
|
52
|
+
file,
|
|
53
|
+
taskKeywords.get(a.name),
|
|
54
|
+
taskKeywords.get(b.name),
|
|
55
|
+
a,
|
|
56
|
+
b
|
|
57
|
+
),
|
|
58
|
+
source: this.getSourceCached(
|
|
59
|
+
file,
|
|
60
|
+
taskKeywords.get(a.name),
|
|
61
|
+
taskKeywords.get(b.name),
|
|
62
|
+
a,
|
|
63
|
+
b
|
|
64
|
+
)
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (!overlapPairs.has(a.name)) overlapPairs.set(a.name, /* @__PURE__ */ new Set());
|
|
68
|
+
if (!overlapPairs.has(b.name)) overlapPairs.set(b.name, /* @__PURE__ */ new Set());
|
|
69
|
+
overlapPairs.get(a.name).add(b.name);
|
|
70
|
+
overlapPairs.get(b.name).add(a.name);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const parallelSafe = this.buildParallelGroups(tasks, overlapPairs);
|
|
75
|
+
const sequential = [];
|
|
76
|
+
for (const task of tasks) {
|
|
77
|
+
const conflicts = overlapPairs.get(task.name);
|
|
78
|
+
if (conflicts && conflicts.size > 0) {
|
|
79
|
+
const overlaps = allOverlaps.filter((o) => o.tasks.includes(task.name));
|
|
80
|
+
const largestConflict = [...conflicts].sort((a, b) => {
|
|
81
|
+
return (taskFiles.get(b)?.size || 0) - (taskFiles.get(a)?.size || 0);
|
|
82
|
+
})[0];
|
|
83
|
+
sequential.push({
|
|
84
|
+
task,
|
|
85
|
+
after: largestConflict,
|
|
86
|
+
overlaps
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const deduped = this.deduplicateSequential(sequential, taskFiles);
|
|
91
|
+
const summary = this.formatSummary(parallelSafe, deduped, allOverlaps);
|
|
92
|
+
return {
|
|
93
|
+
parallelSafe,
|
|
94
|
+
sequential: deduped,
|
|
95
|
+
allOverlaps,
|
|
96
|
+
summary
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Predict which files a task will touch based on multiple signals.
|
|
101
|
+
*/
|
|
102
|
+
predictFiles(task) {
|
|
103
|
+
const files = /* @__PURE__ */ new Set();
|
|
104
|
+
if (task.files) {
|
|
105
|
+
task.files.forEach((f) => files.add(f));
|
|
106
|
+
}
|
|
107
|
+
const keywords = task.keywords || this.extractKeywords(task.description);
|
|
108
|
+
for (const keyword of keywords) {
|
|
109
|
+
const historyFiles = this.searchGitHistory(keyword);
|
|
110
|
+
historyFiles.forEach((f) => files.add(f));
|
|
111
|
+
}
|
|
112
|
+
if (task.files && task.files.length > 0) {
|
|
113
|
+
for (const file of task.files) {
|
|
114
|
+
const dependents = this.findDependents(file);
|
|
115
|
+
dependents.forEach((f) => files.add(f));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
for (const keyword of keywords) {
|
|
119
|
+
const matched = this.searchFilePaths(keyword);
|
|
120
|
+
matched.forEach((f) => files.add(f));
|
|
121
|
+
}
|
|
122
|
+
return files;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Search git log for files changed in commits matching a keyword.
|
|
126
|
+
*/
|
|
127
|
+
searchGitHistory(keyword, maxCommits = 50) {
|
|
128
|
+
const cacheKey = keyword.toLowerCase();
|
|
129
|
+
if (this.gitLogCache.has(cacheKey)) {
|
|
130
|
+
return this.gitLogCache.get(cacheKey);
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const output = execFileSync(
|
|
134
|
+
"git",
|
|
135
|
+
[
|
|
136
|
+
"log",
|
|
137
|
+
`--max-count=${maxCommits}`,
|
|
138
|
+
"--name-only",
|
|
139
|
+
"--pretty=format:",
|
|
140
|
+
"--grep",
|
|
141
|
+
keyword,
|
|
142
|
+
"-i"
|
|
143
|
+
],
|
|
144
|
+
{ cwd: this.repoPath, encoding: "utf-8", timeout: 1e4 }
|
|
145
|
+
);
|
|
146
|
+
const files = output.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
|
|
147
|
+
const freq = /* @__PURE__ */ new Map();
|
|
148
|
+
for (const f of files) {
|
|
149
|
+
freq.set(f, (freq.get(f) || 0) + 1);
|
|
150
|
+
}
|
|
151
|
+
const result = [...freq.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20).map(([f]) => f);
|
|
152
|
+
this.gitLogCache.set(cacheKey, result);
|
|
153
|
+
return result;
|
|
154
|
+
} catch {
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Find files that import/depend on a given file (shallow, grep-based).
|
|
160
|
+
*/
|
|
161
|
+
findDependents(filePath) {
|
|
162
|
+
const ext = extname(filePath);
|
|
163
|
+
if (![".ts", ".tsx", ".js", ".jsx", ".mjs"].includes(ext)) return [];
|
|
164
|
+
const baseName = filePath.replace(extname(filePath), "").replace(/\/index$/, "");
|
|
165
|
+
try {
|
|
166
|
+
const output = execFileSync(
|
|
167
|
+
"git",
|
|
168
|
+
["grep", "-l", baseName, "--", "*.ts", "*.tsx", "*.js", "*.jsx"],
|
|
169
|
+
{ cwd: this.repoPath, encoding: "utf-8", timeout: 1e4 }
|
|
170
|
+
);
|
|
171
|
+
return output.split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && l !== filePath);
|
|
172
|
+
} catch {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Search file paths for keyword matches using git ls-files.
|
|
178
|
+
*/
|
|
179
|
+
searchFilePaths(keyword) {
|
|
180
|
+
try {
|
|
181
|
+
const output = execFileSync("git", ["ls-files", `*${keyword}*`], {
|
|
182
|
+
cwd: this.repoPath,
|
|
183
|
+
encoding: "utf-8",
|
|
184
|
+
timeout: 5e3
|
|
185
|
+
});
|
|
186
|
+
return output.split("\n").map((l) => l.trim()).filter((l) => l.length > 0).slice(0, 10);
|
|
187
|
+
} catch {
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
extractKeywords(description) {
|
|
192
|
+
return extractKeywordsShared(description);
|
|
193
|
+
}
|
|
194
|
+
isInHistory(keywords, file) {
|
|
195
|
+
return keywords.some(
|
|
196
|
+
(k) => (this.gitLogCache.get(k.toLowerCase()) || []).includes(file)
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
estimateConfidenceCached(file, keywordsA, keywordsB, taskA, taskB) {
|
|
200
|
+
if (taskA.files?.includes(file) || taskB.files?.includes(file)) {
|
|
201
|
+
return 0.9;
|
|
202
|
+
}
|
|
203
|
+
if (this.isInHistory(keywordsA, file) && this.isInHistory(keywordsB, file)) {
|
|
204
|
+
return 0.7;
|
|
205
|
+
}
|
|
206
|
+
return 0.3;
|
|
207
|
+
}
|
|
208
|
+
getSourceCached(file, keywordsA, keywordsB, taskA, taskB) {
|
|
209
|
+
if (taskA.files?.includes(file) || taskB.files?.includes(file)) {
|
|
210
|
+
return "explicit";
|
|
211
|
+
}
|
|
212
|
+
if (this.isInHistory(keywordsA, file) || this.isInHistory(keywordsB, file)) {
|
|
213
|
+
return "git-history";
|
|
214
|
+
}
|
|
215
|
+
return "keyword-match";
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Build parallel-safe groups via greedy graph coloring.
|
|
219
|
+
* Tasks that overlap go in different groups.
|
|
220
|
+
*/
|
|
221
|
+
buildParallelGroups(tasks, overlapPairs) {
|
|
222
|
+
const groups = [];
|
|
223
|
+
const assigned = /* @__PURE__ */ new Set();
|
|
224
|
+
const sorted = [...tasks].sort((a, b) => {
|
|
225
|
+
const conflictsA = overlapPairs.get(a.name)?.size || 0;
|
|
226
|
+
const conflictsB = overlapPairs.get(b.name)?.size || 0;
|
|
227
|
+
return conflictsA - conflictsB;
|
|
228
|
+
});
|
|
229
|
+
for (const task of sorted) {
|
|
230
|
+
if (assigned.has(task.name)) continue;
|
|
231
|
+
let placed = false;
|
|
232
|
+
for (const group of groups) {
|
|
233
|
+
const conflicts = overlapPairs.get(task.name) || /* @__PURE__ */ new Set();
|
|
234
|
+
const groupHasConflict = group.some((t) => conflicts.has(t.name));
|
|
235
|
+
if (!groupHasConflict) {
|
|
236
|
+
group.push(task);
|
|
237
|
+
assigned.add(task.name);
|
|
238
|
+
placed = true;
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (!placed) {
|
|
243
|
+
groups.push([task]);
|
|
244
|
+
assigned.add(task.name);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return groups;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Deduplicate sequential recommendations — keep only the smaller task.
|
|
251
|
+
*/
|
|
252
|
+
deduplicateSequential(sequential, taskFiles) {
|
|
253
|
+
const seen = /* @__PURE__ */ new Set();
|
|
254
|
+
const result = [];
|
|
255
|
+
for (const entry of sequential) {
|
|
256
|
+
const key = [entry.task.name, entry.after].sort().join("|");
|
|
257
|
+
if (seen.has(key)) continue;
|
|
258
|
+
seen.add(key);
|
|
259
|
+
const mySize = taskFiles.get(entry.task.name)?.size || 0;
|
|
260
|
+
const otherSize = taskFiles.get(entry.after)?.size || 0;
|
|
261
|
+
if (mySize <= otherSize) {
|
|
262
|
+
result.push(entry);
|
|
263
|
+
} else {
|
|
264
|
+
const otherTask = sequential.find((s) => s.task.name === entry.after);
|
|
265
|
+
if (otherTask) {
|
|
266
|
+
result.push({
|
|
267
|
+
task: otherTask.task,
|
|
268
|
+
after: entry.task.name,
|
|
269
|
+
overlaps: entry.overlaps
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Format human-readable summary.
|
|
278
|
+
*/
|
|
279
|
+
formatSummary(parallelSafe, sequential, overlaps) {
|
|
280
|
+
const lines = [];
|
|
281
|
+
if (overlaps.length === 0) {
|
|
282
|
+
lines.push("All tasks are parallel-safe. No file overlaps detected.");
|
|
283
|
+
return lines.join("\n");
|
|
284
|
+
}
|
|
285
|
+
lines.push(`Found ${overlaps.length} file overlap(s).
|
|
286
|
+
`);
|
|
287
|
+
if (parallelSafe.length === 1) {
|
|
288
|
+
lines.push(
|
|
289
|
+
"All tasks can run in parallel (overlaps are low-confidence)."
|
|
290
|
+
);
|
|
291
|
+
} else {
|
|
292
|
+
lines.push(`Parallel groups (${parallelSafe.length}):`);
|
|
293
|
+
parallelSafe.forEach((group, i) => {
|
|
294
|
+
lines.push(` Group ${i + 1}: ${group.map((t) => t.name).join(", ")}`);
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
if (sequential.length > 0) {
|
|
298
|
+
lines.push("\nSequential recommendations:");
|
|
299
|
+
for (const entry of sequential) {
|
|
300
|
+
lines.push(` "${entry.task.name}" should run after "${entry.after}"`);
|
|
301
|
+
for (const overlap of entry.overlaps.slice(0, 5)) {
|
|
302
|
+
lines.push(
|
|
303
|
+
` - ${overlap.file} (${overlap.source}, ${Math.round(overlap.confidence * 100)}%)`
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return lines.join("\n");
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
export {
|
|
312
|
+
PreflightChecker
|
|
313
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stackmemoryai/stackmemory",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, Claude/Codex/OpenCode wrappers, Linear sync, automatic hooks, and log analysis.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=20.0.0",
|
|
@@ -268,6 +268,30 @@ record_commit_metrics() {
|
|
|
268
268
|
return 0
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
# Auto-capture context after commit
|
|
272
|
+
capture_context() {
|
|
273
|
+
if [ "$STACKMEMORY_ENABLED" != "true" ]; then
|
|
274
|
+
return 0
|
|
275
|
+
fi
|
|
276
|
+
|
|
277
|
+
local commit_info="$1"
|
|
278
|
+
IFS='|' read -r commit_hash commit_msg commit_author branch files_changed <<< "$commit_info"
|
|
279
|
+
|
|
280
|
+
# Only capture on significant commits (3+ files or feature/fix commits)
|
|
281
|
+
if [ "$files_changed" -lt 3 ] && ! echo "$commit_msg" | grep -iE "(feat|fix|refactor|complete|done)" >/dev/null; then
|
|
282
|
+
return 0
|
|
283
|
+
fi
|
|
284
|
+
|
|
285
|
+
log_info "Capturing context for session continuity..."
|
|
286
|
+
if stackmemory snapshot save --task "$commit_msg" >/dev/null 2>&1; then
|
|
287
|
+
log_success "Context captured"
|
|
288
|
+
else
|
|
289
|
+
log_warning "Context capture failed (non-critical)"
|
|
290
|
+
fi
|
|
291
|
+
|
|
292
|
+
return 0
|
|
293
|
+
}
|
|
294
|
+
|
|
271
295
|
# Main execution
|
|
272
296
|
main() {
|
|
273
297
|
log_info "📝 StackMemory post-commit hook starting..."
|
|
@@ -297,6 +321,9 @@ main() {
|
|
|
297
321
|
record_commit_metrics "$commit_info"
|
|
298
322
|
sync_with_linear "$commit_info"
|
|
299
323
|
|
|
324
|
+
# Auto-capture context for session continuity
|
|
325
|
+
capture_context "$commit_info"
|
|
326
|
+
|
|
300
327
|
log_success "🎉 Post-commit processing completed!"
|
|
301
328
|
return 0
|
|
302
329
|
}
|