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/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
+ }
@@ -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
+ }