lockstep-mcp 0.1.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/LICENSE +21 -0
- package/README.md +669 -0
- package/dist/cli.js +367 -0
- package/dist/config.js +48 -0
- package/dist/dashboard.js +1982 -0
- package/dist/install.js +252 -0
- package/dist/macos.js +55 -0
- package/dist/prompts.js +173 -0
- package/dist/server.js +1942 -0
- package/dist/storage.js +1235 -0
- package/dist/tmux.js +87 -0
- package/dist/utils.js +35 -0
- package/dist/worktree.js +356 -0
- package/package.json +66 -0
package/dist/tmux.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getPlannerPrompt, getImplementerPrompt } from "./prompts.js";
|
|
4
|
+
function runTmux(args, inherit = false) {
|
|
5
|
+
const result = spawnSync("tmux", args, { stdio: inherit ? "inherit" : "pipe" });
|
|
6
|
+
if (result.error)
|
|
7
|
+
throw result.error;
|
|
8
|
+
return result.status ?? 0;
|
|
9
|
+
}
|
|
10
|
+
function ensureTmuxAvailable() {
|
|
11
|
+
try {
|
|
12
|
+
const status = runTmux(["-V"]);
|
|
13
|
+
if (status !== 0)
|
|
14
|
+
throw new Error("tmux not available");
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
18
|
+
throw new Error(`tmux not found or not available: ${message}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function sessionExists(session) {
|
|
22
|
+
const status = runTmux(["has-session", "-t", session]);
|
|
23
|
+
return status === 0;
|
|
24
|
+
}
|
|
25
|
+
function sendKeys(target, text) {
|
|
26
|
+
runTmux(["send-keys", "-t", target, "-l", text]);
|
|
27
|
+
runTmux(["send-keys", "-t", target, "C-m"]);
|
|
28
|
+
}
|
|
29
|
+
export async function launchTmux(options = {}) {
|
|
30
|
+
ensureTmuxAvailable();
|
|
31
|
+
const session = options.session ?? "lockstep";
|
|
32
|
+
const repo = path.resolve(options.repo ?? process.cwd());
|
|
33
|
+
const claudeCmd = options.claudeCmd ?? "claude";
|
|
34
|
+
const codexCmd = options.codexCmd ?? "codex";
|
|
35
|
+
const injectPrompts = options.injectPrompts !== false;
|
|
36
|
+
const layout = options.layout ?? "windows";
|
|
37
|
+
const split = options.split ?? "vertical";
|
|
38
|
+
const showDashboard = options.dashboard !== false;
|
|
39
|
+
const dashboardCmd = options.dashboardCmd ?? "lockstep-mcp dashboard --host 127.0.0.1 --port 8787";
|
|
40
|
+
const statusBar = options.statusBar !== false;
|
|
41
|
+
if (!sessionExists(session)) {
|
|
42
|
+
runTmux(["new-session", "-d", "-s", session, "-c", repo, "-n", "claude"]);
|
|
43
|
+
if (statusBar) {
|
|
44
|
+
runTmux(["set-option", "-t", session, "-g", "status", "on"]);
|
|
45
|
+
runTmux(["set-option", "-t", session, "-g", "status-style", "bg=colour237,fg=colour252"]);
|
|
46
|
+
runTmux(["set-option", "-t", session, "-g", "status-left", " lockstep "]);
|
|
47
|
+
runTmux(["set-option", "-t", session, "-g", "status-right", "Ctrl-b n/p | Ctrl-b w"]);
|
|
48
|
+
runTmux(["set-option", "-t", session, "-g", "window-status-format", " #I:#W "]);
|
|
49
|
+
runTmux(["set-option", "-t", session, "-g", "window-status-current-format", " #[bold]#I:#W "]);
|
|
50
|
+
}
|
|
51
|
+
sendKeys(`${session}:0.0`, claudeCmd);
|
|
52
|
+
if (layout === "panes") {
|
|
53
|
+
if (split === "vertical") {
|
|
54
|
+
runTmux(["split-window", "-h", "-t", `${session}:0`, "-c", repo]);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
runTmux(["split-window", "-v", "-t", `${session}:0`, "-c", repo]);
|
|
58
|
+
}
|
|
59
|
+
runTmux(["select-layout", "-t", `${session}:0`, "even-horizontal"]);
|
|
60
|
+
sendKeys(`${session}:0.1`, codexCmd);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
runTmux(["new-window", "-t", session, "-n", "codex", "-c", repo]);
|
|
64
|
+
sendKeys(`${session}:1.0`, codexCmd);
|
|
65
|
+
}
|
|
66
|
+
if (injectPrompts) {
|
|
67
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
68
|
+
sendKeys(`${session}:0.0`, getPlannerPrompt());
|
|
69
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
70
|
+
if (layout === "panes") {
|
|
71
|
+
sendKeys(`${session}:0.1`, getImplementerPrompt());
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
sendKeys(`${session}:1.0`, getImplementerPrompt());
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (showDashboard) {
|
|
78
|
+
const targetIndex = layout === "panes" ? "1.0" : "2.0";
|
|
79
|
+
runTmux(["new-window", "-t", session, "-n", "dashboard", "-c", repo]);
|
|
80
|
+
sendKeys(`${session}:${targetIndex}`, dashboardCmd);
|
|
81
|
+
}
|
|
82
|
+
if (statusBar) {
|
|
83
|
+
runTmux(["display-message", "-t", session, "Lockstep: Ctrl-b n/p switch windows, Ctrl-b w list"]);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
runTmux(["attach", "-t", session], true);
|
|
87
|
+
}
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export function expandHome(input) {
|
|
4
|
+
if (!input.startsWith("~"))
|
|
5
|
+
return input;
|
|
6
|
+
const home = process.env.HOME;
|
|
7
|
+
if (!home)
|
|
8
|
+
return input;
|
|
9
|
+
return path.join(home, input.slice(1));
|
|
10
|
+
}
|
|
11
|
+
export function normalizeRoots(roots) {
|
|
12
|
+
return roots
|
|
13
|
+
.map((root) => expandHome(root))
|
|
14
|
+
.map((root) => path.resolve(root));
|
|
15
|
+
}
|
|
16
|
+
export function isPathUnderRoot(targetPath, root) {
|
|
17
|
+
const rel = path.relative(root, targetPath);
|
|
18
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
19
|
+
}
|
|
20
|
+
export function resolvePath(inputPath, mode, roots) {
|
|
21
|
+
const resolved = path.resolve(expandHome(inputPath));
|
|
22
|
+
if (mode === "open")
|
|
23
|
+
return resolved;
|
|
24
|
+
for (const root of roots) {
|
|
25
|
+
if (isPathUnderRoot(resolved, root))
|
|
26
|
+
return resolved;
|
|
27
|
+
}
|
|
28
|
+
throw new Error(`Path not allowed in strict mode: ${resolved}`);
|
|
29
|
+
}
|
|
30
|
+
export async function ensureDir(dirPath) {
|
|
31
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
32
|
+
}
|
|
33
|
+
export function sleep(ms) {
|
|
34
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
35
|
+
}
|
package/dist/worktree.js
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git worktree management for isolated implementer work
|
|
3
|
+
*/
|
|
4
|
+
import { exec as execCallback } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import fs from "node:fs/promises";
|
|
8
|
+
const exec = promisify(execCallback);
|
|
9
|
+
const LOCKSTEP_WORKTREE_DIR = ".lockstep/worktrees";
|
|
10
|
+
/**
|
|
11
|
+
* Check if a directory is a git repository
|
|
12
|
+
*/
|
|
13
|
+
export async function isGitRepo(repoPath) {
|
|
14
|
+
try {
|
|
15
|
+
await exec("git rev-parse --git-dir", { cwd: repoPath });
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Get the git root directory
|
|
24
|
+
*/
|
|
25
|
+
export async function getGitRoot(repoPath) {
|
|
26
|
+
const { stdout } = await exec("git rev-parse --show-toplevel", { cwd: repoPath });
|
|
27
|
+
return stdout.trim();
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Get the current branch name
|
|
31
|
+
*/
|
|
32
|
+
export async function getCurrentBranch(repoPath) {
|
|
33
|
+
const { stdout } = await exec("git rev-parse --abbrev-ref HEAD", { cwd: repoPath });
|
|
34
|
+
return stdout.trim();
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Create a git worktree for an implementer
|
|
38
|
+
*/
|
|
39
|
+
export async function createWorktree(repoPath, implName) {
|
|
40
|
+
const gitRoot = await getGitRoot(repoPath);
|
|
41
|
+
const worktreeBase = path.join(gitRoot, LOCKSTEP_WORKTREE_DIR);
|
|
42
|
+
const worktreePath = path.join(worktreeBase, implName);
|
|
43
|
+
const branchName = `lockstep/${implName}`;
|
|
44
|
+
// Ensure the worktree directory exists
|
|
45
|
+
await fs.mkdir(worktreeBase, { recursive: true });
|
|
46
|
+
// Check if worktree already exists
|
|
47
|
+
try {
|
|
48
|
+
await fs.access(worktreePath);
|
|
49
|
+
// Worktree exists, clean it up first
|
|
50
|
+
await removeWorktree(worktreePath);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Doesn't exist, that's fine
|
|
54
|
+
}
|
|
55
|
+
// Get current HEAD to branch from
|
|
56
|
+
const { stdout: currentHead } = await exec("git rev-parse HEAD", { cwd: gitRoot });
|
|
57
|
+
const headCommit = currentHead.trim();
|
|
58
|
+
// Delete branch if it exists (from previous run)
|
|
59
|
+
try {
|
|
60
|
+
await exec(`git branch -D "${branchName}"`, { cwd: gitRoot });
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Branch doesn't exist, that's fine
|
|
64
|
+
}
|
|
65
|
+
// Create the worktree with a new branch
|
|
66
|
+
await exec(`git worktree add -b "${branchName}" "${worktreePath}" "${headCommit}"`, { cwd: gitRoot });
|
|
67
|
+
return { worktreePath, branchName };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Remove a git worktree and its branch
|
|
71
|
+
*/
|
|
72
|
+
export async function removeWorktree(worktreePath) {
|
|
73
|
+
try {
|
|
74
|
+
// Get the branch name before removing
|
|
75
|
+
const { stdout: branchOutput } = await exec("git rev-parse --abbrev-ref HEAD", { cwd: worktreePath });
|
|
76
|
+
const branchName = branchOutput.trim();
|
|
77
|
+
// Get the main repo path
|
|
78
|
+
const { stdout: gitDirOutput } = await exec("git rev-parse --git-common-dir", { cwd: worktreePath });
|
|
79
|
+
const gitCommonDir = gitDirOutput.trim();
|
|
80
|
+
const mainRepoPath = path.dirname(gitCommonDir);
|
|
81
|
+
// Remove the worktree
|
|
82
|
+
await exec(`git worktree remove --force "${worktreePath}"`, { cwd: mainRepoPath });
|
|
83
|
+
// Delete the branch if it starts with lockstep/
|
|
84
|
+
if (branchName.startsWith("lockstep/")) {
|
|
85
|
+
try {
|
|
86
|
+
await exec(`git branch -D "${branchName}"`, { cwd: mainRepoPath });
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// Branch might not exist or be the current branch
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
// Fallback: just try to prune and remove directory
|
|
95
|
+
try {
|
|
96
|
+
const gitRoot = await getGitRoot(path.dirname(worktreePath));
|
|
97
|
+
await exec("git worktree prune", { cwd: gitRoot });
|
|
98
|
+
await fs.rm(worktreePath, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// Best effort cleanup
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Get the status of a worktree (commits ahead/behind, uncommitted changes)
|
|
107
|
+
*/
|
|
108
|
+
export async function getWorktreeStatus(worktreePath) {
|
|
109
|
+
// Fetch latest from remote (silently)
|
|
110
|
+
try {
|
|
111
|
+
await exec("git fetch origin", { cwd: worktreePath, timeout: 10000 });
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// Ignore fetch errors (might be offline)
|
|
115
|
+
}
|
|
116
|
+
// Get the main branch (usually main or master)
|
|
117
|
+
let mainBranch = "main";
|
|
118
|
+
try {
|
|
119
|
+
const { stdout } = await exec("git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || echo 'refs/remotes/origin/main'", { cwd: worktreePath });
|
|
120
|
+
mainBranch = stdout.trim().replace("refs/remotes/origin/", "");
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// Default to main
|
|
124
|
+
}
|
|
125
|
+
// Count commits ahead/behind
|
|
126
|
+
let ahead = 0;
|
|
127
|
+
let behind = 0;
|
|
128
|
+
try {
|
|
129
|
+
const { stdout: aheadOutput } = await exec(`git rev-list --count origin/${mainBranch}..HEAD`, { cwd: worktreePath });
|
|
130
|
+
ahead = parseInt(aheadOutput.trim(), 10) || 0;
|
|
131
|
+
const { stdout: behindOutput } = await exec(`git rev-list --count HEAD..origin/${mainBranch}`, { cwd: worktreePath });
|
|
132
|
+
behind = parseInt(behindOutput.trim(), 10) || 0;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Ignore errors (might be no remote)
|
|
136
|
+
}
|
|
137
|
+
// Get modified files
|
|
138
|
+
const { stdout: statusOutput } = await exec("git status --porcelain", { cwd: worktreePath });
|
|
139
|
+
const lines = statusOutput.trim().split("\n").filter(Boolean);
|
|
140
|
+
const modifiedFiles = [];
|
|
141
|
+
const untrackedFiles = [];
|
|
142
|
+
for (const line of lines) {
|
|
143
|
+
const status = line.substring(0, 2);
|
|
144
|
+
const file = line.substring(3);
|
|
145
|
+
if (status.includes("?")) {
|
|
146
|
+
untrackedFiles.push(file);
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
modifiedFiles.push(file);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
ahead,
|
|
154
|
+
behind,
|
|
155
|
+
hasUncommittedChanges: modifiedFiles.length > 0 || untrackedFiles.length > 0,
|
|
156
|
+
modifiedFiles,
|
|
157
|
+
untrackedFiles,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Commit all changes in a worktree
|
|
162
|
+
*/
|
|
163
|
+
export async function commitWorktreeChanges(worktreePath, message, author) {
|
|
164
|
+
try {
|
|
165
|
+
// Stage all changes
|
|
166
|
+
await exec("git add -A", { cwd: worktreePath });
|
|
167
|
+
// Check if there's anything to commit
|
|
168
|
+
const { stdout: statusOutput } = await exec("git status --porcelain", { cwd: worktreePath });
|
|
169
|
+
if (!statusOutput.trim()) {
|
|
170
|
+
return { success: true }; // Nothing to commit
|
|
171
|
+
}
|
|
172
|
+
// Commit
|
|
173
|
+
const fullMessage = `${message}\n\nImplementer: ${author}`;
|
|
174
|
+
await exec(`git commit -m "${fullMessage.replace(/"/g, '\\"')}"`, { cwd: worktreePath });
|
|
175
|
+
// Get the commit hash
|
|
176
|
+
const { stdout: hashOutput } = await exec("git rev-parse HEAD", { cwd: worktreePath });
|
|
177
|
+
return { success: true, commitHash: hashOutput.trim() };
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
const err = error;
|
|
181
|
+
return { success: false, error: err.message };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Attempt to merge a worktree's changes back to main
|
|
186
|
+
*/
|
|
187
|
+
export async function mergeWorktree(worktreePath, targetBranch) {
|
|
188
|
+
try {
|
|
189
|
+
// Get the git root
|
|
190
|
+
const { stdout: gitDirOutput } = await exec("git rev-parse --git-common-dir", { cwd: worktreePath });
|
|
191
|
+
const gitCommonDir = gitDirOutput.trim();
|
|
192
|
+
const mainRepoPath = path.dirname(gitCommonDir);
|
|
193
|
+
// Get the worktree branch
|
|
194
|
+
const { stdout: branchOutput } = await exec("git rev-parse --abbrev-ref HEAD", { cwd: worktreePath });
|
|
195
|
+
const worktreeBranch = branchOutput.trim();
|
|
196
|
+
// Determine target branch
|
|
197
|
+
if (!targetBranch) {
|
|
198
|
+
try {
|
|
199
|
+
const { stdout } = await exec("git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || echo 'refs/remotes/origin/main'", { cwd: mainRepoPath });
|
|
200
|
+
targetBranch = stdout.trim().replace("refs/remotes/origin/", "");
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
targetBranch = "main";
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// Commit any uncommitted changes first
|
|
207
|
+
const status = await getWorktreeStatus(worktreePath);
|
|
208
|
+
if (status.hasUncommittedChanges) {
|
|
209
|
+
await commitWorktreeChanges(worktreePath, "WIP: Uncommitted changes before merge", "lockstep");
|
|
210
|
+
}
|
|
211
|
+
// Check if there's anything to merge
|
|
212
|
+
const { stdout: diffOutput } = await exec(`git diff ${targetBranch}..${worktreeBranch} --stat`, { cwd: mainRepoPath });
|
|
213
|
+
if (!diffOutput.trim()) {
|
|
214
|
+
return { success: true, merged: false }; // Nothing to merge
|
|
215
|
+
}
|
|
216
|
+
// Try to rebase onto target branch first (cleaner history)
|
|
217
|
+
try {
|
|
218
|
+
await exec(`git -C "${worktreePath}" rebase origin/${targetBranch}`, { cwd: mainRepoPath });
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// Rebase failed, abort it
|
|
222
|
+
try {
|
|
223
|
+
await exec(`git -C "${worktreePath}" rebase --abort`, { cwd: mainRepoPath });
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// Ignore
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// Switch to target branch and merge
|
|
230
|
+
const currentBranch = await getCurrentBranch(mainRepoPath);
|
|
231
|
+
try {
|
|
232
|
+
await exec(`git checkout ${targetBranch}`, { cwd: mainRepoPath });
|
|
233
|
+
await exec(`git merge --no-ff ${worktreeBranch} -m "Merge ${worktreeBranch}"`, { cwd: mainRepoPath });
|
|
234
|
+
return { success: true, merged: true };
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
// Merge conflict - get the conflicting files
|
|
238
|
+
const { stdout: conflictOutput } = await exec("git diff --name-only --diff-filter=U", { cwd: mainRepoPath });
|
|
239
|
+
const conflicts = conflictOutput.trim().split("\n").filter(Boolean);
|
|
240
|
+
// Abort the merge
|
|
241
|
+
await exec("git merge --abort", { cwd: mainRepoPath });
|
|
242
|
+
// Switch back to original branch
|
|
243
|
+
await exec(`git checkout ${currentBranch}`, { cwd: mainRepoPath });
|
|
244
|
+
return { success: false, merged: false, conflicts };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
const err = error;
|
|
249
|
+
return { success: false, merged: false, error: err.message };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* List all lockstep worktrees
|
|
254
|
+
*/
|
|
255
|
+
export async function listWorktrees(repoPath) {
|
|
256
|
+
try {
|
|
257
|
+
const gitRoot = await getGitRoot(repoPath);
|
|
258
|
+
const { stdout } = await exec("git worktree list --porcelain", { cwd: gitRoot });
|
|
259
|
+
const worktrees = [];
|
|
260
|
+
const lines = stdout.trim().split("\n");
|
|
261
|
+
let current = {};
|
|
262
|
+
for (const line of lines) {
|
|
263
|
+
if (line.startsWith("worktree ")) {
|
|
264
|
+
current.path = line.substring(9);
|
|
265
|
+
}
|
|
266
|
+
else if (line.startsWith("HEAD ")) {
|
|
267
|
+
current.head = line.substring(5);
|
|
268
|
+
}
|
|
269
|
+
else if (line.startsWith("branch ")) {
|
|
270
|
+
current.branchName = line.substring(7).replace("refs/heads/", "");
|
|
271
|
+
}
|
|
272
|
+
else if (line === "") {
|
|
273
|
+
if (current.path && current.branchName?.startsWith("lockstep/")) {
|
|
274
|
+
worktrees.push(current);
|
|
275
|
+
}
|
|
276
|
+
current = {};
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// Don't forget the last one
|
|
280
|
+
if (current.path && current.branchName?.startsWith("lockstep/")) {
|
|
281
|
+
worktrees.push(current);
|
|
282
|
+
}
|
|
283
|
+
return worktrees;
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
return [];
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Clean up orphaned worktrees (those without matching implementers)
|
|
291
|
+
*/
|
|
292
|
+
export async function cleanupOrphanedWorktrees(repoPath) {
|
|
293
|
+
const cleaned = [];
|
|
294
|
+
try {
|
|
295
|
+
const gitRoot = await getGitRoot(repoPath);
|
|
296
|
+
// Prune stale worktree references
|
|
297
|
+
await exec("git worktree prune", { cwd: gitRoot });
|
|
298
|
+
// Get remaining worktrees
|
|
299
|
+
const worktrees = await listWorktrees(repoPath);
|
|
300
|
+
// Check which directories still exist
|
|
301
|
+
for (const wt of worktrees) {
|
|
302
|
+
try {
|
|
303
|
+
await fs.access(wt.path);
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
// Directory doesn't exist, clean up the reference
|
|
307
|
+
try {
|
|
308
|
+
await exec(`git worktree remove --force "${wt.path}"`, { cwd: gitRoot });
|
|
309
|
+
cleaned.push(wt.path);
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
// Ignore errors
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
// Also clean up the lockstep branches that don't have worktrees
|
|
317
|
+
const { stdout: branchOutput } = await exec("git branch --list 'lockstep/*'", { cwd: gitRoot });
|
|
318
|
+
const branches = branchOutput.trim().split("\n").filter(Boolean).map(b => b.trim().replace("* ", ""));
|
|
319
|
+
for (const branch of branches) {
|
|
320
|
+
const hasWorktree = worktrees.some(wt => wt.branchName === branch);
|
|
321
|
+
if (!hasWorktree) {
|
|
322
|
+
try {
|
|
323
|
+
await exec(`git branch -D "${branch}"`, { cwd: gitRoot });
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
// Ignore errors
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
// Ignore errors
|
|
333
|
+
}
|
|
334
|
+
return cleaned;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Get diff between worktree and main branch
|
|
338
|
+
*/
|
|
339
|
+
export async function getWorktreeDiff(worktreePath) {
|
|
340
|
+
try {
|
|
341
|
+
// Get the main branch
|
|
342
|
+
let mainBranch = "main";
|
|
343
|
+
try {
|
|
344
|
+
const { stdout } = await exec("git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || echo 'refs/remotes/origin/main'", { cwd: worktreePath });
|
|
345
|
+
mainBranch = stdout.trim().replace("refs/remotes/origin/", "");
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
// Default to main
|
|
349
|
+
}
|
|
350
|
+
const { stdout } = await exec(`git diff origin/${mainBranch}...HEAD --stat`, { cwd: worktreePath });
|
|
351
|
+
return stdout;
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
return "";
|
|
355
|
+
}
|
|
356
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lockstep-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local MCP coordination server for multi-agent workflows",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/cli.js",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=18.0.0"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"lockstep-mcp": "dist/cli.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"!dist/**/*.test.js",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"dev": "node --import tsx src/cli.ts server",
|
|
21
|
+
"start": "node dist/cli.js server",
|
|
22
|
+
"dashboard": "node dist/cli.js dashboard",
|
|
23
|
+
"prompts": "node dist/cli.js prompts",
|
|
24
|
+
"install:mcp": "node --import tsx src/cli.ts install",
|
|
25
|
+
"build": "tsc -p tsconfig.json",
|
|
26
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
27
|
+
"test": "vitest run",
|
|
28
|
+
"test:watch": "vitest",
|
|
29
|
+
"test:coverage": "vitest run --coverage",
|
|
30
|
+
"prepare": "npm run build",
|
|
31
|
+
"clean": "rm -rf dist"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"mcp",
|
|
35
|
+
"model-context-protocol",
|
|
36
|
+
"agent",
|
|
37
|
+
"coordination",
|
|
38
|
+
"orchestration",
|
|
39
|
+
"automation",
|
|
40
|
+
"cli"
|
|
41
|
+
],
|
|
42
|
+
"author": "Tim Moore",
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "https://github.com/Tmmoore286/lockstep-mcp.git"
|
|
46
|
+
},
|
|
47
|
+
"homepage": "https://github.com/Tmmoore286/lockstep-mcp",
|
|
48
|
+
"bugs": {
|
|
49
|
+
"url": "https://github.com/Tmmoore286/lockstep-mcp/issues"
|
|
50
|
+
},
|
|
51
|
+
"license": "MIT",
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@modelcontextprotocol/sdk": "^1.25.2",
|
|
54
|
+
"better-sqlite3": "^11.10.0",
|
|
55
|
+
"tsx": "^4.21.0",
|
|
56
|
+
"ws": "^8.18.1"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
60
|
+
"@types/node": "^25.0.9",
|
|
61
|
+
"@types/ws": "^8.18.1",
|
|
62
|
+
"@vitest/coverage-v8": "^4.0.17",
|
|
63
|
+
"typescript": "^5.9.3",
|
|
64
|
+
"vitest": "^4.0.17"
|
|
65
|
+
}
|
|
66
|
+
}
|