@korso/shepherd 0.3.0 → 0.4.1

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 +69 -50
  2. package/dist/index.js +156 -37
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -8,9 +8,9 @@ Shepherd's stdio MCP server. Gives any MCP-capable agent (Claude Code, Codex, et
8
8
 
9
9
  ## CRITICAL: WORKSPACE must match the hub exactly
10
10
 
11
- > **Everyone must set `WORKSPACE` to the identical string, and that string must equal the hub's `ALLOWED_WORKSPACE` env var.**
11
+ > **`WORKSPACE` defaults to `default`. If you override it, the value must equal the hub's `ALLOWED_WORKSPACE` env var exactly.**
12
12
 
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.
13
+ The server fires an automatic `join` call to the hub at startup. If the workspace it sends does not match the hub's `ALLOWED_WORKSPACE`, that call returns HTTP 400 and coordination degrades: every tool reports "session not ready … proceeding uncoordinated" instead of a landscape. The safe default is to **leave `WORKSPACE` unset** so it resolves to `default` — only set it when a maintainer points you at a different workspace. If your agent never sees teammates, check `WORKSPACE` (and `TEAM_TOKEN`) first.
14
14
 
15
15
  ---
16
16
 
@@ -32,22 +32,33 @@ first fetch, and `@korso/shepherd@latest` picks up updates automatically.
32
32
 
33
33
  ---
34
34
 
35
- ## 2. Required environment variables
35
+ ## 2. Environment variables
36
36
 
37
- Every instance of the MCP server needs these eight env vars set in its client config:
37
+ **Only two are required:**
38
38
 
39
39
  | Variable | Description | Example |
40
40
  |---|---|---|
41
41
  | `HUB_URL` | Base URL of the deployed hub | `https://shepherd.example.com` |
42
42
  | `TEAM_TOKEN` | Shared bearer token accepted by the hub | `tok_abc123` |
43
- | `WORKSPACE` | **Must match hub's `ALLOWED_WORKSPACE` exactly** | `shepherd` |
44
- | `REPO` | Repository slug (used for scoping claims) | `shepherd` |
45
- | `BRANCH` | Git branch name | `main` |
46
- | `HUMAN` | Founder name — identifies you in the presence feed | `daichi` |
47
- | `PROGRAM` | Agent program name | `claude-code` |
48
- | `MODEL` | Model ID being used | `claude-sonnet-4-6` |
49
43
 
50
- All eight are required. Missing any one causes an immediate startup failure with a clear error on stderr listing which vars are absent.
44
+ Missing either causes an immediate startup failure with a clear error on stderr
45
+ listing which vars are absent. (No other var triggers this.)
46
+
47
+ **Everything else is optional** — each identity field is resolved at startup as
48
+ **env var → git detection → fallback**, so a plain `npx -y @korso/shepherd` with
49
+ just the two required vars produces a valid, fully-identified session. Set an
50
+ override only to replace what's detected:
51
+
52
+ | Variable | If omitted | Example |
53
+ |---|---|---|
54
+ | `WORKSPACE` | defaults to `default` (**must match hub's `ALLOWED_WORKSPACE` if overridden**) | `shepherd` |
55
+ | `REPO` | `git remote origin` → `owner/repo`, else repo folder name, else `unknown-repo` | `Korsoai/shepherd` |
56
+ | `BRANCH` | `git rev-parse --abbrev-ref HEAD`, else `HEAD` | `main` |
57
+ | `BASE_BRANCH` | `origin/HEAD`, else `origin/main` / `origin/master` (used for the change-awareness heads-up) | `origin/main` |
58
+ | `HUMAN` | git `user.name`, else local-part of `user.email`, else a generated name | `daichi` |
59
+ | `PROGRAM` | defaults to `claude-code` | `codex` |
60
+ | `MODEL` | omitted — **never auto-detected**, so set it if you want it shown | `claude-sonnet-4-6` |
61
+ | `HEARTBEAT_INTERVAL_SECONDS` | defaults to `60` | `30` |
51
62
 
52
63
  ---
53
64
 
@@ -60,19 +71,19 @@ All eight are required. Missing any one causes an immediate startup failure with
60
71
  > everywhere) or a project-root `.mcp.json`. Confirm with `claude mcp list`,
