Skip to content

Commit 0b4b646

Browse files
author
StackMemory Bot (CLI)
committed
refactor: consolidate shared utils and add snapshot CLI command
Extract duplicated git, text, and fs utilities into shared modules: - core/utils/git.ts: getCurrentBranch, detectBaseBranch, getDiffStats, getCommitsSince - core/utils/text.ts: extractKeywords with unified stop word list - core/utils/fs.ts: pruneOldFiles Update consumers (capture, preflight, orchestrator, summary-generator) to use shared utils, removing ~210 lines of duplicated code. Add snapshot CLI command (stackmemory snapshot|snap) for post-run context capture.
1 parent f799344 commit 0b4b646

8 files changed

Lines changed: 366 additions & 204 deletions

File tree

src/cli/commands/orchestrator.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
type TaskDefinition,
2424
} from '../../core/worktree/preflight.js';
2525
import { ContextCapture } from '../../core/worktree/capture.js';
26+
import { extractKeywords } from '../../core/utils/text.js';
2627

2728
// ── Types ──
2829

@@ -446,20 +447,8 @@ export class Conductor {
446447
}
447448

448449
private extractIssueKeywords(issue: LinearIssue): string[] {
449-
const words = new Set<string>();
450-
451-
// From title
452-
issue.title
453-
.toLowerCase()
454-
.replace(/[^a-z0-9\s-_]/g, ' ')
455-
.split(/\s+/)
456-
.filter((w) => w.length > 3)
457-
.forEach((w) => words.add(w));
458-
459-
// From labels
460-
issue.labels.forEach((l) => words.add(l.name.toLowerCase()));
461-
462-
return [...words].slice(0, 8);
450+
const labelText = issue.labels.map((l) => l.name).join(' ');
451+
return extractKeywords(`${issue.title} ${labelText}`, { maxCount: 8 });
463452
}
464453

465454
// ── Dispatch ──

src/cli/commands/snapshot.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* Snapshot CLI command.
3+
* Takes a point-in-time snapshot of what changed after a task completes.
4+
*/
5+
6+
import { Command } from 'commander';
7+
import chalk from 'chalk';
8+
import { ContextCapture } from '../../core/worktree/capture.js';
9+
10+
export function createSnapshotCommand(): Command {
11+
const cmd = new Command('snapshot')
12+
.alias('snap')
13+
.description('Point-in-time snapshot of work (what changed and why)');
14+
15+
// Capture current state
16+
cmd
17+
.command('save')
18+
.alias('s')
19+
.description('Save a snapshot of current branch state')
20+
.option('-t, --task <name>', 'Task name or description')
21+
.option(
22+
'-b, --base <branch>',
23+
'Base branch to diff against (default: auto-detect)'
24+
)
25+
.option(
26+
'-d, --decision <decisions...>',
27+
'Key decisions made during this task'
28+
)
29+
.option('--json', 'Output as JSON')
30+
.action((options) => {
31+
const capture = new ContextCapture();
32+
33+
const result = capture.capture({
34+
task: options.task,
35+
baseBranch: options.base,
36+
decisions: options.decision,
37+
});
38+
39+
if (options.json) {
40+
console.log(JSON.stringify(result, null, 2));
41+
return;
42+
}
43+
44+
console.log(chalk.green('\nSnapshot saved.\n'));
45+
console.log(chalk.gray(` Branch: ${result.branch}`));
46+
console.log(chalk.gray(` Base: ${result.baseBranch}`));
47+
console.log(chalk.gray(` Changed: ${result.filesChanged.length} files`));
48+
console.log(chalk.gray(` Created: ${result.filesCreated.length} files`));
49+
console.log(chalk.gray(` Deleted: ${result.filesDeleted.length} files`));
50+
console.log(chalk.gray(` Commits: ${result.commits.length}`));
51+
52+
if (result.decisions.length > 0) {
53+
console.log(chalk.cyan('\n Decisions:'));
54+
result.decisions.forEach((d) => console.log(chalk.gray(` - ${d}`)));
55+
}
56+
57+
if (result.duration) {
58+
console.log(chalk.gray(` Duration: ${result.duration}`));
59+
}
60+
61+
console.log(chalk.gray(`\n Saved: ${result.id}`));
62+
});
63+
64+
// List captures
65+
cmd
66+
.command('list')
67+
.alias('ls')
68+
.description('List recent snapshots')
69+
.option('-n, --limit <n>', 'Number of captures to show', '10')
70+
.option('--json', 'Output as JSON')
71+
.action((options) => {
72+
const capture = new ContextCapture();
73+
const captures = capture.list(parseInt(options.limit));
74+
75+
if (captures.length === 0) {
76+
console.log(chalk.yellow('No snapshots found.'));
77+
return;
78+
}
79+
80+
if (options.json) {
81+
console.log(JSON.stringify(captures, null, 2));
82+
return;
83+
}
84+
85+
console.log(chalk.cyan(`\nRecent Snapshots (${captures.length}):\n`));
86+
87+
for (const cap of captures) {
88+
const date = new Date(cap.timestamp).toLocaleDateString();
89+
const files = cap.filesChanged.length + cap.filesCreated.length;
90+
console.log(
91+
chalk.gray(
92+
` ${date} ${cap.branch.padEnd(30)} ${files} files ${cap.commits.length} commits`
93+
)
94+
);
95+
}
96+
});
97+
98+
// Show a specific capture or latest
99+
cmd
100+
.command('show [branch]')
101+
.description('Show snapshot details (latest or by branch)')
102+
.option('--json', 'Output as JSON')
103+
.action((branch, options) => {
104+
const capturer = new ContextCapture();
105+
const result = capturer.getLatest(branch);
106+
107+
if (!result) {
108+
console.log(
109+
chalk.yellow(
110+
branch
111+
? `No snapshot found for branch: ${branch}`
112+
: 'No snapshots found.'
113+
)
114+
);
115+
return;
116+
}
117+
118+
if (options.json) {
119+
console.log(JSON.stringify(result, null, 2));
120+
return;
121+
}
122+
123+
console.log(capturer.format(result));
124+
});
125+
126+
return cmd;
127+
}

