@korso/shepherd 0.2.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 (2) hide show
  1. package/dist/index.js +589 -29
  2. package/package.json +1 -1
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,20 +713,23 @@ 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
718
  let agentName = null;
251
719
  const joinBody = {
252
- workspace: config.WORKSPACE,
253
- repo: config.REPO,
254
- branch: config.BRANCH,
255
- human: config.HUMAN,
256
- program: config.PROGRAM,
257
- model: config.MODEL
720
+ workspace: context.workspace,
721
+ repo: context.repo,
722
+ branch: context.branch,
723
+ human: context.human,
724
+ program: context.program
258
725
  };
726
+ if (context.model !== void 0) {
727
+ joinBody.model = context.model;
728
+ }
259
729
  const joinInFlight = hubClient.post("/join", joinBody).then((r) => {
260
730
  sessionId = r.sessionId;
261
731
  agentName = r.agentName;
732
+ heartbeat.start(r.sessionId);
262
733
  }).catch(() => {
263
734
  });
264
735
  async function awaitJoin() {
@@ -278,6 +749,24 @@ function registerTools(server, deps) {
278
749
  return agentName ? `You are ${agentName}.
279
750
 
280
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;
758
+ }
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;
281
770
  }
282
771
  server.registerTool(
283
772
  "work",
@@ -292,14 +781,18 @@ ${body}` : body;
292
781
  return sessionNotReady();
293
782
  }
294
783
  try {
295
- const body = { sessionId, ...args };
784
+ const changeReport = await changeReportForBody();
785
+ const body = { sessionId, ...args, ...changeReport ? { changeReport } : {} };
296
786
  const result = await hubClient.post("/work", body);
297
787
  const text = withIdentity(
298
- `Work claimed (workItemId: ${result.workItemId})
788
+ withChangeRecords(
789
+ result.landscape,
790
+ `Work claimed (workItemId: ${result.workItemId})
299
791
 
300
792
  ` + formatLandscape(result.landscape) + `
301
793
 
302
794
  You hold this claim until you call done (workItemId: ${result.workItemId}) or it expires (~30 min). Calling work or sync renews it.`
795
+ )
303
796
  );
304
797
  return { content: [{ type: "text", text }] };
305
798
  } catch (err) {
@@ -385,9 +878,12 @@ You hold this claim until you call done (workItemId: ${result.workItemId}) or it
385
878
  return sessionNotReady();
386
879
  }
387
880
  try {
388
- const body = { sessionId };
881
+ const changeReport = await changeReportForBody();
882
+ const body = { sessionId, ...changeReport ? { changeReport } : {} };
389
883
  const result = await hubClient.post("/sync", body);
390
- const text = withIdentity(formatLandscape(result.landscape));
884
+ const text = withIdentity(
885
+ withChangeRecords(result.landscape, formatLandscape(result.landscape))
886
+ );
391
887
  return { content: [{ type: "text", text }] };
392
888
  } catch (err) {
393
889
  if (err instanceof HubUnreachable || err instanceof HubRequestError) {
@@ -400,6 +896,51 @@ You hold this claim until you call done (workItemId: ${result.workItemId}) or it
400
896
  return { ready: joinInFlight };
401
897
  }
402
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 };
942
+ }
943
+
403
944
  // src/instructions.ts
404
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.
405
946
 
@@ -415,18 +956,37 @@ Follow this procedure on every session, proactively and without being asked:
415
956
 
416
957
  5. Call \`sync\` when you resume, start a new task, or before large changes, to refresh who is doing what.
417
958
 
418
- 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.`;
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.`;
419
962
 
420
963
  // src/index.ts
421
964
  async function main() {
422
965
  const config = loadConfig();
423
966
  const hubClient = createHubClient({ hubUrl: config.HUB_URL, teamToken: config.TEAM_TOKEN });
967
+ const context = await resolveContext(config);
968
+ const heartbeat = createHeartbeat({
969
+ hubClient,
970
+ intervalSeconds: config.HEARTBEAT_INTERVAL_SECONDS
971
+ });
424
972
  const server = new McpServer(
425
973
  { name: "shepherd", version: "0.1.0" },
426
974
  { instructions: SHEPHERD_INSTRUCTIONS }
427
975
  );
428
- registerTools(server, { hubClient, config });
976
+ registerTools(server, { hubClient, config, context, heartbeat });
429
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;
430
990
  await server.connect(transport);
431
991
  }
432
992
  main().catch((err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@korso/shepherd",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
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",