@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.
- package/README.md +69 -50
- package/dist/index.js +156 -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,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
|
-
|
|
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 (
|
|
759
|
+
` ${rec.agentName} / ${rec.human} (${presence(rec)}) \u2014 committed (${state}): "${intent}"`
|
|
680
760
|
);
|
|
681
761
|
lines.push(` files: ${rec.paths.join(", ")}`);
|
|
682
|
-
if (
|
|
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,
|
|
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
|
|
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
|
-
|
|
825
|
-
|
|
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
|
-
|
|
856
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
932
|
-
(
|
|
933
|
-
|
|
934
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
+
"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",
|