@korso/shepherd 0.1.0 → 0.3.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/README.md +4 -4
- package/dist/index.js +646 -85
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @korso/shepherd — Shepherd MCP Server
|
|
2
2
|
|
|
3
|
-
Shepherd's stdio MCP server. Gives any MCP-capable agent (Claude Code, Codex, etc.)
|
|
3
|
+
Shepherd's stdio MCP server. Gives any MCP-capable agent (Claude Code, Codex, etc.) four advisory coordination tools backed by the shared hub: `work`, `done`, `announce`, and `sync`. The agent **joins the workspace automatically** on startup (no `join` tool), and the server ships standing instructions so the agent self-coordinates without the user prompting it.
|
|
4
4
|
|
|
5
5
|
> **New here?** The [developer quickstart](https://github.com/Korsoai/shepherd/blob/main/docs/shepherd-mcp-quickstart.md) is the fastest path. TL;DR: `npx -y @korso/shepherd` with the env vars below.
|
|
6
6
|
|
|
@@ -10,7 +10,7 @@ Shepherd's stdio MCP server. Gives any MCP-capable agent (Claude Code, Codex, et
|
|
|
10
10
|
|
|
11
11
|
> **Everyone must set `WORKSPACE` to the identical string, and that string must equal the hub's `ALLOWED_WORKSPACE` env var.**
|
|
12
12
|
|
|
13
|
-
If `WORKSPACE` does not match, the server's `join` call to the hub returns HTTP 400
|
|
13
|
+
If `WORKSPACE` does not match, the server's automatic `join` call to the hub (fired at startup) returns HTTP 400. Coordination then degrades: every tool reports "session not ready … proceeding uncoordinated" instead of a landscape. This is the most common silent onboarding mistake — if your agent never sees teammates, check `WORKSPACE` first.
|
|
14
14
|
|
|
15
15
|
---
|
|
16
16
|
|
|
@@ -156,7 +156,7 @@ No stderr output and the process blocking on stdin = healthy. Press Ctrl+C to ex
|
|
|
156
156
|
|
|
157
157
|
and the process exits 1 immediately. This is by design.
|
|
158
158
|
|
|
159
|
-
**Wrong WORKSPACE:** the server starts and connects, but the
|
|
159
|
+
**Wrong WORKSPACE:** the server starts and connects, but the startup auto-join is rejected (400), so every tool call (`work`, `sync`, etc.) reports "session not ready … proceeding uncoordinated". Check that your `WORKSPACE` value exactly matches the hub's `ALLOWED_WORKSPACE`, then restart.
|
|
160
160
|
|
|
161
161
|
---
|
|
162
162
|
|
|
@@ -198,6 +198,6 @@ npm publish --workspace=@korso/shepherd # prepublishOnly runs tsup automatical
|
|
|
198
198
|
| Symptom | Likely cause | Fix |
|
|
199
199
|
|---|---|---|
|
|
200
200
|
| `Configuration error — missing or invalid env vars` | One or more of the 8 env vars is absent | Add the missing vars to your client's `env` block |
|
|
201
|
-
|
|
|
201
|
+
| Tools report "session not ready … proceeding uncoordinated" | Startup auto-join rejected — usually `WORKSPACE` mismatch (or stale `TEAM_TOKEN`) | Set `WORKSPACE` to exactly match the hub's `ALLOWED_WORKSPACE`; re-check `TEAM_TOKEN`; restart |
|
|
202
202
|
| `npm error 404 … @korso/shepherd` | Package not published yet, or name typo | `npm view @korso/shepherd version` to confirm it's live |
|
|
203
203
|
| Process exits immediately with no error | Rare; check for node version incompatibility | Requires Node 18+ (ESM support) |
|
package/dist/index.js
CHANGED
|
@@ -7,14 +7,20 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
7
7
|
// src/config.ts
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
var ConfigSchema = z.object({
|
|
10
|
+
// Hard-required: connection credentials.
|
|
10
11
|
HUB_URL: z.string().min(1, "HUB_URL is required"),
|
|
11
12
|
TEAM_TOKEN: z.string().min(1, "TEAM_TOKEN is required"),
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
// Optional overrides — resolveContext will apply defaults for any that are absent.
|
|
14
|
+
// WORKSPACE default is applied in resolveContext (auto-detected from cwd basename).
|
|
15
|
+
WORKSPACE: z.string().min(1).optional(),
|
|
16
|
+
REPO: z.string().min(1).optional(),
|
|
17
|
+
BRANCH: z.string().min(1).optional(),
|
|
18
|
+
BASE_BRANCH: z.string().min(1).optional(),
|
|
19
|
+
HUMAN: z.string().min(1).optional(),
|
|
20
|
+
PROGRAM: z.string().min(1).optional(),
|
|
21
|
+
MODEL: z.string().min(1).optional(),
|
|
22
|
+
// Heartbeat cadence in seconds; coerced from string env var.
|
|
23
|
+
HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().int().positive().default(60)
|
|
18
24
|
});
|
|
19
25
|
function parseConfig(env) {
|
|
20
26
|
return ConfigSchema.parse({
|
|
@@ -23,9 +29,11 @@ function parseConfig(env) {
|
|
|
23
29
|
WORKSPACE: env["WORKSPACE"],
|
|
24
30
|
REPO: env["REPO"],
|
|
25
31
|
BRANCH: env["BRANCH"],
|
|
32
|
+
BASE_BRANCH: env["BASE_BRANCH"],
|
|
26
33
|
HUMAN: env["HUMAN"],
|
|
27
34
|
PROGRAM: env["PROGRAM"],
|
|
28
|
-
MODEL: env["MODEL"]
|
|
35
|
+
MODEL: env["MODEL"],
|
|
36
|
+
HEARTBEAT_INTERVAL_SECONDS: env["HEARTBEAT_INTERVAL_SECONDS"]
|
|
29
37
|
});
|
|
30
38
|
}
|
|
31
39
|
function loadConfig(env = process.env) {
|
|
@@ -71,12 +79,12 @@ function createHubClient({
|
|
|
71
79
|
}) {
|
|
72
80
|
const baseUrl = hubUrl.replace(/\/$/, "");
|
|
73
81
|
return {
|
|
74
|
-
async post(
|
|
82
|
+
async post(path2, body) {
|
|
75
83
|
const controller = new AbortController();
|
|
76
84
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
77
85
|
let response;
|
|
78
86
|
try {
|
|
79
|
-
response = await fetch(`${baseUrl}${
|
|
87
|
+
response = await fetch(`${baseUrl}${path2}`, {
|
|
80
88
|
method: "POST",
|
|
81
89
|
headers: {
|
|
82
90
|
"Authorization": `Bearer ${teamToken}`,
|
|
@@ -87,7 +95,7 @@ function createHubClient({
|
|
|
87
95
|
});
|
|
88
96
|
} catch (err) {
|
|
89
97
|
clearTimeout(timer);
|
|
90
|
-
const message = err instanceof DOMException && err.name === "AbortError" ? `Hub request timed out after ${timeoutMs}ms (${
|
|
98
|
+
const message = err instanceof DOMException && err.name === "AbortError" ? `Hub request timed out after ${timeoutMs}ms (${path2})` : `Hub unreachable at ${baseUrl}${path2}: ${String(err)}`;
|
|
91
99
|
throw new HubUnreachable(message, err);
|
|
92
100
|
} finally {
|
|
93
101
|
clearTimeout(timer);
|
|
@@ -95,7 +103,7 @@ function createHubClient({
|
|
|
95
103
|
if (!response.ok) {
|
|
96
104
|
throw new HubRequestError(
|
|
97
105
|
response.status,
|
|
98
|
-
`Hub returned HTTP ${response.status} for ${
|
|
106
|
+
`Hub returned HTTP ${response.status} for ${path2}`
|
|
99
107
|
);
|
|
100
108
|
}
|
|
101
109
|
return response.json();
|
|
@@ -103,10 +111,145 @@ function createHubClient({
|
|
|
103
111
|
};
|
|
104
112
|
}
|
|
105
113
|
|
|
114
|
+
// ../shared/dist/names.js
|
|
115
|
+
var adjectives = [
|
|
116
|
+
"Able",
|
|
117
|
+
"Agile",
|
|
118
|
+
"Artful",
|
|
119
|
+
"Avid",
|
|
120
|
+
"Balanced",
|
|
121
|
+
"Brave",
|
|
122
|
+
"Bright",
|
|
123
|
+
"Brisk",
|
|
124
|
+
"Calm",
|
|
125
|
+
"Clear",
|
|
126
|
+
"Clever",
|
|
127
|
+
"Crisp",
|
|
128
|
+
"Daring",
|
|
129
|
+
"Diligent",
|
|
130
|
+
"Deft",
|
|
131
|
+
"Deep",
|
|
132
|
+
"Dynamic",
|
|
133
|
+
"Eager",
|
|
134
|
+
"Earnest",
|
|
135
|
+
"Elegant",
|
|
136
|
+
"Energetic",
|
|
137
|
+
"Fair",
|
|
138
|
+
"Faithful",
|
|
139
|
+
"Fertile",
|
|
140
|
+
"Fierce",
|
|
141
|
+
"Firm",
|
|
142
|
+
"Fleet",
|
|
143
|
+
"Frank",
|
|
144
|
+
"Fresh",
|
|
145
|
+
"Friendly",
|
|
146
|
+
"Frisky",
|
|
147
|
+
"Gentle",
|
|
148
|
+
"Giant",
|
|
149
|
+
"Gifted",
|
|
150
|
+
"Global",
|
|
151
|
+
"Golden",
|
|
152
|
+
"Good",
|
|
153
|
+
"Grace",
|
|
154
|
+
"Grand",
|
|
155
|
+
"Green"
|
|
156
|
+
];
|
|
157
|
+
var nouns = [
|
|
158
|
+
"Anchor",
|
|
159
|
+
"Arrow",
|
|
160
|
+
"Beacon",
|
|
161
|
+
"Bear",
|
|
162
|
+
"Beast",
|
|
163
|
+
"Bell",
|
|
164
|
+
"Blade",
|
|
165
|
+
"Blaze",
|
|
166
|
+
"Bridge",
|
|
167
|
+
"Bronze",
|
|
168
|
+
"Brook",
|
|
169
|
+
"Builder",
|
|
170
|
+
"Buzz",
|
|
171
|
+
"Castle",
|
|
172
|
+
"Cedar",
|
|
173
|
+
"Chain",
|
|
174
|
+
"Charm",
|
|
175
|
+
"Chase",
|
|
176
|
+
"Cliff",
|
|
177
|
+
"Cloud",
|
|
178
|
+
"Coast",
|
|
179
|
+
"Compass",
|
|
180
|
+
"Crown",
|
|
181
|
+
"Crystal",
|
|
182
|
+
"Current",
|
|
183
|
+
"Eagle",
|
|
184
|
+
"Earth",
|
|
185
|
+
"Echo",
|
|
186
|
+
"Edge",
|
|
187
|
+
"Element",
|
|
188
|
+
"Ember",
|
|
189
|
+
"Engine",
|
|
190
|
+
"Fable",
|
|
191
|
+
"Falcon",
|
|
192
|
+
"Fate",
|
|
193
|
+
"Fawn",
|
|
194
|
+
"Feather",
|
|
195
|
+
"Fiber",
|
|
196
|
+
"Field",
|
|
197
|
+
"Fire",
|
|
198
|
+
"Fisher",
|
|
199
|
+
"Flame",
|
|
200
|
+
"Flash",
|
|
201
|
+
"Fleet",
|
|
202
|
+
"Flight",
|
|
203
|
+
"Flint",
|
|
204
|
+
"Flood",
|
|
205
|
+
"Flow"
|
|
206
|
+
];
|
|
207
|
+
function generateName() {
|
|
208
|
+
const randomAdj = adjectives[Math.floor(Math.random() * adjectives.length)];
|
|
209
|
+
const randomNoun = nouns[Math.floor(Math.random() * nouns.length)];
|
|
210
|
+
return randomAdj + randomNoun;
|
|
211
|
+
}
|
|
212
|
+
|
|
106
213
|
// ../shared/dist/contract.js
|
|
107
214
|
import { z as z2 } from "zod";
|
|
108
215
|
var IsoTimestamp = z2.string();
|
|
109
216
|
var DbId = z2.number();
|
|
217
|
+
var ChangeRecord = z2.object({
|
|
218
|
+
agentName: z2.string(),
|
|
219
|
+
human: z2.string(),
|
|
220
|
+
branch: z2.string(),
|
|
221
|
+
kind: z2.enum(["committed", "uncommitted"]),
|
|
222
|
+
commitSha: z2.string().nullable(),
|
|
223
|
+
message: z2.string().nullable(),
|
|
224
|
+
paths: z2.array(z2.string()).min(1),
|
|
225
|
+
authorIsLive: z2.boolean(),
|
|
226
|
+
authorLastActiveAt: IsoTimestamp,
|
|
227
|
+
updatedAt: IsoTimestamp
|
|
228
|
+
});
|
|
229
|
+
var ChangeReportEntry = z2.object({
|
|
230
|
+
kind: z2.enum(["committed", "uncommitted"]),
|
|
231
|
+
// A git object id (lowercase hex, 4–64 chars) for `committed` entries, or null
|
|
232
|
+
// for `uncommitted`. This value is forwarded by the hub to OTHER clients, which
|
|
233
|
+
// feed it straight into local `git` argument vectors (isAncestor/hasCommit/
|
|
234
|
+
// changedLineRanges). Validating the shape at the wire boundary stops an
|
|
235
|
+
// attacker-controlled, flag-like value (e.g. "--output=...") from being parsed
|
|
236
|
+
// by git as an option on a teammate's machine (argument injection). gitContext
|
|
237
|
+
// re-validates defensively as well.
|
|
238
|
+
sha: z2.string().regex(/^[0-9a-f]{4,64}$/).nullable(),
|
|
239
|
+
message: z2.string().nullable(),
|
|
240
|
+
paths: z2.array(z2.string()).min(1).max(500)
|
|
241
|
+
});
|
|
242
|
+
var ChangeReport = z2.object({
|
|
243
|
+
branch: z2.string(),
|
|
244
|
+
baseBranch: z2.string(),
|
|
245
|
+
head: z2.string(),
|
|
246
|
+
truncated: z2.boolean().default(false),
|
|
247
|
+
// The only producer (gitContext.unlandedCommits) emits at most MAX_COMMITS
|
|
248
|
+
// (100) committed entries + 1 uncommitted, so this ceiling is generous. If
|
|
249
|
+
// MAX_COMMITS is ever raised above ~599, raise this in lockstep or the hub
|
|
250
|
+
// will start 400-rejecting otherwise-valid reports.
|
|
251
|
+
entries: z2.array(ChangeReportEntry).max(600)
|
|
252
|
+
});
|
|
110
253
|
var Claim = z2.object({
|
|
111
254
|
workItemId: z2.string().uuid(),
|
|
112
255
|
agentName: z2.string(),
|
|
@@ -134,7 +277,9 @@ var Landscape = z2.object({
|
|
|
134
277
|
// claim is live. Optional with a default so an older client talking to a
|
|
135
278
|
// newer hub (or vice-versa) never fails validation on its absence.
|
|
136
279
|
yourClaims: z2.array(Claim).default([]),
|
|
137
|
-
announcements: z2.array(Announcement)
|
|
280
|
+
announcements: z2.array(Announcement),
|
|
281
|
+
// Per-agent change records for the workspace. Defaulted for version-skew safety.
|
|
282
|
+
changeRecords: z2.array(ChangeRecord).default([])
|
|
138
283
|
});
|
|
139
284
|
var JoinRequest = z2.object({
|
|
140
285
|
workspace: z2.string().min(1),
|
|
@@ -142,7 +287,7 @@ var JoinRequest = z2.object({
|
|
|
142
287
|
branch: z2.string().min(1),
|
|
143
288
|
human: z2.string().min(1),
|
|
144
289
|
program: z2.string().min(1),
|
|
145
|
-
model: z2.string().min(1)
|
|
290
|
+
model: z2.string().min(1).optional()
|
|
146
291
|
});
|
|
147
292
|
var JoinResponse = z2.object({
|
|
148
293
|
agentName: z2.string(),
|
|
@@ -152,7 +297,8 @@ var WorkRequest = z2.object({
|
|
|
152
297
|
sessionId: z2.string().uuid(),
|
|
153
298
|
intent: z2.string().min(1).max(2048),
|
|
154
299
|
pathGlobs: z2.array(z2.string().min(1).max(512)).min(1).max(64),
|
|
155
|
-
ttlSeconds: z2.number().int().positive().optional()
|
|
300
|
+
ttlSeconds: z2.number().int().positive().optional(),
|
|
301
|
+
changeReport: ChangeReport.optional()
|
|
156
302
|
});
|
|
157
303
|
var WorkResponse = z2.object({
|
|
158
304
|
workItemId: z2.string().uuid(),
|
|
@@ -177,16 +323,288 @@ var AnnounceResponse = z2.object({
|
|
|
177
323
|
announcementId: DbId
|
|
178
324
|
});
|
|
179
325
|
var SyncRequest = z2.object({
|
|
180
|
-
sessionId: z2.string().uuid()
|
|
326
|
+
sessionId: z2.string().uuid(),
|
|
327
|
+
changeReport: ChangeReport.optional()
|
|
181
328
|
});
|
|
182
329
|
var SyncResponse = z2.object({
|
|
183
330
|
landscape: Landscape
|
|
184
331
|
});
|
|
185
|
-
var WorkAgentInput = WorkRequest.omit({ sessionId: true });
|
|
332
|
+
var WorkAgentInput = WorkRequest.omit({ sessionId: true, changeReport: true });
|
|
186
333
|
var AnnounceAgentInput = AnnounceRequest.omit({ sessionId: true });
|
|
187
334
|
var DoneAgentInput = DoneRequest.omit({ sessionId: true });
|
|
188
335
|
var JoinAgentInput = z2.object({});
|
|
189
336
|
var SyncAgentInput = z2.object({});
|
|
337
|
+
var HeartbeatRequest = z2.object({
|
|
338
|
+
sessionId: z2.string().uuid()
|
|
339
|
+
});
|
|
340
|
+
var HeartbeatResponse = z2.object({
|
|
341
|
+
ok: z2.literal(true)
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// src/gitContext.ts
|
|
345
|
+
import { execFileSync } from "child_process";
|
|
346
|
+
import * as path from "path";
|
|
347
|
+
var GIT_TIMEOUT_MS = 2e3;
|
|
348
|
+
var MAX_COMMITS = 100;
|
|
349
|
+
var MAX_PATHS_PER_COMMIT = 500;
|
|
350
|
+
var MAX_DIRTY_PATHS = 500;
|
|
351
|
+
var MAX_LINE_RANGE_PATHS = 50;
|
|
352
|
+
function runGit(cwd, args) {
|
|
353
|
+
try {
|
|
354
|
+
const out = execFileSync("git", args, {
|
|
355
|
+
cwd,
|
|
356
|
+
encoding: "utf8",
|
|
357
|
+
timeout: GIT_TIMEOUT_MS,
|
|
358
|
+
// Keep git from prompting for credentials/editors and hanging the timeout.
|
|
359
|
+
windowsHide: true,
|
|
360
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
361
|
+
// Capture stdout; silence stderr so failures stay quiet (we fail open).
|
|
362
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
363
|
+
});
|
|
364
|
+
return out.trim();
|
|
365
|
+
} catch {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function runGitExitOk(cwd, args) {
|
|
370
|
+
try {
|
|
371
|
+
execFileSync("git", args, {
|
|
372
|
+
cwd,
|
|
373
|
+
encoding: "utf8",
|
|
374
|
+
timeout: GIT_TIMEOUT_MS,
|
|
375
|
+
windowsHide: true,
|
|
376
|
+
stdio: "ignore"
|
|
377
|
+
});
|
|
378
|
+
return true;
|
|
379
|
+
} catch {
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
function isValidSha(sha) {
|
|
384
|
+
return /^[0-9a-f]{4,64}$/.test(sha);
|
|
385
|
+
}
|
|
386
|
+
function normalizeRemoteUrl(url) {
|
|
387
|
+
let s = url.trim();
|
|
388
|
+
if (!s) return null;
|
|
389
|
+
s = s.replace(/\.git$/, "");
|
|
390
|
+
const scp = s.match(/^[^/@]+@[^:]+:(.+)$/);
|
|
391
|
+
if (scp) {
|
|
392
|
+
s = scp[1];
|
|
393
|
+
} else {
|
|
394
|
+
s = s.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "");
|
|
395
|
+
const slash = s.indexOf("/");
|
|
396
|
+
if (slash !== -1) {
|
|
397
|
+
s = s.slice(slash + 1);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
s = s.replace(/^\/+|\/+$/g, "");
|
|
401
|
+
const segments = s.split("/").filter(Boolean);
|
|
402
|
+
if (segments.length < 2) return null;
|
|
403
|
+
const owner = segments[segments.length - 2];
|
|
404
|
+
const repo = segments[segments.length - 1];
|
|
405
|
+
return `${owner}/${repo}`;
|
|
406
|
+
}
|
|
407
|
+
function detectRepo(cwd = process.cwd()) {
|
|
408
|
+
const origin = runGit(cwd, ["config", "--get", "remote.origin.url"]);
|
|
409
|
+
if (origin) {
|
|
410
|
+
const normalized = normalizeRemoteUrl(origin);
|
|
411
|
+
if (normalized) return normalized;
|
|
412
|
+
}
|
|
413
|
+
const top = runGit(cwd, ["rev-parse", "--show-toplevel"]);
|
|
414
|
+
if (top) {
|
|
415
|
+
const base = path.basename(top);
|
|
416
|
+
if (base) return base;
|
|
417
|
+
}
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
function detectBranch(cwd = process.cwd()) {
|
|
421
|
+
const branch = runGit(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
422
|
+
if (branch === null || branch === "") return null;
|
|
423
|
+
return branch;
|
|
424
|
+
}
|
|
425
|
+
function detectHuman(cwd = process.cwd()) {
|
|
426
|
+
if (!runGitExitOk(cwd, ["rev-parse", "--is-inside-work-tree"])) {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
const name = runGit(cwd, ["config", "user.name"]);
|
|
430
|
+
if (name) return name;
|
|
431
|
+
const email = runGit(cwd, ["config", "user.email"]);
|
|
432
|
+
if (email) {
|
|
433
|
+
const local = email.split("@")[0];
|
|
434
|
+
if (local) return local;
|
|
435
|
+
}
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
function detectBaseBranch(cwd = process.cwd()) {
|
|
439
|
+
const symref = runGit(cwd, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]);
|
|
440
|
+
if (symref) {
|
|
441
|
+
const stripped = symref.replace(/^refs\/remotes\//, "");
|
|
442
|
+
if (stripped) return stripped;
|
|
443
|
+
}
|
|
444
|
+
for (const candidate of ["origin/main", "origin/master"]) {
|
|
445
|
+
if (runGitExitOk(cwd, ["rev-parse", "--verify", "--quiet", `refs/remotes/${candidate}`])) {
|
|
446
|
+
return candidate;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
function headSha(cwd = process.cwd()) {
|
|
452
|
+
const sha = runGit(cwd, ["rev-parse", "HEAD"]);
|
|
453
|
+
if (sha === null || sha === "") return null;
|
|
454
|
+
return sha;
|
|
455
|
+
}
|
|
456
|
+
function unlandedCommits(cwd = process.cwd(), baseBranch) {
|
|
457
|
+
if (!baseBranch || baseBranch.startsWith("-")) {
|
|
458
|
+
return { commits: [], truncated: false };
|
|
459
|
+
}
|
|
460
|
+
const out = runGit(cwd, [
|
|
461
|
+
"log",
|
|
462
|
+
`${baseBranch}..HEAD`,
|
|
463
|
+
"--name-only",
|
|
464
|
+
`--max-count=${MAX_COMMITS}`,
|
|
465
|
+
"--format=%x01%H%x00%s"
|
|
466
|
+
]);
|
|
467
|
+
if (out === null) {
|
|
468
|
+
return { commits: [], truncated: false };
|
|
469
|
+
}
|
|
470
|
+
if (out === "") {
|
|
471
|
+
return { commits: [], truncated: false };
|
|
472
|
+
}
|
|
473
|
+
let truncated = false;
|
|
474
|
+
const commits = [];
|
|
475
|
+
const records = out.split("").filter((r) => r.length > 0);
|
|
476
|
+
for (const record of records) {
|
|
477
|
+
const newlineIdx = record.indexOf("\n");
|
|
478
|
+
const header = newlineIdx === -1 ? record : record.slice(0, newlineIdx);
|
|
479
|
+
const rest = newlineIdx === -1 ? "" : record.slice(newlineIdx + 1);
|
|
480
|
+
const nulIdx = header.indexOf("\0");
|
|
481
|
+
const sha = (nulIdx === -1 ? header : header.slice(0, nulIdx)).trim();
|
|
482
|
+
const message = nulIdx === -1 ? "" : header.slice(nulIdx + 1);
|
|
483
|
+
if (!sha) continue;
|
|
484
|
+
let paths = rest.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
|
|
485
|
+
if (paths.length > MAX_PATHS_PER_COMMIT) {
|
|
486
|
+
paths = paths.slice(0, MAX_PATHS_PER_COMMIT);
|
|
487
|
+
truncated = true;
|
|
488
|
+
}
|
|
489
|
+
if (paths.length === 0) continue;
|
|
490
|
+
commits.push({ sha, message, paths });
|
|
491
|
+
}
|
|
492
|
+
if (commits.length >= MAX_COMMITS) {
|
|
493
|
+
truncated = true;
|
|
494
|
+
}
|
|
495
|
+
return { commits, truncated };
|
|
496
|
+
}
|
|
497
|
+
function dirtyPaths(cwd = process.cwd()) {
|
|
498
|
+
const out = runGit(cwd, ["status", "--porcelain", "-z", "--untracked-files=all"]);
|
|
499
|
+
if (out === null) {
|
|
500
|
+
return { paths: [], truncated: false };
|
|
501
|
+
}
|
|
502
|
+
const seen = /* @__PURE__ */ new Set();
|
|
503
|
+
const fields = out.split("\0").filter((f) => f.length > 0);
|
|
504
|
+
for (let i = 0; i < fields.length; i++) {
|
|
505
|
+
const field = fields[i];
|
|
506
|
+
const status = field.slice(0, 2);
|
|
507
|
+
const rest = field.slice(2).replace(/^\s+/, "");
|
|
508
|
+
if (rest) seen.add(rest);
|
|
509
|
+
if (/[RC]/.test(status)) {
|
|
510
|
+
const src = fields[i + 1];
|
|
511
|
+
if (src) {
|
|
512
|
+
seen.add(src);
|
|
513
|
+
i++;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
let paths = Array.from(seen);
|
|
518
|
+
let truncated = false;
|
|
519
|
+
if (paths.length > MAX_DIRTY_PATHS) {
|
|
520
|
+
paths = paths.slice(0, MAX_DIRTY_PATHS);
|
|
521
|
+
truncated = true;
|
|
522
|
+
}
|
|
523
|
+
return { paths, truncated };
|
|
524
|
+
}
|
|
525
|
+
function isAncestor(cwd = process.cwd(), sha) {
|
|
526
|
+
if (!isValidSha(sha)) return false;
|
|
527
|
+
return runGitExitOk(cwd, ["merge-base", "--is-ancestor", sha, "HEAD"]);
|
|
528
|
+
}
|
|
529
|
+
function hasCommit(cwd = process.cwd(), sha) {
|
|
530
|
+
if (!isValidSha(sha)) return false;
|
|
531
|
+
return runGitExitOk(cwd, ["cat-file", "-e", `${sha}^{commit}`]);
|
|
532
|
+
}
|
|
533
|
+
function parseHunkRanges(diff) {
|
|
534
|
+
const ranges = [];
|
|
535
|
+
const re = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm;
|
|
536
|
+
let m;
|
|
537
|
+
while ((m = re.exec(diff)) !== null) {
|
|
538
|
+
const start = parseInt(m[1], 10);
|
|
539
|
+
const count = m[2] === void 0 ? 1 : parseInt(m[2], 10);
|
|
540
|
+
if (count <= 0) {
|
|
541
|
+
ranges.push({ start, end: start });
|
|
542
|
+
} else {
|
|
543
|
+
ranges.push({ start, end: start + count - 1 });
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return ranges;
|
|
547
|
+
}
|
|
548
|
+
function changedLineRanges(cwd = process.cwd(), sha, paths) {
|
|
549
|
+
if (!isValidSha(sha) || !paths || paths.length === 0) return {};
|
|
550
|
+
const result = {};
|
|
551
|
+
const capped = paths.length > MAX_LINE_RANGE_PATHS ? paths.slice(0, MAX_LINE_RANGE_PATHS) : paths;
|
|
552
|
+
for (const p of capped) {
|
|
553
|
+
let diff = runGit(cwd, ["diff", "--unified=0", `${sha}~1`, sha, "--", p]);
|
|
554
|
+
if (diff === null) {
|
|
555
|
+
diff = runGit(cwd, ["show", "--unified=0", "--format=", sha, "--", p]);
|
|
556
|
+
}
|
|
557
|
+
if (diff === null || diff === "") continue;
|
|
558
|
+
const ranges = parseHunkRanges(diff);
|
|
559
|
+
if (ranges.length > 0) {
|
|
560
|
+
result[p] = ranges;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return result;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// src/changeReport.ts
|
|
567
|
+
var UNRESOLVED_BASE = "(unknown)";
|
|
568
|
+
async function buildChangeReport(cwd, config) {
|
|
569
|
+
const branch = detectBranch(cwd);
|
|
570
|
+
const head = headSha(cwd);
|
|
571
|
+
if (branch === null && head === null) {
|
|
572
|
+
return void 0;
|
|
573
|
+
}
|
|
574
|
+
const base = config.BASE_BRANCH ?? detectBaseBranch(cwd);
|
|
575
|
+
const entries = [];
|
|
576
|
+
let truncated = false;
|
|
577
|
+
const dirty = dirtyPaths(cwd);
|
|
578
|
+
if (dirty.truncated) truncated = true;
|
|
579
|
+
if (dirty.paths.length > 0) {
|
|
580
|
+
entries.push({
|
|
581
|
+
kind: "uncommitted",
|
|
582
|
+
sha: null,
|
|
583
|
+
message: null,
|
|
584
|
+
paths: dirty.paths
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
if (base) {
|
|
588
|
+
const unlanded = unlandedCommits(cwd, base);
|
|
589
|
+
if (unlanded.truncated) truncated = true;
|
|
590
|
+
for (const c of unlanded.commits) {
|
|
591
|
+
if (c.paths.length === 0) continue;
|
|
592
|
+
entries.push({
|
|
593
|
+
kind: "committed",
|
|
594
|
+
sha: c.sha,
|
|
595
|
+
message: c.message,
|
|
596
|
+
paths: c.paths
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return {
|
|
601
|
+
branch: branch ?? "HEAD",
|
|
602
|
+
baseBranch: base ?? UNRESOLVED_BASE,
|
|
603
|
+
head: head ?? "",
|
|
604
|
+
truncated,
|
|
605
|
+
entries
|
|
606
|
+
};
|
|
607
|
+
}
|
|
190
608
|
|
|
191
609
|
// src/tools.ts
|
|
192
610
|
function formatLandscape(landscape) {
|
|
@@ -233,6 +651,56 @@ function formatLandscape(landscape) {
|
|
|
233
651
|
}
|
|
234
652
|
return lines.join("\n");
|
|
235
653
|
}
|
|
654
|
+
function relativeAge(iso) {
|
|
655
|
+
const then = Date.parse(iso);
|
|
656
|
+
if (Number.isNaN(then)) return "recently";
|
|
657
|
+
const ms = Date.now() - then;
|
|
658
|
+
if (ms < 0) return "just now";
|
|
659
|
+
const mins = Math.floor(ms / 6e4);
|
|
660
|
+
if (mins < 1) return "just now";
|
|
661
|
+
if (mins < 60) return `${mins}m ago`;
|
|
662
|
+
const hours = Math.floor(mins / 60);
|
|
663
|
+
if (hours < 24) return `${hours}h ago`;
|
|
664
|
+
const days = Math.floor(hours / 24);
|
|
665
|
+
return `${days}d ago`;
|
|
666
|
+
}
|
|
667
|
+
function presence(rec) {
|
|
668
|
+
return rec.authorIsLive ? "active now" : `offline, last seen ${relativeAge(rec.authorLastActiveAt)}`;
|
|
669
|
+
}
|
|
670
|
+
function formatChangeRecords(records, cwd = process.cwd()) {
|
|
671
|
+
if (!records || records.length === 0) return "";
|
|
672
|
+
let lineRangeBudget = MAX_LINE_RANGE_PATHS;
|
|
673
|
+
const lines = [];
|
|
674
|
+
for (const rec of records) {
|
|
675
|
+
if (rec.kind === "committed") {
|
|
676
|
+
if (rec.commitSha && isAncestor(cwd, rec.commitSha)) continue;
|
|
677
|
+
const intent = rec.message ?? "(work in progress)";
|
|
678
|
+
lines.push(
|
|
679
|
+
` ${rec.agentName} / ${rec.human} (${presence(rec)}) \u2014 committed (not yet on your base): "${intent}"`
|
|
680
|
+
);
|
|
681
|
+
lines.push(` files: ${rec.paths.join(", ")}`);
|
|
682
|
+
if (rec.commitSha && lineRangeBudget > 0 && hasCommit(cwd, rec.commitSha)) {
|
|
683
|
+
const budgetedPaths = rec.paths.slice(0, lineRangeBudget);
|
|
684
|
+
lineRangeBudget -= budgetedPaths.length;
|
|
685
|
+
const ranges = changedLineRanges(cwd, rec.commitSha, budgetedPaths);
|
|
686
|
+
for (const p of Object.keys(ranges)) {
|
|
687
|
+
const spans = ranges[p].map((r) => r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`);
|
|
688
|
+
if (spans.length > 0) {
|
|
689
|
+
lines.push(` ${p}: lines ${spans.join(", ")} (for context)`);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
} else {
|
|
694
|
+
const claim = rec.message ?? "uncommitted edits in progress";
|
|
695
|
+
lines.push(
|
|
696
|
+
` ${rec.agentName} / ${rec.human} (${presence(rec)}) \u2014 ${claim} (uncommitted, may change)`
|
|
697
|
+
);
|
|
698
|
+
lines.push(` files: ${rec.paths.join(", ")}`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
if (lines.length === 0) return "";
|
|
702
|
+
return "Unlanded changes touching your area (awareness only \u2014 these are not blockers):\n" + lines.join("\n");
|
|
703
|
+
}
|
|
236
704
|
function degradedResult(err) {
|
|
237
705
|
const detail = err instanceof HubUnreachable || err instanceof HubRequestError ? err.message : String(err);
|
|
238
706
|
return {
|
|
@@ -245,73 +713,87 @@ function degradedResult(err) {
|
|
|
245
713
|
};
|
|
246
714
|
}
|
|
247
715
|
function registerTools(server, deps) {
|
|
248
|
-
const { hubClient, config } = deps;
|
|
716
|
+
const { hubClient, config, context, heartbeat } = deps;
|
|
249
717
|
let sessionId = null;
|
|
250
|
-
let
|
|
718
|
+
let agentName = null;
|
|
719
|
+
const joinBody = {
|
|
720
|
+
workspace: context.workspace,
|
|
721
|
+
repo: context.repo,
|
|
722
|
+
branch: context.branch,
|
|
723
|
+
human: context.human,
|
|
724
|
+
program: context.program
|
|
725
|
+
};
|
|
726
|
+
if (context.model !== void 0) {
|
|
727
|
+
joinBody.model = context.model;
|
|
728
|
+
}
|
|
729
|
+
const joinInFlight = hubClient.post("/join", joinBody).then((r) => {
|
|
730
|
+
sessionId = r.sessionId;
|
|
731
|
+
agentName = r.agentName;
|
|
732
|
+
heartbeat.start(r.sessionId);
|
|
733
|
+
}).catch(() => {
|
|
734
|
+
});
|
|
251
735
|
async function awaitJoin() {
|
|
252
|
-
|
|
736
|
+
await joinInFlight;
|
|
253
737
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
},
|
|
261
|
-
async (_args) => {
|
|
262
|
-
try {
|
|
263
|
-
const body = {
|
|
264
|
-
workspace: config.WORKSPACE,
|
|
265
|
-
repo: config.REPO,
|
|
266
|
-
branch: config.BRANCH,
|
|
267
|
-
human: config.HUMAN,
|
|
268
|
-
program: config.PROGRAM,
|
|
269
|
-
model: config.MODEL
|
|
270
|
-
};
|
|
271
|
-
const pending = hubClient.post("/join", body);
|
|
272
|
-
joinInFlight = pending.then((r) => {
|
|
273
|
-
sessionId = r.sessionId;
|
|
274
|
-
}).catch(() => {
|
|
275
|
-
});
|
|
276
|
-
const result = await pending;
|
|
277
|
-
sessionId = result.sessionId;
|
|
278
|
-
return {
|
|
279
|
-
content: [
|
|
280
|
-
{
|
|
281
|
-
type: "text",
|
|
282
|
-
text: `Joined as ${result.agentName}.`
|
|
283
|
-
}
|
|
284
|
-
]
|
|
285
|
-
};
|
|
286
|
-
} catch (err) {
|
|
287
|
-
if (err instanceof HubUnreachable || err instanceof HubRequestError) {
|
|
288
|
-
return degradedResult(err);
|
|
738
|
+
function sessionNotReady() {
|
|
739
|
+
return {
|
|
740
|
+
content: [
|
|
741
|
+
{
|
|
742
|
+
type: "text",
|
|
743
|
+
text: "Shepherd coordination session not ready (hub unreachable at startup) \u2014 proceeding uncoordinated."
|
|
289
744
|
}
|
|
290
|
-
|
|
291
|
-
|
|
745
|
+
]
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
function withIdentity(body) {
|
|
749
|
+
return agentName ? `You are ${agentName}.
|
|
750
|
+
|
|
751
|
+
${body}` : body;
|
|
752
|
+
}
|
|
753
|
+
async function changeReportForBody() {
|
|
754
|
+
try {
|
|
755
|
+
return await buildChangeReport(process.cwd(), config) ?? void 0;
|
|
756
|
+
} catch {
|
|
757
|
+
return void 0;
|
|
292
758
|
}
|
|
293
|
-
|
|
759
|
+
}
|
|
760
|
+
function withChangeRecords(landscape, body) {
|
|
761
|
+
let section = "";
|
|
762
|
+
try {
|
|
763
|
+
section = formatChangeRecords(landscape.changeRecords ?? [], process.cwd());
|
|
764
|
+
} catch {
|
|
765
|
+
section = "";
|
|
766
|
+
}
|
|
767
|
+
return section ? `${body}
|
|
768
|
+
|
|
769
|
+
${section}` : body;
|
|
770
|
+
}
|
|
294
771
|
server.registerTool(
|
|
295
772
|
"work",
|
|
296
773
|
{
|
|
297
774
|
title: "Claim a unit of work",
|
|
298
|
-
description:
|
|
775
|
+
description: 'Claim a unit of work BEFORE you start editing files in an area of the codebase (per unit of work, NOT per edit). Pass a one-line `intent` and the `pathGlobs` covering the files you expect to touch \u2014 scope them as specifically as you reasonably can (e.g. ["src/auth/**"], not ["src/**"] and not a single file). It atomically checks whether a teammate is already in those files and claims them for you, returning any conflicts and what others are working on. Hold one claim across all edits in that area; don\'t re-claim per file.',
|
|
299
776
|
inputSchema: WorkAgentInput.shape
|
|
300
777
|
},
|
|
301
778
|
async (args) => {
|
|
302
779
|
await awaitJoin();
|
|
303
780
|
if (sessionId === null) {
|
|
304
|
-
return
|
|
305
|
-
isError: true,
|
|
306
|
-
content: [{ type: "text", text: "Call join first \u2014 no active session." }]
|
|
307
|
-
};
|
|
781
|
+
return sessionNotReady();
|
|
308
782
|
}
|
|
309
783
|
try {
|
|
310
|
-
const
|
|
784
|
+
const changeReport = await changeReportForBody();
|
|
785
|
+
const body = { sessionId, ...args, ...changeReport ? { changeReport } : {} };
|
|
311
786
|
const result = await hubClient.post("/work", body);
|
|
312
|
-
const text =
|
|
787
|
+
const text = withIdentity(
|
|
788
|
+
withChangeRecords(
|
|
789
|
+
result.landscape,
|
|
790
|
+
`Work claimed (workItemId: ${result.workItemId})
|
|
791
|
+
|
|
792
|
+
` + formatLandscape(result.landscape) + `
|
|
313
793
|
|
|
314
|
-
|
|
794
|
+
You hold this claim until you call done (workItemId: ${result.workItemId}) or it expires (~30 min). Calling work or sync renews it.`
|
|
795
|
+
)
|
|
796
|
+
);
|
|
315
797
|
return { content: [{ type: "text", text }] };
|
|
316
798
|
} catch (err) {
|
|
317
799
|
if (err instanceof HubUnreachable || err instanceof HubRequestError) {
|
|
@@ -325,16 +807,13 @@ function registerTools(server, deps) {
|
|
|
325
807
|
"done",
|
|
326
808
|
{
|
|
327
809
|
title: "Release a work claim",
|
|
328
|
-
description: "Call when
|
|
810
|
+
description: "Call when a unit of work is complete to release your claim so teammates know the files are free. Pass the workItemId returned by the work tool.",
|
|
329
811
|
inputSchema: DoneAgentInput.shape
|
|
330
812
|
},
|
|
331
813
|
async (args) => {
|
|
332
814
|
await awaitJoin();
|
|
333
815
|
if (sessionId === null) {
|
|
334
|
-
return
|
|
335
|
-
isError: true,
|
|
336
|
-
content: [{ type: "text", text: "Call join first \u2014 no active session." }]
|
|
337
|
-
};
|
|
816
|
+
return sessionNotReady();
|
|
338
817
|
}
|
|
339
818
|
try {
|
|
340
819
|
const body = { sessionId, ...args };
|
|
@@ -343,7 +822,7 @@ function registerTools(server, deps) {
|
|
|
343
822
|
content: [
|
|
344
823
|
{
|
|
345
824
|
type: "text",
|
|
346
|
-
text: "Work item released."
|
|
825
|
+
text: "Work item released. Call work again before your next edit in a new area."
|
|
347
826
|
}
|
|
348
827
|
]
|
|
349
828
|
};
|
|
@@ -359,16 +838,13 @@ function registerTools(server, deps) {
|
|
|
359
838
|
"announce",
|
|
360
839
|
{
|
|
361
840
|
title: "Broadcast a message to teammates",
|
|
362
|
-
description: "Broadcast a heads-up to the other agents
|
|
841
|
+
description: "Broadcast a heads-up to the other agents, or direct a finding to a specific agent. This is awareness only \u2014 not a task assignment. To direct it, pass that agent's name (exactly as shown in the landscape) as targetAgentName; omit it to broadcast to everyone in the workspace. Delivery is best-effort: the recipient sees it on their next work/sync, once.",
|
|
363
842
|
inputSchema: AnnounceAgentInput.shape
|
|
364
843
|
},
|
|
365
844
|
async (args) => {
|
|
366
845
|
await awaitJoin();
|
|
367
846
|
if (sessionId === null) {
|
|
368
|
-
return
|
|
369
|
-
isError: true,
|
|
370
|
-
content: [{ type: "text", text: "Call join first \u2014 no active session." }]
|
|
371
|
-
};
|
|
847
|
+
return sessionNotReady();
|
|
372
848
|
}
|
|
373
849
|
try {
|
|
374
850
|
const body = { sessionId, ...args };
|
|
@@ -393,21 +869,21 @@ function registerTools(server, deps) {
|
|
|
393
869
|
"sync",
|
|
394
870
|
{
|
|
395
871
|
title: "Sync team landscape",
|
|
396
|
-
description: "
|
|
872
|
+
description: "Pull the latest team landscape (who's working on what, any messages for you) and renew your active claims. Call when you resume, start a new task, or before large changes \u2014 or any time you want to check for teammate activity without claiming work.",
|
|
397
873
|
inputSchema: SyncAgentInput.shape
|
|
398
874
|
},
|
|
399
875
|
async (_args) => {
|
|
400
876
|
await awaitJoin();
|
|
401
877
|
if (sessionId === null) {
|
|
402
|
-
return
|
|
403
|
-
isError: true,
|
|
404
|
-
content: [{ type: "text", text: "Call join first \u2014 no active session." }]
|
|
405
|
-
};
|
|
878
|
+
return sessionNotReady();
|
|
406
879
|
}
|
|
407
880
|
try {
|
|
408
|
-
const
|
|
881
|
+
const changeReport = await changeReportForBody();
|
|
882
|
+
const body = { sessionId, ...changeReport ? { changeReport } : {} };
|
|
409
883
|
const result = await hubClient.post("/sync", body);
|
|
410
|
-
const text =
|
|
884
|
+
const text = withIdentity(
|
|
885
|
+
withChangeRecords(result.landscape, formatLandscape(result.landscape))
|
|
886
|
+
);
|
|
411
887
|
return { content: [{ type: "text", text }] };
|
|
412
888
|
} catch (err) {
|
|
413
889
|
if (err instanceof HubUnreachable || err instanceof HubRequestError) {
|
|
@@ -417,15 +893,100 @@ function registerTools(server, deps) {
|
|
|
417
893
|
}
|
|
418
894
|
}
|
|
419
895
|
);
|
|
896
|
+
return { ready: joinInFlight };
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// src/resolveContext.ts
|
|
900
|
+
var defaultDeps = {
|
|
901
|
+
detectRepo,
|
|
902
|
+
detectBranch,
|
|
903
|
+
detectHuman
|
|
904
|
+
};
|
|
905
|
+
var DEFAULT_WORKSPACE = "default";
|
|
906
|
+
async function resolveContext(config, cwd = process.cwd(), deps = defaultDeps) {
|
|
907
|
+
const repo = config.REPO ?? deps.detectRepo(cwd) ?? "unknown-repo";
|
|
908
|
+
const branch = config.BRANCH ?? deps.detectBranch(cwd) ?? "HEAD";
|
|
909
|
+
const human = config.HUMAN ?? deps.detectHuman(cwd) ?? generateName();
|
|
910
|
+
const program = config.PROGRAM ?? "claude-code";
|
|
911
|
+
const model = config.MODEL ?? void 0;
|
|
912
|
+
const workspace = config.WORKSPACE ?? DEFAULT_WORKSPACE;
|
|
913
|
+
return { workspace, repo, branch, human, program, model };
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// src/heartbeat.ts
|
|
917
|
+
function createHeartbeat({
|
|
918
|
+
hubClient,
|
|
919
|
+
intervalSeconds
|
|
920
|
+
}) {
|
|
921
|
+
let timer = null;
|
|
922
|
+
function stop() {
|
|
923
|
+
if (timer !== null) {
|
|
924
|
+
clearInterval(timer);
|
|
925
|
+
timer = null;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
function start(sessionId) {
|
|
929
|
+
stop();
|
|
930
|
+
timer = setInterval(() => {
|
|
931
|
+
void Promise.resolve(hubClient.post("/heartbeat", { sessionId })).catch(
|
|
932
|
+
(err) => {
|
|
933
|
+
console.error(
|
|
934
|
+
`[shepherd] heartbeat failed: ${err instanceof Error ? err.message : String(err)}`
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
);
|
|
938
|
+
}, intervalSeconds * 1e3);
|
|
939
|
+
timer.unref();
|
|
940
|
+
}
|
|
941
|
+
return { start, stop };
|
|
420
942
|
}
|
|
421
943
|
|
|
944
|
+
// src/instructions.ts
|
|
945
|
+
var SHEPHERD_INSTRUCTIONS = `You are connected to Shepherd, the shared coordination hub for a team of agents (human and AI) working in the same repositories. You have ALREADY been joined to the workspace automatically \u2014 there is no setup step, and you must not ask the user how to connect.
|
|
946
|
+
|
|
947
|
+
Follow this procedure on every session, proactively and without being asked:
|
|
948
|
+
|
|
949
|
+
1. Before you start working on an AREA of the codebase, call \`work\` ONCE. Pass a one-line \`intent\` and the \`pathGlobs\` covering the files you expect to touch. Scope the globs as specifically as you reasonably can \u2014 tight enough to avoid colliding with unrelated work, broad enough to cover the task (e.g. ["src/auth/**"], not ["src/**"] and not a single file). Hold that one claim across all your edits in that area; do NOT re-claim per file. If it reports a conflict, coordinate or pick different work \u2014 never silently collide.
|
|
950
|
+
|
|
951
|
+
2. Call \`done\` when that unit of work is complete, using its \`workItemId\`, so teammates see the files freed.
|
|
952
|
+
|
|
953
|
+
3. Re-call \`work\` only when you move to a DIFFERENT area not covered by a live claim. (\`work\` and \`sync\` also renew your existing claims.)
|
|
954
|
+
|
|
955
|
+
4. Call \`announce\` whenever you discover something another agent needs \u2014 a shared decision, a gotcha, an API change, a finding. If the landscape shows a specific agent working in the affected area, direct it to them by passing their name as \`targetAgentName\`; otherwise broadcast. Awareness only, not task assignment.
|
|
956
|
+
|
|
957
|
+
5. Call \`sync\` when you resume, start a new task, or before large changes, to refresh who is doing what.
|
|
958
|
+
|
|
959
|
+
Skip \`work\` entirely for read-only exploration. These tools are advisory and degrade gracefully if the hub is unreachable \u2014 never block your real work on them.
|
|
960
|
+
|
|
961
|
+
Commit work-in-progress as you go rather than sitting on a large dirty tree: committed work becomes a precise, presence-independent signal to teammates (with line-level detail and automatic resolution once it lands), whereas uncommitted edits are only a best-effort, decaying hint.`;
|
|
962
|
+
|
|
422
963
|
// src/index.ts
|
|
423
964
|
async function main() {
|
|
424
965
|
const config = loadConfig();
|
|
425
966
|
const hubClient = createHubClient({ hubUrl: config.HUB_URL, teamToken: config.TEAM_TOKEN });
|
|
426
|
-
const
|
|
427
|
-
|
|
967
|
+
const context = await resolveContext(config);
|
|
968
|
+
const heartbeat = createHeartbeat({
|
|
969
|
+
hubClient,
|
|
970
|
+
intervalSeconds: config.HEARTBEAT_INTERVAL_SECONDS
|
|
971
|
+
});
|
|
972
|
+
const server = new McpServer(
|
|
973
|
+
{ name: "shepherd", version: "0.1.0" },
|
|
974
|
+
{ instructions: SHEPHERD_INSTRUCTIONS }
|
|
975
|
+
);
|
|
976
|
+
registerTools(server, { hubClient, config, context, heartbeat });
|
|
428
977
|
const transport = new StdioServerTransport();
|
|
978
|
+
const shutdown = () => {
|
|
979
|
+
heartbeat.stop();
|
|
980
|
+
};
|
|
981
|
+
process.once("SIGINT", () => {
|
|
982
|
+
shutdown();
|
|
983
|
+
process.exit(0);
|
|
984
|
+
});
|
|
985
|
+
process.once("SIGTERM", () => {
|
|
986
|
+
shutdown();
|
|
987
|
+
process.exit(0);
|
|
988
|
+
});
|
|
989
|
+
transport.onclose = shutdown;
|
|
429
990
|
await server.connect(transport);
|
|
430
991
|
}
|
|
431
992
|
main().catch((err) => {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@korso/shepherd",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Shepherd MCP server — gives any MCP-capable agent (Claude Code, Codex, etc.)
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Shepherd MCP server — gives any MCP-capable agent (Claude Code, Codex, etc.) four advisory cross-session coordination tools (work/done/announce/sync) backed by the shared Shepherd hub. Joins the workspace automatically and ships standing instructions so the agent self-coordinates.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"bin": {
|