@korso/shepherd 0.4.5 → 0.5.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 +336 -336
- package/dist/inboxHook.js +0 -0
- package/dist/index.js +346 -84
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,336 +1,336 @@
|
|
|
1
|
-
# @korso/shepherd — Shepherd MCP Server
|
|
2
|
-
|
|
3
|
-
Shepherd's stdio MCP server. Gives any MCP-capable agent (Claude Code, Codex, etc.) four advisory coordination tools backed by the shared hub: `work`, `done`, `announce`, and `sync`. The agent **joins the workspace automatically** on startup (no `join` tool), and the server ships standing instructions so the agent self-coordinates without the user prompting it.
|
|
4
|
-
|
|
5
|
-
> **New here?** The [developer quickstart](https://github.com/Korsoai/shepherd/blob/main/docs/shepherd-mcp-quickstart.md) is the fastest path. TL;DR: `npx -y @korso/shepherd` with the env vars below.
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
## CRITICAL: WORKSPACE must match the hub exactly
|
|
10
|
-
|
|
11
|
-
> **`WORKSPACE` defaults to `default`. If you override it, the value must equal the hub's `ALLOWED_WORKSPACE` env var exactly.**
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
---
|
|
16
|
-
|
|
17
|
-
## Install
|
|
18
|
-
|
|
19
|
-
The server is published to npm and runs via `npx` — no clone or build required
|
|
20
|
-
(Node 18+):
|
|
21
|
-
|
|
22
|
-
```sh
|
|
23
|
-
npx -y @korso/shepherd
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
You won't normally run that by hand; you put it in your MCP client config (below)
|
|
27
|
-
with the required env vars. `npx` caches the package, so startup is fast after the
|
|
28
|
-
first fetch, and `@korso/shepherd@latest` picks up updates automatically.
|
|
29
|
-
|
|
30
|
-
> Hacking on the server itself? See **[Develop from source](#develop-from-source)**
|
|
31
|
-
> at the bottom.
|
|
32
|
-
|
|
33
|
-
---
|
|
34
|
-
|
|
35
|
-
## 2. Environment variables
|
|
36
|
-
|
|
37
|
-
**Only two are required:**
|
|
38
|
-
|
|
39
|
-
| Variable | Description | Example |
|
|
40
|
-
|---|---|---|
|
|
41
|
-
| `HUB_URL` | Base URL of the deployed hub | `https://shepherd.example.com` |
|
|
42
|
-
| `TEAM_TOKEN` | Shared bearer token accepted by the hub | `tok_abc123` |
|
|
43
|
-
|
|
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 this device's **cached** last-detected name, 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` |
|
|
62
|
-
| `SHEPHERD_INBOX_DIR` | defaults to `~/.shepherd/inbox`. Override only to relocate the **announcement-push** inbox (see below); the background heartbeat writes incoming announcements here. If you set it, point your client hook/extension at the **same** dir | `~/.shepherd/inbox` |
|
|
63
|
-
|
|
64
|
-
**Device-identity cache.** Whenever `HUMAN` is unset and git **does** detect a
|
|
65
|
-
name, that name is cached for your OS user at `~/.shepherd/identity.json`. A
|
|
66
|
-
later launch from a directory where git can't be read (e.g. a multi-repo
|
|
67
|
-
workspace root) then reuses the cached name instead of inventing a fresh random
|
|
68
|
-
one each time. The cache refreshes automatically the next time git reports a
|
|
69
|
-
different name, and an explicit `HUMAN` override always wins and never touches
|
|
70
|
-
the cache. It is best-effort: if the file can't be read or written, resolution
|
|
71
|
-
just falls back to a generated name.
|
|
72
|
-
|
|
73
|
-
---
|
|
74
|
-
|
|
75
|
-
## Announcement push (on by default)
|
|
76
|
-
|
|
77
|
-
Announcements reach an agent **without it having to ask**. The background
|
|
78
|
-
heartbeat pulls any pending announcements from the hub every beat and stages them
|
|
79
|
-
in a local **inbox file** (per working directory, under `SHEPHERD_INBOX_DIR`,
|
|
80
|
-
default `~/.shepherd/inbox`). That file is then drained by two paths:
|
|
81
|
-
|
|
82
|
-
1. **Universal drainer (always on, every client).** Whenever the agent calls any
|
|
83
|
-
Shepherd tool (`work`/`sync`/`done`/`announce`), the result also includes
|
|
84
|
-
anything sitting in the inbox. So even with no hook configured, no announcement
|
|
85
|
-
is ever lost — the worst case is the old behaviour (delivered on the next
|
|
86
|
-
Shepherd tool call), never silent drops.
|
|
87
|
-
2. **Passive client hook/extension (optional, per client).** To get announcements
|
|
88
|
-
**without** waiting for a Shepherd tool call — surfaced on the agent's next
|
|
89
|
-
action of any kind — wire up your client's hook below. This is the
|
|
90
|
-
"a subagent finished" style of notification.
|
|
91
|
-
|
|
92
|
-
Both paths read the **same** inbox file and de-duplicate by announcement id, so
|
|
93
|
-
running both is safe (the hub hands each announcement to exactly one drain; the
|
|
94
|
-
merge is just defensive). It's cheap: a **local file read — no network** (the
|
|
95
|
-
heartbeat already did the fetch), and it only adds to the model's context when
|
|
96
|
-
something is actually waiting.
|
|
97
|
-
|
|
98
|
-
It delivers to an agent **while it's active**; an idle agent picks messages up the
|
|
99
|
-
moment it next does anything. (Waking a fully-idle agent is out of scope — for
|
|
100
|
-
Claude Code that needs Channels; Codex/Pi have no equivalent.)
|
|
101
|
-
|
|
102
|
-
### Claude Code — `PreToolUse` hook
|
|
103
|
-
|
|
104
|
-
`PreToolUse` fires before every tool, giving the most frequent passive delivery.
|
|
105
|
-
The hook needs no arguments — it resolves the same default inbox dir the server
|
|
106
|
-
uses (override both with `SHEPHERD_INBOX_DIR` if you relocated it):
|
|
107
|
-
|
|
108
|
-
```json
|
|
109
|
-
{
|
|
110
|
-
"mcpServers": {
|
|
111
|
-
"shepherd": {
|
|
112
|
-
"command": "npx",
|
|
113
|
-
"args": ["-y", "@korso/shepherd"],
|
|
114
|
-
"env": {
|
|
115
|
-
"HUB_URL": "https://shepherd.example.com",
|
|
116
|
-
"TEAM_TOKEN": "tok_abc123"
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
},
|
|
120
|
-
"hooks": {
|
|
121
|
-
"PreToolUse": [
|
|
122
|
-
{
|
|
123
|
-
"matcher": "*",
|
|
124
|
-
"hooks": [
|
|
125
|
-
{ "type": "command", "command": "npx -y -p @korso/shepherd shepherd-inbox-hook" }
|
|
126
|
-
]
|
|
127
|
-
}
|
|
128
|
-
]
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
### Codex — `UserPromptSubmit` hook
|
|
134
|
-
|
|
135
|
-
Codex uses the **same** hook contract as Claude Code (JSON on stdin, a
|
|
136
|
-
`hookSpecificOutput.additionalContext` reply), so the **same bin** serves it. Use
|
|
137
|
-
`UserPromptSubmit` — Codex's `PreToolUse` only fires for Bash, not `apply_patch`
|
|
138
|
-
or MCP calls. Hooks must be enabled with `features.hooks = true`. In
|
|
139
|
-
`~/.codex/config.toml`:
|
|
140
|
-
|
|
141
|
-
```toml
|
|
142
|
-
[features]
|
|
143
|
-
hooks = true
|
|
144
|
-
|
|
145
|
-
[[hooks.UserPromptSubmit]]
|
|
146
|
-
command = ["npx", "-y", "-p", "@korso/shepherd", "shepherd-inbox-hook"]
|
|
147
|
-
# On Windows use command_windows instead:
|
|
148
|
-
# command_windows = ["cmd", "/c", "npx -y -p @korso/shepherd shepherd-inbox-hook"]
|
|
149
|
-
```
|
|
150
|
-
|
|
151
|
-
### Pi — extension
|
|
152
|
-
|
|
153
|
-
Pi has no stdin/stdout hook; it loads in-process extensions. Ship the bundled
|
|
154
|
-
extension into Pi's extensions dir:
|
|
155
|
-
|
|
156
|
-
```sh
|
|
157
|
-
# global, applies everywhere:
|
|
158
|
-
mkdir -p ~/.pi/agent/extensions
|
|
159
|
-
cp "$(npm root -g)/@korso/shepherd/dist/inboxExtension.js" ~/.pi/agent/extensions/shepherd-inbox.js
|
|
160
|
-
# …or per-project: copy into .pi/extensions/ in the repo root.
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
It runs on every user turn (`before_agent_start`), drains the same inbox, and
|
|
164
|
-
injects pending announcements. (Or load it ad hoc with
|
|
165
|
-
`pi -e /abs/path/to/dist/inboxExtension.js`.)
|
|
166
|
-
|
|
167
|
-
### Notes
|
|
168
|
-
|
|
169
|
-
Every path is **fail-open**: a missing dir, unreachable hub, or any error means
|
|
170
|
-
nothing is surfaced and the tool call / turn proceeds normally — coordination
|
|
171
|
-
never blocks the agent. The inbox is keyed per working directory; two sessions in
|
|
172
|
-
the exact same directory share it (a benign edge — they're the same repo). If you
|
|
173
|
-
override `SHEPHERD_INBOX_DIR` on the server, set it on the hook/extension to the
|
|
174
|
-
same value (the Claude/Codex bin and the Pi extension both read
|
|
175
|
-
`SHEPHERD_INBOX_DIR`, or you can pass the dir as the first CLI arg to the bin).
|
|
176
|
-
|
|
177
|
-
---
|
|
178
|
-
|
|
179
|
-
## 3. MCP client configuration
|
|
180
|
-
|
|
181
|
-
### Claude Code
|
|
182
|
-
|
|
183
|
-
> **Do not use `~/.claude/mcp.json` — Claude Code does not read it** (a config
|
|
184
|
-
> there loads silently into nothing). Use `claude mcp add` (user scope, applies
|
|
185
|
-
> everywhere) or a project-root `.mcp.json`. Confirm with `claude mcp list`,
|
|
186
|
-
> which should show `shepherd … ✔ Connected`.
|
|
187
|
-
|
|
188
|
-
Recommended — register once at user scope. Written as a **single line** so it
|
|
189
|
-
pastes cleanly into PowerShell, cmd, bash, and zsh (on PowerShell the bash `\`
|
|
190
|
-
line-continuation does not work). Minimal: just the two required vars (identity
|
|
191
|
-
is auto-detected from git):
|
|
192
|
-
|
|
193
|
-
```powershell
|
|
194
|
-
claude mcp add shepherd -s user -e HUB_URL=https://shepherd.example.com -e TEAM_TOKEN=tok_abc123 -- npx -y @korso/shepherd
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
Add any optional overrides from §2 with extra `-e` flags (e.g. `-e MODEL=claude-sonnet-4-6 -e HUMAN=daichi`).
|
|
198
|
-
|
|
199
|
-
Alternative — a `.mcp.json` at the **root of the repo you're working in**
|
|
200
|
-
(optional overrides shown commented-style; drop the ones you don't need):
|
|
201
|
-
|
|
202
|
-
```json
|
|
203
|
-
{
|
|
204
|
-
"mcpServers": {
|
|
205
|
-
"shepherd": {
|
|
206
|
-
"command": "npx",
|
|
207
|
-
"args": ["-y", "@korso/shepherd"],
|
|
208
|
-
"env": {
|
|
209
|
-
"HUB_URL": "https://shepherd.example.com",
|
|
210
|
-
"TEAM_TOKEN": "tok_abc123",
|
|
211
|
-
"MODEL": "claude-sonnet-4-6"
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
```
|
|
217
|
-
|
|
218
|
-
> Windows note: the server is a thin stdio client to the Linux-hosted hub, and
|
|
219
|
-
> `npx` works the same on every OS — no file paths to escape. The hub itself runs
|
|
220
|
-
> on Linux (Postgres), so the Windows-native durability concerns from the spike
|
|
221
|
-
> don't apply to clients.
|
|
222
|
-
|
|
223
|
-
### Codex (`~/.codex/config.toml`)
|
|
224
|
-
|
|
225
|
-
Codex uses the same MCP stdio protocol but configures it in **TOML**, not JSON —
|
|
226
|
-
at `~/.codex/config.toml` (global) or `.codex/config.toml` in a trusted project.
|
|
227
|
-
The table is `mcp_servers` with an **underscore** (`mcp-servers`/`mcpServers` are
|
|
228
|
-
silently ignored). Either run `codex mcp add`:
|
|
229
|
-
|
|
230
|
-
```sh
|
|
231
|
-
codex mcp add shepherd --env HUB_URL=https://shepherd.example.com --env TEAM_TOKEN=tok_abc123 --env PROGRAM=codex -- npx -y @korso/shepherd
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
…or add the table directly:
|
|
235
|
-
|
|
236
|
-
```toml
|
|
237
|
-
[mcp_servers.shepherd]
|
|
238
|
-
command = "npx"
|
|
239
|
-
args = ["-y", "@korso/shepherd"]
|
|
240
|
-
env = { HUB_URL = "https://shepherd.example.com", TEAM_TOKEN = "tok_abc123", PROGRAM = "codex", MODEL = "o4-mini" }
|
|
241
|
-
```
|
|
242
|
-
|
|
243
|
-
### Pi (`~/.pi/agent/mcp.json` or `.pi/mcp.json`)
|
|
244
|
-
|
|
245
|
-
Pi uses a JSON `mcpServers` block (project config overrides global):
|
|
246
|
-
|
|
247
|
-
```json
|
|
248
|
-
{
|
|
249
|
-
"mcpServers": {
|
|
250
|
-
"shepherd": {
|
|
251
|
-
"command": "npx",
|
|
252
|
-
"args": ["-y", "@korso/shepherd"],
|
|
253
|
-
"env": {
|
|
254
|
-
"HUB_URL": "https://shepherd.example.com",
|
|
255
|
-
"TEAM_TOKEN": "tok_abc123",
|
|
256
|
-
"PROGRAM": "pi"
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
```
|
|
262
|
-
|
|
263
|
-
---
|
|
264
|
-
|
|
265
|
-
## 4. Verify the server starts (quick smoke test)
|
|
266
|
-
|
|
267
|
-
Run with the two required vars set to confirm it connects and idles on stdin.
|
|
268
|
-
PowerShell (set env vars, then run):
|
|
269
|
-
|
|
270
|
-
```powershell
|
|
271
|
-
$env:HUB_URL = "https://shepherd.example.com"
|
|
272
|
-
$env:TEAM_TOKEN = "tok_abc123"
|
|
273
|
-
npx -y @korso/shepherd
|
|
274
|
-
```
|
|
275
|
-
|
|
276
|
-
bash/zsh: `HUB_URL=https://shepherd.example.com TEAM_TOKEN=tok_abc123 npx -y @korso/shepherd`
|
|
277
|
-
|
|
278
|
-
No stderr output and the process blocking on stdin = healthy. Press Ctrl+C to exit.
|
|
279
|
-
|
|
280
|
-
**Missing env vars:** if you omit `HUB_URL` or `TEAM_TOKEN`, you will see:
|
|
281
|
-
|
|
282
|
-
```
|
|
283
|
-
[shepherd] Configuration error — missing or invalid env vars:
|
|
284
|
-
HUB_URL: HUB_URL is required
|
|
285
|
-
TEAM_TOKEN: TEAM_TOKEN is required
|
|
286
|
-
```
|
|
287
|
-
|
|
288
|
-
and the process exits 1 immediately. This is by design. The optional identity
|
|
289
|
-
vars never cause this — they fall back to git detection / defaults.
|
|
290
|
-
|
|
291
|
-
**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.
|
|
292
|
-
|
|
293
|
-
---
|
|
294
|
-
|
|
295
|
-
## Develop from source
|
|
296
|
-
|
|
297
|
-
Only needed if you're changing the MCP server itself. Clone the monorepo and
|
|
298
|
-
point your client at a local build instead of npx:
|
|
299
|
-
|
|
300
|
-
```sh
|
|
301
|
-
git clone https://github.com/Korsoai/shepherd.git
|
|
302
|
-
cd shepherd
|
|
303
|
-
npm install
|
|
304
|
-
npm run build # tsc -b — compiles the workspace for dev + tests
|
|
305
|
-
```
|
|
306
|
-
|
|
307
|
-
For an exact preview of the published artifact (a single self-contained bundle
|
|
308
|
-
with `@shepherd/shared` inlined), build the package directly:
|
|
309
|
-
|
|
310
|
-
```sh
|
|
311
|
-
npm run build --workspace=@korso/shepherd # runs tsup → packages/mcp-server/dist/index.js
|
|
312
|
-
```
|
|
313
|
-
|
|
314
|
-
Then use `node /absolute/path/to/shepherd/packages/mcp-server/dist/index.js` as
|
|
315
|
-
the `command` in your MCP config (Windows: escape backslashes in JSON).
|
|
316
|
-
|
|
317
|
-
### Publishing a new version
|
|
318
|
-
|
|
319
|
-
```sh
|
|
320
|
-
# bump "version" in packages/mcp-server/package.json, then:
|
|
321
|
-
npm publish --workspace=@korso/shepherd # prepublishOnly runs tsup automatically
|
|
322
|
-
```
|
|
323
|
-
|
|
324
|
-
`publishConfig.access` is `public`, so the scoped package publishes publicly.
|
|
325
|
-
|
|
326
|
-
---
|
|
327
|
-
|
|
328
|
-
## Troubleshooting
|
|
329
|
-
|
|
330
|
-
| Symptom | Likely cause | Fix |
|
|
331
|
-
|---|---|---|
|
|
332
|
-
| `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 |
|
|
333
|
-
| 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 |
|
|
334
|
-
| Agent shows up under a surprising name/repo/branch | Identity auto-detected from git, or reused from the device-identity cache when launched outside a git work tree | Override with `HUMAN`/`REPO`/`BRANCH`/`MODEL` env vars (§2); a correct git `user.name` on the next in-repo launch refreshes the cache, or delete `~/.shepherd/identity.json` to clear it |
|
|
335
|
-
| `npm error 404 … @korso/shepherd` | Package not published yet, or name typo | `npm view @korso/shepherd version` to confirm it's live |
|
|
336
|
-
| Process exits immediately with no error | Rare; check for node version incompatibility | Requires Node 18+ (ESM support) |
|
|
1
|
+
# @korso/shepherd — Shepherd MCP Server
|
|
2
|
+
|
|
3
|
+
Shepherd's stdio MCP server. Gives any MCP-capable agent (Claude Code, Codex, etc.) four advisory coordination tools backed by the shared hub: `work`, `done`, `announce`, and `sync`. The agent **joins the workspace automatically** on startup (no `join` tool), and the server ships standing instructions so the agent self-coordinates without the user prompting it.
|
|
4
|
+
|
|
5
|
+
> **New here?** The [developer quickstart](https://github.com/Korsoai/shepherd/blob/main/docs/shepherd-mcp-quickstart.md) is the fastest path. TL;DR: `npx -y @korso/shepherd` with the env vars below.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## CRITICAL: WORKSPACE must match the hub exactly
|
|
10
|
+
|
|
11
|
+
> **`WORKSPACE` defaults to `default`. If you override it, the value must equal the hub's `ALLOWED_WORKSPACE` env var exactly.**
|
|
12
|
+
|
|
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
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
The server is published to npm and runs via `npx` — no clone or build required
|
|
20
|
+
(Node 18+):
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
npx -y @korso/shepherd
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
You won't normally run that by hand; you put it in your MCP client config (below)
|
|
27
|
+
with the required env vars. `npx` caches the package, so startup is fast after the
|
|
28
|
+
first fetch, and `@korso/shepherd@latest` picks up updates automatically.
|
|
29
|
+
|
|
30
|
+
> Hacking on the server itself? See **[Develop from source](#develop-from-source)**
|
|
31
|
+
> at the bottom.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## 2. Environment variables
|
|
36
|
+
|
|
37
|
+
**Only two are required:**
|
|
38
|
+
|
|
39
|
+
| Variable | Description | Example |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| `HUB_URL` | Base URL of the deployed hub | `https://shepherd.example.com` |
|
|
42
|
+
| `TEAM_TOKEN` | Shared bearer token accepted by the hub | `tok_abc123` |
|
|
43
|
+
|
|
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 this device's **cached** last-detected name, 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` |
|
|
62
|
+
| `SHEPHERD_INBOX_DIR` | defaults to `~/.shepherd/inbox`. Override only to relocate the **announcement-push** inbox (see below); the background heartbeat writes incoming announcements here. If you set it, point your client hook/extension at the **same** dir | `~/.shepherd/inbox` |
|
|
63
|
+
|
|
64
|
+
**Device-identity cache.** Whenever `HUMAN` is unset and git **does** detect a
|
|
65
|
+
name, that name is cached for your OS user at `~/.shepherd/identity.json`. A
|
|
66
|
+
later launch from a directory where git can't be read (e.g. a multi-repo
|
|
67
|
+
workspace root) then reuses the cached name instead of inventing a fresh random
|
|
68
|
+
one each time. The cache refreshes automatically the next time git reports a
|
|
69
|
+
different name, and an explicit `HUMAN` override always wins and never touches
|
|
70
|
+
the cache. It is best-effort: if the file can't be read or written, resolution
|
|
71
|
+
just falls back to a generated name.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Announcement push (on by default)
|
|
76
|
+
|
|
77
|
+
Announcements reach an agent **without it having to ask**. The background
|
|
78
|
+
heartbeat pulls any pending announcements from the hub every beat and stages them
|
|
79
|
+
in a local **inbox file** (per working directory, under `SHEPHERD_INBOX_DIR`,
|
|
80
|
+
default `~/.shepherd/inbox`). That file is then drained by two paths:
|
|
81
|
+
|
|
82
|
+
1. **Universal drainer (always on, every client).** Whenever the agent calls any
|
|
83
|
+
Shepherd tool (`work`/`sync`/`done`/`announce`), the result also includes
|
|
84
|
+
anything sitting in the inbox. So even with no hook configured, no announcement
|
|
85
|
+
is ever lost — the worst case is the old behaviour (delivered on the next
|
|
86
|
+
Shepherd tool call), never silent drops.
|
|
87
|
+
2. **Passive client hook/extension (optional, per client).** To get announcements
|
|
88
|
+
**without** waiting for a Shepherd tool call — surfaced on the agent's next
|
|
89
|
+
action of any kind — wire up your client's hook below. This is the
|
|
90
|
+
"a subagent finished" style of notification.
|
|
91
|
+
|
|
92
|
+
Both paths read the **same** inbox file and de-duplicate by announcement id, so
|
|
93
|
+
running both is safe (the hub hands each announcement to exactly one drain; the
|
|
94
|
+
merge is just defensive). It's cheap: a **local file read — no network** (the
|
|
95
|
+
heartbeat already did the fetch), and it only adds to the model's context when
|
|
96
|
+
something is actually waiting.
|
|
97
|
+
|
|
98
|
+
It delivers to an agent **while it's active**; an idle agent picks messages up the
|
|
99
|
+
moment it next does anything. (Waking a fully-idle agent is out of scope — for
|
|
100
|
+
Claude Code that needs Channels; Codex/Pi have no equivalent.)
|
|
101
|
+
|
|
102
|
+
### Claude Code — `PreToolUse` hook
|
|
103
|
+
|
|
104
|
+
`PreToolUse` fires before every tool, giving the most frequent passive delivery.
|
|
105
|
+
The hook needs no arguments — it resolves the same default inbox dir the server
|
|
106
|
+
uses (override both with `SHEPHERD_INBOX_DIR` if you relocated it):
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{
|
|
110
|
+
"mcpServers": {
|
|
111
|
+
"shepherd": {
|
|
112
|
+
"command": "npx",
|
|
113
|
+
"args": ["-y", "@korso/shepherd"],
|
|
114
|
+
"env": {
|
|
115
|
+
"HUB_URL": "https://shepherd.example.com",
|
|
116
|
+
"TEAM_TOKEN": "tok_abc123"
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
"hooks": {
|
|
121
|
+
"PreToolUse": [
|
|
122
|
+
{
|
|
123
|
+
"matcher": "*",
|
|
124
|
+
"hooks": [
|
|
125
|
+
{ "type": "command", "command": "npx -y -p @korso/shepherd shepherd-inbox-hook" }
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Codex — `UserPromptSubmit` hook
|
|
134
|
+
|
|
135
|
+
Codex uses the **same** hook contract as Claude Code (JSON on stdin, a
|
|
136
|
+
`hookSpecificOutput.additionalContext` reply), so the **same bin** serves it. Use
|
|
137
|
+
`UserPromptSubmit` — Codex's `PreToolUse` only fires for Bash, not `apply_patch`
|
|
138
|
+
or MCP calls. Hooks must be enabled with `features.hooks = true`. In
|
|
139
|
+
`~/.codex/config.toml`:
|
|
140
|
+
|
|
141
|
+
```toml
|
|
142
|
+
[features]
|
|
143
|
+
hooks = true
|
|
144
|
+
|
|
145
|
+
[[hooks.UserPromptSubmit]]
|
|
146
|
+
command = ["npx", "-y", "-p", "@korso/shepherd", "shepherd-inbox-hook"]
|
|
147
|
+
# On Windows use command_windows instead:
|
|
148
|
+
# command_windows = ["cmd", "/c", "npx -y -p @korso/shepherd shepherd-inbox-hook"]
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Pi — extension
|
|
152
|
+
|
|
153
|
+
Pi has no stdin/stdout hook; it loads in-process extensions. Ship the bundled
|
|
154
|
+
extension into Pi's extensions dir:
|
|
155
|
+
|
|
156
|
+
```sh
|
|
157
|
+
# global, applies everywhere:
|
|
158
|
+
mkdir -p ~/.pi/agent/extensions
|
|
159
|
+
cp "$(npm root -g)/@korso/shepherd/dist/inboxExtension.js" ~/.pi/agent/extensions/shepherd-inbox.js
|
|
160
|
+
# …or per-project: copy into .pi/extensions/ in the repo root.
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
It runs on every user turn (`before_agent_start`), drains the same inbox, and
|
|
164
|
+
injects pending announcements. (Or load it ad hoc with
|
|
165
|
+
`pi -e /abs/path/to/dist/inboxExtension.js`.)
|
|
166
|
+
|
|
167
|
+
### Notes
|
|
168
|
+
|
|
169
|
+
Every path is **fail-open**: a missing dir, unreachable hub, or any error means
|
|
170
|
+
nothing is surfaced and the tool call / turn proceeds normally — coordination
|
|
171
|
+
never blocks the agent. The inbox is keyed per working directory; two sessions in
|
|
172
|
+
the exact same directory share it (a benign edge — they're the same repo). If you
|
|
173
|
+
override `SHEPHERD_INBOX_DIR` on the server, set it on the hook/extension to the
|
|
174
|
+
same value (the Claude/Codex bin and the Pi extension both read
|
|
175
|
+
`SHEPHERD_INBOX_DIR`, or you can pass the dir as the first CLI arg to the bin).
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## 3. MCP client configuration
|
|
180
|
+
|
|
181
|
+
### Claude Code
|
|
182
|
+
|
|
183
|
+
> **Do not use `~/.claude/mcp.json` — Claude Code does not read it** (a config
|
|
184
|
+
> there loads silently into nothing). Use `claude mcp add` (user scope, applies
|
|
185
|
+
> everywhere) or a project-root `.mcp.json`. Confirm with `claude mcp list`,
|
|
186
|
+
> which should show `shepherd … ✔ Connected`.
|
|
187
|
+
|
|
188
|
+
Recommended — register once at user scope. Written as a **single line** so it
|
|
189
|
+
pastes cleanly into PowerShell, cmd, bash, and zsh (on PowerShell the bash `\`
|
|
190
|
+
line-continuation does not work). Minimal: just the two required vars (identity
|
|
191
|
+
is auto-detected from git):
|
|
192
|
+
|
|
193
|
+
```powershell
|
|
194
|
+
claude mcp add shepherd -s user -e HUB_URL=https://shepherd.example.com -e TEAM_TOKEN=tok_abc123 -- npx -y @korso/shepherd
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Add any optional overrides from §2 with extra `-e` flags (e.g. `-e MODEL=claude-sonnet-4-6 -e HUMAN=daichi`).
|
|
198
|
+
|
|
199
|
+
Alternative — a `.mcp.json` at the **root of the repo you're working in**
|
|
200
|
+
(optional overrides shown commented-style; drop the ones you don't need):
|
|
201
|
+
|
|
202
|
+
```json
|
|
203
|
+
{
|
|
204
|
+
"mcpServers": {
|
|
205
|
+
"shepherd": {
|
|
206
|
+
"command": "npx",
|
|
207
|
+
"args": ["-y", "@korso/shepherd"],
|
|
208
|
+
"env": {
|
|
209
|
+
"HUB_URL": "https://shepherd.example.com",
|
|
210
|
+
"TEAM_TOKEN": "tok_abc123",
|
|
211
|
+
"MODEL": "claude-sonnet-4-6"
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
> Windows note: the server is a thin stdio client to the Linux-hosted hub, and
|
|
219
|
+
> `npx` works the same on every OS — no file paths to escape. The hub itself runs
|
|
220
|
+
> on Linux (Postgres), so the Windows-native durability concerns from the spike
|
|
221
|
+
> don't apply to clients.
|
|
222
|
+
|
|
223
|
+
### Codex (`~/.codex/config.toml`)
|
|
224
|
+
|
|
225
|
+
Codex uses the same MCP stdio protocol but configures it in **TOML**, not JSON —
|
|
226
|
+
at `~/.codex/config.toml` (global) or `.codex/config.toml` in a trusted project.
|
|
227
|
+
The table is `mcp_servers` with an **underscore** (`mcp-servers`/`mcpServers` are
|
|
228
|
+
silently ignored). Either run `codex mcp add`:
|
|
229
|
+
|
|
230
|
+
```sh
|
|
231
|
+
codex mcp add shepherd --env HUB_URL=https://shepherd.example.com --env TEAM_TOKEN=tok_abc123 --env PROGRAM=codex -- npx -y @korso/shepherd
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
…or add the table directly:
|
|
235
|
+
|
|
236
|
+
```toml
|
|
237
|
+
[mcp_servers.shepherd]
|
|
238
|
+
command = "npx"
|
|
239
|
+
args = ["-y", "@korso/shepherd"]
|
|
240
|
+
env = { HUB_URL = "https://shepherd.example.com", TEAM_TOKEN = "tok_abc123", PROGRAM = "codex", MODEL = "o4-mini" }
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Pi (`~/.pi/agent/mcp.json` or `.pi/mcp.json`)
|
|
244
|
+
|
|
245
|
+
Pi uses a JSON `mcpServers` block (project config overrides global):
|
|
246
|
+
|
|
247
|
+
```json
|
|
248
|
+
{
|
|
249
|
+
"mcpServers": {
|
|
250
|
+
"shepherd": {
|
|
251
|
+
"command": "npx",
|
|
252
|
+
"args": ["-y", "@korso/shepherd"],
|
|
253
|
+
"env": {
|
|
254
|
+
"HUB_URL": "https://shepherd.example.com",
|
|
255
|
+
"TEAM_TOKEN": "tok_abc123",
|
|
256
|
+
"PROGRAM": "pi"
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## 4. Verify the server starts (quick smoke test)
|
|
266
|
+
|
|
267
|
+
Run with the two required vars set to confirm it connects and idles on stdin.
|
|
268
|
+
PowerShell (set env vars, then run):
|
|
269
|
+
|
|
270
|
+
```powershell
|
|
271
|
+
$env:HUB_URL = "https://shepherd.example.com"
|
|
272
|
+
$env:TEAM_TOKEN = "tok_abc123"
|
|
273
|
+
npx -y @korso/shepherd
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
bash/zsh: `HUB_URL=https://shepherd.example.com TEAM_TOKEN=tok_abc123 npx -y @korso/shepherd`
|
|
277
|
+
|
|
278
|
+
No stderr output and the process blocking on stdin = healthy. Press Ctrl+C to exit.
|
|
279
|
+
|
|
280
|
+
**Missing env vars:** if you omit `HUB_URL` or `TEAM_TOKEN`, you will see:
|
|
281
|
+
|
|
282
|
+
```
|
|
283
|
+
[shepherd] Configuration error — missing or invalid env vars:
|
|
284
|
+
HUB_URL: HUB_URL is required
|
|
285
|
+
TEAM_TOKEN: TEAM_TOKEN is required
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
and the process exits 1 immediately. This is by design. The optional identity
|
|
289
|
+
vars never cause this — they fall back to git detection / defaults.
|
|
290
|
+
|
|
291
|
+
**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.
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## Develop from source
|
|
296
|
+
|
|
297
|
+
Only needed if you're changing the MCP server itself. Clone the monorepo and
|
|
298
|
+
point your client at a local build instead of npx:
|
|
299
|
+
|
|
300
|
+
```sh
|
|
301
|
+
git clone https://github.com/Korsoai/shepherd.git
|
|
302
|
+
cd shepherd
|
|
303
|
+
npm install
|
|
304
|
+
npm run build # tsc -b — compiles the workspace for dev + tests
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
For an exact preview of the published artifact (a single self-contained bundle
|
|
308
|
+
with `@shepherd/shared` inlined), build the package directly:
|
|
309
|
+
|
|
310
|
+
```sh
|
|
311
|
+
npm run build --workspace=@korso/shepherd # runs tsup → packages/mcp-server/dist/index.js
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
Then use `node /absolute/path/to/shepherd/packages/mcp-server/dist/index.js` as
|
|
315
|
+
the `command` in your MCP config (Windows: escape backslashes in JSON).
|
|
316
|
+
|
|
317
|
+
### Publishing a new version
|
|
318
|
+
|
|
319
|
+
```sh
|
|
320
|
+
# bump "version" in packages/mcp-server/package.json, then:
|
|
321
|
+
npm publish --workspace=@korso/shepherd # prepublishOnly runs tsup automatically
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
`publishConfig.access` is `public`, so the scoped package publishes publicly.
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
## Troubleshooting
|
|
329
|
+
|
|
330
|
+
| Symptom | Likely cause | Fix |
|
|
331
|
+
|---|---|---|
|
|
332
|
+
| `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 |
|
|
333
|
+
| 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 |
|
|
334
|
+
| Agent shows up under a surprising name/repo/branch | Identity auto-detected from git, or reused from the device-identity cache when launched outside a git work tree | Override with `HUMAN`/`REPO`/`BRANCH`/`MODEL` env vars (§2); a correct git `user.name` on the next in-repo launch refreshes the cache, or delete `~/.shepherd/identity.json` to clear it |
|
|
335
|
+
| `npm error 404 … @korso/shepherd` | Package not published yet, or name typo | `npm view @korso/shepherd version` to confirm it's live |
|
|
336
|
+
| Process exits immediately with no error | Rare; check for node version incompatibility | Requires Node 18+ (ESM support) |
|
package/dist/inboxHook.js
CHANGED
|
File without changes
|
package/dist/index.js
CHANGED
|
@@ -7,11 +7,18 @@ 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:
|
|
10
|
+
// Hard-required: Hub endpoint.
|
|
11
11
|
HUB_URL: z.string().min(1, "HUB_URL is required"),
|
|
12
|
-
|
|
12
|
+
// Auth credentials. Exactly one form is needed (enforced by the refine below):
|
|
13
|
+
// - SHEPHERD_TOKEN: the hosted Hub credential (carries its own workspace).
|
|
14
|
+
// - TEAM_TOKEN: the self-host credential.
|
|
15
|
+
// SHEPHERD_TOKEN wins when both are present (see the derived `authToken`).
|
|
16
|
+
SHEPHERD_TOKEN: z.string().min(1).optional(),
|
|
17
|
+
TEAM_TOKEN: z.string().min(1).optional(),
|
|
13
18
|
// Optional overrides — resolveContext will apply defaults for any that are absent.
|
|
14
19
|
// WORKSPACE default is applied in resolveContext (auto-detected from cwd basename).
|
|
20
|
+
// NOTE: WORKSPACE is IGNORED by the hosted Hub — the SHEPHERD_TOKEN carries the
|
|
21
|
+
// workspace identity. It remains meaningful only for self-host (TEAM_TOKEN) setups.
|
|
15
22
|
WORKSPACE: z.string().min(1).optional(),
|
|
16
23
|
REPO: z.string().min(1).optional(),
|
|
17
24
|
BRANCH: z.string().min(1).optional(),
|
|
@@ -28,10 +35,14 @@ var ConfigSchema = z.object({
|
|
|
28
35
|
// work/sync/done/announce tool results as before. Both the MCP server and the
|
|
29
36
|
// hook must agree on this path.
|
|
30
37
|
SHEPHERD_INBOX_DIR: z.string().min(1).optional()
|
|
38
|
+
}).refine((c) => Boolean(c.SHEPHERD_TOKEN || c.TEAM_TOKEN), {
|
|
39
|
+
message: "Either SHEPHERD_TOKEN or TEAM_TOKEN is required",
|
|
40
|
+
path: ["SHEPHERD_TOKEN"]
|
|
31
41
|
});
|
|
32
42
|
function parseConfig(env) {
|
|
33
|
-
|
|
43
|
+
const parsed = ConfigSchema.parse({
|
|
34
44
|
HUB_URL: env["HUB_URL"],
|
|
45
|
+
SHEPHERD_TOKEN: env["SHEPHERD_TOKEN"],
|
|
35
46
|
TEAM_TOKEN: env["TEAM_TOKEN"],
|
|
36
47
|
WORKSPACE: env["WORKSPACE"],
|
|
37
48
|
REPO: env["REPO"],
|
|
@@ -43,6 +54,8 @@ function parseConfig(env) {
|
|
|
43
54
|
HEARTBEAT_INTERVAL_SECONDS: env["HEARTBEAT_INTERVAL_SECONDS"],
|
|
44
55
|
SHEPHERD_INBOX_DIR: env["SHEPHERD_INBOX_DIR"]
|
|
45
56
|
});
|
|
57
|
+
const authToken = parsed.SHEPHERD_TOKEN ?? parsed.TEAM_TOKEN;
|
|
58
|
+
return { ...parsed, authToken };
|
|
46
59
|
}
|
|
47
60
|
function loadConfig(env = process.env) {
|
|
48
61
|
try {
|
|
@@ -82,51 +95,63 @@ var HubRequestError = class extends Error {
|
|
|
82
95
|
};
|
|
83
96
|
function createHubClient({
|
|
84
97
|
hubUrl,
|
|
85
|
-
|
|
98
|
+
token,
|
|
86
99
|
timeoutMs = DEFAULT_TIMEOUT_MS
|
|
87
100
|
}) {
|
|
88
101
|
const baseUrl = hubUrl.replace(/\/$/, "");
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
102
|
+
async function request(method, path3, body) {
|
|
103
|
+
const controller = new AbortController();
|
|
104
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
105
|
+
const headers = {
|
|
106
|
+
"Authorization": `Bearer ${token}`
|
|
107
|
+
};
|
|
108
|
+
if (method === "POST") {
|
|
109
|
+
headers["Content-Type"] = "application/json";
|
|
110
|
+
}
|
|
111
|
+
let response;
|
|
112
|
+
try {
|
|
113
|
+
response = await fetch(`${baseUrl}${path3}`, {
|
|
114
|
+
method,
|
|
115
|
+
headers,
|
|
116
|
+
...method === "POST" ? { body: JSON.stringify(body) } : {},
|
|
117
|
+
signal: controller.signal
|
|
118
|
+
});
|
|
119
|
+
} catch (err) {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
const message = err instanceof DOMException && err.name === "AbortError" ? `Hub request timed out after ${timeoutMs}ms (${path3})` : `Hub unreachable at ${baseUrl}${path3}: ${String(err)}`;
|
|
122
|
+
throw new HubUnreachable(message, err);
|
|
123
|
+
} finally {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
}
|
|
126
|
+
if (!response.ok) {
|
|
127
|
+
let detail = "";
|
|
94
128
|
try {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
"Authorization": `Bearer ${teamToken}`,
|
|
99
|
-
"Content-Type": "application/json"
|
|
100
|
-
},
|
|
101
|
-
body: JSON.stringify(body),
|
|
102
|
-
signal: controller.signal
|
|
103
|
-
});
|
|
104
|
-
} catch (err) {
|
|
105
|
-
clearTimeout(timer);
|
|
106
|
-
const message = err instanceof DOMException && err.name === "AbortError" ? `Hub request timed out after ${timeoutMs}ms (${path2})` : `Hub unreachable at ${baseUrl}${path2}: ${String(err)}`;
|
|
107
|
-
throw new HubUnreachable(message, err);
|
|
108
|
-
} finally {
|
|
109
|
-
clearTimeout(timer);
|
|
110
|
-
}
|
|
111
|
-
if (!response.ok) {
|
|
112
|
-
let detail = "";
|
|
113
|
-
try {
|
|
114
|
-
const data = await response.json();
|
|
115
|
-
if (data && typeof data === "object" && "error" in data && typeof data.error === "string") {
|
|
116
|
-
detail = `: ${data.error}`;
|
|
117
|
-
}
|
|
118
|
-
} catch {
|
|
129
|
+
const data = await response.json();
|
|
130
|
+
if (data && typeof data === "object" && "error" in data && typeof data.error === "string") {
|
|
131
|
+
detail = `: ${data.error}`;
|
|
119
132
|
}
|
|
120
|
-
|
|
121
|
-
response.status,
|
|
122
|
-
`Hub returned HTTP ${response.status} for ${path2}${detail}`
|
|
123
|
-
);
|
|
133
|
+
} catch {
|
|
124
134
|
}
|
|
125
|
-
|
|
135
|
+
throw new HubRequestError(
|
|
136
|
+
response.status,
|
|
137
|
+
`Hub returned HTTP ${response.status} for ${path3}${detail}`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return response.json();
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
post(path3, body) {
|
|
144
|
+
return request("POST", path3, body);
|
|
145
|
+
},
|
|
146
|
+
get(path3) {
|
|
147
|
+
return request("GET", path3);
|
|
126
148
|
}
|
|
127
149
|
};
|
|
128
150
|
}
|
|
129
151
|
|
|
152
|
+
// src/tools.ts
|
|
153
|
+
import { z as z3 } from "zod";
|
|
154
|
+
|
|
130
155
|
// ../shared/dist/names.js
|
|
131
156
|
var adjectives = [
|
|
132
157
|
"Able",
|
|
@@ -499,10 +524,119 @@ var LeaveRequest = z2.object({
|
|
|
499
524
|
var LeaveResponse = z2.object({
|
|
500
525
|
ok: z2.literal(true)
|
|
501
526
|
});
|
|
527
|
+
var Role = z2.enum(["admin", "member"]);
|
|
528
|
+
var WorkspaceSummary = z2.object({
|
|
529
|
+
id: z2.string(),
|
|
530
|
+
slug: z2.string(),
|
|
531
|
+
name: z2.string(),
|
|
532
|
+
role: Role
|
|
533
|
+
});
|
|
534
|
+
var CreateWorkspaceRequest = z2.object({
|
|
535
|
+
name: z2.string().min(1)
|
|
536
|
+
});
|
|
537
|
+
var ListWorkspacesResponse = z2.object({
|
|
538
|
+
workspaces: z2.array(WorkspaceSummary)
|
|
539
|
+
});
|
|
540
|
+
var MintTokenRequest = z2.object({
|
|
541
|
+
name: z2.string().min(1).optional()
|
|
542
|
+
});
|
|
543
|
+
var MintTokenResponse = z2.object({
|
|
544
|
+
// The raw shp_ token, shown once at creation and never returned again.
|
|
545
|
+
token: z2.string(),
|
|
546
|
+
id: z2.string()
|
|
547
|
+
});
|
|
548
|
+
var TokenSummary = z2.object({
|
|
549
|
+
id: z2.string(),
|
|
550
|
+
name: z2.string().nullable(),
|
|
551
|
+
// ISO timestamp string (see IsoTimestamp note above) or null when unused / not revoked.
|
|
552
|
+
lastUsedAt: IsoTimestamp.nullable(),
|
|
553
|
+
createdAt: IsoTimestamp,
|
|
554
|
+
revokedAt: IsoTimestamp.nullable()
|
|
555
|
+
});
|
|
556
|
+
var ListTokensResponse = z2.object({
|
|
557
|
+
tokens: z2.array(TokenSummary)
|
|
558
|
+
});
|
|
559
|
+
var CreateInviteRequest = z2.object({
|
|
560
|
+
expiresInDays: z2.number().int().positive().optional(),
|
|
561
|
+
maxUses: z2.number().int().positive().optional()
|
|
562
|
+
});
|
|
563
|
+
var InviteResponse = z2.object({
|
|
564
|
+
code: z2.string(),
|
|
565
|
+
// ISO timestamp string, or null when the invite never expires.
|
|
566
|
+
expiresAt: IsoTimestamp.nullable(),
|
|
567
|
+
maxUses: z2.number().int().positive(),
|
|
568
|
+
useCount: z2.number().int().nonnegative()
|
|
569
|
+
});
|
|
570
|
+
var RedeemInviteResponse = z2.object({
|
|
571
|
+
// The workspace the caller just joined.
|
|
572
|
+
workspace: WorkspaceSummary
|
|
573
|
+
});
|
|
574
|
+
var MemberSummary = z2.object({
|
|
575
|
+
accountId: z2.string(),
|
|
576
|
+
displayName: z2.string().nullable(),
|
|
577
|
+
githubLogin: z2.string().nullable(),
|
|
578
|
+
avatarUrl: z2.string().nullable(),
|
|
579
|
+
role: Role
|
|
580
|
+
});
|
|
581
|
+
var ListMembersResponse = z2.object({
|
|
582
|
+
members: z2.array(MemberSummary)
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
// src/marker.ts
|
|
586
|
+
import * as fs from "fs";
|
|
587
|
+
import * as path from "path";
|
|
588
|
+
var MARKER_FILENAME = ".shepherd";
|
|
589
|
+
function findRepoRoot(cwd) {
|
|
590
|
+
let dir = path.resolve(cwd);
|
|
591
|
+
for (; ; ) {
|
|
592
|
+
if (fs.existsSync(path.join(dir, ".git"))) return dir;
|
|
593
|
+
const parent = path.dirname(dir);
|
|
594
|
+
if (parent === dir) return null;
|
|
595
|
+
dir = parent;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
function markerPath(cwd) {
|
|
599
|
+
const root = findRepoRoot(cwd);
|
|
600
|
+
return root === null ? null : path.join(root, MARKER_FILENAME);
|
|
601
|
+
}
|
|
602
|
+
function readMarker(cwd = process.cwd()) {
|
|
603
|
+
const file = markerPath(cwd);
|
|
604
|
+
if (file === null) return null;
|
|
605
|
+
let raw;
|
|
606
|
+
try {
|
|
607
|
+
raw = fs.readFileSync(file, "utf8");
|
|
608
|
+
} catch {
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
try {
|
|
612
|
+
const parsed = JSON.parse(raw);
|
|
613
|
+
if (parsed !== null && typeof parsed === "object" && typeof parsed.workspace === "string" && parsed.workspace.length > 0) {
|
|
614
|
+
return { workspace: parsed.workspace };
|
|
615
|
+
}
|
|
616
|
+
return null;
|
|
617
|
+
} catch {
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
function writeMarker(cwd = process.cwd(), slug) {
|
|
622
|
+
const file = markerPath(cwd);
|
|
623
|
+
if (file === null) {
|
|
624
|
+
throw new Error("not inside a git repository \u2014 cannot write .shepherd marker");
|
|
625
|
+
}
|
|
626
|
+
fs.writeFileSync(file, JSON.stringify({ workspace: slug }) + "\n", "utf8");
|
|
627
|
+
}
|
|
628
|
+
function removeMarker(cwd = process.cwd()) {
|
|
629
|
+
const file = markerPath(cwd);
|
|
630
|
+
if (file === null) return;
|
|
631
|
+
try {
|
|
632
|
+
fs.rmSync(file, { force: true });
|
|
633
|
+
} catch {
|
|
634
|
+
}
|
|
635
|
+
}
|
|
502
636
|
|
|
503
637
|
// src/gitContext.ts
|
|
504
638
|
import { execFileSync } from "child_process";
|
|
505
|
-
import * as
|
|
639
|
+
import * as path2 from "path";
|
|
506
640
|
var GIT_TIMEOUT_MS = 2e3;
|
|
507
641
|
var MAX_COMMITS = 100;
|
|
508
642
|
var MAX_PATHS_PER_COMMIT = 500;
|
|
@@ -550,7 +684,7 @@ function detectRepo(cwd = process.cwd()) {
|
|
|
550
684
|
}
|
|
551
685
|
const top = runGit(cwd, ["rev-parse", "--show-toplevel"]);
|
|
552
686
|
if (top) {
|
|
553
|
-
const base =
|
|
687
|
+
const base = path2.basename(top);
|
|
554
688
|
if (base) return base;
|
|
555
689
|
}
|
|
556
690
|
return null;
|
|
@@ -749,13 +883,13 @@ import { createHash } from "crypto";
|
|
|
749
883
|
import {
|
|
750
884
|
appendFileSync,
|
|
751
885
|
mkdirSync,
|
|
752
|
-
readFileSync,
|
|
886
|
+
readFileSync as readFileSync2,
|
|
753
887
|
renameSync,
|
|
754
|
-
rmSync,
|
|
755
|
-
existsSync
|
|
888
|
+
rmSync as rmSync2,
|
|
889
|
+
existsSync as existsSync2
|
|
756
890
|
} from "fs";
|
|
757
891
|
import { homedir, tmpdir } from "os";
|
|
758
|
-
import { dirname, join, resolve } from "path";
|
|
892
|
+
import { dirname as dirname2, join as join2, resolve as resolve2 } from "path";
|
|
759
893
|
function defaultInboxDir() {
|
|
760
894
|
let base = "";
|
|
761
895
|
try {
|
|
@@ -764,18 +898,18 @@ function defaultInboxDir() {
|
|
|
764
898
|
base = "";
|
|
765
899
|
}
|
|
766
900
|
if (!base) base = tmpdir();
|
|
767
|
-
return
|
|
901
|
+
return join2(base, ".shepherd", "inbox");
|
|
768
902
|
}
|
|
769
903
|
function inboxFilePath(dir, cwd) {
|
|
770
|
-
let normalized =
|
|
904
|
+
let normalized = resolve2(cwd);
|
|
771
905
|
if (process.platform === "win32") normalized = normalized.toLowerCase();
|
|
772
906
|
const hash = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
|
|
773
|
-
return
|
|
907
|
+
return join2(dir, `${hash}.jsonl`);
|
|
774
908
|
}
|
|
775
909
|
function appendAnnouncements(filePath, announcements) {
|
|
776
910
|
if (!announcements || announcements.length === 0) return;
|
|
777
911
|
try {
|
|
778
|
-
mkdirSync(
|
|
912
|
+
mkdirSync(dirname2(filePath), { recursive: true });
|
|
779
913
|
const payload = announcements.map((a) => JSON.stringify(a)).join("\n") + "\n";
|
|
780
914
|
appendFileSync(filePath, payload, "utf8");
|
|
781
915
|
} catch {
|
|
@@ -785,17 +919,17 @@ function drainInbox(filePath) {
|
|
|
785
919
|
const tmp = `${filePath}.draining`;
|
|
786
920
|
let raw = "";
|
|
787
921
|
try {
|
|
788
|
-
if (
|
|
789
|
-
raw +=
|
|
790
|
-
|
|
922
|
+
if (existsSync2(tmp)) {
|
|
923
|
+
raw += readFileSync2(tmp, "utf8");
|
|
924
|
+
rmSync2(tmp, { force: true });
|
|
791
925
|
}
|
|
792
926
|
} catch {
|
|
793
927
|
}
|
|
794
928
|
try {
|
|
795
|
-
if (
|
|
929
|
+
if (existsSync2(filePath)) {
|
|
796
930
|
renameSync(filePath, tmp);
|
|
797
|
-
raw +=
|
|
798
|
-
|
|
931
|
+
raw += readFileSync2(tmp, "utf8");
|
|
932
|
+
rmSync2(tmp, { force: true });
|
|
799
933
|
}
|
|
800
934
|
} catch {
|
|
801
935
|
}
|
|
@@ -956,8 +1090,11 @@ function formatChangeRecords(records, cwd = process.cwd()) {
|
|
|
956
1090
|
if (lines.length === 0) return "";
|
|
957
1091
|
return "Unlanded changes touching your area (awareness only \u2014 these are not blockers):\n" + lines.join("\n");
|
|
958
1092
|
}
|
|
1093
|
+
function hubErrorDetail(err) {
|
|
1094
|
+
return err instanceof HubUnreachable || err instanceof HubRequestError ? err.message : String(err);
|
|
1095
|
+
}
|
|
959
1096
|
function degradedResult(err) {
|
|
960
|
-
const detail =
|
|
1097
|
+
const detail = hubErrorDetail(err);
|
|
961
1098
|
return {
|
|
962
1099
|
content: [
|
|
963
1100
|
{
|
|
@@ -969,8 +1106,13 @@ function degradedResult(err) {
|
|
|
969
1106
|
}
|
|
970
1107
|
function registerTools(server, deps) {
|
|
971
1108
|
const { hubClient, config, context, heartbeat, inboxFile } = deps;
|
|
1109
|
+
const markerCwd = deps.cwd ?? process.cwd();
|
|
972
1110
|
let sessionId = null;
|
|
973
1111
|
let agentName = null;
|
|
1112
|
+
const isHosted = Boolean(config.SHEPHERD_TOKEN);
|
|
1113
|
+
const selfHostMismatch = !isHosted && context.linked && config.WORKSPACE !== void 0 && config.WORKSPACE !== context.workspace;
|
|
1114
|
+
let hostedWorkspaceRejected = false;
|
|
1115
|
+
const dormant = !context.linked || selfHostMismatch;
|
|
974
1116
|
let joinFailure = null;
|
|
975
1117
|
const joinBody = {
|
|
976
1118
|
workspace: context.workspace,
|
|
@@ -982,7 +1124,16 @@ function registerTools(server, deps) {
|
|
|
982
1124
|
if (context.model !== void 0) {
|
|
983
1125
|
joinBody.model = context.model;
|
|
984
1126
|
}
|
|
985
|
-
|
|
1127
|
+
if (!context.linked) {
|
|
1128
|
+
console.error(
|
|
1129
|
+
"[shepherd] This repo isn't linked to a Shepherd workspace \u2014 staying uncoordinated. Run `link` to choose one."
|
|
1130
|
+
);
|
|
1131
|
+
} else if (selfHostMismatch) {
|
|
1132
|
+
console.error(
|
|
1133
|
+
`[shepherd] This repo is linked to workspace "${context.workspace}" but your configured token is for a different workspace \u2014 coordination disabled.`
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
const joinInFlight = dormant ? Promise.resolve() : hubClient.post("/join", joinBody).then((raw) => {
|
|
986
1137
|
const parsed = JoinResponse.safeParse(raw);
|
|
987
1138
|
if (!parsed.success || !parsed.data.sessionId) {
|
|
988
1139
|
joinFailure = "validation";
|
|
@@ -996,9 +1147,16 @@ function registerTools(server, deps) {
|
|
|
996
1147
|
heartbeat.start(parsed.data.sessionId);
|
|
997
1148
|
}).catch((err) => {
|
|
998
1149
|
joinFailure = classifyJoinFailure(err);
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1150
|
+
if (err instanceof HubRequestError && (err.status === 403 || err.status === 404)) {
|
|
1151
|
+
hostedWorkspaceRejected = true;
|
|
1152
|
+
console.error(
|
|
1153
|
+
`[shepherd] This repo is linked to workspace "${context.workspace}" but your configured token is for a different workspace \u2014 coordination disabled.`
|
|
1154
|
+
);
|
|
1155
|
+
} else {
|
|
1156
|
+
console.error(
|
|
1157
|
+
`[shepherd] join failed (${joinFailure}): ${err instanceof Error ? err.message : String(err)}`
|
|
1158
|
+
);
|
|
1159
|
+
}
|
|
1002
1160
|
});
|
|
1003
1161
|
async function awaitJoin() {
|
|
1004
1162
|
await joinInFlight;
|
|
@@ -1013,6 +1171,33 @@ function registerTools(server, deps) {
|
|
|
1013
1171
|
]
|
|
1014
1172
|
};
|
|
1015
1173
|
}
|
|
1174
|
+
function notLinked() {
|
|
1175
|
+
return {
|
|
1176
|
+
content: [
|
|
1177
|
+
{
|
|
1178
|
+
type: "text",
|
|
1179
|
+
text: "This repo isn't linked to a Shepherd workspace \u2014 run `link` to choose one, or ignore to stay uncoordinated."
|
|
1180
|
+
}
|
|
1181
|
+
]
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
function workspaceMismatch() {
|
|
1185
|
+
return {
|
|
1186
|
+
content: [
|
|
1187
|
+
{
|
|
1188
|
+
type: "text",
|
|
1189
|
+
text: `This repo is linked to \`${context.workspace}\` but your configured token is for a different workspace \u2014 coordination disabled.`
|
|
1190
|
+
}
|
|
1191
|
+
]
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
async function coordinationGate() {
|
|
1195
|
+
await awaitJoin();
|
|
1196
|
+
if (!context.linked) return notLinked();
|
|
1197
|
+
if (selfHostMismatch || hostedWorkspaceRejected) return workspaceMismatch();
|
|
1198
|
+
if (sessionId === null) return sessionNotReady();
|
|
1199
|
+
return null;
|
|
1200
|
+
}
|
|
1016
1201
|
function withIdentity(body) {
|
|
1017
1202
|
return agentName ? `You are ${agentName}.
|
|
1018
1203
|
|
|
@@ -1052,10 +1237,8 @@ ${section}` : body;
|
|
|
1052
1237
|
inputSchema: WorkAgentInput.shape
|
|
1053
1238
|
},
|
|
1054
1239
|
async (args) => {
|
|
1055
|
-
await
|
|
1056
|
-
if (
|
|
1057
|
-
return sessionNotReady();
|
|
1058
|
-
}
|
|
1240
|
+
const gated = await coordinationGate();
|
|
1241
|
+
if (gated) return gated;
|
|
1059
1242
|
try {
|
|
1060
1243
|
const changeReport = await changeReportForBody();
|
|
1061
1244
|
const body = { sessionId, ...args, ...changeReport ? { changeReport } : {} };
|
|
@@ -1091,10 +1274,8 @@ You hold this claim until you call done (workItemId: ${result.workItemId}) or it
|
|
|
1091
1274
|
inputSchema: DoneAgentInput.shape
|
|
1092
1275
|
},
|
|
1093
1276
|
async (args) => {
|
|
1094
|
-
await
|
|
1095
|
-
if (
|
|
1096
|
-
return sessionNotReady();
|
|
1097
|
-
}
|
|
1277
|
+
const gated = await coordinationGate();
|
|
1278
|
+
if (gated) return gated;
|
|
1098
1279
|
try {
|
|
1099
1280
|
const body = { sessionId, ...args };
|
|
1100
1281
|
const result = await hubClient.post("/done", body);
|
|
@@ -1125,10 +1306,8 @@ ${msgs}` : base }
|
|
|
1125
1306
|
inputSchema: AnnounceAgentInput.shape
|
|
1126
1307
|
},
|
|
1127
1308
|
async (args) => {
|
|
1128
|
-
await
|
|
1129
|
-
if (
|
|
1130
|
-
return sessionNotReady();
|
|
1131
|
-
}
|
|
1309
|
+
const gated = await coordinationGate();
|
|
1310
|
+
if (gated) return gated;
|
|
1132
1311
|
try {
|
|
1133
1312
|
const body = { sessionId, ...args };
|
|
1134
1313
|
const result = await hubClient.post("/announce", body);
|
|
@@ -1159,10 +1338,8 @@ ${msgs}` : base }
|
|
|
1159
1338
|
inputSchema: SyncAgentInput.shape
|
|
1160
1339
|
},
|
|
1161
1340
|
async (_args) => {
|
|
1162
|
-
await
|
|
1163
|
-
if (
|
|
1164
|
-
return sessionNotReady();
|
|
1165
|
-
}
|
|
1341
|
+
const gated = await coordinationGate();
|
|
1342
|
+
if (gated) return gated;
|
|
1166
1343
|
try {
|
|
1167
1344
|
const changeReport = await changeReportForBody();
|
|
1168
1345
|
const body = { sessionId, ...changeReport ? { changeReport } : {} };
|
|
@@ -1183,6 +1360,88 @@ ${msgs}` : base }
|
|
|
1183
1360
|
}
|
|
1184
1361
|
}
|
|
1185
1362
|
);
|
|
1363
|
+
function advisory(text) {
|
|
1364
|
+
return { content: [{ type: "text", text }] };
|
|
1365
|
+
}
|
|
1366
|
+
server.registerTool(
|
|
1367
|
+
"link",
|
|
1368
|
+
{
|
|
1369
|
+
title: "Link this repo to a Shepherd workspace",
|
|
1370
|
+
description: "Opt this repository into Shepherd coordination by writing a committed `.shepherd` marker naming the workspace. Call with no argument to see the workspaces you can link to, then call again with one. You can only link to a workspace you are a member of. Takes effect on the next session (restart to coordinate now). Use `unlink` to opt out.",
|
|
1371
|
+
inputSchema: z3.object({
|
|
1372
|
+
workspace: z3.string().min(1).optional().describe("The workspace slug to link this repo to. Omit to list your choices.")
|
|
1373
|
+
}).shape
|
|
1374
|
+
},
|
|
1375
|
+
async (args) => {
|
|
1376
|
+
const requested = args.workspace;
|
|
1377
|
+
if (!isHosted) {
|
|
1378
|
+
const allowed = config.WORKSPACE;
|
|
1379
|
+
if (!allowed) {
|
|
1380
|
+
return advisory(
|
|
1381
|
+
"Self-host mode has no configured workspace (WORKSPACE is unset) \u2014 cannot link."
|
|
1382
|
+
);
|
|
1383
|
+
}
|
|
1384
|
+
if (requested === void 0) {
|
|
1385
|
+
return advisory(
|
|
1386
|
+
`This is a self-host deployment with a single workspace: \`${allowed}\`.
|
|
1387
|
+
Run \`link\` again with workspace "${allowed}" to opt this repo in.`
|
|
1388
|
+
);
|
|
1389
|
+
}
|
|
1390
|
+
if (requested !== allowed) {
|
|
1391
|
+
return advisory(
|
|
1392
|
+
`This self-host deployment only serves the workspace \`${allowed}\`; you asked for \`${requested}\`. Choose: ${allowed}`
|
|
1393
|
+
);
|
|
1394
|
+
}
|
|
1395
|
+
writeMarker(markerCwd, allowed);
|
|
1396
|
+
return advisory(
|
|
1397
|
+
`Linked this repo to \`${allowed}\` \u2014 restart the session for it to take effect (it applies on the next launch).`
|
|
1398
|
+
);
|
|
1399
|
+
}
|
|
1400
|
+
let slugs;
|
|
1401
|
+
try {
|
|
1402
|
+
const res = await hubClient.get("/workspaces");
|
|
1403
|
+
slugs = (res.workspaces ?? []).map((w) => w.slug).filter((s) => typeof s === "string" && s.length > 0);
|
|
1404
|
+
} catch (err) {
|
|
1405
|
+
const detail = hubErrorDetail(err);
|
|
1406
|
+
return advisory(
|
|
1407
|
+
`Couldn't reach the coordination hub to list your workspaces \u2014 link not changed. ${detail}`
|
|
1408
|
+
);
|
|
1409
|
+
}
|
|
1410
|
+
if (slugs.length === 0) {
|
|
1411
|
+
return advisory(
|
|
1412
|
+
"Your account isn't a member of any workspaces yet \u2014 nothing to link to. Create or join a workspace first, then run `link` again."
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
if (requested === void 0) {
|
|
1416
|
+
return advisory(
|
|
1417
|
+
"You can link this repo to one of these workspaces:\n" + slugs.map((s) => ` - ${s}`).join("\n") + "\n\nRun `link` again with one of them as the `workspace` argument."
|
|
1418
|
+
);
|
|
1419
|
+
}
|
|
1420
|
+
if (!slugs.includes(requested)) {
|
|
1421
|
+
return advisory(
|
|
1422
|
+
`You're not a member of \`${requested}\`; choose one of: ${slugs.join(", ")}`
|
|
1423
|
+
);
|
|
1424
|
+
}
|
|
1425
|
+
writeMarker(markerCwd, requested);
|
|
1426
|
+
return advisory(
|
|
1427
|
+
`Linked this repo to \`${requested}\` \u2014 restart the session for it to take effect (it applies on the next launch).`
|
|
1428
|
+
);
|
|
1429
|
+
}
|
|
1430
|
+
);
|
|
1431
|
+
server.registerTool(
|
|
1432
|
+
"unlink",
|
|
1433
|
+
{
|
|
1434
|
+
title: "Unlink this repo from its Shepherd workspace",
|
|
1435
|
+
description: "Opt this repository OUT of Shepherd coordination by removing its `.shepherd` marker. The repo stays uncoordinated (no claims, no presence) until you `link` it again.",
|
|
1436
|
+
inputSchema: z3.object({}).shape
|
|
1437
|
+
},
|
|
1438
|
+
async () => {
|
|
1439
|
+
removeMarker(markerCwd);
|
|
1440
|
+
return advisory(
|
|
1441
|
+
"Unlinked \u2014 this repo will stay uncoordinated until re-linked."
|
|
1442
|
+
);
|
|
1443
|
+
}
|
|
1444
|
+
);
|
|
1186
1445
|
async function leave() {
|
|
1187
1446
|
try {
|
|
1188
1447
|
await joinInFlight;
|
|
@@ -1198,9 +1457,9 @@ ${msgs}` : base }
|
|
|
1198
1457
|
}
|
|
1199
1458
|
|
|
1200
1459
|
// src/identityCache.ts
|
|
1201
|
-
import { mkdirSync as mkdirSync2, readFileSync as
|
|
1460
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
1202
1461
|
import { homedir as homedir2, tmpdir as tmpdir2 } from "os";
|
|
1203
|
-
import { dirname as
|
|
1462
|
+
import { dirname as dirname3, join as join3 } from "path";
|
|
1204
1463
|
function defaultIdentityCachePath() {
|
|
1205
1464
|
let base = "";
|
|
1206
1465
|
try {
|
|
@@ -1209,12 +1468,12 @@ function defaultIdentityCachePath() {
|
|
|
1209
1468
|
base = "";
|
|
1210
1469
|
}
|
|
1211
1470
|
if (!base) base = tmpdir2();
|
|
1212
|
-
return
|
|
1471
|
+
return join3(base, ".shepherd", "identity.json");
|
|
1213
1472
|
}
|
|
1214
1473
|
function readCachedHuman(filePath = defaultIdentityCachePath()) {
|
|
1215
1474
|
let raw;
|
|
1216
1475
|
try {
|
|
1217
|
-
raw =
|
|
1476
|
+
raw = readFileSync3(filePath, "utf8");
|
|
1218
1477
|
} catch {
|
|
1219
1478
|
return null;
|
|
1220
1479
|
}
|
|
@@ -1229,9 +1488,9 @@ function readCachedHuman(filePath = defaultIdentityCachePath()) {
|
|
|
1229
1488
|
function writeCachedHuman(human, filePath = defaultIdentityCachePath()) {
|
|
1230
1489
|
if (typeof human !== "string" || human.trim().length === 0) return;
|
|
1231
1490
|
try {
|
|
1232
|
-
mkdirSync2(
|
|
1491
|
+
mkdirSync2(dirname3(filePath), { recursive: true });
|
|
1233
1492
|
const payload = JSON.stringify({ human });
|
|
1234
|
-
|
|
1493
|
+
writeFileSync2(filePath, payload + "\n", "utf8");
|
|
1235
1494
|
} catch {
|
|
1236
1495
|
}
|
|
1237
1496
|
}
|
|
@@ -1241,6 +1500,7 @@ var defaultDeps = {
|
|
|
1241
1500
|
detectRepo,
|
|
1242
1501
|
detectBranch,
|
|
1243
1502
|
detectHuman,
|
|
1503
|
+
readMarker,
|
|
1244
1504
|
readCachedHuman,
|
|
1245
1505
|
writeCachedHuman
|
|
1246
1506
|
};
|
|
@@ -1253,8 +1513,10 @@ async function resolveContext(config, cwd = process.cwd(), deps = defaultDeps) {
|
|
|
1253
1513
|
const human = resolveHuman(config, cwd, deps);
|
|
1254
1514
|
const program = config.PROGRAM ?? "claude-code";
|
|
1255
1515
|
const model = config.MODEL ?? void 0;
|
|
1256
|
-
const
|
|
1257
|
-
|
|
1516
|
+
const marker = deps.readMarker(cwd);
|
|
1517
|
+
const linked = marker !== null;
|
|
1518
|
+
const workspace = marker?.workspace ?? config.WORKSPACE ?? DEFAULT_WORKSPACE;
|
|
1519
|
+
return { workspace, repo, branch, human, program, model, linked };
|
|
1258
1520
|
}
|
|
1259
1521
|
function resolveHuman(config, cwd, deps) {
|
|
1260
1522
|
if (config.HUMAN) return config.HUMAN;
|
|
@@ -1347,7 +1609,7 @@ Commit work-in-progress as you go rather than sitting on a large dirty tree: com
|
|
|
1347
1609
|
// src/index.ts
|
|
1348
1610
|
async function main() {
|
|
1349
1611
|
const config = loadConfig();
|
|
1350
|
-
const hubClient = createHubClient({ hubUrl: config.HUB_URL,
|
|
1612
|
+
const hubClient = createHubClient({ hubUrl: config.HUB_URL, token: config.authToken });
|
|
1351
1613
|
const context = await resolveContext(config);
|
|
1352
1614
|
const inboxDir = config.SHEPHERD_INBOX_DIR ?? defaultInboxDir();
|
|
1353
1615
|
const inboxFile = inboxFilePath(inboxDir, process.cwd());
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@korso/shepherd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|