@adhdev/daemon-core 0.9.76-rc.10 → 0.9.76-rc.11
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/commands/router.d.ts +4 -0
- package/dist/config/mesh-config.d.ts +3 -0
- package/dist/git/git-worktree.d.ts +64 -0
- package/dist/git/index.d.ts +2 -0
- package/dist/index.js +575 -295
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +595 -320
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/coordinator-prompt.d.ts +1 -0
- package/dist/repo-mesh-types.d.ts +4 -0
- package/package.json +1 -1
- package/src/commands/router.ts +151 -10
- package/src/config/mesh-config.ts +6 -0
- package/src/git/git-worktree.ts +214 -0
- package/src/git/index.ts +14 -0
- package/src/mesh/coordinator-prompt.ts +25 -10
- package/src/repo-mesh-types.ts +4 -0
|
@@ -149,6 +149,10 @@ export interface LocalMeshNodeEntry {
|
|
|
149
149
|
policy: RepoMeshNodePolicy;
|
|
150
150
|
/** For single-machine mesh: same daemon, different worktree */
|
|
151
151
|
isLocalWorktree?: boolean;
|
|
152
|
+
/** Branch this worktree tracks (set when created via clone_mesh_node) */
|
|
153
|
+
worktreeBranch?: string;
|
|
154
|
+
/** Node ID this worktree was cloned from */
|
|
155
|
+
clonedFromNodeId?: string;
|
|
152
156
|
}
|
|
153
157
|
export interface RepoMeshStatus {
|
|
154
158
|
meshId: string;
|
package/package.json
CHANGED
package/src/commands/router.ts
CHANGED
|
@@ -226,6 +226,43 @@ export class DaemonCommandRouter {
|
|
|
226
226
|
this.deps = deps;
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
private getCachedInlineMesh(meshId: string, inlineMesh?: unknown): any | undefined {
|
|
230
|
+
if (inlineMesh && typeof inlineMesh === 'object') {
|
|
231
|
+
this.inlineMeshCache.set(meshId, inlineMesh as any);
|
|
232
|
+
return inlineMesh as any;
|
|
233
|
+
}
|
|
234
|
+
return this.inlineMeshCache.get(meshId);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private async getMeshForCommand(meshId: string, inlineMesh?: unknown): Promise<{ mesh: any; inline: boolean } | null> {
|
|
238
|
+
try {
|
|
239
|
+
const { getMesh } = await import('../config/mesh-config.js');
|
|
240
|
+
const mesh = getMesh(meshId);
|
|
241
|
+
if (mesh) return { mesh, inline: false };
|
|
242
|
+
} catch { /* fall through to inline cache */ }
|
|
243
|
+
const cached = this.getCachedInlineMesh(meshId, inlineMesh);
|
|
244
|
+
return cached ? { mesh: cached, inline: true } : null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private updateInlineMeshNode(meshId: string, mesh: any, node: any): void {
|
|
248
|
+
if (!mesh || !Array.isArray(mesh.nodes) || !node?.id) return;
|
|
249
|
+
const idx = mesh.nodes.findIndex((entry: any) => entry?.id === node.id || entry?.nodeId === node.id);
|
|
250
|
+
if (idx >= 0) mesh.nodes[idx] = node;
|
|
251
|
+
else mesh.nodes.push(node);
|
|
252
|
+
mesh.updatedAt = new Date().toISOString();
|
|
253
|
+
this.inlineMeshCache.set(meshId, mesh);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private removeInlineMeshNode(meshId: string, mesh: any, nodeId: string): boolean {
|
|
257
|
+
if (!mesh || !Array.isArray(mesh.nodes)) return false;
|
|
258
|
+
const idx = mesh.nodes.findIndex((entry: any) => entry?.id === nodeId || entry?.nodeId === nodeId);
|
|
259
|
+
if (idx === -1) return false;
|
|
260
|
+
mesh.nodes.splice(idx, 1);
|
|
261
|
+
mesh.updatedAt = new Date().toISOString();
|
|
262
|
+
this.inlineMeshCache.set(meshId, mesh);
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
|
|
229
266
|
private async traceSessionHostAction<T>(
|
|
230
267
|
action: string,
|
|
231
268
|
args: any,
|
|
@@ -1022,14 +1059,107 @@ export class DaemonCommandRouter {
|
|
|
1022
1059
|
const nodeId = typeof args?.nodeId === 'string' ? args.nodeId.trim() : '';
|
|
1023
1060
|
if (!meshId || !nodeId) return { success: false, error: 'meshId and nodeId required' };
|
|
1024
1061
|
try {
|
|
1025
|
-
const
|
|
1026
|
-
const
|
|
1062
|
+
const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh);
|
|
1063
|
+
const mesh = meshRecord?.mesh;
|
|
1064
|
+
const node = mesh?.nodes?.find((n: any) => n.id === nodeId || n.nodeId === nodeId);
|
|
1065
|
+
|
|
1066
|
+
// If this is a worktree node, clean up the git worktree first
|
|
1067
|
+
if (node?.isLocalWorktree && node.workspace) {
|
|
1068
|
+
try {
|
|
1069
|
+
const sourceNode = node.clonedFromNodeId
|
|
1070
|
+
? mesh?.nodes.find((n: any) => n.id === node.clonedFromNodeId || n.nodeId === node.clonedFromNodeId)
|
|
1071
|
+
: mesh?.nodes.find((n: any) => !n.isLocalWorktree);
|
|
1072
|
+
const repoRoot = sourceNode?.repoRoot || sourceNode?.workspace;
|
|
1073
|
+
if (repoRoot) {
|
|
1074
|
+
const { removeWorktree } = await import('../git/git-worktree.js');
|
|
1075
|
+
await removeWorktree(repoRoot, node.workspace);
|
|
1076
|
+
}
|
|
1077
|
+
} catch (e: any) {
|
|
1078
|
+
LOG.warn('MeshNode', `Worktree cleanup failed for ${nodeId}: ${e.message}`);
|
|
1079
|
+
// Continue with node removal even if worktree cleanup fails
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
let removed = false;
|
|
1084
|
+
if (meshRecord?.inline) {
|
|
1085
|
+
removed = this.removeInlineMeshNode(meshId, mesh, nodeId);
|
|
1086
|
+
} else {
|
|
1087
|
+
const { removeNode } = await import('../config/mesh-config.js');
|
|
1088
|
+
removed = removeNode(meshId, nodeId);
|
|
1089
|
+
}
|
|
1027
1090
|
return { success: true, removed };
|
|
1028
1091
|
} catch (e: any) {
|
|
1029
1092
|
return { success: false, error: e.message };
|
|
1030
1093
|
}
|
|
1031
1094
|
}
|
|
1032
1095
|
|
|
1096
|
+
case 'clone_mesh_node': {
|
|
1097
|
+
const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
|
|
1098
|
+
const sourceNodeId = typeof args?.sourceNodeId === 'string' ? args.sourceNodeId.trim() : '';
|
|
1099
|
+
const branch = typeof args?.branch === 'string' ? args.branch.trim() : '';
|
|
1100
|
+
const baseBranch = typeof args?.baseBranch === 'string' ? args.baseBranch.trim() : undefined;
|
|
1101
|
+
if (!meshId) return { success: false, error: 'meshId required' };
|
|
1102
|
+
if (!sourceNodeId) return { success: false, error: 'sourceNodeId required' };
|
|
1103
|
+
if (!branch) return { success: false, error: 'branch required' };
|
|
1104
|
+
|
|
1105
|
+
try {
|
|
1106
|
+
const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh);
|
|
1107
|
+
const mesh = meshRecord?.mesh;
|
|
1108
|
+
if (!mesh) return { success: false, error: 'Mesh not found' };
|
|
1109
|
+
|
|
1110
|
+
const sourceNode = mesh.nodes?.find((n: any) => n.id === sourceNodeId || n.nodeId === sourceNodeId);
|
|
1111
|
+
if (!sourceNode) return { success: false, error: `Source node '${sourceNodeId}' not found in mesh` };
|
|
1112
|
+
|
|
1113
|
+
const repoRoot = sourceNode.repoRoot || sourceNode.workspace;
|
|
1114
|
+
const { createWorktree } = await import('../git/git-worktree.js');
|
|
1115
|
+
const result = await createWorktree({
|
|
1116
|
+
repoRoot,
|
|
1117
|
+
branch,
|
|
1118
|
+
baseBranch,
|
|
1119
|
+
meshName: mesh.name,
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
let node: any;
|
|
1123
|
+
if (meshRecord.inline) {
|
|
1124
|
+
const { randomUUID } = await import('crypto');
|
|
1125
|
+
node = {
|
|
1126
|
+
id: `node_${randomUUID().replace(/-/g, '')}`,
|
|
1127
|
+
workspace: result.worktreePath,
|
|
1128
|
+
repoRoot: result.worktreePath,
|
|
1129
|
+
daemonId: sourceNode.daemonId,
|
|
1130
|
+
userOverrides: { ...(sourceNode.userOverrides || {}) },
|
|
1131
|
+
policy: { ...(sourceNode.policy || {}) },
|
|
1132
|
+
isLocalWorktree: true,
|
|
1133
|
+
worktreeBranch: result.branch,
|
|
1134
|
+
clonedFromNodeId: sourceNodeId,
|
|
1135
|
+
};
|
|
1136
|
+
this.updateInlineMeshNode(meshId, mesh, node);
|
|
1137
|
+
} else {
|
|
1138
|
+
const { addNode } = await import('../config/mesh-config.js');
|
|
1139
|
+
node = addNode(meshId, {
|
|
1140
|
+
workspace: result.worktreePath,
|
|
1141
|
+
repoRoot: result.worktreePath,
|
|
1142
|
+
daemonId: sourceNode.daemonId,
|
|
1143
|
+
userOverrides: { ...(sourceNode.userOverrides || {}) },
|
|
1144
|
+
isLocalWorktree: true,
|
|
1145
|
+
worktreeBranch: result.branch,
|
|
1146
|
+
clonedFromNodeId: sourceNodeId,
|
|
1147
|
+
policy: { ...(sourceNode.policy || {}) },
|
|
1148
|
+
});
|
|
1149
|
+
if (!node) return { success: false, error: 'Failed to register worktree node' };
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
return {
|
|
1153
|
+
success: true,
|
|
1154
|
+
node,
|
|
1155
|
+
worktreePath: result.worktreePath,
|
|
1156
|
+
branch: result.branch,
|
|
1157
|
+
};
|
|
1158
|
+
} catch (e: any) {
|
|
1159
|
+
return { success: false, error: e.message };
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1033
1163
|
// ─── Mesh Coordinator Launch ───
|
|
1034
1164
|
case 'launch_mesh_coordinator': {
|
|
1035
1165
|
const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
|
|
@@ -1112,6 +1242,25 @@ export class DaemonCommandRouter {
|
|
|
1112
1242
|
};
|
|
1113
1243
|
}
|
|
1114
1244
|
|
|
1245
|
+
// Build the coordinator prompt before mutating workspace config or launching.
|
|
1246
|
+
// Prompt generation failures are configuration/data-shape errors; fail closed so
|
|
1247
|
+
// broken mesh state is visible instead of silently launching with weaker rules.
|
|
1248
|
+
let systemPrompt = '';
|
|
1249
|
+
try {
|
|
1250
|
+
systemPrompt = buildCoordinatorSystemPrompt({ mesh, coordinatorCliType: cliType });
|
|
1251
|
+
} catch (error: any) {
|
|
1252
|
+
const message = error?.message || String(error);
|
|
1253
|
+
LOG.error('MeshCoordinator', `Failed to build coordinator prompt: ${message}`);
|
|
1254
|
+
return {
|
|
1255
|
+
success: false,
|
|
1256
|
+
code: 'mesh_coordinator_prompt_failed',
|
|
1257
|
+
error: `Failed to build Repo Mesh coordinator prompt: ${message}`,
|
|
1258
|
+
meshId,
|
|
1259
|
+
cliType,
|
|
1260
|
+
workspace,
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1115
1264
|
// 1. Write provider-declared MCP config to workspace for CLIs that auto-import it.
|
|
1116
1265
|
const { existsSync, readFileSync, writeFileSync, copyFileSync } = await import('fs');
|
|
1117
1266
|
const mcpConfigPath = coordinatorSetup.configPath;
|
|
@@ -1149,14 +1298,6 @@ export class DaemonCommandRouter {
|
|
|
1149
1298
|
writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2), 'utf-8');
|
|
1150
1299
|
LOG.info('MeshCoordinator', `Wrote ${mcpConfigPath} with ${coordinatorSetup.serverName} server`);
|
|
1151
1300
|
|
|
1152
|
-
// 2. Build coordinator system prompt
|
|
1153
|
-
let systemPrompt = '';
|
|
1154
|
-
try {
|
|
1155
|
-
systemPrompt = buildCoordinatorSystemPrompt({ mesh });
|
|
1156
|
-
} catch {
|
|
1157
|
-
systemPrompt = `You are a Repo Mesh Coordinator for "${mesh.name}". Use the adhdev-mesh MCP tools (mesh_status, mesh_list_nodes, mesh_send_task, mesh_read_chat, mesh_launch_session, etc.) to orchestrate work across ${mesh.nodes.length} node(s).`;
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
1301
|
const cliArgs: string[] = [];
|
|
1161
1302
|
if (systemPrompt) {
|
|
1162
1303
|
cliArgs.push('--append-system-prompt', systemPrompt);
|
|
@@ -160,9 +160,12 @@ export function deleteMesh(meshId: string): boolean {
|
|
|
160
160
|
export interface AddNodeOptions {
|
|
161
161
|
workspace: string;
|
|
162
162
|
repoRoot?: string;
|
|
163
|
+
daemonId?: string;
|
|
163
164
|
userOverrides?: Partial<RepoMeshNodeCapabilities>;
|
|
164
165
|
policy?: RepoMeshNodePolicy;
|
|
165
166
|
isLocalWorktree?: boolean;
|
|
167
|
+
worktreeBranch?: string;
|
|
168
|
+
clonedFromNodeId?: string;
|
|
166
169
|
}
|
|
167
170
|
|
|
168
171
|
export function addNode(meshId: string, opts: AddNodeOptions): LocalMeshNodeEntry | undefined {
|
|
@@ -183,9 +186,12 @@ export function addNode(meshId: string, opts: AddNodeOptions): LocalMeshNodeEntr
|
|
|
183
186
|
id: `node_${randomUUID().replace(/-/g, '')}`,
|
|
184
187
|
workspace: opts.workspace.trim(),
|
|
185
188
|
repoRoot: opts.repoRoot,
|
|
189
|
+
daemonId: opts.daemonId,
|
|
186
190
|
userOverrides: opts.userOverrides || {},
|
|
187
191
|
policy: opts.policy || {},
|
|
188
192
|
isLocalWorktree: opts.isLocalWorktree,
|
|
193
|
+
worktreeBranch: opts.worktreeBranch,
|
|
194
|
+
clonedFromNodeId: opts.clonedFromNodeId,
|
|
189
195
|
};
|
|
190
196
|
|
|
191
197
|
mesh.nodes.push(node);
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git Worktree — Create/remove/list worktrees for Repo Mesh node cloning
|
|
3
|
+
*
|
|
4
|
+
* Used by the `clone_mesh_node` daemon command to create isolated
|
|
5
|
+
* worktree-based nodes for parallel branch work within a mesh.
|
|
6
|
+
*
|
|
7
|
+
* Worktrees are placed outside the source repo to avoid .gitignore
|
|
8
|
+
* pollution and submodule conflicts:
|
|
9
|
+
* <repoParent>/.adhdev-worktrees/<meshName>/<branch>/
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
import { mkdir } from 'node:fs/promises';
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
import { execFile } from 'node:child_process';
|
|
16
|
+
import { promisify } from 'node:util';
|
|
17
|
+
|
|
18
|
+
const execFileAsync = promisify(execFile);
|
|
19
|
+
|
|
20
|
+
const WORKTREE_DIR_NAME = '.adhdev-worktrees';
|
|
21
|
+
const GIT_TIMEOUT_MS = 30_000;
|
|
22
|
+
const GIT_MAX_BUFFER = 4 * 1024 * 1024;
|
|
23
|
+
|
|
24
|
+
// ─── Types ──────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export interface WorktreeCreateOptions {
|
|
27
|
+
/** Absolute path to the source repo's git root */
|
|
28
|
+
repoRoot: string;
|
|
29
|
+
/** Branch name for the new worktree */
|
|
30
|
+
branch: string;
|
|
31
|
+
/** Starting point for the branch (default: HEAD) */
|
|
32
|
+
baseBranch?: string;
|
|
33
|
+
/** Mesh name, used for organizing worktree directories */
|
|
34
|
+
meshName: string;
|
|
35
|
+
/** Override the auto-resolved target directory */
|
|
36
|
+
targetDir?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface WorktreeCreateResult {
|
|
40
|
+
success: true;
|
|
41
|
+
worktreePath: string;
|
|
42
|
+
branch: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface WorktreeEntry {
|
|
46
|
+
path: string;
|
|
47
|
+
head: string;
|
|
48
|
+
branch: string | null;
|
|
49
|
+
bare: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface WorktreeRemoveResult {
|
|
53
|
+
success: true;
|
|
54
|
+
removedPath: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Path Resolution ────────────────────────────
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the target directory for a new worktree.
|
|
61
|
+
* Places worktrees at: <repoParent>/.adhdev-worktrees/<meshName>/<branch>/
|
|
62
|
+
*/
|
|
63
|
+
export function resolveWorktreePath(repoRoot: string, meshName: string, branch: string): string {
|
|
64
|
+
// Sanitize branch name for filesystem (e.g. feat/auth → feat-auth)
|
|
65
|
+
const safeBranch = branch.replace(/[/\\:*?"<>|]/g, '-').replace(/^\.+|\.+$/g, '');
|
|
66
|
+
const safeMeshName = meshName.replace(/[/\\:*?"<>|]/g, '-').replace(/^\.+|\.+$/g, '');
|
|
67
|
+
const parentDir = path.dirname(repoRoot);
|
|
68
|
+
return path.join(parentDir, WORKTREE_DIR_NAME, safeMeshName, safeBranch);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Create ─────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Create a new git worktree with a fresh branch.
|
|
75
|
+
*
|
|
76
|
+
* Runs: git worktree add <targetDir> -b <branch> [baseBranch]
|
|
77
|
+
*/
|
|
78
|
+
export async function createWorktree(opts: WorktreeCreateOptions): Promise<WorktreeCreateResult> {
|
|
79
|
+
const { repoRoot, branch, baseBranch, meshName } = opts;
|
|
80
|
+
const targetDir = opts.targetDir || resolveWorktreePath(repoRoot, meshName, branch);
|
|
81
|
+
|
|
82
|
+
if (existsSync(targetDir)) {
|
|
83
|
+
throw new Error(`Worktree target directory already exists: ${targetDir}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Ensure parent directory exists
|
|
87
|
+
await mkdir(path.dirname(targetDir), { recursive: true });
|
|
88
|
+
|
|
89
|
+
const args = ['worktree', 'add', targetDir, '-b', branch];
|
|
90
|
+
if (baseBranch) {
|
|
91
|
+
args.push(baseBranch);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
await execFileAsync('git', args, {
|
|
96
|
+
cwd: repoRoot,
|
|
97
|
+
encoding: 'utf8',
|
|
98
|
+
timeout: GIT_TIMEOUT_MS,
|
|
99
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
100
|
+
windowsHide: true,
|
|
101
|
+
});
|
|
102
|
+
} catch (error: any) {
|
|
103
|
+
const stderr = typeof error.stderr === 'string' ? error.stderr : '';
|
|
104
|
+
// Clean error messages for common failures
|
|
105
|
+
if (/already exists/i.test(stderr)) {
|
|
106
|
+
throw new Error(`Branch '${branch}' already exists or is checked out in another worktree`);
|
|
107
|
+
}
|
|
108
|
+
throw new Error(`git worktree add failed: ${stderr.trim() || error.message}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
success: true,
|
|
113
|
+
worktreePath: targetDir,
|
|
114
|
+
branch,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─── Remove ─────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Remove a git worktree and clean up the directory.
|
|
122
|
+
*
|
|
123
|
+
* Runs: git worktree remove <worktreePath> --force
|
|
124
|
+
*/
|
|
125
|
+
export async function removeWorktree(repoRoot: string, worktreePath: string): Promise<WorktreeRemoveResult> {
|
|
126
|
+
if (!existsSync(worktreePath)) {
|
|
127
|
+
// Already gone — just prune
|
|
128
|
+
await pruneWorktrees(repoRoot);
|
|
129
|
+
return { success: true, removedPath: worktreePath };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
await execFileAsync('git', ['worktree', 'remove', worktreePath, '--force'], {
|
|
134
|
+
cwd: repoRoot,
|
|
135
|
+
encoding: 'utf8',
|
|
136
|
+
timeout: GIT_TIMEOUT_MS,
|
|
137
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
138
|
+
windowsHide: true,
|
|
139
|
+
});
|
|
140
|
+
} catch (error: any) {
|
|
141
|
+
const stderr = typeof error.stderr === 'string' ? error.stderr : '';
|
|
142
|
+
throw new Error(`git worktree remove failed: ${stderr.trim() || error.message}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { success: true, removedPath: worktreePath };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── List ───────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* List all worktrees for a repository.
|
|
152
|
+
*
|
|
153
|
+
* Runs: git worktree list --porcelain
|
|
154
|
+
*/
|
|
155
|
+
export async function listWorktrees(repoRoot: string): Promise<WorktreeEntry[]> {
|
|
156
|
+
const { stdout } = await execFileAsync('git', ['worktree', 'list', '--porcelain'], {
|
|
157
|
+
cwd: repoRoot,
|
|
158
|
+
encoding: 'utf8',
|
|
159
|
+
timeout: GIT_TIMEOUT_MS,
|
|
160
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
161
|
+
windowsHide: true,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return parseWorktreeListOutput(stdout);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Parse `git worktree list --porcelain` output into structured entries.
|
|
169
|
+
*/
|
|
170
|
+
export function parseWorktreeListOutput(output: string): WorktreeEntry[] {
|
|
171
|
+
const entries: WorktreeEntry[] = [];
|
|
172
|
+
const blocks = output.trim().split(/\n\n+/);
|
|
173
|
+
|
|
174
|
+
for (const block of blocks) {
|
|
175
|
+
if (!block.trim()) continue;
|
|
176
|
+
const lines = block.trim().split('\n');
|
|
177
|
+
const entry: WorktreeEntry = { path: '', head: '', branch: null, bare: false };
|
|
178
|
+
|
|
179
|
+
for (const line of lines) {
|
|
180
|
+
if (line.startsWith('worktree ')) {
|
|
181
|
+
entry.path = line.slice('worktree '.length).trim();
|
|
182
|
+
} else if (line.startsWith('HEAD ')) {
|
|
183
|
+
entry.head = line.slice('HEAD '.length).trim();
|
|
184
|
+
} else if (line.startsWith('branch ')) {
|
|
185
|
+
const ref = line.slice('branch '.length).trim();
|
|
186
|
+
// refs/heads/feat/auth → feat/auth
|
|
187
|
+
entry.branch = ref.replace(/^refs\/heads\//, '');
|
|
188
|
+
} else if (line === 'bare') {
|
|
189
|
+
entry.bare = true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (entry.path) {
|
|
194
|
+
entries.push(entry);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return entries;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ─── Prune ──────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
async function pruneWorktrees(repoRoot: string): Promise<void> {
|
|
204
|
+
try {
|
|
205
|
+
await execFileAsync('git', ['worktree', 'prune'], {
|
|
206
|
+
cwd: repoRoot,
|
|
207
|
+
encoding: 'utf8',
|
|
208
|
+
timeout: GIT_TIMEOUT_MS,
|
|
209
|
+
windowsHide: true,
|
|
210
|
+
});
|
|
211
|
+
} catch {
|
|
212
|
+
// Prune is best-effort
|
|
213
|
+
}
|
|
214
|
+
}
|
package/src/git/index.ts
CHANGED
|
@@ -73,3 +73,17 @@ export type {
|
|
|
73
73
|
|
|
74
74
|
export { TurnSnapshotTracker } from './turn-snapshot-tracker.js';
|
|
75
75
|
export type { TurnCompletedCallback } from './turn-snapshot-tracker.js';
|
|
76
|
+
|
|
77
|
+
export {
|
|
78
|
+
createWorktree,
|
|
79
|
+
listWorktrees,
|
|
80
|
+
parseWorktreeListOutput,
|
|
81
|
+
removeWorktree,
|
|
82
|
+
resolveWorktreePath,
|
|
83
|
+
} from './git-worktree.js';
|
|
84
|
+
export type {
|
|
85
|
+
WorktreeCreateOptions,
|
|
86
|
+
WorktreeCreateResult,
|
|
87
|
+
WorktreeEntry,
|
|
88
|
+
WorktreeRemoveResult,
|
|
89
|
+
} from './git-worktree.js';
|
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
RepoMeshStatus,
|
|
17
17
|
RepoMeshNodeStatus,
|
|
18
18
|
} from '../repo-mesh-types.js';
|
|
19
|
+
import { DEFAULT_MESH_POLICY } from '../repo-mesh-types.js';
|
|
19
20
|
|
|
20
21
|
// ─── Prompt Builder ─────────────────────────────
|
|
21
22
|
|
|
@@ -23,10 +24,11 @@ export interface CoordinatorPromptContext {
|
|
|
23
24
|
mesh: LocalMeshEntry;
|
|
24
25
|
status?: RepoMeshStatus;
|
|
25
26
|
userInstruction?: string;
|
|
27
|
+
coordinatorCliType?: string;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export function buildCoordinatorSystemPrompt(ctx: CoordinatorPromptContext): string {
|
|
29
|
-
const { mesh, status, userInstruction } = ctx;
|
|
31
|
+
const { mesh, status, userInstruction, coordinatorCliType } = ctx;
|
|
30
32
|
const sections: string[] = [];
|
|
31
33
|
|
|
32
34
|
// ── Identity ──
|
|
@@ -45,7 +47,7 @@ Repository: \`${mesh.repoIdentity}\`${mesh.defaultBranch ? `\nDefault branch: \`
|
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
// ── Policy ──
|
|
48
|
-
sections.push(buildPolicySection(mesh.policy));
|
|
50
|
+
sections.push(buildPolicySection({ ...DEFAULT_MESH_POLICY, ...(mesh.policy || {}) }));
|
|
49
51
|
|
|
50
52
|
// ── Tools ──
|
|
51
53
|
sections.push(TOOLS_SECTION);
|
|
@@ -54,14 +56,14 @@ Repository: \`${mesh.repoIdentity}\`${mesh.defaultBranch ? `\nDefault branch: \`
|
|
|
54
56
|
sections.push(WORKFLOW_SECTION);
|
|
55
57
|
|
|
56
58
|
// ── Rules ──
|
|
57
|
-
sections.push(
|
|
59
|
+
sections.push(buildRulesSection(coordinatorCliType));
|
|
58
60
|
|
|
59
61
|
// ── User instruction ──
|
|
60
62
|
if (userInstruction) {
|
|
61
63
|
sections.push(`## Additional Context\n${userInstruction}`);
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
if (mesh.coordinator
|
|
66
|
+
if (mesh.coordinator?.systemPromptSuffix) {
|
|
65
67
|
sections.push(mesh.coordinator.systemPromptSuffix);
|
|
66
68
|
}
|
|
67
69
|
|
|
@@ -130,7 +132,9 @@ const TOOLS_SECTION = `## Available Tools
|
|
|
130
132
|
| \`mesh_read_chat\` | Read an agent's recent messages to check progress |
|
|
131
133
|
| \`mesh_git_status\` | Check git status on a specific node |
|
|
132
134
|
| \`mesh_checkpoint\` | Create a git checkpoint on a node |
|
|
133
|
-
| \`mesh_approve\` | Approve/reject a pending agent action
|
|
135
|
+
| \`mesh_approve\` | Approve/reject a pending agent action |
|
|
136
|
+
| \`mesh_clone_node\` | Create a worktree node for isolated parallel branch work |
|
|
137
|
+
| \`mesh_remove_node\` | Remove a node (cleans up worktree if applicable) |`;
|
|
134
138
|
|
|
135
139
|
const WORKFLOW_SECTION = `## Orchestration Workflow
|
|
136
140
|
|
|
@@ -138,21 +142,32 @@ const WORKFLOW_SECTION = `## Orchestration Workflow
|
|
|
138
142
|
2. **Plan** — Decompose the user's request into independent tasks for parallel execution, or sequential tasks when dependencies exist.
|
|
139
143
|
3. **Delegate** — For each task:
|
|
140
144
|
a. Pick the best node (consider: health, dirty state, current workload).
|
|
141
|
-
b. If
|
|
142
|
-
c.
|
|
145
|
+
b. If you need branch isolation for parallel work, call \`mesh_clone_node\` to create a worktree node first.
|
|
146
|
+
c. If no session exists, call \`mesh_launch_session\` to start one.
|
|
147
|
+
d. Call \`mesh_send_task\` with a **complete, self-contained** instruction that includes all context the agent needs (file paths, line numbers, what to change, why). Do not send partial instructions expecting future follow-up.
|
|
143
148
|
4. **Monitor** — Periodically call \`mesh_read_chat\` to check progress. Handle approvals via \`mesh_approve\`.
|
|
144
149
|
5. **Verify** — When a task reports completion, call \`mesh_git_status\` to verify changes were made.
|
|
145
150
|
6. **Checkpoint** — Call \`mesh_checkpoint\` to save the work.
|
|
146
|
-
7. **
|
|
151
|
+
7. **Clean up** — Remove worktree nodes via \`mesh_remove_node\` after their work is merged or no longer needed.
|
|
152
|
+
8. **Report** — Summarize what was done, what changed, and any issues.`;
|
|
147
153
|
|
|
148
|
-
|
|
154
|
+
function buildRulesSection(coordinatorCliType?: string): string {
|
|
155
|
+
const coordinatorNote = coordinatorCliType
|
|
156
|
+
? `\n- **Coordinator runtime is not a delegation default.** This coordinator is running as \`${coordinatorCliType}\`, but delegated node sessions must follow the user's requested provider, not the coordinator's own runtime.`
|
|
157
|
+
: '';
|
|
158
|
+
|
|
159
|
+
return `## Rules
|
|
149
160
|
|
|
150
161
|
- **Minimize coordinator context.** The coordinator's job is routing, not implementing. Do not read source files, run commands, or analyze code directly — delegate all of that to node agents. Your context should stay lean.
|
|
151
162
|
- **Delegate analysis too.** If you need to understand a bug or explore the codebase, send that investigation as a task to a node. Do not do it yourself.
|
|
163
|
+
- **Respect explicit provider requests.** If the user names an agent/provider, pass the matching provider type to \`mesh_launch_session\`: Hermes → \`hermes-cli\`, Claude Code/Claude → \`claude-cli\`, Codex → \`codex-cli\`, Gemini → \`gemini-cli\`. Never substitute \`claude-cli\` just because the coordinator itself is Claude Code.
|
|
152
164
|
- **Front-load the task message.** When calling \`mesh_send_task\`, include everything the agent needs: what files to touch, what the problem is, what the fix should look like. The agent won't ask follow-up questions.
|
|
153
165
|
- **Don't inspect code.** Trust the agent's output. Verify via \`mesh_git_status\`, not by reading source files.
|
|
154
166
|
- **Don't over-parallelize.** Start with 1-2 concurrent tasks. Scale up if they succeed.
|
|
155
167
|
- **Handle failures gracefully.** If a task fails, read the chat to understand why, then retry or reassign.
|
|
156
168
|
- **Keep the user informed.** Report progress after each delegation round — one or two sentences, not a narration.
|
|
157
169
|
- **Respect node capabilities.** Don't send build tasks to read-only nodes. Don't push from nodes that aren't allowed to.
|
|
158
|
-
- **Never fabricate tool results.** Always call the actual tool; never pretend you did
|
|
170
|
+
- **Never fabricate tool results.** Always call the actual tool; never pretend you did.
|
|
171
|
+
- **Clean up worktree nodes.** After a worktree task completes and its changes are merged or checkpointed, call \`mesh_remove_node\` to free resources.
|
|
172
|
+
- **Name worktree branches meaningfully.** Use descriptive names like \`feat/auth-refactor\` or \`fix/build-123\`.${coordinatorNote}`;
|
|
173
|
+
}
|
package/src/repo-mesh-types.ts
CHANGED
|
@@ -189,6 +189,10 @@ export interface LocalMeshNodeEntry {
|
|
|
189
189
|
policy: RepoMeshNodePolicy;
|
|
190
190
|
/** For single-machine mesh: same daemon, different worktree */
|
|
191
191
|
isLocalWorktree?: boolean;
|
|
192
|
+
/** Branch this worktree tracks (set when created via clone_mesh_node) */
|
|
193
|
+
worktreeBranch?: string;
|
|
194
|
+
/** Node ID this worktree was cloned from */
|
|
195
|
+
clonedFromNodeId?: string;
|
|
192
196
|
}
|
|
193
197
|
|
|
194
198
|
// ─── Mesh Status (runtime, not persisted) ───────
|