61
72
  > which should show `shepherd … ✔ Connected`.
62
73
 
63
- Recommended — register once at user scope (works identically on Windows/macOS/Linux):
74
+ Recommended — register once at user scope. Written as a **single line** so it
75
+ pastes cleanly into PowerShell, cmd, bash, and zsh (on PowerShell the bash `\`
76
+ line-continuation does not work). Minimal: just the two required vars (identity
77
+ is auto-detected from git):
64
78
 
65
- ```sh
66
- claude mcp add shepherd -s user \
67
- -e HUB_URL=https://shepherd.example.com \
68
- -e TEAM_TOKEN=tok_abc123 \
69
- -e WORKSPACE=shepherd \
70
- -e REPO=shepherd -e BRANCH=main -e HUMAN=daichi \
71
- -e PROGRAM=claude-code -e MODEL=claude-sonnet-4-6 \
72
- -- npx -y @korso/shepherd
79
+ ```powershell
80
+ claude mcp add shepherd -s user -e HUB_URL=https://shepherd.example.com -e TEAM_TOKEN=tok_abc123 -- npx -y @korso/shepherd
73
81
  ```
74
82
 
75
- Alternative — a `.mcp.json` at the **root of the repo you're working in**:
83
+ Add any optional overrides from §2 with extra `-e` flags (e.g. `-e MODEL=claude-sonnet-4-6 -e HUMAN=daichi`).
84
+
85
+ Alternative — a `.mcp.json` at the **root of the repo you're working in**
86
+ (optional overrides shown commented-style; drop the ones you don't need):
76
87
 
77
88
  ```json
78
89
  {
@@ -83,11 +94,6 @@ Alternative — a `.mcp.json` at the **root of the repo you're working in**:
83
94
  "env": {
84
95
  "HUB_URL": "https://shepherd.example.com",
85
96
  "TEAM_TOKEN": "tok_abc123",
86
- "WORKSPACE": "shepherd",
87
- "REPO": "shepherd",
88
- "BRANCH": "main",
89
- "HUMAN": "daichi",
90
- "PROGRAM": "claude-code",
91
97
  "MODEL": "claude-sonnet-4-6"
92
98
  }
93
99
  }
@@ -100,9 +106,29 @@ Alternative — a `.mcp.json` at the **root of the repo you're working in**:
100
106
  > on Linux (Postgres), so the Windows-native durability concerns from the spike
101
107
  > don't apply to clients.
102
108
 
103
- ### Codex (`~/.codex/config.json` or `codex.json`)
109
+ ### Codex (`~/.codex/config.toml`)
110
+
111
+ Codex uses the same MCP stdio protocol but configures it in **TOML**, not JSON —
112
+ at `~/.codex/config.toml` (global) or `.codex/config.toml` in a trusted project.
113
+ The table is `mcp_servers` with an **underscore** (`mcp-servers`/`mcpServers` are
114
+ silently ignored). Either run `codex mcp add`:
104
115
 
105
- Codex uses the same MCP stdio protocol. Add a server entry under `mcpServers`:
116
+ ```sh
117
+ codex mcp add shepherd --env HUB_URL=https://shepherd.example.com --env TEAM_TOKEN=tok_abc123 --env PROGRAM=codex -- npx -y @korso/shepherd
118
+ ```
119
+
120
+ …or add the table directly:
121
+
122
+ ```toml
123
+ [mcp_servers.shepherd]
124
+ command = "npx"
125
+ args = ["-y", "@korso/shepherd"]
126
+ env = { HUB_URL = "https://shepherd.example.com", TEAM_TOKEN = "tok_abc123", PROGRAM = "codex", MODEL = "o4-mini" }
127
+ ```
128
+
129
+ ### Pi (`~/.pi/agent/mcp.json` or `.pi/mcp.json`)
130
+
131
+ Pi uses a JSON `mcpServers` block (project config overrides global):
106
132
 
107
133
  ```json
108
134
  {
@@ -113,12 +139,7 @@ Codex uses the same MCP stdio protocol. Add a server entry under `mcpServers`:
113
139
  "env": {
114
140
  "HUB_URL": "https://shepherd.example.com",
115
141
  "TEAM_TOKEN": "tok_abc123",
116
- "WORKSPACE": "shepherd",
117
- "REPO": "shepherd",
118
- "BRANCH": "main",
119
- "HUMAN": "alex",
120
- "PROGRAM": "codex",
121
- "MODEL": "o4-mini"
142
+ "PROGRAM": "pi"
122
143
  }
123
144
  }
124
145
  }
@@ -129,34 +150,31 @@ Codex uses the same MCP stdio protocol. Add a server entry under `mcpServers`:
129
150
 
130
151
  ## 4. Verify the server starts (quick smoke test)
131
152
 
132
- Run with all env vars set to confirm it connects and idles on stdin:
153
+ Run with the two required vars set to confirm it connects and idles on stdin.
154
+ PowerShell (set env vars, then run):
133
155
 
134
- ```sh
135
- HUB_URL=https://shepherd.example.com \
136
- TEAM_TOKEN=tok_abc123 \
137
- WORKSPACE=shepherd \
138
- REPO=shepherd \
139
- BRANCH=main \
140
- HUMAN=daichi \
141
- PROGRAM=claude-code \
142
- MODEL=claude-sonnet-4-6 \
156
+ ```powershell
157
+ $env:HUB_URL = "https://shepherd.example.com"
158
+ $env:TEAM_TOKEN = "tok_abc123"
143
159
  npx -y @korso/shepherd
144
160
  ```
145
161
 
162
+ bash/zsh: `HUB_URL=https://shepherd.example.com TEAM_TOKEN=tok_abc123 npx -y @korso/shepherd`
163
+
146
164
  No stderr output and the process blocking on stdin = healthy. Press Ctrl+C to exit.
147
165
 
148
- **Missing env vars:** if you deliberately omit a var, you will see:
166
+ **Missing env vars:** if you omit `HUB_URL` or `TEAM_TOKEN`, you will see:
149
167
 
150
168
  ```
151
169
  [shepherd] Configuration error — missing or invalid env vars:
152
170
  HUB_URL: HUB_URL is required
153
171
  TEAM_TOKEN: TEAM_TOKEN is required
154
- ...
155
172
  ```
156
173
 
157
- and the process exits 1 immediately. This is by design.
174
+ and the process exits 1 immediately. This is by design. The optional identity
175
+ vars never cause this — they fall back to git detection / defaults.
158
176
 
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.
177
+ **Wrong WORKSPACE:** if you override `WORKSPACE` to a value the hub doesn't allow, 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". Either leave `WORKSPACE` unset (resolves to `default`) or set it to exactly match the hub's `ALLOWED_WORKSPACE`, then restart.
160
178
 
161
179
  ---
162
180
 
@@ -197,7 +215,8 @@ npm publish --workspace=@korso/shepherd # prepublishOnly runs tsup automatical
197
215
 
198
216
  | Symptom | Likely cause | Fix |
199
217
  |---|---|---|
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
- | 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 |
218
+ | `Configuration error — missing or invalid env vars` | `HUB_URL` or `TEAM_TOKEN` is absent (only these two are required) | Add the missing var(s) to your client's `env` block |
219
+ | Tools report "session not ready … proceeding uncoordinated" | Startup auto-join rejected — usually a stale `TEAM_TOKEN`, or a `WORKSPACE` override the hub doesn't allow | Re-check `TEAM_TOKEN`; leave `WORKSPACE` unset (→ `default`) or match the hub's `ALLOWED_WORKSPACE`; restart |
220
+ | Agent shows up under a surprising name/repo/branch | Identity auto-detected from git | Override with `HUMAN`/`REPO`/`BRANCH`/`MODEL` env vars (§2) |
202
221
  | `npm error 404 … @korso/shepherd` | Package not published yet, or name typo | `npm view @korso/shepherd version` to confirm it's live |
203
222
  | Process exits immediately with no error | Rare; check for node version incompatibility | Requires Node 18+ (ESM support) |
package/dist/index.js CHANGED
@@ -281,6 +281,47 @@ var Landscape = z2.object({
281
281
  // Per-agent change records for the workspace. Defaulted for version-skew safety.
282
282
  changeRecords: z2.array(ChangeRecord).default([])
283
283
  });
284
+ var WorkspaceAgent = z2.object({
285
+ name: z2.string(),
286
+ human: z2.string(),
287
+ program: z2.string(),
288
+ // model is nullable in the DB (may be unknown when an agent first joins).
289
+ model: z2.string().nullable(),
290
+ // repo/branch/lastHeartbeatAt come from the agent's most-recent session and
291
+ // are null when the agent has no session yet.
292
+ repo: z2.string().nullable(),
293
+ branch: z2.string().nullable(),
294
+ lastHeartbeatAt: IsoTimestamp.nullable(),
295
+ presence: z2.enum(["live", "offline"])
296
+ });
297
+ var TaskStatus = z2.enum(["active", "done", "dropped"]);
298
+ var WorkspaceTask = z2.object({
299
+ agentName: z2.string(),
300
+ program: z2.string(),
301
+ model: z2.string().nullable(),
302
+ repo: z2.string(),
303
+ intent: z2.string(),
304
+ pathGlobs: z2.array(z2.string()),
305
+ status: TaskStatus,
306
+ createdAt: IsoTimestamp,
307
+ endedAt: IsoTimestamp.nullable()
308
+ });
309
+ var WorkspaceAnnouncement = z2.object({
310
+ fromAgentName: z2.string(),
311
+ fromHuman: z2.string(),
312
+ body: z2.string(),
313
+ targetAgentName: z2.string().nullable(),
314
+ repo: z2.string(),
315
+ createdAt: IsoTimestamp
316
+ });
317
+ var WorkspaceLandscapeResponse = z2.object({
318
+ agents: z2.array(WorkspaceAgent),
319
+ tasks: z2.array(WorkspaceTask),
320
+ announcements: z2.array(WorkspaceAnnouncement),
321
+ // The server's clock, so the client computes "expires in / last seen" against
322
+ // the hub rather than the (possibly skewed) browser clock.
323
+ serverTime: IsoTimestamp
324
+ });
284
325
  var JoinRequest = z2.object({
285
326
  workspace: z2.string().min(1),
286
327
  repo: z2.string().min(1),
@@ -309,7 +350,11 @@ var DoneRequest = z2.object({
309
350
  workItemId: z2.string().uuid()
310
351
  });
311
352
  var DoneResponse = z2.object({
312
- ok: z2.literal(true)
353
+ ok: z2.literal(true),
354
+ // Pending announcements for the caller, delivered as a side effect of done so
355
+ // a message lands the moment a teammate finishes a unit of work (not only on
356
+ // their next work/sync). Defaulted for version-skew safety with older hubs.
357
+ announcements: z2.array(Announcement).default([])
313
358
  });
314
359
  var AnnounceRequest = z2.object({
315
360
  sessionId: z2.string().uuid(),
@@ -320,7 +365,11 @@ var AnnounceRequest = z2.object({
320
365
  var AnnounceResponse = z2.object({
321
366
  ok: z2.literal(true),
322
367
  // bigint PK serialised as number; see DbId note above
323
- announcementId: DbId
368
+ announcementId: DbId,
369
+ // Pending announcements for the caller, delivered as a side effect of announce
370
+ // (a turn where the agent is already reading hub output) so inbound messages
371
+ // surface promptly. Excludes the just-sent one. Defaulted for version skew.
372
+ announcements: z2.array(Announcement).default([])
324
373
  });
325
374
  var SyncRequest = z2.object({
326
375
  sessionId: z2.string().uuid(),
@@ -335,11 +384,22 @@ var DoneAgentInput = DoneRequest.omit({ sessionId: true });
335
384
  var JoinAgentInput = z2.object({});
336
385
  var SyncAgentInput = z2.object({});
337
386
  var HeartbeatRequest = z2.object({
338
- sessionId: z2.string().uuid()
387
+ sessionId: z2.string().uuid(),
388
+ // Optional change report so the BACKGROUND heartbeat keeps an agent's durable
389
+ // change records fresh (commits surface within ~one heartbeat interval, not
390
+ // only when it next calls work/sync). Processed presence-style: it refreshes
391
+ // change records but, like the rest of heartbeat, does NOT renew claim TTLs.
392
+ changeReport: ChangeReport.optional()
339
393
  });
340
394
  var HeartbeatResponse = z2.object({
341
395
  ok: z2.literal(true)
342
396
  });
397
+ var LeaveRequest = z2.object({
398
+ sessionId: z2.string().uuid()
399
+ });
400
+ var LeaveResponse = z2.object({
401
+ ok: z2.literal(true)
402
+ });
343
403
 
344
404
  // src/gitContext.ts
345
405
  import { execFileSync } from "child_process";
@@ -404,6 +464,14 @@ function normalizeRemoteUrl(url) {
404
464
  const repo = segments[segments.length - 1];
405
465
  return `${owner}/${repo}`;
406
466
  }
467
+ function canonicalizeRepo(input) {
468
+ const s = input.trim();
469
+ const looksLikeUrl = /:\/\//.test(s) || /^[^/@]+@[^:]+:/.test(s);
470
+ const base = looksLikeUrl ? normalizeRemoteUrl(s) ?? s : s.replace(/\.git$/, "").replace(/^\/+|\/+$/g, "");
471
+ const segments = base.split("/").filter(Boolean);
472
+ const name = segments.length > 0 ? segments[segments.length - 1] : base;
473
+ return name.toLowerCase();
474
+ }
407
475
  function detectRepo(cwd = process.cwd()) {
408
476
  const origin = runGit(cwd, ["config", "--get", "remote.origin.url"]);
409
477
  if (origin) {
@@ -651,6 +719,15 @@ function formatLandscape(landscape) {
651
719
  }
652
720
  return lines.join("\n");
653
721
  }
722
+ function formatAnnouncements(announcements) {
723
+ if (!announcements || announcements.length === 0) return "";
724
+ const lines = ["Messages for you:"];
725
+ for (const a of announcements) {
726
+ const target = a.targetAgentName ? ` \u2192 ${a.targetAgentName}` : " (broadcast)";
727
+ lines.push(` [${a.fromAgentName}${target}] ${a.body}`);
728
+ }
729
+ return lines.join("\n");
730
+ }
654
731
  function relativeAge(iso) {
655
732
  const then = Date.parse(iso);
656
733
  if (Number.isNaN(then)) return "recently";
@@ -673,16 +750,19 @@ function formatChangeRecords(records, cwd = process.cwd()) {
673
750
  const lines = [];
674
751
  for (const rec of records) {
675
752
  if (rec.kind === "committed") {
676
- if (rec.commitSha && isAncestor(cwd, rec.commitSha)) continue;
753
+ const sha = rec.commitSha;
754
+ if (sha && isAncestor(cwd, sha)) continue;
755
+ const present = sha ? hasCommit(cwd, sha) : false;
756
+ const state = present ? "landed, not yet in your branch \u2014 pull/rebase" : "not yet on your base \u2014 unpushed, coordinate";
677
757
  const intent = rec.message ?? "(work in progress)";
678
758
  lines.push(
679
- ` ${rec.agentName} / ${rec.human} (${presence(rec)}) \u2014 committed (not yet on your base): "${intent}"`
759
+ ` ${rec.agentName} / ${rec.human} (${presence(rec)}) \u2014 committed (${state}): "${intent}"`
680
760
  );
681
761
  lines.push(` files: ${rec.paths.join(", ")}`);
682
- if (rec.commitSha && lineRangeBudget > 0 && hasCommit(cwd, rec.commitSha)) {
762
+ if (sha && present && lineRangeBudget > 0) {
683
763
  const budgetedPaths = rec.paths.slice(0, lineRangeBudget);
684
764
  lineRangeBudget -= budgetedPaths.length;
685
- const ranges = changedLineRanges(cwd, rec.commitSha, budgetedPaths);
765
+ const ranges = changedLineRanges(cwd, sha, budgetedPaths);
686
766
  for (const p of Object.keys(ranges)) {
687
767
  const spans = ranges[p].map((r) => r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`);
688
768
  if (spans.length > 0) {
@@ -772,7 +852,7 @@ ${section}` : body;
772
852
  "work",
773
853
  {
774
854
  title: "Claim a unit of work",
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.',
855
+ description: 'Claim a unit of work BEFORE you start producing or changing files in an area of the codebase \u2014 source OR a plan/design doc (per unit of work, NOT per edit). Authoring a plan counts: claim the doc\'s path before you write it. 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.',
776
856
  inputSchema: WorkAgentInput.shape
777
857
  },
778
858
  async (args) => {
@@ -817,13 +897,14 @@ You hold this claim until you call done (workItemId: ${result.workItemId}) or it
817
897
  }
818
898
  try {
819
899
  const body = { sessionId, ...args };
820
- await hubClient.post("/done", body);
900
+ const result = await hubClient.post("/done", body);
901
+ const base = "Work item released. Call work again before your next edit in a new area.";
902
+ const msgs = formatAnnouncements(result.announcements ?? []);
821
903
  return {
822
904
  content: [
823
- {
824
- type: "text",
825
- text: "Work item released. Call work again before your next edit in a new area."
826
- }
905
+ { type: "text", text: msgs ? `${base}
906
+
907
+ ${msgs}` : base }
827
908
  ]
828
909
  };
829
910
  } catch (err) {
@@ -849,12 +930,13 @@ You hold this claim until you call done (workItemId: ${result.workItemId}) or it
849
930
  try {
850
931
  const body = { sessionId, ...args };
851
932
  const result = await hubClient.post("/announce", body);
933
+ const base = `Announcement sent (id: ${result.announcementId}).`;
934
+ const msgs = formatAnnouncements(result.announcements ?? []);
852
935
  return {
853
936
  content: [
854
- {
855
- type: "text",
856
- text: `Announcement sent (id: ${result.announcementId}).`
857
- }
937
+ { type: "text", text: msgs ? `${base}
938
+
939
+ ${msgs}` : base }
858
940
  ]
859
941
  };
860
942
  } catch (err) {
@@ -893,7 +975,18 @@ You hold this claim until you call done (workItemId: ${result.workItemId}) or it
893
975
  }
894
976
  }
895
977
  );
896
- return { ready: joinInFlight };
978
+ async function leave() {
979
+ try {
980
+ await joinInFlight;
981
+ if (!sessionId) return;
982
+ await hubClient.post("/leave", { sessionId });
983
+ } catch (err) {
984
+ console.error(
985
+ `[shepherd] leave failed: ${err instanceof Error ? err.message : String(err)}`
986
+ );
987
+ }
988
+ }
989
+ return { ready: joinInFlight, leave };
897
990
  }
898
991
 
899
992
  // src/resolveContext.ts
@@ -904,7 +997,9 @@ var defaultDeps = {
904
997
  };
905
998
  var DEFAULT_WORKSPACE = "default";
906
999
  async function resolveContext(config, cwd = process.cwd(), deps = defaultDeps) {
907
- const repo = config.REPO ?? deps.detectRepo(cwd) ?? "unknown-repo";
1000
+ const repo = canonicalizeRepo(
1001
+ config.REPO ?? deps.detectRepo(cwd) ?? "unknown-repo"
1002
+ );
908
1003
  const branch = config.BRANCH ?? deps.detectBranch(cwd) ?? "HEAD";
909
1004
  const human = config.HUMAN ?? deps.detectHuman(cwd) ?? generateName();
910
1005
  const program = config.PROGRAM ?? "claude-code";
@@ -916,7 +1011,8 @@ async function resolveContext(config, cwd = process.cwd(), deps = defaultDeps) {
916
1011
  // src/heartbeat.ts
917
1012
  function createHeartbeat({
918
1013
  hubClient,
919
- intervalSeconds
1014
+ intervalSeconds,
1015
+ buildReport
920
1016
  }) {
921
1017
  let timer = null;
922
1018
  function stop() {
@@ -925,16 +1021,26 @@ function createHeartbeat({
925
1021
  timer = null;
926
1022
  }
927
1023
  }
1024
+ async function beat(sessionId) {
1025
+ let changeReport;
1026
+ if (buildReport) {
1027
+ try {
1028
+ changeReport = await buildReport();
1029
+ } catch {
1030
+ changeReport = void 0;
1031
+ }
1032
+ }
1033
+ const body = changeReport ? { sessionId, changeReport } : { sessionId };
1034
+ await hubClient.post("/heartbeat", body);
1035
+ }
928
1036
  function start(sessionId) {
929
1037
  stop();
930
1038
  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
- );
1039
+ void beat(sessionId).catch((err) => {
1040
+ console.error(
1041
+ `[shepherd] heartbeat failed: ${err instanceof Error ? err.message : String(err)}`
1042
+ );
1043
+ });
938
1044
  }, intervalSeconds * 1e3);
939
1045
  timer.unref();
940
1046
  }
@@ -946,7 +1052,7 @@ var SHEPHERD_INSTRUCTIONS = `You are connected to Shepherd, the shared coordinat
946
1052
 
947
1053
  Follow this procedure on every session, proactively and without being asked:
948
1054
 
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.
1055
+ 1. Before you start producing or changing files in an AREA of the codebase, call \`work\` ONCE. This includes authoring a plan or design doc: claim the doc's path (e.g. ["docs/plans/auth.md"], or the directory you'll write into) BEFORE you write it \u2014 a plan you're about to author counts as a unit of work, not exploration. 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
1056
 
951
1057
  2. Call \`done\` when that unit of work is complete, using its \`workItemId\`, so teammates see the files freed.
952
1058
 
@@ -956,7 +1062,7 @@ Follow this procedure on every session, proactively and without being asked:
956
1062
 
957
1063
  5. Call \`sync\` when you resume, start a new task, or before large changes, to refresh who is doing what.
958
1064
 
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.
1065
+ Skip \`work\` entirely for read-only exploration \u2014 reading, searching, or thinking that produces no file. The moment you're going to WRITE something, source or doc, claim it first. These tools are advisory and degrade gracefully if the hub is unreachable \u2014 never block your real work on them.
960
1066
 
961
1067
  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
1068
 
@@ -967,26 +1073,39 @@ async function main() {
967
1073
  const context = await resolveContext(config);
968
1074
  const heartbeat = createHeartbeat({
969
1075
  hubClient,
970
- intervalSeconds: config.HEARTBEAT_INTERVAL_SECONDS
1076
+ intervalSeconds: config.HEARTBEAT_INTERVAL_SECONDS,
1077
+ // Attach a best-effort change report to each beat so commits surface to
1078
+ // teammates within ~one interval. Fail-open: any git error → presence-only.
1079
+ buildReport: async () => {
1080
+ try {
1081
+ return await buildChangeReport(process.cwd(), config) ?? void 0;
1082
+ } catch {
1083
+ return void 0;
1084
+ }
1085
+ }
971
1086
  });
972
1087
  const server = new McpServer(
973
1088
  { name: "shepherd", version: "0.1.0" },
974
1089
  { instructions: SHEPHERD_INSTRUCTIONS }
975
1090
  );
976
- registerTools(server, { hubClient, config, context, heartbeat });
1091
+ const tools = registerTools(server, { hubClient, config, context, heartbeat });
977
1092
  const transport = new StdioServerTransport();
978
- const shutdown = () => {
1093
+ let shuttingDown = false;
1094
+ const shutdown = async () => {
1095
+ if (shuttingDown) return;
1096
+ shuttingDown = true;
979
1097
  heartbeat.stop();
1098
+ await tools.leave();
980
1099
  };
981
1100
  process.once("SIGINT", () => {
982
- shutdown();
983
- process.exit(0);
1101
+ void shutdown().finally(() => process.exit(0));
984
1102
  });
985
1103
  process.once("SIGTERM", () => {
986
- shutdown();
987
- process.exit(0);
1104
+ void shutdown().finally(() => process.exit(0));
988
1105
  });
989
- transport.onclose = shutdown;
1106
+ transport.onclose = () => {
1107
+ void shutdown();
1108
+ };
990
1109
  await server.connect(transport);
991
1110
  }
992
1111
  main().catch((err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@korso/shepherd",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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",