@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 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: connection credentials.
10
+ // Hard-required: Hub endpoint.
11
11
  HUB_URL: z.string().min(1, "HUB_URL is required"),
12
- TEAM_TOKEN: z.string().min(1, "TEAM_TOKEN is required"),
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
- return ConfigSchema.parse({
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
- teamToken,
98
+ token,
86
99
  timeoutMs = DEFAULT_TIMEOUT_MS
87
100
  }) {
88
101
  const baseUrl = hubUrl.replace(/\/$/, "");
89
- return {
90
- async post(path2, body) {
91
- const controller = new AbortController();
92
- const timer = setTimeout(() => controller.abort(), timeoutMs);
93
- let response;
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
- response = await fetch(`${baseUrl}${path2}`, {
96
- method: "POST",
97
- headers: {
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
- throw new HubRequestError(
121
- response.status,
122
- `Hub returned HTTP ${response.status} for ${path2}${detail}`
123
- );
133
+ } catch {
124
134
  }
125
- return response.json();
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 path from "path";
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 = path.basename(top);
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 join(base, ".shepherd", "inbox");
901
+ return join2(base, ".shepherd", "inbox");
768
902
  }
769
903
  function inboxFilePath(dir, cwd) {
770
- let normalized = resolve(cwd);
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 join(dir, `${hash}.jsonl`);
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(dirname(filePath), { recursive: true });
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 (existsSync(tmp)) {
789
- raw += readFileSync(tmp, "utf8");
790
- rmSync(tmp, { force: true });
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 (existsSync(filePath)) {
929
+ if (existsSync2(filePath)) {
796
930
  renameSync(filePath, tmp);
797
- raw += readFileSync(tmp, "utf8");
798
- rmSync(tmp, { force: true });
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 = err instanceof HubUnreachable || err instanceof HubRequestError ? err.message : String(err);
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
- const joinInFlight = hubClient.post("/join", joinBody).then((raw) => {
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
- console.error(
1000
- `[shepherd] join failed (${joinFailure}): ${err instanceof Error ? err.message : String(err)}`
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 awaitJoin();
1056
- if (sessionId === null) {
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 awaitJoin();
1095
- if (sessionId === null) {
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 awaitJoin();
1129
- if (sessionId === null) {
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 awaitJoin();
1163
- if (sessionId === null) {
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 readFileSync2, writeFileSync } from "fs";
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 dirname2, join as join2 } from "path";
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 join2(base, ".shepherd", "identity.json");
1471
+ return join3(base, ".shepherd", "identity.json");
1213
1472
  }
1214
1473
  function readCachedHuman(filePath = defaultIdentityCachePath()) {
1215
1474
  let raw;
1216
1475
  try {
1217
- raw = readFileSync2(filePath, "utf8");
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(dirname2(filePath), { recursive: true });
1491
+ mkdirSync2(dirname3(filePath), { recursive: true });
1233
1492
  const payload = JSON.stringify({ human });
1234
- writeFileSync(filePath, payload + "\n", "utf8");
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 workspace = config.WORKSPACE ?? DEFAULT_WORKSPACE;
1257
- return { workspace, repo, branch, human, program, model };
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, teamToken: config.TEAM_TOKEN });
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.4.5",
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",