src/core/retrieval/summary-generator.ts

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
DEFAULT_RETRIEVAL_CONFIG,
2525
} from './types.js';
2626
import { logger } from '../monitoring/logger.js';
27+
import { extractKeywords as extractKeywordsShared } from '../utils/text.js';
2728

2829
export class CompressedSummaryGenerator {
2930
private db: Database.Database;
@@ -747,23 +748,7 @@ export class CompressedSummaryGenerator {
747748
}
748749

749750
private extractKeywords(text: string): string[] {
750-
const stopWords = new Set([
751-
'the',
752-
'a',
753-
'an',
754-
'and',
755-
'or',
756-
'but',
757-
'in',
758-
'on',
759-
'at',
760-
'to',
761-
'for',
762-
]);
763-
return text
764-
.toLowerCase()
765-
.split(/\W+/)
766-
.filter((word) => word.length > 2 && !stopWords.has(word));
751+
return extractKeywordsShared(text, { maxCount: 20 });
767752
}
768753

769754
/**

src/core/utils/fs.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* Shared filesystem utilities.
3+
*/
4+
5+
import { readdirSync, unlinkSync } from 'fs';
6+
import { join } from 'path';
7+
8+
export function pruneOldFiles(dir: string, ext: string, maxKeep: number): void {
9+
try {
10+
const files = readdirSync(dir)
11+
.filter((f) => f.endsWith(ext))
12+
.sort()
13+
.reverse();
14+
15+
for (const old of files.slice(maxKeep)) {
16+
unlinkSync(join(dir, old));
17+
}
18+
} catch {
19+
// Not critical
20+
}
21+
}

src/core/utils/git.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* Shared git utilities.
3+
* Consolidates git operations used across capture, handoff, preflight, and CLI wrappers.
4+
*/
5+
6+
import { execFileSync } from 'child_process';
7+
8+
export interface CommitInfo {
9+
hash: string;
10+
message: string;
11+
author: string;
12+
date: string;
13+
}
14+
15+
export interface DiffStats {
16+
changed: string[];
17+
created: string[];
18+
deleted: string[];
19+
}
20+
21+
export function getCurrentBranch(cwd?: string): string {
22+
try {
23+
return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
24+
cwd: cwd || process.cwd(),
25+
encoding: 'utf-8',
26+
stdio: ['pipe', 'pipe', 'pipe'],
27+
}).trim();
28+
} catch {
29+
return 'unknown';
30+
}
31+
}
32+
33+
export function detectBaseBranch(cwd?: string): string {
34+
const dir = cwd || process.cwd();
35+
for (const base of ['main', 'master', 'develop']) {
36+
try {
37+
execFileSync('git', ['rev-parse', '--verify', base], {
38+
cwd: dir,
39+
encoding: 'utf-8',
40+
stdio: 'pipe',
41+
});
42+
return base;
43+
} catch {
44+
continue;
45+
}
46+
}
47+
return 'main';
48+
}
49+
50+
export function getDiffStats(baseBranch: string, cwd?: string): DiffStats {
51+
try {
52+
const output = execFileSync(
53+
'git',
54+
['diff', '--name-status', `${baseBranch}...HEAD`],
55+
{ cwd: cwd || process.cwd(), encoding: 'utf-8', timeout: 10000 }
56+
);
57+
58+
const changed: string[] = [];
59+
const created: string[] = [];
60+
const deleted: string[] = [];
61+
62+
for (const line of output.split('\n').filter((l) => l.trim())) {
63+
const [status, ...pathParts] = line.split('\t');
64+
const filePath = pathParts.join('\t');
65+
if (!filePath) continue;
66+
67+
switch (status.charAt(0)) {
68+
case 'A':
69+
created.push(filePath);
70+
break;
71+
case 'D':
72+
deleted.push(filePath);
73+
break;
74+
case 'M':
75+
case 'R':
76+
case 'C':
77+
changed.push(filePath);
78+
break;
79+
}
80+
}
81+
82+
return { changed, created, deleted };
83+
} catch {
84+
return { changed: [], created: [], deleted: [] };
85+
}
86+
}
87+
88+
export function getCommitsSince(
89+
baseBranch: string,
90+
cwd?: string
91+
): CommitInfo[] {
92+
try {
93+
const output = execFileSync(
94+
'git',
95+
[
96+
'log',
97+
`${baseBranch}..HEAD`,
98+
'--pretty=format:%H%x00%s%x00%an%x00%aI',
99+
'--no-merges',
100+
],
101+
{ cwd: cwd || process.cwd(), encoding: 'utf-8', timeout: 10000 }
102+
);
103+
104+
return output
105+
.split('\n')
106+
.filter((l) => l.trim())
107+
.map((line) => {
108+
const [hash, message, author, date] = line.split('\0');
109+
return { hash, message, author, date };
110+
});
111+
} catch {
112+
return [];
113+
}
114+
}

0 commit comments

Comments
 (0)