@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.
- package/dist/src/cli/commands/{symphony.js → orchestrate.js} +44 -13
- package/dist/src/cli/commands/orchestrator.js +773 -0
- 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/dist/src/integrations/claude-code/subagent-client.js +1 -85
- package/dist/src/integrations/claude-code/task-coordinator.js +9 -4
- package/package.json +1 -1
- package/scripts/git-hooks/post-commit-stackmemory.sh +27 -0
|
@@ -0,0 +1,87 @@
|
|
|
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 { Command } from "commander";
|
|
6
|
+
import chalk from "chalk";
|
|
7
|
+
import {
|
|
8
|
+
PreflightChecker
|
|
9
|
+
} from "../../core/worktree/preflight.js";
|
|
10
|
+
function createPreflightCommand() {
|
|
11
|
+
const cmd = new Command("preflight").alias("pf").description("Check file overlap before running parallel tasks").argument("<tasks...>", "Task descriptions (quoted strings)").option(
|
|
12
|
+
"-k, --keywords <keywords>",
|
|
13
|
+
"Comma-separated keywords per task (task1:kw1,kw2;task2:kw3)"
|
|
14
|
+
).option(
|
|
15
|
+
"-f, --files <files>",
|
|
16
|
+
"Comma-separated files per task (task1:file1,file2;task2:file3)"
|
|
17
|
+
).option("--json", "Output as JSON").action((taskArgs, options) => {
|
|
18
|
+
const checker = new PreflightChecker();
|
|
19
|
+
const tasks = taskArgs.map((desc, i) => {
|
|
20
|
+
const task = {
|
|
21
|
+
name: `task-${i + 1}`,
|
|
22
|
+
description: desc
|
|
23
|
+
};
|
|
24
|
+
if (options.keywords) {
|
|
25
|
+
const kwParts = options.keywords.split(";");
|
|
26
|
+
if (kwParts[i]) {
|
|
27
|
+
const [, kws] = kwParts[i].split(":");
|
|
28
|
+
if (kws) task.keywords = kws.split(",");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (options.files) {
|
|
32
|
+
const fileParts = options.files.split(";");
|
|
33
|
+
if (fileParts[i]) {
|
|
34
|
+
const [, fs] = fileParts[i].split(":");
|
|
35
|
+
if (fs) task.files = fs.split(",");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (desc.length <= 40) {
|
|
39
|
+
task.name = desc;
|
|
40
|
+
}
|
|
41
|
+
return task;
|
|
42
|
+
});
|
|
43
|
+
const result = checker.check(tasks);
|
|
44
|
+
if (options.json) {
|
|
45
|
+
console.log(JSON.stringify(result, null, 2));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
console.log(chalk.cyan("\nPre-flight Check\n"));
|
|
49
|
+
console.log(chalk.gray(`Tasks: ${tasks.length}`));
|
|
50
|
+
console.log(chalk.gray(`Overlaps: ${result.allOverlaps.length}
|
|
51
|
+
`));
|
|
52
|
+
if (result.parallelSafe.length === 1 && result.allOverlaps.length === 0) {
|
|
53
|
+
console.log(chalk.green("All tasks are parallel-safe.\n"));
|
|
54
|
+
for (const task of result.parallelSafe[0]) {
|
|
55
|
+
console.log(chalk.green(` + ${task.name}`));
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
for (let i = 0; i < result.parallelSafe.length; i++) {
|
|
59
|
+
const group = result.parallelSafe[i];
|
|
60
|
+
console.log(chalk.cyan(`Group ${i + 1} (parallel-safe):`));
|
|
61
|
+
for (const task of group) {
|
|
62
|
+
console.log(chalk.green(` + ${task.name}`));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (result.sequential.length > 0) {
|
|
67
|
+
console.log(chalk.yellow("\nSequential (file overlaps detected):"));
|
|
68
|
+
for (const entry of result.sequential) {
|
|
69
|
+
console.log(
|
|
70
|
+
chalk.yellow(` "${entry.task.name}" -> after "${entry.after}"`)
|
|
71
|
+
);
|
|
72
|
+
for (const overlap of entry.overlaps.slice(0, 5)) {
|
|
73
|
+
const conf = Math.round(overlap.confidence * 100);
|
|
74
|
+
console.log(
|
|
75
|
+
chalk.gray(` ${overlap.file} (${overlap.source}, ${conf}%)`)
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
console.log(chalk.gray(`
|
|
81
|
+
${result.summary}`));
|
|
82
|
+
});
|
|
83
|
+
return cmd;
|
|
84
|
+
}
|
|
85
|
+
export {
|
|
86
|
+
createPreflightCommand
|
|
87
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
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 { Command } from "commander";
|
|
6
|
+
import chalk from "chalk";
|
|
7
|
+
import { ContextCapture } from "../../core/worktree/capture.js";
|
|
8
|
+
function createSnapshotCommand() {
|
|
9
|
+
const cmd = new Command("snapshot").alias("snap").description("Point-in-time snapshot of work (what changed and why)");
|
|
10
|
+
cmd.command("save").alias("s").description("Save a snapshot of current branch state").option("-t, --task <name>", "Task name or description").option(
|
|
11
|
+
"-b, --base <branch>",
|
|
12
|
+
"Base branch to diff against (default: auto-detect)"
|
|
13
|
+
).option(
|
|
14
|
+
"-d, --decision <decisions...>",
|
|
15
|
+
"Key decisions made during this task"
|
|
16
|
+
).option("--json", "Output as JSON").action((options) => {
|
|
17
|
+
const capture = new ContextCapture();
|
|
18
|
+
const result = capture.capture({
|
|
19
|
+
task: options.task,
|
|
20
|
+
baseBranch: options.base,
|
|
21
|
+
decisions: options.decision
|
|
22
|
+
});
|
|
23
|
+
if (options.json) {
|
|
24
|
+
console.log(JSON.stringify(result, null, 2));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
console.log(chalk.green("\nSnapshot saved.\n"));
|
|
28
|
+
console.log(chalk.gray(` Branch: ${result.branch}`));
|
|
29
|
+
console.log(chalk.gray(` Base: ${result.baseBranch}`));
|
|
30
|
+
console.log(chalk.gray(` Changed: ${result.filesChanged.length} files`));
|
|
31
|
+
console.log(chalk.gray(` Created: ${result.filesCreated.length} files`));
|
|
32
|
+
console.log(chalk.gray(` Deleted: ${result.filesDeleted.length} files`));
|
|
33
|
+
console.log(chalk.gray(` Commits: ${result.commits.length}`));
|
|
34
|
+
if (result.decisions.length > 0) {
|
|
35
|
+
console.log(chalk.cyan("\n Decisions:"));
|
|
36
|
+
result.decisions.forEach((d) => console.log(chalk.gray(` - ${d}`)));
|
|
37
|
+
}
|
|
38
|
+
if (result.duration) {
|
|
39
|
+
console.log(chalk.gray(` Duration: ${result.duration}`));
|
|
40
|
+
}
|
|
41
|
+
console.log(chalk.gray(`
|
|
42
|
+
Saved: ${result.id}`));
|
|
43
|
+
});
|
|
44
|
+
cmd.command("list").alias("ls").description("List recent snapshots").option("-n, --limit <n>", "Number of captures to show", "10").option("--json", "Output as JSON").action((options) => {
|
|
45
|
+
const capture = new ContextCapture();
|
|
46
|
+
const captures = capture.list(parseInt(options.limit));
|
|
47
|
+
if (captures.length === 0) {
|
|
48
|
+
console.log(chalk.yellow("No snapshots found."));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (options.json) {
|
|
52
|
+
console.log(JSON.stringify(captures, null, 2));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
console.log(chalk.cyan(`
|
|
56
|
+
Recent Snapshots (${captures.length}):
|
|
57
|
+
`));
|
|
58
|
+
for (const cap of captures) {
|
|
59
|
+
const date = new Date(cap.timestamp).toLocaleDateString();
|
|
60
|
+
const files = cap.filesChanged.length + cap.filesCreated.length;
|
|
61
|
+
console.log(
|
|
62
|
+
chalk.gray(
|
|
63
|
+
` ${date} ${cap.branch.padEnd(30)} ${files} files ${cap.commits.length} commits`
|
|
64
|
+
)
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
cmd.command("show [branch]").description("Show snapshot details (latest or by branch)").option("--json", "Output as JSON").action((branch, options) => {
|
|
69
|
+
const capturer = new ContextCapture();
|
|
70
|
+
const result = capturer.getLatest(branch);
|
|
71
|
+
if (!result) {
|
|
72
|
+
console.log(
|
|
73
|
+
chalk.yellow(
|
|
74
|
+
branch ? `No snapshot found for branch: ${branch}` : "No snapshots found."
|
|
75
|
+
)
|
|
76
|
+
);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (options.json) {
|
|
80
|
+
console.log(JSON.stringify(result, null, 2));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
console.log(capturer.format(result));
|
|
84
|
+
});
|
|
85
|
+
return cmd;
|
|
86
|
+
}
|
|
87
|
+
export {
|
|
88
|
+
createSnapshotCommand
|
|
89
|
+
};
|
package/dist/src/cli/index.js
CHANGED
|
@@ -57,7 +57,9 @@ import { createBenchCommand } from "./commands/bench.js";
|
|
|
57
57
|
import { createDigestCommands } from "./commands/digest.js";
|
|
58
58
|
import { createTeamCommands } from "./commands/team.js";
|
|
59
59
|
import { createDesiresCommands } from "./commands/desires.js";
|
|
60
|
-
import {
|
|
60
|
+
import { createConductorCommands } from "./commands/orchestrate.js";
|
|
61
|
+
import { createPreflightCommand } from "./commands/preflight.js";
|
|
62
|
+
import { createSnapshotCommand } from "./commands/snapshot.js";
|
|
61
63
|
import chalk from "chalk";
|
|
62
64
|
import * as fs from "fs";
|
|
63
65
|
import * as path from "path";
|
|
@@ -508,7 +510,9 @@ program.addCommand(createBenchCommand());
|
|
|
508
510
|
program.addCommand(createDigestCommands());
|
|
509
511
|
program.addCommand(createTeamCommands());
|
|
510
512
|
program.addCommand(createDesiresCommands());
|
|
511
|
-
program.addCommand(
|
|
513
|
+
program.addCommand(createConductorCommands());
|
|
514
|
+
program.addCommand(createPreflightCommand());
|
|
515
|
+
program.addCommand(createSnapshotCommand());
|
|
512
516
|
registerSetupCommands(program);
|
|
513
517
|
program.command("mm-spike").description(
|
|
514
518
|
"Run multi-agent planning/implementation spike (planner/implementer/critic)"
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
DEFAULT_RETRIEVAL_CONFIG
|
|
7
7
|
} from "./types.js";
|
|
8
8
|
import { logger } from "../monitoring/logger.js";
|
|
9
|
+
import { extractKeywords as extractKeywordsShared } from "../utils/text.js";
|
|
9
10
|
class CompressedSummaryGenerator {
|
|
10
11
|
db;
|
|
11
12
|
frameManager;
|
|
@@ -570,20 +571,7 @@ class CompressedSummaryGenerator {
|
|
|
570
571
|
}
|
|
571
572
|
}
|
|
572
573
|
extractKeywords(text) {
|
|
573
|
-
|
|
574
|
-
"the",
|
|
575
|
-
"a",
|
|
576
|
-
"an",
|
|
577
|
-
"and",
|
|
578
|
-
"or",
|
|
579
|
-
"but",
|
|
580
|
-
"in",
|
|
581
|
-
"on",
|
|
582
|
-
"at",
|
|
583
|
-
"to",
|
|
584
|
-
"for"
|
|
585
|
-
]);
|
|
586
|
-
return text.toLowerCase().split(/\W+/).filter((word) => word.length > 2 && !stopWords.has(word));
|
|
574
|
+
return extractKeywordsShared(text, { maxCount: 20 });
|
|
587
575
|
}
|
|
588
576
|
/**
|
|
589
577
|
* Clear the cache
|
|
@@ -0,0 +1,18 @@
|
|
|
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 { readdirSync, unlinkSync } from "fs";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
function pruneOldFiles(dir, ext, maxKeep) {
|
|
8
|
+
try {
|
|
9
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(ext)).sort().reverse();
|
|
10
|
+
for (const old of files.slice(maxKeep)) {
|
|
11
|
+
unlinkSync(join(dir, old));
|
|
12
|
+
}
|
|
13
|
+
} catch {
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export {
|
|
17
|
+
pruneOldFiles
|
|
18
|
+
};
|
|
@@ -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
|
+
};
|