@stackmemoryai/stackmemory 1.3.1 → 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.
@@ -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
+ };
@@ -19,7 +19,6 @@ import {
19
19
  import { AnthropicBatchClient } from "../anthropic/batch-client.js";
20
20
  class ClaudeCodeSubagentClient {
21
21
  tempDir;
22
- activeSubagents = /* @__PURE__ */ new Map();
23
22
  mockMode;
24
23
  constructor(mockMode = false) {
25
24
  this.mockMode = mockMode;
@@ -344,13 +343,7 @@ Context (JSON): ${JSON.stringify(request.context)}`;
344
343
  */
345
344
  spawnClaude(prompt, timeout) {
346
345
  return new Promise((resolve, reject) => {
347
- const args = [
348
- "-p",
349
- "--output-format",
350
- "stream-json",
351
- "--dangerously-skip-permissions",
352
- prompt
353
- ];
346
+ const args = ["-p", "--output-format", "stream-json", prompt];
354
347
  const claude = spawn("claude", args, {
355
348
  cwd: process.cwd(),
356
349
  env: { ...process.env },
@@ -548,84 +541,11 @@ function greetUser(name: string): string {
548
541
  }
549
542
  }
550
543
  }
551
- /**
552
- * Create a mock Task tool response for development
553
- * This simulates what Claude Code's Task tool would return
554
- */
555
- async mockTaskToolExecution(request) {
556
- const startTime = Date.now();
557
- await new Promise(
558
- (resolve) => setTimeout(resolve, 1e3 + Math.random() * 2e3)
559
- );
560
- const mockResponses = {
561
- planning: {
562
- tasks: [
563
- { id: "1", type: "analyze", description: "Analyze requirements" },
564
- { id: "2", type: "implement", description: "Implement solution" },
565
- { id: "3", type: "test", description: "Test implementation" },
566
- { id: "4", type: "review", description: "Review and improve" }
567
- ],
568
- dependencies: { "2": ["1"], "3": ["2"], "4": ["3"] }
569
- },
570
- code: {
571
- implementation: `
572
- export class Solution {
573
- constructor(private config: any) {}
574
-
575
- async execute(input: string): Promise<string> {
576
- // Implementation generated by Code subagent
577
- return this.process(input);
578
- }
579
-
580
- private process(input: string): string {
581
- return \`Processed: \${input}\`;
582
- }
583
- }`,
584
- files: ["src/solution.ts"]
585
- },
586
- testing: {
587
- tests: `
588
- describe('Solution', () => {
589
- it('should process input correctly', () => {
590
- const solution = new Solution({});
591
- const result = solution.execute('test');
592
- expect(result).toBe('Processed: test');
593
- });
594
-
595
- it('should handle edge cases', () => {
596
- // Edge case tests
597
- });
598
- });`,
599
- coverage: { lines: 95, branches: 88, functions: 100 }
600
- },
601
- review: {
602
- quality: 0.82,
603
- issues: [
604
- { severity: "high", message: "Missing error handling" },
605
- { severity: "medium", message: "Could improve type safety" }
606
- ],
607
- suggestions: [
608
- "Add try-catch blocks",
609
- "Use stricter TypeScript types",
610
- "Add input validation"
611
- ]
612
- }
613
- };
614
- return {
615
- success: true,
616
- result: mockResponses[request.type] || { status: "completed" },
617
- output: `Mock ${request.type} subagent completed successfully`,
618
- duration: Date.now() - startTime,
619
- subagentType: request.type,
620
- tokens: Math.floor(Math.random() * 5e3) + 1e3
621
- };
622
- }
623
544
  /**
624
545
  * Get active subagent statistics
625
546
  */
626
547
  getStats() {
627
548
  return {
628
- activeSubagents: this.activeSubagents.size,
629
549
  tempDir: this.tempDir
630
550
  };
631
551
  }
@@ -633,10 +553,6 @@ describe('Solution', () => {
633
553
  * Cleanup all resources
634
554
  */
635
555
  async cleanupAll() {
636
- for (const [id, controller] of this.activeSubagents) {
637
- controller.abort();
638
- }
639
- this.activeSubagents.clear();
640
556
  if (fs.existsSync(this.tempDir)) {
641
557
  const files = await fs.promises.readdir(this.tempDir);
642
558
  for (const file of files) {
@@ -166,11 +166,16 @@ class ClaudeCodeTaskCoordinator {
166
166
  completedTasks: this.completedTasks.length
167
167
  });
168
168
  if (this.activeTasks.size > 0) {
169
- const timeoutPromise = new Promise(
170
- (resolve) => setTimeout(resolve, 3e4)
171
- );
169
+ let timer;
170
+ const timeoutPromise = new Promise((resolve) => {
171
+ timer = setTimeout(resolve, 3e4);
172
+ });
172
173
  const completionPromise = this.waitForTaskCompletion();
173
- await Promise.race([completionPromise, timeoutPromise]);
174
+ try {
175
+ await Promise.race([completionPromise, timeoutPromise]);
176
+ } finally {
177
+ clearTimeout(timer);
178
+ }
174
179
  if (this.activeTasks.size > 0) {
175
180
  logger.warn("Force terminating active tasks", {
176
181
  remainingTasks: this.activeTasks.size
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackmemoryai/stackmemory",
3
- "version": "1.3.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
  }