@korso/shepherd 0.6.2 → 0.7.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 +67 -21
- package/dist/inboxExtension.js +110 -18
- package/dist/inboxHook.js +122 -23
- package/dist/index.js +4 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
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
4
|
|
|
5
|
-
> **New here?** The [developer quickstart](https://github.com/
|
|
5
|
+
> **New here?** The [developer quickstart](https://github.com/Korso-AI/shepherd/blob/main/docs/shepherd-mcp-quickstart.md) is the fastest path. TL;DR: `npx -y --package=@korso/shepherd shepherd-mcp` with the env vars below.
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -20,7 +20,7 @@ The server is published to npm and runs via `npx` — no clone or build required
|
|
|
20
20
|
(Node 18+):
|
|
21
21
|
|
|
22
22
|
```sh
|
|
23
|
-
npx -y
|
|
23
|
+
npx -y --package=@korso/shepherd shepherd-mcp
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
You won't normally run that by hand; you put it in your MCP client config (below)
|
|
@@ -45,14 +45,14 @@ Missing either causes an immediate startup failure with a clear error on stderr
|
|
|
45
45
|
listing which vars are absent. (No other var triggers this.)
|
|
46
46
|
|
|
47
47
|
**Everything else is optional** — each identity field is resolved at startup as
|
|
48
|
-
**env var → git detection → fallback**, so a plain `npx -y
|
|
48
|
+
**env var → git detection → fallback**, so a plain `npx -y --package=@korso/shepherd shepherd-mcp` with
|
|
49
49
|
just the two required vars produces a valid, fully-identified session. Set an
|
|
50
50
|
override only to replace what's detected:
|
|
51
51
|
|
|
52
52
|
| Variable | If omitted | Example |
|
|
53
53
|
|---|---|---|
|
|
54
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` | `
|
|
55
|
+
| `REPO` | `git remote origin` → `owner/repo`, else repo folder name, else `unknown-repo` | `Korso-AI/shepherd` |
|
|
56
56
|
| `BRANCH` | `git rev-parse --abbrev-ref HEAD`, else `HEAD` | `main` |
|
|
57
57
|
| `BASE_BRANCH` | `origin/HEAD`, else `origin/main` / `origin/master` (used for the change-awareness heads-up) | `origin/main` |
|
|
58
58
|
| `HUMAN` | git `user.name`, else local-part of `user.email`, else this device's **cached** last-detected name, else a generated name | `daichi` |
|
|
@@ -99,9 +99,22 @@ It delivers to an agent **while it's active**; an idle agent picks messages up t
|
|
|
99
99
|
moment it next does anything. (Waking a fully-idle agent is out of scope — for
|
|
100
100
|
Claude Code that needs Channels; Codex/Pi have no equivalent.)
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
**The hook also carries the unlinked-repo nudge.** The server instructions tell
|
|
103
|
+
the agent to ask about linking on its first write in a new repo, but instructions
|
|
104
|
+
sitting passively in context don't trigger themselves — an agent focused on the
|
|
105
|
+
task can skip straight to editing. So on every invocation the hook also checks
|
|
106
|
+
this repo's link state and, when the repo is **neither linked (`.shepherd`
|
|
107
|
+
marker) nor declined**, injects a reminder to run `link`/`decline` — on
|
|
108
|
+
`SessionStart` (front-loads the ask), right before a file-writing tool
|
|
109
|
+
(`Edit`/`Write`/`MultiEdit`/`NotebookEdit`; read-only tools never nudge), and on
|
|
110
|
+
tool-less events like Codex's `UserPromptSubmit` or a Pi turn. The
|
|
111
|
+
nudge is advisory and self-extinguishing: the moment the repo is linked or
|
|
112
|
+
declined it goes quiet, and like everything else here it fails open.
|
|
113
|
+
|
|
114
|
+
### Claude Code — `PreToolUse` + `SessionStart` hooks
|
|
115
|
+
|
|
116
|
+
`PreToolUse` fires before every tool, giving the most frequent passive delivery;
|
|
117
|
+
`SessionStart` surfaces the link ask at the top of a session in an unlinked repo.
|
|
105
118
|
The hook needs no arguments — it resolves the same default inbox dir the server
|
|
106
119
|
uses (override both with `SHEPHERD_INBOX_DIR` if you relocated it):
|
|
107
120
|
|
|
@@ -110,7 +123,7 @@ uses (override both with `SHEPHERD_INBOX_DIR` if you relocated it):
|
|
|
110
123
|
"mcpServers": {
|
|
111
124
|
"shepherd": {
|
|
112
125
|
"command": "npx",
|
|
113
|
-
"args": ["-y", "
|
|
126
|
+
"args": ["-y", "--package=@korso/shepherd", "shepherd-mcp"],
|
|
114
127
|
"env": {
|
|
115
128
|
"HUB_URL": "https://shepherd.example.com",
|
|
116
129
|
"TEAM_TOKEN": "tok_abc123"
|
|
@@ -118,11 +131,18 @@ uses (override both with `SHEPHERD_INBOX_DIR` if you relocated it):
|
|
|
118
131
|
}
|
|
119
132
|
},
|
|
120
133
|
"hooks": {
|
|
134
|
+
"SessionStart": [
|
|
135
|
+
{
|
|
136
|
+
"hooks": [
|
|
137
|
+
{ "type": "command", "command": "npx -y --package=@korso/shepherd shepherd-inbox-hook" }
|
|
138
|
+
]
|
|
139
|
+
}
|
|
140
|
+
],
|
|
121
141
|
"PreToolUse": [
|
|
122
142
|
{
|
|
123
143
|
"matcher": "*",
|
|
124
144
|
"hooks": [
|
|
125
|
-
{ "type": "command", "command": "npx -y
|
|
145
|
+
{ "type": "command", "command": "npx -y --package=@korso/shepherd shepherd-inbox-hook" }
|
|
126
146
|
]
|
|
127
147
|
}
|
|
128
148
|
]
|
|
@@ -143,9 +163,9 @@ or MCP calls. Hooks must be enabled with `features.hooks = true`. In
|
|
|
143
163
|
hooks = true
|
|
144
164
|
|
|
145
165
|
[[hooks.UserPromptSubmit]]
|
|
146
|
-
command = ["npx", "-y", "
|
|
166
|
+
command = ["npx", "-y", "--package=@korso/shepherd", "shepherd-inbox-hook"]
|
|
147
167
|
# On Windows use command_windows instead:
|
|
148
|
-
# command_windows = ["cmd", "/c", "npx -y
|
|
168
|
+
# command_windows = ["cmd", "/c", "npx -y --package=@korso/shepherd shepherd-inbox-hook"]
|
|
149
169
|
```
|
|
150
170
|
|
|
151
171
|
### Pi — extension
|
|
@@ -161,8 +181,8 @@ cp "$(npm root -g)/@korso/shepherd/dist/inboxExtension.js" ~/.pi/agent/extension
|
|
|
161
181
|
```
|
|
162
182
|
|
|
163
183
|
It runs on every user turn (`before_agent_start`), drains the same inbox, and
|
|
164
|
-
injects pending announcements. (Or load it ad hoc
|
|
165
|
-
`pi -e /abs/path/to/dist/inboxExtension.js`.)
|
|
184
|
+
injects pending announcements plus the unlinked-repo nudge. (Or load it ad hoc
|
|
185
|
+
with `pi -e /abs/path/to/dist/inboxExtension.js`.)
|
|
166
186
|
|
|
167
187
|
### Notes
|
|
168
188
|
|
|
@@ -191,7 +211,7 @@ line-continuation does not work). Minimal: just the two required vars (identity
|
|
|
191
211
|
is auto-detected from git):
|
|
192
212
|
|
|
193
213
|
```powershell
|
|
194
|
-
claude mcp add shepherd -s user -e HUB_URL=https://shepherd.example.com -e TEAM_TOKEN=tok_abc123 -- npx -y
|
|
214
|
+
claude mcp add shepherd -s user -e HUB_URL=https://shepherd.example.com -e TEAM_TOKEN=tok_abc123 -- npx -y --package=@korso/shepherd shepherd-mcp
|
|
195
215
|
```
|
|
196
216
|
|
|
197
217
|
Add any optional overrides from §2 with extra `-e` flags (e.g. `-e MODEL=claude-sonnet-4-6 -e HUMAN=daichi`).
|
|
@@ -204,7 +224,7 @@ Alternative — a `.mcp.json` at the **root of the repo you're working in**
|
|
|
204
224
|
"mcpServers": {
|
|
205
225
|
"shepherd": {
|
|
206
226
|
"command": "npx",
|
|
207
|
-
"args": ["-y", "
|
|
227
|
+
"args": ["-y", "--package=@korso/shepherd", "shepherd-mcp"],
|
|
208
228
|
"env": {
|
|
209
229
|
"HUB_URL": "https://shepherd.example.com",
|
|
210
230
|
"TEAM_TOKEN": "tok_abc123",
|
|
@@ -228,7 +248,7 @@ The table is `mcp_servers` with an **underscore** (`mcp-servers`/`mcpServers` ar
|
|
|
228
248
|
silently ignored). Either run `codex mcp add`:
|
|
229
249
|
|
|
230
250
|
```sh
|
|
231
|
-
codex mcp add shepherd --env HUB_URL=https://shepherd.example.com --env TEAM_TOKEN=tok_abc123 --env PROGRAM=codex -- npx -y
|
|
251
|
+
codex mcp add shepherd --env HUB_URL=https://shepherd.example.com --env TEAM_TOKEN=tok_abc123 --env PROGRAM=codex -- npx -y --package=@korso/shepherd shepherd-mcp
|
|
232
252
|
```
|
|
233
253
|
|
|
234
254
|
…or add the table directly:
|
|
@@ -236,7 +256,7 @@ codex mcp add shepherd --env HUB_URL=https://shepherd.example.com --env TEAM_TOK
|
|
|
236
256
|
```toml
|
|
237
257
|
[mcp_servers.shepherd]
|
|
238
258
|
command = "npx"
|
|
239
|
-
args = ["-y", "
|
|
259
|
+
args = ["-y", "--package=@korso/shepherd", "shepherd-mcp"]
|
|
240
260
|
env = { HUB_URL = "https://shepherd.example.com", TEAM_TOKEN = "tok_abc123", PROGRAM = "codex", MODEL = "o4-mini" }
|
|
241
261
|
```
|
|
242
262
|
|
|
@@ -249,7 +269,7 @@ Pi uses a JSON `mcpServers` block (project config overrides global):
|
|
|
249
269
|
"mcpServers": {
|
|
250
270
|
"shepherd": {
|
|
251
271
|
"command": "npx",
|
|
252
|
-
"args": ["-y", "
|
|
272
|
+
"args": ["-y", "--package=@korso/shepherd", "shepherd-mcp"],
|
|
253
273
|
"env": {
|
|
254
274
|
"HUB_URL": "https://shepherd.example.com",
|
|
255
275
|
"TEAM_TOKEN": "tok_abc123",
|
|
@@ -260,6 +280,32 @@ Pi uses a JSON `mcpServers` block (project config overrides global):
|
|
|
260
280
|
}
|
|
261
281
|
```
|
|
262
282
|
|
|
283
|
+
### Cursor (`~/.cursor/mcp.json` or `.cursor/mcp.json`)
|
|
284
|
+
|
|
285
|
+
Cursor reads the same JSON `mcpServers` shape — global at `~/.cursor/mcp.json`,
|
|
286
|
+
or per-project at `.cursor/mcp.json`:
|
|
287
|
+
|
|
288
|
+
```json
|
|
289
|
+
{
|
|
290
|
+
"mcpServers": {
|
|
291
|
+
"shepherd": {
|
|
292
|
+
"command": "npx",
|
|
293
|
+
"args": ["-y", "--package=@korso/shepherd", "shepherd-mcp"],
|
|
294
|
+
"env": {
|
|
295
|
+
"HUB_URL": "https://shepherd.example.com",
|
|
296
|
+
"TEAM_TOKEN": "tok_abc123",
|
|
297
|
+
"PROGRAM": "cursor"
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Confirm under **Settings → MCP** that `shepherd` is listed with its tools.
|
|
305
|
+
Announcement push: Cursor has no hook equivalent (see the hooks above for
|
|
306
|
+
Claude Code/Codex/Pi), so announcements arrive via the universal drainer — on
|
|
307
|
+
the agent's next Shepherd tool call — rather than pushed between calls.
|
|
308
|
+
|
|
263
309
|
---
|
|
264
310
|
|
|
265
311
|
## 4. Verify the server starts (quick smoke test)
|
|
@@ -270,10 +316,10 @@ PowerShell (set env vars, then run):
|
|
|
270
316
|
```powershell
|
|
271
317
|
$env:HUB_URL = "https://shepherd.example.com"
|
|
272
318
|
$env:TEAM_TOKEN = "tok_abc123"
|
|
273
|
-
npx -y
|
|
319
|
+
npx -y --package=@korso/shepherd shepherd-mcp
|
|
274
320
|
```
|
|
275
321
|
|
|
276
|
-
bash/zsh: `HUB_URL=https://shepherd.example.com TEAM_TOKEN=tok_abc123 npx -y
|
|
322
|
+
bash/zsh: `HUB_URL=https://shepherd.example.com TEAM_TOKEN=tok_abc123 npx -y --package=@korso/shepherd shepherd-mcp`
|
|
277
323
|
|
|
278
324
|
No stderr output and the process blocking on stdin = healthy. Press Ctrl+C to exit.
|
|
279
325
|
|
|
@@ -298,7 +344,7 @@ Only needed if you're changing the MCP server itself. Clone the monorepo and
|
|
|
298
344
|
point your client at a local build instead of npx:
|
|
299
345
|
|
|
300
346
|
```sh
|
|
301
|
-
git clone https://github.com/
|
|
347
|
+
git clone https://github.com/Korso-AI/shepherd.git
|
|
302
348
|
cd shepherd
|
|
303
349
|
npm install
|
|
304
350
|
npm run build # tsc -b — compiles the workspace for dev + tests
|
package/dist/inboxExtension.js
CHANGED
|
@@ -1,16 +1,59 @@
|
|
|
1
1
|
// src/inbox.ts
|
|
2
|
-
import { createHash } from "crypto";
|
|
2
|
+
import { createHash as createHash2 } from "crypto";
|
|
3
3
|
import {
|
|
4
4
|
appendFileSync,
|
|
5
|
-
mkdirSync,
|
|
6
|
-
readFileSync,
|
|
5
|
+
mkdirSync as mkdirSync2,
|
|
6
|
+
readFileSync as readFileSync3,
|
|
7
7
|
renameSync,
|
|
8
|
-
rmSync,
|
|
9
|
-
existsSync
|
|
8
|
+
rmSync as rmSync3,
|
|
9
|
+
existsSync as existsSync3
|
|
10
10
|
} from "fs";
|
|
11
|
+
import { homedir as homedir2, tmpdir as tmpdir2 } from "os";
|
|
12
|
+
import { dirname as dirname3, join as join3, resolve as resolve3 } from "path";
|
|
13
|
+
|
|
14
|
+
// src/marker.ts
|
|
15
|
+
import * as fs from "fs";
|
|
16
|
+
import * as path from "path";
|
|
17
|
+
var MARKER_FILENAME = ".shepherd";
|
|
18
|
+
function findRepoRoot(cwd) {
|
|
19
|
+
let dir = path.resolve(cwd);
|
|
20
|
+
for (; ; ) {
|
|
21
|
+
if (fs.existsSync(path.join(dir, ".git"))) return dir;
|
|
22
|
+
const parent = path.dirname(dir);
|
|
23
|
+
if (parent === dir) return null;
|
|
24
|
+
dir = parent;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function markerPath(cwd) {
|
|
28
|
+
const root = findRepoRoot(cwd);
|
|
29
|
+
return root === null ? null : path.join(root, MARKER_FILENAME);
|
|
30
|
+
}
|
|
31
|
+
function readMarker(cwd = process.cwd()) {
|
|
32
|
+
const file = markerPath(cwd);
|
|
33
|
+
if (file === null) return null;
|
|
34
|
+
let raw;
|
|
35
|
+
try {
|
|
36
|
+
raw = fs.readFileSync(file, "utf8");
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(raw);
|
|
42
|
+
if (parsed !== null && typeof parsed === "object" && typeof parsed.workspace === "string" && parsed.workspace.length > 0) {
|
|
43
|
+
return { workspace: parsed.workspace };
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// src/declined.ts
|
|
52
|
+
import { createHash } from "crypto";
|
|
53
|
+
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
11
54
|
import { homedir, tmpdir } from "os";
|
|
12
|
-
import { dirname, join, resolve } from "path";
|
|
13
|
-
function
|
|
55
|
+
import { dirname as dirname2, join as join2, resolve as resolve2 } from "path";
|
|
56
|
+
function defaultDeclinedDir() {
|
|
14
57
|
let base = "";
|
|
15
58
|
try {
|
|
16
59
|
base = homedir();
|
|
@@ -18,29 +61,78 @@ function defaultInboxDir() {
|
|
|
18
61
|
base = "";
|
|
19
62
|
}
|
|
20
63
|
if (!base) base = tmpdir();
|
|
21
|
-
return
|
|
64
|
+
return join2(base, ".shepherd", "declined");
|
|
22
65
|
}
|
|
23
|
-
function
|
|
24
|
-
let normalized =
|
|
66
|
+
function declinedFilePath(repoRoot, dir = defaultDeclinedDir()) {
|
|
67
|
+
let normalized = resolve2(repoRoot);
|
|
25
68
|
if (process.platform === "win32") normalized = normalized.toLowerCase();
|
|
26
69
|
const hash = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
|
|
27
|
-
return
|
|
70
|
+
return join2(dir, hash);
|
|
71
|
+
}
|
|
72
|
+
function isDeclined(repoRoot, dir = defaultDeclinedDir()) {
|
|
73
|
+
const file = declinedFilePath(repoRoot, dir);
|
|
74
|
+
let raw;
|
|
75
|
+
try {
|
|
76
|
+
raw = readFileSync2(file, "utf8");
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(raw);
|
|
82
|
+
return typeof parsed?.declinedAt === "string";
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// src/linkNudge.ts
|
|
89
|
+
var WRITE_TOOLS = /* @__PURE__ */ new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
90
|
+
var LINK_NUDGE_TEXT = '[Shepherd] This repo is not linked to a Shepherd workspace and has not been declined. Before writing files here, call the shepherd `link` tool with no argument: it auto-links if the user belongs to exactly one workspace, or lists workspaces \u2014 then ask the user "Coordinate this repo with Shepherd? Which workspace?" and call `link <workspace>` with their answer, or `decline` if they say no. Ask at most once per repo.';
|
|
91
|
+
function buildLinkNudge(cwd, toolName, deps = {}) {
|
|
92
|
+
try {
|
|
93
|
+
if (toolName !== void 0 && !WRITE_TOOLS.has(toolName)) return "";
|
|
94
|
+
const repoRoot = findRepoRoot(cwd);
|
|
95
|
+
if (repoRoot === null) return "";
|
|
96
|
+
if (readMarker(cwd) !== null) return "";
|
|
97
|
+
if (isDeclined(repoRoot, deps.declinedDir)) return "";
|
|
98
|
+
return LINK_NUDGE_TEXT;
|
|
99
|
+
} catch {
|
|
100
|
+
return "";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// src/inbox.ts
|
|
105
|
+
function defaultInboxDir() {
|
|
106
|
+
let base = "";
|
|
107
|
+
try {
|
|
108
|
+
base = homedir2();
|
|
109
|
+
} catch {
|
|
110
|
+
base = "";
|
|
111
|
+
}
|
|
112
|
+
if (!base) base = tmpdir2();
|
|
113
|
+
return join3(base, ".shepherd", "inbox");
|
|
114
|
+
}
|
|
115
|
+
function inboxFilePath(dir, cwd) {
|
|
116
|
+
let normalized = resolve3(cwd);
|
|
117
|
+
if (process.platform === "win32") normalized = normalized.toLowerCase();
|
|
118
|
+
const hash = createHash2("sha256").update(normalized).digest("hex").slice(0, 16);
|
|
119
|
+
return join3(dir, `${hash}.jsonl`);
|
|
28
120
|
}
|
|
29
121
|
function drainInbox(filePath) {
|
|
30
122
|
const tmp = `${filePath}.draining`;
|
|
31
123
|
let raw = "";
|
|
32
124
|
try {
|
|
33
|
-
if (
|
|
34
|
-
raw +=
|
|
35
|
-
|
|
125
|
+
if (existsSync3(tmp)) {
|
|
126
|
+
raw += readFileSync3(tmp, "utf8");
|
|
127
|
+
rmSync3(tmp, { force: true });
|
|
36
128
|
}
|
|
37
129
|
} catch {
|
|
38
130
|
}
|
|
39
131
|
try {
|
|
40
|
-
if (
|
|
132
|
+
if (existsSync3(filePath)) {
|
|
41
133
|
renameSync(filePath, tmp);
|
|
42
|
-
raw +=
|
|
43
|
-
|
|
134
|
+
raw += readFileSync3(tmp, "utf8");
|
|
135
|
+
rmSync3(tmp, { force: true });
|
|
44
136
|
}
|
|
45
137
|
} catch {
|
|
46
138
|
}
|
|
@@ -80,7 +172,7 @@ function shepherdInbox(pi) {
|
|
|
80
172
|
const dir = process.env["SHEPHERD_INBOX_DIR"] || defaultInboxDir();
|
|
81
173
|
const cwd = ctx?.cwd ?? process.cwd();
|
|
82
174
|
const announcements = drainInbox(inboxFilePath(dir, cwd));
|
|
83
|
-
const content = formatInboxAnnouncements(announcements);
|
|
175
|
+
const content = [buildLinkNudge(cwd), formatInboxAnnouncements(announcements)].filter(Boolean).join("\n\n");
|
|
84
176
|
if (!content) return void 0;
|
|
85
177
|
return { message: { customType: "shepherd-inbox", content, display: true } };
|
|
86
178
|
} catch {
|
package/dist/inboxHook.js
CHANGED
|
@@ -1,18 +1,61 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/inbox.ts
|
|
4
|
-
import { createHash } from "crypto";
|
|
4
|
+
import { createHash as createHash2 } from "crypto";
|
|
5
5
|
import {
|
|
6
6
|
appendFileSync,
|
|
7
|
-
mkdirSync,
|
|
8
|
-
readFileSync,
|
|
7
|
+
mkdirSync as mkdirSync2,
|
|
8
|
+
readFileSync as readFileSync3,
|
|
9
9
|
renameSync,
|
|
10
|
-
rmSync,
|
|
11
|
-
existsSync
|
|
10
|
+
rmSync as rmSync3,
|
|
11
|
+
existsSync as existsSync3
|
|
12
12
|
} from "fs";
|
|
13
|
+
import { homedir as homedir2, tmpdir as tmpdir2 } from "os";
|
|
14
|
+
import { dirname as dirname3, join as join3, resolve as resolve3 } from "path";
|
|
15
|
+
|
|
16
|
+
// src/marker.ts
|
|
17
|
+
import * as fs from "fs";
|
|
18
|
+
import * as path from "path";
|
|
19
|
+
var MARKER_FILENAME = ".shepherd";
|
|
20
|
+
function findRepoRoot(cwd) {
|
|
21
|
+
let dir = path.resolve(cwd);
|
|
22
|
+
for (; ; ) {
|
|
23
|
+
if (fs.existsSync(path.join(dir, ".git"))) return dir;
|
|
24
|
+
const parent = path.dirname(dir);
|
|
25
|
+
if (parent === dir) return null;
|
|
26
|
+
dir = parent;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function markerPath(cwd) {
|
|
30
|
+
const root = findRepoRoot(cwd);
|
|
31
|
+
return root === null ? null : path.join(root, MARKER_FILENAME);
|
|
32
|
+
}
|
|
33
|
+
function readMarker(cwd = process.cwd()) {
|
|
34
|
+
const file = markerPath(cwd);
|
|
35
|
+
if (file === null) return null;
|
|
36
|
+
let raw;
|
|
37
|
+
try {
|
|
38
|
+
raw = fs.readFileSync(file, "utf8");
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const parsed = JSON.parse(raw);
|
|
44
|
+
if (parsed !== null && typeof parsed === "object" && typeof parsed.workspace === "string" && parsed.workspace.length > 0) {
|
|
45
|
+
return { workspace: parsed.workspace };
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/declined.ts
|
|
54
|
+
import { createHash } from "crypto";
|
|
55
|
+
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
13
56
|
import { homedir, tmpdir } from "os";
|
|
14
|
-
import { dirname, join, resolve } from "path";
|
|
15
|
-
function
|
|
57
|
+
import { dirname as dirname2, join as join2, resolve as resolve2 } from "path";
|
|
58
|
+
function defaultDeclinedDir() {
|
|
16
59
|
let base = "";
|
|
17
60
|
try {
|
|
18
61
|
base = homedir();
|
|
@@ -20,29 +63,78 @@ function defaultInboxDir() {
|
|
|
20
63
|
base = "";
|
|
21
64
|
}
|
|
22
65
|
if (!base) base = tmpdir();
|
|
23
|
-
return
|
|
66
|
+
return join2(base, ".shepherd", "declined");
|
|
24
67
|
}
|
|
25
|
-
function
|
|
26
|
-
let normalized =
|
|
68
|
+
function declinedFilePath(repoRoot, dir = defaultDeclinedDir()) {
|
|
69
|
+
let normalized = resolve2(repoRoot);
|
|
27
70
|
if (process.platform === "win32") normalized = normalized.toLowerCase();
|
|
28
71
|
const hash = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
|
|
29
|
-
return
|
|
72
|
+
return join2(dir, hash);
|
|
73
|
+
}
|
|
74
|
+
function isDeclined(repoRoot, dir = defaultDeclinedDir()) {
|
|
75
|
+
const file = declinedFilePath(repoRoot, dir);
|
|
76
|
+
let raw;
|
|
77
|
+
try {
|
|
78
|
+
raw = readFileSync2(file, "utf8");
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(raw);
|
|
84
|
+
return typeof parsed?.declinedAt === "string";
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// src/linkNudge.ts
|
|
91
|
+
var WRITE_TOOLS = /* @__PURE__ */ new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
92
|
+
var LINK_NUDGE_TEXT = '[Shepherd] This repo is not linked to a Shepherd workspace and has not been declined. Before writing files here, call the shepherd `link` tool with no argument: it auto-links if the user belongs to exactly one workspace, or lists workspaces \u2014 then ask the user "Coordinate this repo with Shepherd? Which workspace?" and call `link <workspace>` with their answer, or `decline` if they say no. Ask at most once per repo.';
|
|
93
|
+
function buildLinkNudge(cwd, toolName, deps = {}) {
|
|
94
|
+
try {
|
|
95
|
+
if (toolName !== void 0 && !WRITE_TOOLS.has(toolName)) return "";
|
|
96
|
+
const repoRoot = findRepoRoot(cwd);
|
|
97
|
+
if (repoRoot === null) return "";
|
|
98
|
+
if (readMarker(cwd) !== null) return "";
|
|
99
|
+
if (isDeclined(repoRoot, deps.declinedDir)) return "";
|
|
100
|
+
return LINK_NUDGE_TEXT;
|
|
101
|
+
} catch {
|
|
102
|
+
return "";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// src/inbox.ts
|
|
107
|
+
function defaultInboxDir() {
|
|
108
|
+
let base = "";
|
|
109
|
+
try {
|
|
110
|
+
base = homedir2();
|
|
111
|
+
} catch {
|
|
112
|
+
base = "";
|
|
113
|
+
}
|
|
114
|
+
if (!base) base = tmpdir2();
|
|
115
|
+
return join3(base, ".shepherd", "inbox");
|
|
116
|
+
}
|
|
117
|
+
function inboxFilePath(dir, cwd) {
|
|
118
|
+
let normalized = resolve3(cwd);
|
|
119
|
+
if (process.platform === "win32") normalized = normalized.toLowerCase();
|
|
120
|
+
const hash = createHash2("sha256").update(normalized).digest("hex").slice(0, 16);
|
|
121
|
+
return join3(dir, `${hash}.jsonl`);
|
|
30
122
|
}
|
|
31
123
|
function drainInbox(filePath) {
|
|
32
124
|
const tmp = `${filePath}.draining`;
|
|
33
125
|
let raw = "";
|
|
34
126
|
try {
|
|
35
|
-
if (
|
|
36
|
-
raw +=
|
|
37
|
-
|
|
127
|
+
if (existsSync3(tmp)) {
|
|
128
|
+
raw += readFileSync3(tmp, "utf8");
|
|
129
|
+
rmSync3(tmp, { force: true });
|
|
38
130
|
}
|
|
39
131
|
} catch {
|
|
40
132
|
}
|
|
41
133
|
try {
|
|
42
|
-
if (
|
|
134
|
+
if (existsSync3(filePath)) {
|
|
43
135
|
renameSync(filePath, tmp);
|
|
44
|
-
raw +=
|
|
45
|
-
|
|
136
|
+
raw += readFileSync3(tmp, "utf8");
|
|
137
|
+
rmSync3(tmp, { force: true });
|
|
46
138
|
}
|
|
47
139
|
} catch {
|
|
48
140
|
}
|
|
@@ -74,8 +166,7 @@ function formatInboxAnnouncements(announcements) {
|
|
|
74
166
|
}
|
|
75
167
|
return lines.join("\n");
|
|
76
168
|
}
|
|
77
|
-
function buildHookOutput(rawStdin, inboxDir, drain = drainInbox) {
|
|
78
|
-
if (!inboxDir) return "";
|
|
169
|
+
function buildHookOutput(rawStdin, inboxDir, drain = drainInbox, nudge = buildLinkNudge) {
|
|
79
170
|
let input;
|
|
80
171
|
try {
|
|
81
172
|
input = JSON.parse(rawStdin);
|
|
@@ -83,13 +174,21 @@ function buildHookOutput(rawStdin, inboxDir, drain = drainInbox) {
|
|
|
83
174
|
return "";
|
|
84
175
|
}
|
|
85
176
|
if (!input || typeof input.cwd !== "string" || input.cwd.length === 0) return "";
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
|
|
177
|
+
const parts = [];
|
|
178
|
+
const nudgeText = nudge(
|
|
179
|
+
input.cwd,
|
|
180
|
+
typeof input.tool_name === "string" ? input.tool_name : void 0
|
|
181
|
+
);
|
|
182
|
+
if (nudgeText) parts.push(nudgeText);
|
|
183
|
+
if (inboxDir) {
|
|
184
|
+
const text = formatInboxAnnouncements(drain(inboxFilePath(inboxDir, input.cwd)));
|
|
185
|
+
if (text) parts.push(text);
|
|
186
|
+
}
|
|
187
|
+
if (parts.length === 0) return "";
|
|
89
188
|
return JSON.stringify({
|
|
90
189
|
hookSpecificOutput: {
|
|
91
190
|
hookEventName: input.hook_event_name || "PreToolUse",
|
|
92
|
-
additionalContext:
|
|
191
|
+
additionalContext: parts.join("\n\n")
|
|
93
192
|
}
|
|
94
193
|
});
|
|
95
194
|
}
|
package/dist/index.js
CHANGED
|
@@ -783,7 +783,10 @@ function detectHuman(cwd = process.cwd()) {
|
|
|
783
783
|
return null;
|
|
784
784
|
}
|
|
785
785
|
const name = runGit(cwd, ["config", "user.name"]);
|
|
786
|
-
if (name)
|
|
786
|
+
if (name) {
|
|
787
|
+
const local = name.includes("@") ? name.split("@")[0] : name;
|
|
788
|
+
if (local) return local;
|
|
789
|
+
}
|
|
787
790
|
const email = runGit(cwd, ["config", "user.email"]);
|
|
788
791
|
if (email) {
|
|
789
792
|
const local = email.split("@")[0];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@korso/shepherd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|