@korso/shepherd 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -50
- package/dist/index.js +154 -37
- 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
|
-
>
|
|
11
|
+
> **`WORKSPACE` defaults to `default`. If you override it, the value must equal the hub's `ALLOWED_WORKSPACE` env var exactly.**
|
|
12
12
|
|
|
13
|
-
|
|
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.
|
|
35
|
+
## 2. Environment variables
|
|
36
36
|
|
|
37
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
```
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
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
|
-
```
|
|
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
|
|
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
|
|
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` |
|
|
201
|
-
| Tools report "session not ready … proceeding uncoordinated" | Startup auto-join rejected — usually `WORKSPACE`
|
|
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,12 @@ 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
|
+
return base.toLowerCase();
|
|
472
|
+
}
|
|
407
473
|
function detectRepo(cwd = process.cwd()) {
|
|
408
474
|
const origin = runGit(cwd, ["config", "--get", "remote.origin.url"]);
|
|
409
475
|
if (origin) {
|
|
@@ -651,6 +717,15 @@ function formatLandscape(landscape) {
|
|
|
651
717
|
}
|
|
652
718
|
return lines.join("\n");
|
|
653
719
|
}
|
|
720
|
+
function formatAnnouncements(announcements) {
|
|
721
|
+
if (!announcements || announcements.length === 0) return "";
|
|
722
|
+
const lines = ["Messages for you:"];
|
|
723
|
+
for (const a of announcements) {
|
|
724
|
+
const target = a.targetAgentName ? ` \u2192 ${a.targetAgentName}` : " (broadcast)";
|
|
725
|
+
lines.push(` [${a.fromAgentName}${target}] ${a.body}`);
|
|
726
|
+
}
|
|
727
|
+
return lines.join("\n");
|
|
728
|
+
}
|
|
654
729
|
function relativeAge(iso) {
|
|
655
730
|
const then = Date.parse(iso);
|
|
656
731
|
if (Number.isNaN(then)) return "recently";
|
|
@@ -673,16 +748,19 @@ function formatChangeRecords(records, cwd = process.cwd()) {
|
|
|
673
748
|
const lines = [];
|
|
674
749
|
for (const rec of records) {
|
|
675
750
|
if (rec.kind === "committed") {
|
|
676
|
-
|
|
751
|
+
const sha = rec.commitSha;
|
|
752
|
+
if (sha && isAncestor(cwd, sha)) continue;
|
|
753
|
+
const present = sha ? hasCommit(cwd, sha) : false;
|
|
754
|
+
const state = present ? "landed, not yet in your branch \u2014 pull/rebase" : "not yet on your base \u2014 unpushed, coordinate";
|
|
677
755
|
const intent = rec.message ?? "(work in progress)";
|
|
678
756
|
lines.push(
|
|
679
|
-
` ${rec.agentName} / ${rec.human} (${presence(rec)}) \u2014 committed (
|
|
757
|
+
` ${rec.agentName} / ${rec.human} (${presence(rec)}) \u2014 committed (${state}): "${intent}"`
|
|
680
758
|
);
|
|
681
759
|
lines.push(` files: ${rec.paths.join(", ")}`);
|
|
682
|
-
if (
|
|
760
|
+
if (sha && present && lineRangeBudget > 0) {
|
|
683
761
|
const budgetedPaths = rec.paths.slice(0, lineRangeBudget);
|
|
684
762
|
lineRangeBudget -= budgetedPaths.length;
|
|
685
|
-
const ranges = changedLineRanges(cwd,
|
|
763
|
+
const ranges = changedLineRanges(cwd, sha, budgetedPaths);
|
|
686
764
|
for (const p of Object.keys(ranges)) {
|
|
687
765
|
const spans = ranges[p].map((r) => r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`);
|
|
688
766
|
if (spans.length > 0) {
|
|
@@ -772,7 +850,7 @@ ${section}` : body;
|
|
|
772
850
|
"work",
|
|
773
851
|
{
|
|
774
852
|
title: "Claim a unit of work",
|
|
775
|
-
description: 'Claim a unit of work BEFORE you start
|
|
853
|
+
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
854
|
inputSchema: WorkAgentInput.shape
|
|
777
855
|
},
|
|
778
856
|
async (args) => {
|
|
@@ -817,13 +895,14 @@ You hold this claim until you call done (workItemId: ${result.workItemId}) or it
|
|
|
817
895
|
}
|
|
818
896
|
try {
|
|
819
897
|
const body = { sessionId, ...args };
|
|
820
|
-
await hubClient.post("/done", body);
|
|
898
|
+
const result = await hubClient.post("/done", body);
|
|
899
|
+
const base = "Work item released. Call work again before your next edit in a new area.";
|
|
900
|
+
const msgs = formatAnnouncements(result.announcements ?? []);
|
|
821
901
|
return {
|
|
822
902
|
content: [
|
|
823
|
-
{
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
}
|
|
903
|
+
{ type: "text", text: msgs ? `${base}
|
|
904
|
+
|
|
905
|
+
${msgs}` : base }
|
|
827
906
|
]
|
|
828
907
|
};
|
|
829
908
|
} catch (err) {
|
|
@@ -849,12 +928,13 @@ You hold this claim until you call done (workItemId: ${result.workItemId}) or it
|
|
|
849
928
|
try {
|
|
850
929
|
const body = { sessionId, ...args };
|
|
851
930
|
const result = await hubClient.post("/announce", body);
|
|
931
|
+
const base = `Announcement sent (id: ${result.announcementId}).`;
|
|
932
|
+
const msgs = formatAnnouncements(result.announcements ?? []);
|
|
852
933
|
return {
|
|
853
934
|
content: [
|
|
854
|
-
{
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
}
|
|
935
|
+
{ type: "text", text: msgs ? `${base}
|
|
936
|
+
|
|
937
|
+
${msgs}` : base }
|
|
858
938
|
]
|
|
859
939
|
};
|
|
860
940
|
} catch (err) {
|
|
@@ -893,7 +973,18 @@ You hold this claim until you call done (workItemId: ${result.workItemId}) or it
|
|
|
893
973
|
}
|
|
894
974
|
}
|
|
895
975
|
);
|
|
896
|
-
|
|
976
|
+
async function leave() {
|
|
977
|
+
try {
|
|
978
|
+
await joinInFlight;
|
|
979
|
+
if (!sessionId) return;
|
|
980
|
+
await hubClient.post("/leave", { sessionId });
|
|
981
|
+
} catch (err) {
|
|
982
|
+
console.error(
|
|
983
|
+
`[shepherd] leave failed: ${err instanceof Error ? err.message : String(err)}`
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
return { ready: joinInFlight, leave };
|
|
897
988
|
}
|
|
898
989
|
|
|
899
990
|
// src/resolveContext.ts
|
|
@@ -904,7 +995,9 @@ var defaultDeps = {
|
|
|
904
995
|
};
|
|
905
996
|
var DEFAULT_WORKSPACE = "default";
|
|
906
997
|
async function resolveContext(config, cwd = process.cwd(), deps = defaultDeps) {
|
|
907
|
-
const repo =
|
|
998
|
+
const repo = canonicalizeRepo(
|
|
999
|
+
config.REPO ?? deps.detectRepo(cwd) ?? "unknown-repo"
|
|
1000
|
+
);
|
|
908
1001
|
const branch = config.BRANCH ?? deps.detectBranch(cwd) ?? "HEAD";
|
|
909
1002
|
const human = config.HUMAN ?? deps.detectHuman(cwd) ?? generateName();
|
|
910
1003
|
const program = config.PROGRAM ?? "claude-code";
|
|
@@ -916,7 +1009,8 @@ async function resolveContext(config, cwd = process.cwd(), deps = defaultDeps) {
|
|
|
916
1009
|
// src/heartbeat.ts
|
|
917
1010
|
function createHeartbeat({
|
|
918
1011
|
hubClient,
|
|
919
|
-
intervalSeconds
|
|
1012
|
+
intervalSeconds,
|
|
1013
|
+
buildReport
|
|
920
1014
|
}) {
|
|
921
1015
|
let timer = null;
|
|
922
1016
|
function stop() {
|
|
@@ -925,16 +1019,26 @@ function createHeartbeat({
|
|
|
925
1019
|
timer = null;
|
|
926
1020
|
}
|
|
927
1021
|
}
|
|
1022
|
+
async function beat(sessionId) {
|
|
1023
|
+
let changeReport;
|
|
1024
|
+
if (buildReport) {
|
|
1025
|
+
try {
|
|
1026
|
+
changeReport = await buildReport();
|
|
1027
|
+
} catch {
|
|
1028
|
+
changeReport = void 0;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
const body = changeReport ? { sessionId, changeReport } : { sessionId };
|
|
1032
|
+
await hubClient.post("/heartbeat", body);
|
|
1033
|
+
}
|
|
928
1034
|
function start(sessionId) {
|
|
929
1035
|
stop();
|
|
930
1036
|
timer = setInterval(() => {
|
|
931
|
-
void
|
|
932
|
-
(
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
}
|
|
937
|
-
);
|
|
1037
|
+
void beat(sessionId).catch((err) => {
|
|
1038
|
+
console.error(
|
|
1039
|
+
`[shepherd] heartbeat failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1040
|
+
);
|
|
1041
|
+
});
|
|
938
1042
|
}, intervalSeconds * 1e3);
|
|
939
1043
|
timer.unref();
|
|
940
1044
|
}
|
|
@@ -946,7 +1050,7 @@ var SHEPHERD_INSTRUCTIONS = `You are connected to Shepherd, the shared coordinat
|
|
|
946
1050
|
|
|
947
1051
|
Follow this procedure on every session, proactively and without being asked:
|
|
948
1052
|
|
|
949
|
-
1. Before you start
|
|
1053
|
+
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
1054
|
|
|
951
1055
|
2. Call \`done\` when that unit of work is complete, using its \`workItemId\`, so teammates see the files freed.
|
|
952
1056
|
|
|
@@ -956,7 +1060,7 @@ Follow this procedure on every session, proactively and without being asked:
|
|
|
956
1060
|
|
|
957
1061
|
5. Call \`sync\` when you resume, start a new task, or before large changes, to refresh who is doing what.
|
|
958
1062
|
|
|
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.
|
|
1063
|
+
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
1064
|
|
|
961
1065
|
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
1066
|
|
|
@@ -967,26 +1071,39 @@ async function main() {
|
|
|
967
1071
|
const context = await resolveContext(config);
|
|
968
1072
|
const heartbeat = createHeartbeat({
|
|
969
1073
|
hubClient,
|
|
970
|
-
intervalSeconds: config.HEARTBEAT_INTERVAL_SECONDS
|
|
1074
|
+
intervalSeconds: config.HEARTBEAT_INTERVAL_SECONDS,
|
|
1075
|
+
// Attach a best-effort change report to each beat so commits surface to
|
|
1076
|
+
// teammates within ~one interval. Fail-open: any git error → presence-only.
|
|
1077
|
+
buildReport: async () => {
|
|
1078
|
+
try {
|
|
1079
|
+
return await buildChangeReport(process.cwd(), config) ?? void 0;
|
|
1080
|
+
} catch {
|
|
1081
|
+
return void 0;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
971
1084
|
});
|
|
972
1085
|
const server = new McpServer(
|
|
973
1086
|
{ name: "shepherd", version: "0.1.0" },
|
|
974
1087
|
{ instructions: SHEPHERD_INSTRUCTIONS }
|
|
975
1088
|
);
|
|
976
|
-
registerTools(server, { hubClient, config, context, heartbeat });
|
|
1089
|
+
const tools = registerTools(server, { hubClient, config, context, heartbeat });
|
|
977
1090
|
const transport = new StdioServerTransport();
|
|
978
|
-
|
|
1091
|
+
let shuttingDown = false;
|
|
1092
|
+
const shutdown = async () => {
|
|
1093
|
+
if (shuttingDown) return;
|
|
1094
|
+
shuttingDown = true;
|
|
979
1095
|
heartbeat.stop();
|
|
1096
|
+
await tools.leave();
|
|
980
1097
|
};
|
|
981
1098
|
process.once("SIGINT", () => {
|
|
982
|
-
shutdown();
|
|
983
|
-
process.exit(0);
|
|
1099
|
+
void shutdown().finally(() => process.exit(0));
|
|
984
1100
|
});
|
|
985
1101
|
process.once("SIGTERM", () => {
|
|
986
|
-
shutdown();
|
|
987
|
-
process.exit(0);
|
|
1102
|
+
void shutdown().finally(() => process.exit(0));
|
|
988
1103
|
});
|
|
989
|
-
transport.onclose =
|
|
1104
|
+
transport.onclose = () => {
|
|
1105
|
+
void shutdown();
|
|
1106
|
+
};
|
|
990
1107
|
await server.connect(transport);
|
|
991
1108
|
}
|
|
992
1109
|
main().catch((err) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@korso/shepherd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|