@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 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 (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.
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
- ### Claude Code — `PreToolUse` hook
103
-
104
- `PreToolUse` fires before every tool, giving the most frequent passive delivery.
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
- Pi has no stdin/stdout hook; it loads in-process extensions. Ship the bundled
154
- extension into Pi's extensions dir:
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 with
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: Cursor has no hook equivalent (see the hooks above for
286
- Claude Code/Codex/Pi), so announcements arrive via the universal drainer — on
287
- the agent's next Shepherd tool call — rather than pushed between calls.
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
 
@@ -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 defaultInboxDir() {
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 join(base, ".shepherd", "inbox");
64
+ return join2(base, ".shepherd", "declined");
22
65
  }
23
- function inboxFilePath(dir, cwd) {
24
- let normalized = resolve(cwd);
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 join(dir, `${hash}.jsonl`);
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 (existsSync(tmp)) {
34
- raw += readFileSync(tmp, "utf8");
35
- rmSync(tmp, { force: true });
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 (existsSync(filePath)) {
132
+ if (existsSync3(filePath)) {
41
133
  renameSync(filePath, tmp);
42
- raw += readFileSync(tmp, "utf8");
43
- rmSync(tmp, { force: true });
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 defaultInboxDir() {
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 join(base, ".shepherd", "inbox");
66
+ return join2(base, ".shepherd", "declined");
24
67
  }
25
- function inboxFilePath(dir, cwd) {
26
- let normalized = resolve(cwd);
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 join(dir, `${hash}.jsonl`);
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 (existsSync(tmp)) {
36
- raw += readFileSync(tmp, "utf8");
37
- rmSync(tmp, { force: true });
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 (existsSync(filePath)) {
134
+ if (existsSync3(filePath)) {
43
135
  renameSync(filePath, tmp);
44
- raw += readFileSync(tmp, "utf8");
45
- rmSync(tmp, { force: true });
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 buildHookOutput(rawStdin, inboxDir, drain = drainInbox) {
78
- if (!inboxDir) return "";
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.cwd !== "string" || input.cwd.length === 0) return "";
86
- const announcements = drain(inboxFilePath(inboxDir, input.cwd));
87
- const text = formatInboxAnnouncements(announcements);
88
- if (!text) return "";
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: text
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 mirror of
394
- // fromAdmin). The dashboard renders these as "<agent> → admin". Not delivered
395
- // to other agents. Defaulted for version-skew safety with older hubs.
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
- // absent or null => broadcast to all agents in the workspace
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
- // true => address the human operator (the dashboard) instead of agents. The
463
- // mirror of the operator's admin → agent DM: it shows in the workspace feed as
464
- // "<agent> → admin" and is NOT delivered to other agents. Mutually exclusive
465
- // with targetAgentName (the hub rejects setting both). Defaulted/optional for
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) return 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: "Broadcast a message to teammates",
1420
- description: "Broadcast a heads-up to the other agents, direct a finding to a specific agent, or reply to the human operator. This is awareness only \u2014 not a task assignment. To direct it to an agent, pass that 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') \u2014 as targetAgentName. Several agents can share one handle (alex-rivera-1, alex-rivera-2, \u2026); the suffix is what picks one, so the bare handle is rejected. The hub rejects any targetAgentName that doesn't match a live agent in your repo \u2014 if you mean the whole team, omit it to broadcast. To reply to the operator (the dashboard), pass toAdmin: true. targetAgentName and toAdmin are mutually exclusive. Delivery is best-effort: the recipient sees it on their next work/sync, once.",
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
- const res = await hubClient.get("/workspaces");
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
- var SHEPHERD_INSTRUCTIONS = `You are connected to Shepherd, the shared coordination hub for a team of agents (human and AI) working in the same repositories.
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
- On your first coordination-relevant action in a repo, if it isn't linked and hasn't been declined, call \`link\` with no argument: it auto-starts coordination if you belong to exactly one workspace, or lists your workspaces \u2014 ask the user "Coordinate this repo with Shepherd? Which workspace?" then call \`link <workspace>\` with their answer, or \`decline\` if they say no. Ask at most once per repo: once linked or declined, do not ask again \u2014 the marker is committed and teammates inherit it, so coordination is automatic from then on.
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
- Once linked, follow this procedure on every session, proactively and without being asked:
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 \`targetAgentName\`; otherwise broadcast. Awareness only, not task assignment.
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: SHEPHERD_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.6.3",
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",