@korso/shepherd 0.6.3 → 0.8.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 +101 -14
- package/dist/inboxExtension.js +112 -18
- package/dist/inboxHook.js +140 -25
- package/dist/index.js +470 -27
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -60,6 +60,7 @@ override only to replace what's detected:
|
|
|
60
60
|
| `MODEL` | omitted — **never auto-detected**, so set it if you want it shown | `claude-sonnet-4-6` |
|
|
61
61
|
| `HEARTBEAT_INTERVAL_SECONDS` | defaults to `60` | `30` |
|
|
62
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
|
+
| `SHEPHERD_NO_AUTO_HOOKS` | unset — set to `1`/`true` to stop the server from auto-installing the client delivery hook on first run (see below) | `1` |
|
|
63
64
|
|
|
64
65
|
**Device-identity cache.** Whenever `HUMAN` is unset and git **does** detect a
|
|
65
66
|
name, that name is cached for your OS user at `~/.shepherd/identity.json`. A
|
|
@@ -84,10 +85,37 @@ default `~/.shepherd/inbox`). That file is then drained by two paths:
|
|
|
84
85
|
anything sitting in the inbox. So even with no hook configured, no announcement
|
|
85
86
|
is ever lost — the worst case is the old behaviour (delivered on the next
|
|
86
87
|
Shepherd tool call), never silent drops.
|
|
87
|
-
2. **Passive client hook/extension (
|
|
88
|
-
**without** waiting for a Shepherd tool call — surfaced on
|
|
89
|
-
action of any kind —
|
|
90
|
-
|
|
88
|
+
2. **Passive client hook/extension (installed automatically, per client).** To
|
|
89
|
+
get announcements **without** waiting for a Shepherd tool call — surfaced on
|
|
90
|
+
the agent's next action of any kind — the client needs its hook wired up.
|
|
91
|
+
**You normally don't do this by hand**: the first time the server runs under
|
|
92
|
+
Claude Code, Codex, or Pi, it installs the hook itself (see *Automatic hook
|
|
93
|
+
install* below). The per-client sections that follow document exactly what
|
|
94
|
+
gets installed, for auditing or manual setup.
|
|
95
|
+
|
|
96
|
+
### Automatic hook install
|
|
97
|
+
|
|
98
|
+
On its first `initialize` handshake the server detects the connecting client
|
|
99
|
+
and, for Claude Code / Codex / Pi, installs the delivery hook **once per
|
|
100
|
+
machine**:
|
|
101
|
+
|
|
102
|
+
- **Claude Code** — merges the `SessionStart` + `PreToolUse` hook entries into
|
|
103
|
+
`~/.claude/settings.json` (additive JSON merge; an unparseable file is left
|
|
104
|
+
untouched).
|
|
105
|
+
- **Codex** — appends the `[[hooks.UserPromptSubmit]]` block (and
|
|
106
|
+
`features.hooks = true`) to `~/.codex/config.toml` (text-level append; it
|
|
107
|
+
respects an explicit `hooks = false` and never rewrites existing content).
|
|
108
|
+
- **Pi** — copies the bundled extension to
|
|
109
|
+
`~/.pi/agent/extensions/shepherd-inbox.js`.
|
|
110
|
+
- **Cursor** — merges a `beforeSubmitPrompt` entry into `~/.cursor/hooks.json`
|
|
111
|
+
(additive JSON merge). Only that event is wired: it is the one Cursor event
|
|
112
|
+
verified to inject hook output into the agent's context, and wiring an
|
|
113
|
+
unverified event would consume announcements without delivering them.
|
|
114
|
+
|
|
115
|
+
A record under `~/.shepherd/hooks/` guarantees at-most-once: if you remove the
|
|
116
|
+
hook, Shepherd won't re-add it. Everything is fail-open (an error just means no
|
|
117
|
+
hook, never a broken session), and `SHEPHERD_NO_AUTO_HOOKS=1` disables the whole
|
|
118
|
+
mechanism.
|
|
91
119
|
|
|
92
120
|
Both paths read the **same** inbox file and de-duplicate by announcement id, so
|
|
93
121
|
running both is safe (the hub hands each announcement to exactly one drain; the
|
|
@@ -99,9 +127,35 @@ It delivers to an agent **while it's active**; an idle agent picks messages up t
|
|
|
99
127
|
moment it next does anything. (Waking a fully-idle agent is out of scope — for
|
|
100
128
|
Claude Code that needs Channels; Codex/Pi have no equivalent.)
|
|
101
129
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
130
|
+
**The hook also carries the unlinked-repo nudge.** The server instructions tell
|
|
131
|
+
the agent to ask about linking on its first write in a new repo, but instructions
|
|
132
|
+
sitting passively in context don't trigger themselves — an agent focused on the
|
|
133
|
+
task can skip straight to editing. So on every invocation the hook also checks
|
|
134
|
+
this repo's link state and, when the repo is **neither linked (`.shepherd`
|
|
135
|
+
marker) nor declined**, injects a reminder to run `link`/`decline` — on
|
|
136
|
+
`SessionStart` (front-loads the ask), right before a file-writing tool
|
|
137
|
+
(`Edit`/`Write`/`MultiEdit`/`NotebookEdit`; read-only tools never nudge), and on
|
|
138
|
+
tool-less events like Codex's `UserPromptSubmit` or a Pi turn. The
|
|
139
|
+
nudge is advisory and self-extinguishing: the moment the repo is linked or
|
|
140
|
+
declined it goes quiet, and like everything else here it fails open.
|
|
141
|
+
|
|
142
|
+
**First-run ask (zero-setup).** Independently of the hook, the server watches an
|
|
143
|
+
unlinked, undeclined repo for its first file edit (a lightweight `git status`
|
|
144
|
+
poll) and — on clients that support MCP elicitation — asks the user directly via
|
|
145
|
+
a popup: *"Coordinate this repo with Shepherd?"* with the workspace choices and
|
|
146
|
+
a "No — don't ask again" option. Only an explicitly **submitted** answer is
|
|
147
|
+
recorded (a dismissed or auto-declined popup means "ask again next session"), so
|
|
148
|
+
the question is answered at most once and never by accident. Linking activates
|
|
149
|
+
coordination live, mid-session; "don't ask again" hides the coordination tools
|
|
150
|
+
for that repo entirely. Clients without elicitation fall back to the
|
|
151
|
+
instructions + hook nudge above.
|
|
152
|
+
|
|
153
|
+
### Claude Code — `PreToolUse` + `SessionStart` hooks
|
|
154
|
+
|
|
155
|
+
*(Installed automatically on first run — shown for reference/manual setup.)*
|
|
156
|
+
|
|
157
|
+
`PreToolUse` fires before every tool, giving the most frequent passive delivery;
|
|
158
|
+
`SessionStart` surfaces the link ask at the top of a session in an unlinked repo.
|
|
105
159
|
The hook needs no arguments — it resolves the same default inbox dir the server
|
|
106
160
|
uses (override both with `SHEPHERD_INBOX_DIR` if you relocated it):
|
|
107
161
|
|
|
@@ -118,6 +172,13 @@ uses (override both with `SHEPHERD_INBOX_DIR` if you relocated it):
|
|
|
118
172
|
}
|
|
119
173
|
},
|
|
120
174
|
"hooks": {
|
|
175
|
+
"SessionStart": [
|
|
176
|
+
{
|
|
177
|
+
"hooks": [
|
|
178
|
+
{ "type": "command", "command": "npx -y --package=@korso/shepherd shepherd-inbox-hook" }
|
|
179
|
+
]
|
|
180
|
+
}
|
|
181
|
+
],
|
|
121
182
|
"PreToolUse": [
|
|
122
183
|
{
|
|
123
184
|
"matcher": "*",
|
|
@@ -132,6 +193,8 @@ uses (override both with `SHEPHERD_INBOX_DIR` if you relocated it):
|
|
|
132
193
|
|
|
133
194
|
### Codex — `UserPromptSubmit` hook
|
|
134
195
|
|
|
196
|
+
*(Installed automatically on first run — shown for reference/manual setup.)*
|
|
197
|
+
|
|
135
198
|
Codex uses the **same** hook contract as Claude Code (JSON on stdin, a
|
|
136
199
|
`hookSpecificOutput.additionalContext` reply), so the **same bin** serves it. Use
|
|
137
200
|
`UserPromptSubmit` — Codex's `PreToolUse` only fires for Bash, not `apply_patch`
|
|
@@ -150,8 +213,10 @@ command = ["npx", "-y", "--package=@korso/shepherd", "shepherd-inbox-hook"]
|
|
|
150
213
|
|
|
151
214
|
### Pi — extension
|
|
152
215
|
|
|
153
|
-
|
|
154
|
-
|
|
216
|
+
*(Installed automatically on first run — shown for reference/manual setup.)*
|
|
217
|
+
|
|
218
|
+
Pi has no stdin/stdout hook; it loads in-process extensions. The auto-install
|
|
219
|
+
copies the bundled extension into Pi's extensions dir; by hand:
|
|
155
220
|
|
|
156
221
|
```sh
|
|
157
222
|
# global, applies everywhere:
|
|
@@ -161,8 +226,29 @@ cp "$(npm root -g)/@korso/shepherd/dist/inboxExtension.js" ~/.pi/agent/extension
|
|
|
161
226
|
```
|
|
162
227
|
|
|
163
228
|
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`.)
|
|
229
|
+
injects pending announcements plus the unlinked-repo nudge. (Or load it ad hoc
|
|
230
|
+
with `pi -e /abs/path/to/dist/inboxExtension.js`.)
|
|
231
|
+
|
|
232
|
+
### Cursor — `beforeSubmitPrompt` hook
|
|
233
|
+
|
|
234
|
+
*(Installed automatically on first run — shown for reference/manual setup.)*
|
|
235
|
+
|
|
236
|
+
Cursor runs hooks from `~/.cursor/hooks.json` with JSON on stdin and a JSON
|
|
237
|
+
reply on stdout; the same bin detects Cursor's dialect (BOM-prefixed payload,
|
|
238
|
+
`workspace_roots` instead of `cwd`) and answers with the top-level
|
|
239
|
+
`additionalContext` form Cursor injects into the agent's context. Only
|
|
240
|
+
`beforeSubmitPrompt` is used — verified (Cursor 3.9.16) to reach the model:
|
|
241
|
+
|
|
242
|
+
```json
|
|
243
|
+
{
|
|
244
|
+
"version": 1,
|
|
245
|
+
"hooks": {
|
|
246
|
+
"beforeSubmitPrompt": [
|
|
247
|
+
{ "command": "npx -y --package=@korso/shepherd shepherd-inbox-hook" }
|
|
248
|
+
]
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
```
|
|
166
252
|
|
|
167
253
|
### Notes
|
|
168
254
|
|
|
@@ -282,9 +368,10 @@ or per-project at `.cursor/mcp.json`:
|
|
|
282
368
|
```
|
|
283
369
|
|
|
284
370
|
Confirm under **Settings → MCP** that `shepherd` is listed with its tools.
|
|
285
|
-
Announcement push:
|
|
286
|
-
|
|
287
|
-
the
|
|
371
|
+
Announcement push: handled by the auto-installed `beforeSubmitPrompt` hook in
|
|
372
|
+
`~/.cursor/hooks.json` (see the Cursor hook section above) — announcements and
|
|
373
|
+
the link nudge land on each user turn, plus the universal drainer on every
|
|
374
|
+
Shepherd tool call.
|
|
288
375
|
|
|
289
376
|
---
|
|
290
377
|
|
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
|
}
|
|
@@ -60,6 +152,7 @@ function drainInbox(filePath) {
|
|
|
60
152
|
}
|
|
61
153
|
return out;
|
|
62
154
|
}
|
|
155
|
+
var REPLY_ROUTING_HINT = "(The senders can't see this chat. If a message needs a reply, send it with the `announce` tool \u2014 directed to the sender by name \u2014 not here.)";
|
|
63
156
|
function formatInboxAnnouncements(announcements) {
|
|
64
157
|
if (!announcements || announcements.length === 0) return "";
|
|
65
158
|
const count = announcements.length;
|
|
@@ -70,6 +163,7 @@ function formatInboxAnnouncements(announcements) {
|
|
|
70
163
|
const target = a.targetAgentName ? ` \u2192 ${a.targetAgentName}` : " (broadcast)";
|
|
71
164
|
lines.push(` [${a.fromAgentName}${target}] ${a.body}`);
|
|
72
165
|
}
|
|
166
|
+
lines.push(REPLY_ROUTING_HINT);
|
|
73
167
|
return lines.join("\n");
|
|
74
168
|
}
|
|
75
169
|
|
|
@@ -80,7 +174,7 @@ function shepherdInbox(pi) {
|
|
|
80
174
|
const dir = process.env["SHEPHERD_INBOX_DIR"] || defaultInboxDir();
|
|
81
175
|
const cwd = ctx?.cwd ?? process.cwd();
|
|
82
176
|
const announcements = drainInbox(inboxFilePath(dir, cwd));
|
|
83
|
-
const content = formatInboxAnnouncements(announcements);
|
|
177
|
+
const content = [buildLinkNudge(cwd), formatInboxAnnouncements(announcements)].filter(Boolean).join("\n\n");
|
|
84
178
|
if (!content) return void 0;
|
|
85
179
|
return { message: { customType: "shepherd-inbox", content, display: true } };
|
|
86
180
|
} 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
|
}
|
|
@@ -62,6 +154,7 @@ function drainInbox(filePath) {
|
|
|
62
154
|
}
|
|
63
155
|
return out;
|
|
64
156
|
}
|
|
157
|
+
var REPLY_ROUTING_HINT = "(The senders can't see this chat. If a message needs a reply, send it with the `announce` tool \u2014 directed to the sender by name \u2014 not here.)";
|
|
65
158
|
function formatInboxAnnouncements(announcements) {
|
|
66
159
|
if (!announcements || announcements.length === 0) return "";
|
|
67
160
|
const count = announcements.length;
|
|
@@ -72,24 +165,46 @@ function formatInboxAnnouncements(announcements) {
|
|
|
72
165
|
const target = a.targetAgentName ? ` \u2192 ${a.targetAgentName}` : " (broadcast)";
|
|
73
166
|
lines.push(` [${a.fromAgentName}${target}] ${a.body}`);
|
|
74
167
|
}
|
|
168
|
+
lines.push(REPLY_ROUTING_HINT);
|
|
75
169
|
return lines.join("\n");
|
|
76
170
|
}
|
|
77
|
-
function
|
|
78
|
-
|
|
171
|
+
function nativeWorkspacePath(root) {
|
|
172
|
+
const win = /^\/([A-Za-z]:[/\\].*)$/.exec(root);
|
|
173
|
+
return win ? win[1] : root;
|
|
174
|
+
}
|
|
175
|
+
function buildHookOutput(rawStdin, inboxDir, drain = drainInbox, nudge = buildLinkNudge) {
|
|
79
176
|
let input;
|
|
80
177
|
try {
|
|
81
|
-
input = JSON.parse(rawStdin);
|
|
178
|
+
input = JSON.parse(rawStdin.replace(/^\uFEFF/, ""));
|
|
82
179
|
} catch {
|
|
83
180
|
return "";
|
|
84
181
|
}
|
|
85
|
-
if (!input || typeof input
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
|
|
182
|
+
if (!input || typeof input !== "object") return "";
|
|
183
|
+
const isCursor = typeof input.cursor_version === "string" || Array.isArray(input.workspace_roots);
|
|
184
|
+
const firstRoot = Array.isArray(input.workspace_roots) ? input.workspace_roots[0] : void 0;
|
|
185
|
+
const cwd = typeof input.cwd === "string" && input.cwd.length > 0 ? input.cwd : isCursor && typeof firstRoot === "string" && firstRoot.length > 0 ? nativeWorkspacePath(firstRoot) : null;
|
|
186
|
+
if (cwd === null) return "";
|
|
187
|
+
const parts = [];
|
|
188
|
+
const nudgeText = nudge(
|
|
189
|
+
cwd,
|
|
190
|
+
typeof input.tool_name === "string" ? input.tool_name : void 0
|
|
191
|
+
);
|
|
192
|
+
if (nudgeText) parts.push(nudgeText);
|
|
193
|
+
if (inboxDir) {
|
|
194
|
+
const text = formatInboxAnnouncements(drain(inboxFilePath(inboxDir, cwd)));
|
|
195
|
+
if (text) parts.push(text);
|
|
196
|
+
}
|
|
197
|
+
if (parts.length === 0) return "";
|
|
198
|
+
if (isCursor) {
|
|
199
|
+
return JSON.stringify({
|
|
200
|
+
continue: true,
|
|
201
|
+
additionalContext: parts.join("\n\n")
|
|
202
|
+
});
|
|
203
|
+
}
|
|
89
204
|
return JSON.stringify({
|
|
90
205
|
hookSpecificOutput: {
|
|
91
206
|
hookEventName: input.hook_event_name || "PreToolUse",
|
|
92
|
-
additionalContext:
|
|
207
|
+
additionalContext: parts.join("\n\n")
|
|
93
208
|
}
|
|
94
209
|
});
|
|
95
210
|
}
|
package/dist/index.js
CHANGED
|
@@ -34,7 +34,11 @@ var ConfigSchema = z.object({
|
|
|
34
34
|
// context on its next action. Unset → no inbox, announcements flow only via
|
|
35
35
|
// work/sync/done/announce tool results as before. Both the MCP server and the
|
|
36
36
|
// hook must agree on this path.
|
|
37
|
-
SHEPHERD_INBOX_DIR: z.string().min(1).optional()
|
|
37
|
+
SHEPHERD_INBOX_DIR: z.string().min(1).optional(),
|
|
38
|
+
// Opt-out for the zero-setup hook auto-install (Layer 4). Any of "1", "true",
|
|
39
|
+
// "yes" (case-insensitive) disables it; everything else (including unset)
|
|
40
|
+
// leaves the default-on behavior. See hookInstall.ts and the README.
|
|
41
|
+
SHEPHERD_NO_AUTO_HOOKS: z.string().optional().transform((v) => ["1", "true", "yes"].includes((v ?? "").toLowerCase()))
|
|
38
42
|
}).refine((c) => Boolean(c.SHEPHERD_TOKEN || c.TEAM_TOKEN), {
|
|
39
43
|
message: "Either SHEPHERD_TOKEN or TEAM_TOKEN is required",
|
|
40
44
|
path: ["SHEPHERD_TOKEN"]
|
|
@@ -52,7 +56,8 @@ function parseConfig(env) {
|
|
|
52
56
|
PROGRAM: env["PROGRAM"],
|
|
53
57
|
MODEL: env["MODEL"],
|
|
54
58
|
HEARTBEAT_INTERVAL_SECONDS: env["HEARTBEAT_INTERVAL_SECONDS"],
|
|
55
|
-
SHEPHERD_INBOX_DIR: env["SHEPHERD_INBOX_DIR"]
|
|
59
|
+
SHEPHERD_INBOX_DIR: env["SHEPHERD_INBOX_DIR"],
|
|
60
|
+
SHEPHERD_NO_AUTO_HOOKS: env["SHEPHERD_NO_AUTO_HOOKS"]
|
|
56
61
|
});
|
|
57
62
|
const authToken = parsed.SHEPHERD_TOKEN ?? parsed.TEAM_TOKEN;
|
|
58
63
|
return { ...parsed, authToken };
|
|
@@ -390,10 +395,16 @@ var WorkspaceAnnouncement = z2.object({
|
|
|
390
395
|
// (no agent session). The dashboard renders these as "me" (right-aligned).
|
|
391
396
|
// Defaulted for version-skew safety with older hubs.
|
|
392
397
|
fromAdmin: z2.boolean().default(false),
|
|
393
|
-
// True when an agent addressed the message TO the operator (the
|
|
394
|
-
//
|
|
395
|
-
// to other agents. Defaulted for
|
|
398
|
+
// True when an agent addressed the message TO the operator side (the
|
|
399
|
+
// dashboard) — collectively (legacy) or a specific member (see
|
|
400
|
+
// targetMemberName). Not delivered to other agents. Defaulted for
|
|
401
|
+
// version-skew safety with older hubs.
|
|
396
402
|
toAdmin: z2.boolean().default(false),
|
|
403
|
+
// When an agent addressed a SPECIFIC workspace member, the member's display
|
|
404
|
+
// name snapshotted at send time — the dashboard renders "→ <name>" instead of
|
|
405
|
+
// the collective "→ admin". Null for legacy/collective operator messages and
|
|
406
|
+
// everything else. Defaulted for version-skew safety with older hubs.
|
|
407
|
+
targetMemberName: z2.string().nullable().default(null),
|
|
397
408
|
createdAt: IsoTimestamp
|
|
398
409
|
});
|
|
399
410
|
var WorkspaceLandscapeResponse = z2.object({
|
|
@@ -457,13 +468,20 @@ var DoneResponse = z2.object({
|
|
|
457
468
|
var AnnounceRequest = z2.object({
|
|
458
469
|
sessionId: z2.string().uuid(),
|
|
459
470
|
body: z2.string().min(1).max(8192),
|
|
460
|
-
//
|
|
471
|
+
// THE preferred addressing field: one name that reaches either kind of
|
|
472
|
+
// teammate. The hub resolves it in order — a LIVE AGENT in the sender's repo
|
|
473
|
+
// (exact landscape name, e.g. "alex-rivera-2"), else the operator label
|
|
474
|
+
// ("admin" by default => the dashboard collectively), else a WORKSPACE MEMBER
|
|
475
|
+
// (a dashboard user, matched case-insensitively on display name, GitHub
|
|
476
|
+
// login, or email). No match => 400 listing both sets. Absent/null =>
|
|
477
|
+
// broadcast to all agents. Mutually exclusive with the legacy fields below.
|
|
478
|
+
target: z2.string().min(1).nullable().optional(),
|
|
479
|
+
// LEGACY (kept for older clients; prefer `target`): the exact live-agent name.
|
|
461
480
|
targetAgentName: z2.string().nullable().optional(),
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
// "<agent> → admin" and is NOT delivered to other
|
|
465
|
-
//
|
|
466
|
-
// version skew with older clients.
|
|
481
|
+
// LEGACY (kept for older clients; prefer `target` with a member's name):
|
|
482
|
+
// true => address the human operators (the dashboard) collectively. Shows in
|
|
483
|
+
// the workspace feed as "<agent> → admin" and is NOT delivered to other
|
|
484
|
+
// agents. Mutually exclusive with targetAgentName and target.
|
|
467
485
|
toAdmin: z2.boolean().optional()
|
|
468
486
|
});
|
|
469
487
|
var AnnounceResponse = z2.object({
|
|
@@ -783,7 +801,10 @@ function detectHuman(cwd = process.cwd()) {
|
|
|
783
801
|
return null;
|
|
784
802
|
}
|
|
785
803
|
const name = runGit(cwd, ["config", "user.name"]);
|
|
786
|
-
if (name)
|
|
804
|
+
if (name) {
|
|
805
|
+
const local = name.includes("@") ? name.split("@")[0] : name;
|
|
806
|
+
if (local) return local;
|
|
807
|
+
}
|
|
787
808
|
const email = runGit(cwd, ["config", "user.email"]);
|
|
788
809
|
if (email) {
|
|
789
810
|
const local = email.split("@")[0];
|
|
@@ -1033,6 +1054,7 @@ function drainInbox(filePath) {
|
|
|
1033
1054
|
}
|
|
1034
1055
|
return out;
|
|
1035
1056
|
}
|
|
1057
|
+
var REPLY_ROUTING_HINT = "(The senders can't see this chat. If a message needs a reply, send it with the `announce` tool \u2014 directed to the sender by name \u2014 not here.)";
|
|
1036
1058
|
function mergeAnnouncements(...lists) {
|
|
1037
1059
|
const byId = /* @__PURE__ */ new Map();
|
|
1038
1060
|
for (const list of lists) {
|
|
@@ -1044,6 +1066,125 @@ function mergeAnnouncements(...lists) {
|
|
|
1044
1066
|
return [...byId.values()].sort((x, y) => x.id - y.id);
|
|
1045
1067
|
}
|
|
1046
1068
|
|
|
1069
|
+
// src/editTripwire.ts
|
|
1070
|
+
import { execFile } from "child_process";
|
|
1071
|
+
function createEditTripwire({
|
|
1072
|
+
cwd,
|
|
1073
|
+
intervalMs = 3e4,
|
|
1074
|
+
onEdits,
|
|
1075
|
+
runGitStatus = defaultRunGitStatus
|
|
1076
|
+
}) {
|
|
1077
|
+
let timer = null;
|
|
1078
|
+
let baseline = null;
|
|
1079
|
+
let disarmed = false;
|
|
1080
|
+
function stop() {
|
|
1081
|
+
disarmed = true;
|
|
1082
|
+
if (timer !== null) {
|
|
1083
|
+
clearInterval(timer);
|
|
1084
|
+
timer = null;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
async function check() {
|
|
1088
|
+
const out = await runGitStatus(cwd);
|
|
1089
|
+
if (out === null || baseline === null || disarmed) return;
|
|
1090
|
+
for (const path3 of parsePorcelainPaths(out)) {
|
|
1091
|
+
if (!baseline.has(path3)) {
|
|
1092
|
+
stop();
|
|
1093
|
+
try {
|
|
1094
|
+
onEdits();
|
|
1095
|
+
} catch (err) {
|
|
1096
|
+
console.error(
|
|
1097
|
+
`[shepherd] edit-tripwire handler failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
function start() {
|
|
1105
|
+
stop();
|
|
1106
|
+
disarmed = false;
|
|
1107
|
+
void (async () => {
|
|
1108
|
+
const out = await runGitStatus(cwd);
|
|
1109
|
+
if (out === null || disarmed) return;
|
|
1110
|
+
baseline = new Set(parsePorcelainPaths(out));
|
|
1111
|
+
timer = setInterval(() => {
|
|
1112
|
+
void check().catch(() => {
|
|
1113
|
+
});
|
|
1114
|
+
}, intervalMs);
|
|
1115
|
+
timer.unref();
|
|
1116
|
+
})();
|
|
1117
|
+
}
|
|
1118
|
+
return { start, stop };
|
|
1119
|
+
}
|
|
1120
|
+
function parsePorcelainPaths(out) {
|
|
1121
|
+
return out.split("\n").filter((line) => line.length > 3).map((line) => line.slice(3));
|
|
1122
|
+
}
|
|
1123
|
+
function defaultRunGitStatus(cwd) {
|
|
1124
|
+
return new Promise((resolve4) => {
|
|
1125
|
+
execFile(
|
|
1126
|
+
"git",
|
|
1127
|
+
["status", "--porcelain"],
|
|
1128
|
+
{ cwd, timeout: 5e3, windowsHide: true },
|
|
1129
|
+
(err, stdout) => {
|
|
1130
|
+
resolve4(err ? null : stdout);
|
|
1131
|
+
}
|
|
1132
|
+
);
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// src/linkPopup.ts
|
|
1137
|
+
var NEVER_ASK_CHOICE = "No \u2014 don't ask again";
|
|
1138
|
+
async function offerLinkPopup({
|
|
1139
|
+
repoName,
|
|
1140
|
+
elicit,
|
|
1141
|
+
listWorkspaces,
|
|
1142
|
+
linkWorkspace,
|
|
1143
|
+
neverAskAgain
|
|
1144
|
+
}) {
|
|
1145
|
+
let slugs;
|
|
1146
|
+
try {
|
|
1147
|
+
slugs = await listWorkspaces();
|
|
1148
|
+
} catch {
|
|
1149
|
+
return { outcome: "unanswered" };
|
|
1150
|
+
}
|
|
1151
|
+
if (slugs.length === 0) return { outcome: "unanswered" };
|
|
1152
|
+
let response;
|
|
1153
|
+
try {
|
|
1154
|
+
response = await elicit({
|
|
1155
|
+
message: `Shepherd: "${repoName}" isn't linked to a team workspace, so teammates can't see the work happening here. Link it to start coordinating? This writes a small .shepherd file naming the workspace (commit it so teammates inherit the link). Dismiss to decide later \u2014 you'll be asked again next session.`,
|
|
1156
|
+
requestedSchema: {
|
|
1157
|
+
type: "object",
|
|
1158
|
+
properties: {
|
|
1159
|
+
decision: {
|
|
1160
|
+
type: "string",
|
|
1161
|
+
title: "Workspace",
|
|
1162
|
+
description: `Workspace to link this repo to, or "${NEVER_ASK_CHOICE}".`,
|
|
1163
|
+
enum: [...slugs, NEVER_ASK_CHOICE]
|
|
1164
|
+
}
|
|
1165
|
+
},
|
|
1166
|
+
required: ["decision"]
|
|
1167
|
+
}
|
|
1168
|
+
});
|
|
1169
|
+
} catch {
|
|
1170
|
+
return { outcome: "unanswered" };
|
|
1171
|
+
}
|
|
1172
|
+
if (response?.action !== "accept") return { outcome: "unanswered" };
|
|
1173
|
+
const decision = response.content?.decision;
|
|
1174
|
+
if (typeof decision !== "string") return { outcome: "unanswered" };
|
|
1175
|
+
if (decision === NEVER_ASK_CHOICE) {
|
|
1176
|
+
neverAskAgain();
|
|
1177
|
+
return { outcome: "declined" };
|
|
1178
|
+
}
|
|
1179
|
+
if (!slugs.includes(decision)) return { outcome: "unanswered" };
|
|
1180
|
+
try {
|
|
1181
|
+
await linkWorkspace(decision);
|
|
1182
|
+
} catch {
|
|
1183
|
+
return { outcome: "unanswered" };
|
|
1184
|
+
}
|
|
1185
|
+
return { outcome: "linked", workspace: decision };
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1047
1188
|
// src/tools.ts
|
|
1048
1189
|
function classifyJoinFailure(err) {
|
|
1049
1190
|
if (err instanceof HubUnreachable) return "unreachable";
|
|
@@ -1113,6 +1254,7 @@ function formatLandscape(landscape) {
|
|
|
1113
1254
|
const target = a.targetAgentName ? ` \u2192 ${a.targetAgentName}` : " (broadcast)";
|
|
1114
1255
|
lines.push(` [${a.fromAgentName}${target}] ${a.body}`);
|
|
1115
1256
|
}
|
|
1257
|
+
lines.push(REPLY_ROUTING_HINT);
|
|
1116
1258
|
} else {
|
|
1117
1259
|
lines.push("ANNOUNCEMENTS: none");
|
|
1118
1260
|
}
|
|
@@ -1125,6 +1267,7 @@ function formatAnnouncements(announcements) {
|
|
|
1125
1267
|
const target = a.targetAgentName ? ` \u2192 ${a.targetAgentName}` : " (broadcast)";
|
|
1126
1268
|
lines.push(` [${a.fromAgentName}${target}] ${a.body}`);
|
|
1127
1269
|
}
|
|
1270
|
+
lines.push(REPLY_ROUTING_HINT);
|
|
1128
1271
|
return lines.join("\n");
|
|
1129
1272
|
}
|
|
1130
1273
|
function relativeAge(iso) {
|
|
@@ -1207,13 +1350,28 @@ function registerTools(server, deps) {
|
|
|
1207
1350
|
const dormant = !context.linked || selfHostMismatch;
|
|
1208
1351
|
let linked = context.linked;
|
|
1209
1352
|
let declined = context.declined;
|
|
1353
|
+
const gatedTools = [];
|
|
1354
|
+
let surfaceVisible = true;
|
|
1355
|
+
function syncToolSurface() {
|
|
1356
|
+
const visible = linked || !declined;
|
|
1357
|
+
if (visible === surfaceVisible) return;
|
|
1358
|
+
surfaceVisible = visible;
|
|
1359
|
+
for (const tool of gatedTools) {
|
|
1360
|
+
if (visible) tool?.enable();
|
|
1361
|
+
else tool?.disable();
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
let tripwire = null;
|
|
1210
1365
|
function rememberDecline() {
|
|
1211
1366
|
if (repoRoot !== null) setDeclined(repoRoot, declinedDir);
|
|
1212
1367
|
declined = true;
|
|
1368
|
+
tripwire?.stop();
|
|
1369
|
+
syncToolSurface();
|
|
1213
1370
|
}
|
|
1214
1371
|
function forgetDecline() {
|
|
1215
1372
|
if (repoRoot !== null) clearDeclined(repoRoot, declinedDir);
|
|
1216
1373
|
declined = false;
|
|
1374
|
+
syncToolSurface();
|
|
1217
1375
|
}
|
|
1218
1376
|
let joinFailure = null;
|
|
1219
1377
|
let joinInFlight = Promise.resolve();
|
|
@@ -1344,7 +1502,7 @@ ${body}` : body;
|
|
|
1344
1502
|
|
|
1345
1503
|
${section}` : body;
|
|
1346
1504
|
}
|
|
1347
|
-
server.registerTool(
|
|
1505
|
+
const workTool = server.registerTool(
|
|
1348
1506
|
"work",
|
|
1349
1507
|
{
|
|
1350
1508
|
title: "Claim a unit of work",
|
|
@@ -1381,7 +1539,7 @@ You hold this claim until you call done (workItemId: ${result.workItemId}) or it
|
|
|
1381
1539
|
}
|
|
1382
1540
|
}
|
|
1383
1541
|
);
|
|
1384
|
-
server.registerTool(
|
|
1542
|
+
const doneTool = server.registerTool(
|
|
1385
1543
|
"done",
|
|
1386
1544
|
{
|
|
1387
1545
|
title: "Release a work claim",
|
|
@@ -1413,11 +1571,11 @@ ${msgs}` : base }
|
|
|
1413
1571
|
}
|
|
1414
1572
|
}
|
|
1415
1573
|
);
|
|
1416
|
-
server.registerTool(
|
|
1574
|
+
const announceTool = server.registerTool(
|
|
1417
1575
|
"announce",
|
|
1418
1576
|
{
|
|
1419
|
-
title: "
|
|
1420
|
-
description: "Broadcast a heads-up to the other agents, direct a finding to a specific
|
|
1577
|
+
title: "Message teammates (agents or humans)",
|
|
1578
|
+
description: "Broadcast a heads-up to the other agents, direct a finding to a specific teammate, or reply to a human on the dashboard. This is awareness only \u2014 not a task assignment. To direct it, pass ONE name as `target`: an agent's EXACT name as shown in the landscape \u2014 including its numeric suffix (e.g. 'alex-rivera-2', NOT the bare handle 'alex-rivera'; several agents can share one handle, the suffix picks one) \u2014 OR a human's name to reach that workspace member on the dashboard (reply to the person a message came from by using their sender name as target), OR 'admin' to reach the dashboard collectively. The hub rejects a target that matches no live agent and no member \u2014 if you mean the whole team, omit target to broadcast. (targetAgentName and toAdmin are deprecated aliases; don't combine them with target.) Delivery is best-effort: agents see it on their next work/sync, once; humans see the feed.",
|
|
1421
1579
|
inputSchema: AnnounceAgentInput.shape
|
|
1422
1580
|
},
|
|
1423
1581
|
async (args) => {
|
|
@@ -1445,7 +1603,7 @@ ${msgs}` : base }
|
|
|
1445
1603
|
}
|
|
1446
1604
|
}
|
|
1447
1605
|
);
|
|
1448
|
-
server.registerTool(
|
|
1606
|
+
const syncTool = server.registerTool(
|
|
1449
1607
|
"sync",
|
|
1450
1608
|
{
|
|
1451
1609
|
title: "Sync team landscape",
|
|
@@ -1478,10 +1636,16 @@ ${msgs}` : base }
|
|
|
1478
1636
|
function advisory(text) {
|
|
1479
1637
|
return { content: [{ type: "text", text }] };
|
|
1480
1638
|
}
|
|
1639
|
+
async function linkableSlugs() {
|
|
1640
|
+
if (!isHosted) return config.WORKSPACE ? [config.WORKSPACE] : [];
|
|
1641
|
+
const res = await hubClient.get("/workspaces");
|
|
1642
|
+
return (res.workspaces ?? []).map((w) => w.slug).filter((s) => typeof s === "string" && s.length > 0);
|
|
1643
|
+
}
|
|
1481
1644
|
async function linkAndActivate(slug) {
|
|
1482
1645
|
writeMarker(markerCwd, slug);
|
|
1483
1646
|
forgetDecline();
|
|
1484
1647
|
linked = true;
|
|
1648
|
+
tripwire?.stop();
|
|
1485
1649
|
const result = await activate(slug);
|
|
1486
1650
|
if (result.ok) {
|
|
1487
1651
|
return advisory(`Linked this repo to \`${slug}\` \u2014 coordinating in \`${slug}\` now.`);
|
|
@@ -1517,8 +1681,7 @@ ${msgs}` : base }
|
|
|
1517
1681
|
}
|
|
1518
1682
|
let slugs;
|
|
1519
1683
|
try {
|
|
1520
|
-
|
|
1521
|
-
slugs = (res.workspaces ?? []).map((w) => w.slug).filter((s) => typeof s === "string" && s.length > 0);
|
|
1684
|
+
slugs = await linkableSlugs();
|
|
1522
1685
|
} catch (err) {
|
|
1523
1686
|
const detail = hubErrorDetail(err);
|
|
1524
1687
|
return advisory(
|
|
@@ -1546,7 +1709,7 @@ ${msgs}` : base }
|
|
|
1546
1709
|
return linkAndActivate(requested);
|
|
1547
1710
|
}
|
|
1548
1711
|
);
|
|
1549
|
-
server.registerTool(
|
|
1712
|
+
const unlinkTool = server.registerTool(
|
|
1550
1713
|
"unlink",
|
|
1551
1714
|
{
|
|
1552
1715
|
title: "Unlink this repo from its Shepherd workspace",
|
|
@@ -1557,6 +1720,7 @@ ${msgs}` : base }
|
|
|
1557
1720
|
removeMarker(markerCwd);
|
|
1558
1721
|
rememberDecline();
|
|
1559
1722
|
linked = false;
|
|
1723
|
+
syncToolSurface();
|
|
1560
1724
|
if (sessionId !== null) {
|
|
1561
1725
|
heartbeat.stop();
|
|
1562
1726
|
await leave();
|
|
@@ -1568,7 +1732,7 @@ ${msgs}` : base }
|
|
|
1568
1732
|
);
|
|
1569
1733
|
}
|
|
1570
1734
|
);
|
|
1571
|
-
server.registerTool(
|
|
1735
|
+
const declineTool = server.registerTool(
|
|
1572
1736
|
"decline",
|
|
1573
1737
|
{
|
|
1574
1738
|
title: "Decline Shepherd coordination for this repo",
|
|
@@ -1598,8 +1762,64 @@ ${msgs}` : base }
|
|
|
1598
1762
|
);
|
|
1599
1763
|
}
|
|
1600
1764
|
}
|
|
1765
|
+
gatedTools.push(workTool, doneTool, announceTool, syncTool, unlinkTool, declineTool);
|
|
1766
|
+
surfaceVisible = true;
|
|
1767
|
+
syncToolSurface();
|
|
1768
|
+
async function runFirstRunAsk() {
|
|
1769
|
+
try {
|
|
1770
|
+
if (linked || declined) return;
|
|
1771
|
+
const getCaps = deps.firstRunAsk?.getClientCapabilities ?? (() => server.server.getClientCapabilities());
|
|
1772
|
+
if (!getCaps()?.elicitation) return;
|
|
1773
|
+
const elicit = deps.firstRunAsk?.elicit ?? // Generous timeout: the user may leave the dialog open while they think.
|
|
1774
|
+
// The param cast bridges our structural ElicitParams to the SDK's
|
|
1775
|
+
// stricter schema type — the shapes agree (flat string enum).
|
|
1776
|
+
((params) => server.server.elicitInput(
|
|
1777
|
+
params,
|
|
1778
|
+
{ timeout: 10 * 6e4 }
|
|
1779
|
+
));
|
|
1780
|
+
const { outcome, workspace } = await offerLinkPopup({
|
|
1781
|
+
repoName: context.repo,
|
|
1782
|
+
elicit,
|
|
1783
|
+
listWorkspaces: linkableSlugs,
|
|
1784
|
+
linkWorkspace: async (slug) => {
|
|
1785
|
+
await linkAndActivate(slug);
|
|
1786
|
+
},
|
|
1787
|
+
neverAskAgain: rememberDecline
|
|
1788
|
+
});
|
|
1789
|
+
if (outcome === "linked" && inboxFile) {
|
|
1790
|
+
appendAnnouncements(inboxFile, [postLinkGuidance(workspace ?? "")]);
|
|
1791
|
+
}
|
|
1792
|
+
if (outcome !== "unanswered") {
|
|
1793
|
+
console.error(`[shepherd] first-run ask answered by the user: ${outcome}`);
|
|
1794
|
+
}
|
|
1795
|
+
} catch (err) {
|
|
1796
|
+
console.error(
|
|
1797
|
+
`[shepherd] first-run ask failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1798
|
+
);
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
if (!context.linked && !context.declined) {
|
|
1802
|
+
const createTripwire_ = deps.firstRunAsk?.createTripwire ?? createEditTripwire;
|
|
1803
|
+
tripwire = createTripwire_({
|
|
1804
|
+
cwd: markerCwd,
|
|
1805
|
+
onEdits: () => {
|
|
1806
|
+
void runFirstRunAsk();
|
|
1807
|
+
}
|
|
1808
|
+
});
|
|
1809
|
+
tripwire.start();
|
|
1810
|
+
}
|
|
1601
1811
|
return { ready: joinInFlight, leave };
|
|
1602
1812
|
}
|
|
1813
|
+
function postLinkGuidance(workspace) {
|
|
1814
|
+
return {
|
|
1815
|
+
id: -Date.now(),
|
|
1816
|
+
fromAgentName: "shepherd",
|
|
1817
|
+
fromHuman: "shepherd",
|
|
1818
|
+
targetAgentName: null,
|
|
1819
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1820
|
+
body: `The user just linked this repository to the \`${workspace}\` workspace \u2014 coordination is NOW ACTIVE. Standing procedure from here on: call \`work\` (one-line intent + pathGlobs) BEFORE changing files in an area; \`done\` when that unit of work is complete; \`announce\` anything teammates need to know; \`sync\` when you resume or switch tasks. Start by calling \`work\` for the files you're editing right now.`
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1603
1823
|
|
|
1604
1824
|
// src/identityCache.ts
|
|
1605
1825
|
import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
|
|
@@ -1738,11 +1958,25 @@ function createHeartbeat({
|
|
|
1738
1958
|
}
|
|
1739
1959
|
|
|
1740
1960
|
// src/instructions.ts
|
|
1741
|
-
|
|
1961
|
+
function buildInstructions(state, workspace) {
|
|
1962
|
+
switch (state) {
|
|
1963
|
+
case "linked":
|
|
1964
|
+
return `${INTRO} This repository is linked to the \`${workspace ?? "team"}\` workspace, so coordination is active.
|
|
1742
1965
|
|
|
1743
|
-
|
|
1966
|
+
${PROCEDURE}`;
|
|
1967
|
+
case "declined":
|
|
1968
|
+
return "Shepherd (team coordination) is connected, but the user declined coordination for this repository. Do not call Shepherd tools or bring up coordination here. If the user asks to start coordinating this repo, call `link`.";
|
|
1969
|
+
case "unanswered":
|
|
1970
|
+
return `${INTRO}
|
|
1744
1971
|
|
|
1745
|
-
|
|
1972
|
+
${FIRST_RUN_ASK}`;
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
var INTRO = "You are connected to Shepherd, the shared coordination hub for a team of agents (human and AI) working in the same repositories.";
|
|
1976
|
+
var FIRST_RUN_ASK = `This repository isn't linked to a Shepherd workspace yet, so coordination is dormant. Shepherd normally asks the user directly (a popup) when file edits are detected \u2014 you don't need to raise it yourself.
|
|
1977
|
+
|
|
1978
|
+
If the user asks you to set up coordination \u2014 or you're about to change files and no popup or Shepherd message has settled the question \u2014 ask at most once: call \`link\` with no argument. It auto-links when the user belongs to exactly one workspace, or lists the choices; ask the user which workspace, then call \`link\` again with their answer. If they say no, call \`decline\` so they're never asked again. Once linked, the tool results will guide the coordination procedure.`;
|
|
1979
|
+
var PROCEDURE = `Follow this procedure on every session, proactively and without being asked:
|
|
1746
1980
|
|
|
1747
1981
|
1. Before you start producing or changing files in an AREA of the codebase, call \`work\` ONCE. This includes authoring a plan or design doc: claim the doc's path (e.g. ["docs/plans/auth.md"], or the directory you'll write into) BEFORE you write it \u2014 a plan you're about to author counts as a unit of work, not exploration. Pass a one-line \`intent\` and the \`pathGlobs\` covering the files you expect to touch. Scope the globs as specifically as you reasonably can \u2014 tight enough to avoid colliding with unrelated work, broad enough to cover the task (e.g. ["src/auth/**"], not ["src/**"] and not a single file). Hold that one claim across all your edits in that area; do NOT re-claim per file. If it reports a conflict, coordinate or pick different work \u2014 never silently collide.
|
|
1748
1982
|
|
|
@@ -1750,7 +1984,7 @@ Once linked, follow this procedure on every session, proactively and without bei
|
|
|
1750
1984
|
|
|
1751
1985
|
3. Re-call \`work\` only when you move to a DIFFERENT area not covered by a live claim. (\`work\` and \`sync\` also renew your existing claims.)
|
|
1752
1986
|
|
|
1753
|
-
4. Call \`announce\` whenever you discover something another agent needs \u2014 a shared decision, a gotcha, an API change, a finding. If the landscape shows a specific agent working in the affected area, direct it to them by passing their name as \`
|
|
1987
|
+
4. Call \`announce\` whenever you discover something another agent needs \u2014 a shared decision, a gotcha, an API change, a finding. If the landscape shows a specific agent working in the affected area, direct it to them by passing their name as \`target\`; otherwise broadcast. A human teammate's name (or \`admin\`) as \`target\` reaches them on the dashboard \u2014 reply to a human's message that way, directed to its sender, never in your own chat. Awareness only, not task assignment.
|
|
1754
1988
|
|
|
1755
1989
|
5. Call \`sync\` when you resume, start a new task, or before large changes, to refresh who is doing what.
|
|
1756
1990
|
|
|
@@ -1758,6 +1992,209 @@ Skip \`work\` entirely for read-only exploration \u2014 reading, searching, or t
|
|
|
1758
1992
|
|
|
1759
1993
|
Commit work-in-progress as you go rather than sitting on a large dirty tree: committed work becomes a precise, presence-independent signal to teammates (with line-level detail and automatic resolution once it lands), whereas uncommitted edits are only a best-effort, decaying hint.`;
|
|
1760
1994
|
|
|
1995
|
+
// src/hookInstall.ts
|
|
1996
|
+
import { readFileSync as readFileSync5, writeFileSync as writeFileSync4, mkdirSync as mkdirSync4, copyFileSync, existsSync as existsSync4 } from "fs";
|
|
1997
|
+
import { homedir as homedir4 } from "os";
|
|
1998
|
+
import { dirname as dirname5, join as join5 } from "path";
|
|
1999
|
+
import { fileURLToPath } from "url";
|
|
2000
|
+
function detectClient(clientName) {
|
|
2001
|
+
const name = (clientName ?? "").toLowerCase();
|
|
2002
|
+
if (!name) return "unknown";
|
|
2003
|
+
if (name.includes("claude")) return "claude";
|
|
2004
|
+
if (name.includes("codex")) return "codex";
|
|
2005
|
+
if (name.includes("cursor")) return "cursor";
|
|
2006
|
+
if (/(^|[^a-z0-9])pi([^a-z0-9]|$)/.test(name)) return "pi";
|
|
2007
|
+
return "unknown";
|
|
2008
|
+
}
|
|
2009
|
+
var HOOK_COMMAND = "npx -y --package=@korso/shepherd shepherd-inbox-hook";
|
|
2010
|
+
var HOOK_MARKER = "shepherd-inbox-hook";
|
|
2011
|
+
var CODEX_HOOK_BLOCK = [
|
|
2012
|
+
"",
|
|
2013
|
+
"# Added by Shepherd: delivers teammate announcements to the agent. Remove to disable.",
|
|
2014
|
+
"[[hooks.UserPromptSubmit]]",
|
|
2015
|
+
'command = ["npx", "-y", "--package=@korso/shepherd", "shepherd-inbox-hook"]',
|
|
2016
|
+
""
|
|
2017
|
+
].join("\n");
|
|
2018
|
+
async function autoInstallHooks({
|
|
2019
|
+
clientName,
|
|
2020
|
+
homeDir = homedir4(),
|
|
2021
|
+
disabled = false,
|
|
2022
|
+
extensionSource,
|
|
2023
|
+
log = (msg) => console.error(msg)
|
|
2024
|
+
}) {
|
|
2025
|
+
const client = detectClient(clientName);
|
|
2026
|
+
try {
|
|
2027
|
+
if (disabled) return { client, status: "disabled" };
|
|
2028
|
+
if (client === "unknown") {
|
|
2029
|
+
return { client, status: "unsupported" };
|
|
2030
|
+
}
|
|
2031
|
+
const recordFile = join5(homeDir, ".shepherd", "hooks", `${client}.json`);
|
|
2032
|
+
if (existsSync4(recordFile)) return { client, status: "already-attempted" };
|
|
2033
|
+
let status;
|
|
2034
|
+
if (client === "claude") {
|
|
2035
|
+
status = installClaude(homeDir, log);
|
|
2036
|
+
} else if (client === "codex") {
|
|
2037
|
+
status = installCodex(homeDir, log);
|
|
2038
|
+
} else if (client === "cursor") {
|
|
2039
|
+
status = installCursor(homeDir, log);
|
|
2040
|
+
} else {
|
|
2041
|
+
status = installPi(homeDir, extensionSource, log);
|
|
2042
|
+
}
|
|
2043
|
+
mkdirSync4(dirname5(recordFile), { recursive: true });
|
|
2044
|
+
writeFileSync4(
|
|
2045
|
+
recordFile,
|
|
2046
|
+
JSON.stringify({ status, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2) + "\n",
|
|
2047
|
+
"utf8"
|
|
2048
|
+
);
|
|
2049
|
+
if (status === "installed") {
|
|
2050
|
+
log(
|
|
2051
|
+
`[shepherd] Installed the announcement-delivery hook for ${client} (disable by removing it, or set SHEPHERD_NO_AUTO_HOOKS=1 to never auto-install).`
|
|
2052
|
+
);
|
|
2053
|
+
}
|
|
2054
|
+
return { client, status };
|
|
2055
|
+
} catch (err) {
|
|
2056
|
+
log(
|
|
2057
|
+
`[shepherd] hook auto-install skipped: ${err instanceof Error ? err.message : String(err)}`
|
|
2058
|
+
);
|
|
2059
|
+
return { client, status: "skipped" };
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
function installClaude(homeDir, log) {
|
|
2063
|
+
const settingsFile = join5(homeDir, ".claude", "settings.json");
|
|
2064
|
+
let raw = "";
|
|
2065
|
+
if (existsSync4(settingsFile)) {
|
|
2066
|
+
raw = readFileSync5(settingsFile, "utf8");
|
|
2067
|
+
if (raw.includes(HOOK_MARKER)) return "already-present";
|
|
2068
|
+
}
|
|
2069
|
+
let settings = {};
|
|
2070
|
+
if (raw.trim()) {
|
|
2071
|
+
try {
|
|
2072
|
+
const parsed = JSON.parse(raw);
|
|
2073
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
2074
|
+
throw new Error("settings.json is not a JSON object");
|
|
2075
|
+
}
|
|
2076
|
+
settings = parsed;
|
|
2077
|
+
} catch {
|
|
2078
|
+
log(
|
|
2079
|
+
`[shepherd] ${settingsFile} could not be parsed \u2014 not touching it. Add the hook manually (see the dashboard's Connect screen).`
|
|
2080
|
+
);
|
|
2081
|
+
return "skipped";
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
const hooks = settings["hooks"] ??= {};
|
|
2085
|
+
if (typeof hooks !== "object" || hooks === null || Array.isArray(hooks)) {
|
|
2086
|
+
log(`[shepherd] ${settingsFile} has an unexpected "hooks" shape \u2014 not touching it.`);
|
|
2087
|
+
return "skipped";
|
|
2088
|
+
}
|
|
2089
|
+
const hooksObj = hooks;
|
|
2090
|
+
for (const event of ["SessionStart", "PreToolUse"]) {
|
|
2091
|
+
const existing = hooksObj[event] ??= [];
|
|
2092
|
+
if (!Array.isArray(existing)) {
|
|
2093
|
+
log(`[shepherd] ${settingsFile} has an unexpected hooks.${event} shape \u2014 not touching it.`);
|
|
2094
|
+
return "skipped";
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
hooksObj["SessionStart"].push({
|
|
2098
|
+
hooks: [{ type: "command", command: HOOK_COMMAND }]
|
|
2099
|
+
});
|
|
2100
|
+
hooksObj["PreToolUse"].push({
|
|
2101
|
+
matcher: "*",
|
|
2102
|
+
hooks: [{ type: "command", command: HOOK_COMMAND }]
|
|
2103
|
+
});
|
|
2104
|
+
mkdirSync4(dirname5(settingsFile), { recursive: true });
|
|
2105
|
+
writeFileSync4(settingsFile, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
2106
|
+
return "installed";
|
|
2107
|
+
}
|
|
2108
|
+
function installCodex(homeDir, log) {
|
|
2109
|
+
const configFile = join5(homeDir, ".codex", "config.toml");
|
|
2110
|
+
const manualHint = "Add the hook manually (see the dashboard's Connect screen).";
|
|
2111
|
+
if (!existsSync4(configFile)) {
|
|
2112
|
+
mkdirSync4(dirname5(configFile), { recursive: true });
|
|
2113
|
+
writeFileSync4(configFile, `[features]
|
|
2114
|
+
hooks = true
|
|
2115
|
+
${CODEX_HOOK_BLOCK}`, "utf8");
|
|
2116
|
+
return "installed";
|
|
2117
|
+
}
|
|
2118
|
+
const toml = readFileSync5(configFile, "utf8");
|
|
2119
|
+
if (toml.includes(HOOK_MARKER)) return "already-present";
|
|
2120
|
+
if (/^\s*\[hooks\.UserPromptSubmit\]\s*$/m.test(toml)) {
|
|
2121
|
+
log(`[shepherd] ${configFile} defines [hooks.UserPromptSubmit] \u2014 not touching it. ${manualHint}`);
|
|
2122
|
+
return "skipped";
|
|
2123
|
+
}
|
|
2124
|
+
if (/^\s*\[features\]/m.test(toml)) {
|
|
2125
|
+
const hooksKey = /^\s*hooks\s*=\s*(.+)$/m.exec(toml);
|
|
2126
|
+
if (hooksKey && hooksKey[1].trim() !== "true") {
|
|
2127
|
+
log(`[shepherd] ${configFile} sets hooks = ${hooksKey[1].trim()} \u2014 respecting it. ${manualHint}`);
|
|
2128
|
+
return "skipped";
|
|
2129
|
+
}
|
|
2130
|
+
let updated = toml;
|
|
2131
|
+
if (!hooksKey) {
|
|
2132
|
+
updated = toml.replace(/^(\s*\[features\]\s*)$/m, `$1
|
|
2133
|
+
hooks = true`);
|
|
2134
|
+
}
|
|
2135
|
+
writeFileSync4(configFile, updated + CODEX_HOOK_BLOCK, "utf8");
|
|
2136
|
+
return "installed";
|
|
2137
|
+
}
|
|
2138
|
+
writeFileSync4(configFile, `${toml}
|
|
2139
|
+
[features]
|
|
2140
|
+
hooks = true
|
|
2141
|
+
${CODEX_HOOK_BLOCK}`, "utf8");
|
|
2142
|
+
return "installed";
|
|
2143
|
+
}
|
|
2144
|
+
function installCursor(homeDir, log) {
|
|
2145
|
+
const hooksFile = join5(homeDir, ".cursor", "hooks.json");
|
|
2146
|
+
let raw = "";
|
|
2147
|
+
if (existsSync4(hooksFile)) {
|
|
2148
|
+
raw = readFileSync5(hooksFile, "utf8");
|
|
2149
|
+
if (raw.includes(HOOK_MARKER)) return "already-present";
|
|
2150
|
+
}
|
|
2151
|
+
let config = {};
|
|
2152
|
+
if (raw.trim()) {
|
|
2153
|
+
try {
|
|
2154
|
+
const parsed = JSON.parse(raw);
|
|
2155
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
2156
|
+
throw new Error("hooks.json is not a JSON object");
|
|
2157
|
+
}
|
|
2158
|
+
config = parsed;
|
|
2159
|
+
} catch {
|
|
2160
|
+
log(
|
|
2161
|
+
`[shepherd] ${hooksFile} could not be parsed \u2014 not touching it. Add the hook manually (see the dashboard's Connect screen).`
|
|
2162
|
+
);
|
|
2163
|
+
return "skipped";
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
config["version"] ??= 1;
|
|
2167
|
+
const hooks = config["hooks"] ??= {};
|
|
2168
|
+
if (typeof hooks !== "object" || hooks === null || Array.isArray(hooks)) {
|
|
2169
|
+
log(`[shepherd] ${hooksFile} has an unexpected "hooks" shape \u2014 not touching it.`);
|
|
2170
|
+
return "skipped";
|
|
2171
|
+
}
|
|
2172
|
+
const hooksObj = hooks;
|
|
2173
|
+
const entries = hooksObj["beforeSubmitPrompt"] ??= [];
|
|
2174
|
+
if (!Array.isArray(entries)) {
|
|
2175
|
+
log(
|
|
2176
|
+
`[shepherd] ${hooksFile} has an unexpected hooks.beforeSubmitPrompt shape \u2014 not touching it.`
|
|
2177
|
+
);
|
|
2178
|
+
return "skipped";
|
|
2179
|
+
}
|
|
2180
|
+
entries.push({ command: HOOK_COMMAND });
|
|
2181
|
+
mkdirSync4(dirname5(hooksFile), { recursive: true });
|
|
2182
|
+
writeFileSync4(hooksFile, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
2183
|
+
return "installed";
|
|
2184
|
+
}
|
|
2185
|
+
function installPi(homeDir, extensionSource, log) {
|
|
2186
|
+
const source = extensionSource ?? join5(dirname5(fileURLToPath(import.meta.url)), "inboxExtension.js");
|
|
2187
|
+
const dest = join5(homeDir, ".pi", "agent", "extensions", "shepherd-inbox.js");
|
|
2188
|
+
if (existsSync4(dest)) return "already-present";
|
|
2189
|
+
if (!existsSync4(source)) {
|
|
2190
|
+
log(`[shepherd] bundled Pi extension not found at ${source} \u2014 skipping auto-install.`);
|
|
2191
|
+
return "skipped";
|
|
2192
|
+
}
|
|
2193
|
+
mkdirSync4(dirname5(dest), { recursive: true });
|
|
2194
|
+
copyFileSync(source, dest);
|
|
2195
|
+
return "installed";
|
|
2196
|
+
}
|
|
2197
|
+
|
|
1761
2198
|
// src/index.ts
|
|
1762
2199
|
async function main() {
|
|
1763
2200
|
const config = loadConfig();
|
|
@@ -1784,10 +2221,16 @@ async function main() {
|
|
|
1784
2221
|
});
|
|
1785
2222
|
const server = new McpServer(
|
|
1786
2223
|
{ name: "shepherd", version: "0.1.0" },
|
|
1787
|
-
{ instructions:
|
|
2224
|
+
{ instructions: buildInstructions(context.linkState, context.workspace) }
|
|
1788
2225
|
);
|
|
1789
2226
|
const tools = registerTools(server, { hubClient, config, context, heartbeat, inboxFile });
|
|
1790
2227
|
const transport = new StdioServerTransport();
|
|
2228
|
+
server.server.oninitialized = () => {
|
|
2229
|
+
void autoInstallHooks({
|
|
2230
|
+
clientName: server.server.getClientVersion()?.name,
|
|
2231
|
+
disabled: config.SHEPHERD_NO_AUTO_HOOKS
|
|
2232
|
+
});
|
|
2233
|
+
};
|
|
1791
2234
|
let shuttingDown = false;
|
|
1792
2235
|
const shutdown = async () => {
|
|
1793
2236
|
if (shuttingDown) return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@korso/shepherd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|