@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.
Files changed (3) hide show
  1. package/README.md +4 -4
  2. package/dist/index.js +646 -85
  3. 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.) five advisory coordination tools backed by the shared hub: `join`, `work`, `done`, `announce`, and `sync`.
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 immediately. The error is loud on purpose — you will see it in stderr the moment the first tool is called. This is the most common silent onboarding mistake; the guard turns it into a loud, immediate failure.
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 first tool call (`work`, `sync`, etc.) returns a 400 from the hub. Check that your `WORKSPACE` value exactly matches the hub's `ALLOWED_WORKSPACE`.
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
- | Hub returns 400 on first tool call | `WORKSPACE` mismatch between client and hub | Set `WORKSPACE` to exactly match the hub's `ALLOWED_WORKSPACE` |
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
- WORKSPACE: z.string().min(1, "WORKSPACE is required"),
13
- REPO: z.string().min(1, "REPO is required"),
14
- BRANCH: z.string().min(1, "BRANCH is required"),
15
- HUMAN: z.string().min(1, "HUMAN is required"),
16
- PROGRAM: z.string().min(1, "PROGRAM is required"),
17
- MODEL: z.string().min(1, "MODEL is required")
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(path, body) {
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}${path}`, {
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 (${path})` : `Hub unreachable at ${baseUrl}${path}: ${String(err)}`;
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 ${path}`
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 joinInFlight = null;
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
- if (joinInFlight) await joinInFlight;
736
+ await joinInFlight;
253
737
  }
254
- server.registerTool(
255
- "join",
256
- {
257
- title: "Join coordination hub",
258
- description: "Register yourself with the team coordination hub at the start of a session. Call once before any other coordination tool. Returns your assigned agent name and session ID that the other tools use automatically.",
259
- inputSchema: JoinAgentInput.shape
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
- throw err;
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: "Call this BEFORE you start a unit of work. It atomically checks whether any teammate's agent is already touching the same files and claims the work for you, returning any conflicts and what others are working on. Always call this before editing files.",
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 body = { sessionId, ...args };
784
+ const changeReport = await changeReportForBody();
785
+ const body = { sessionId, ...args, ...changeReport ? { changeReport } : {} };
311
786
  const result = await hubClient.post("/work", body);
312
- const text = `Work claimed (workItemId: ${result.workItemId})
787
+ const text = withIdentity(
788
+ withChangeRecords(
789
+ result.landscape,
790
+ `Work claimed (workItemId: ${result.workItemId})
791
+
792
+ ` + formatLandscape(result.landscape) + `
313
793
 
314
- ` + formatLandscape(result.landscape);
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 you finish a unit of work to release your claim so teammates know the files are free. Pass the workItemId returned by the work tool.",
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 (or hand a specific agent a finding). This is awareness only \u2014 not a task assignment. Omit targetAgentName to broadcast to everyone in the workspace.",
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: "Optional periodic pull of the latest team landscape (who's working on what, any messages for you). Also keeps your claims alive. Call this if you want to check for teammate activity without starting new work.",
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 body = { sessionId };
881
+ const changeReport = await changeReportForBody();
882
+ const body = { sessionId, ...changeReport ? { changeReport } : {} };
409
883
  const result = await hubClient.post("/sync", body);
410
- const text = formatLandscape(result.landscape);
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 server = new McpServer({ name: "shepherd", version: "0.1.0" });
427
- registerTools(server, { hubClient, config });
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.1.0",
4
- "description": "Shepherd MCP server — gives any MCP-capable agent (Claude Code, Codex, etc.) five advisory cross-session coordination tools (join/work/done/announce/sync) backed by the shared Shepherd hub.",
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": {