@korso/shepherd 0.7.0 → 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 +76 -9
- package/dist/inboxExtension.js +2 -0
- package/dist/inboxHook.js +20 -4
- package/dist/index.js +466 -26
- 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
|
|
@@ -111,8 +139,21 @@ tool-less events like Codex's `UserPromptSubmit` or a Pi turn. The
|
|
|
111
139
|
nudge is advisory and self-extinguishing: the moment the repo is linked or
|
|
112
140
|
declined it goes quiet, and like everything else here it fails open.
|
|
113
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
|
+
|
|
114
153
|
### Claude Code — `PreToolUse` + `SessionStart` hooks
|
|
115
154
|
|
|
155
|
+
*(Installed automatically on first run — shown for reference/manual setup.)*
|
|
156
|
+
|
|
116
157
|
`PreToolUse` fires before every tool, giving the most frequent passive delivery;
|
|
117
158
|
`SessionStart` surfaces the link ask at the top of a session in an unlinked repo.
|
|
118
159
|
The hook needs no arguments — it resolves the same default inbox dir the server
|
|
@@ -152,6 +193,8 @@ uses (override both with `SHEPHERD_INBOX_DIR` if you relocated it):
|
|
|
152
193
|
|
|
153
194
|
### Codex — `UserPromptSubmit` hook
|
|
154
195
|
|
|
196
|
+
*(Installed automatically on first run — shown for reference/manual setup.)*
|
|
197
|
+
|
|
155
198
|
Codex uses the **same** hook contract as Claude Code (JSON on stdin, a
|
|
156
199
|
`hookSpecificOutput.additionalContext` reply), so the **same bin** serves it. Use
|
|
157
200
|
`UserPromptSubmit` — Codex's `PreToolUse` only fires for Bash, not `apply_patch`
|
|
@@ -170,8 +213,10 @@ command = ["npx", "-y", "--package=@korso/shepherd", "shepherd-inbox-hook"]
|
|
|
170
213
|
|
|
171
214
|
### Pi — extension
|
|
172
215
|
|
|
173
|
-
|
|
174
|
-
|
|
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:
|
|
175
220
|
|
|
176
221
|
```sh
|
|
177
222
|
# global, applies everywhere:
|
|
@@ -184,6 +229,27 @@ It runs on every user turn (`before_agent_start`), drains the same inbox, and
|
|
|
184
229
|
injects pending announcements plus the unlinked-repo nudge. (Or load it ad hoc
|
|
185
230
|
with `pi -e /abs/path/to/dist/inboxExtension.js`.)
|
|
186
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
|
+
```
|
|
252
|
+
|
|
187
253
|
### Notes
|
|
188
254
|
|
|
189
255
|
Every path is **fail-open**: a missing dir, unreachable hub, or any error means
|
|
@@ -302,9 +368,10 @@ or per-project at `.cursor/mcp.json`:
|
|
|
302
368
|
```
|
|
303
369
|
|
|
304
370
|
Confirm under **Settings → MCP** that `shepherd` is listed with its tools.
|
|
305
|
-
Announcement push:
|
|
306
|
-
|
|
307
|
-
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.
|
|
308
375
|
|
|
309
376
|
---
|
|
310
377
|
|
package/dist/inboxExtension.js
CHANGED
|
@@ -152,6 +152,7 @@ function drainInbox(filePath) {
|
|
|
152
152
|
}
|
|
153
153
|
return out;
|
|
154
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.)";
|
|
155
156
|
function formatInboxAnnouncements(announcements) {
|
|
156
157
|
if (!announcements || announcements.length === 0) return "";
|
|
157
158
|
const count = announcements.length;
|
|
@@ -162,6 +163,7 @@ function formatInboxAnnouncements(announcements) {
|
|
|
162
163
|
const target = a.targetAgentName ? ` \u2192 ${a.targetAgentName}` : " (broadcast)";
|
|
163
164
|
lines.push(` [${a.fromAgentName}${target}] ${a.body}`);
|
|
164
165
|
}
|
|
166
|
+
lines.push(REPLY_ROUTING_HINT);
|
|
165
167
|
return lines.join("\n");
|
|
166
168
|
}
|
|
167
169
|
|
package/dist/inboxHook.js
CHANGED
|
@@ -154,6 +154,7 @@ function drainInbox(filePath) {
|
|
|
154
154
|
}
|
|
155
155
|
return out;
|
|
156
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.)";
|
|
157
158
|
function formatInboxAnnouncements(announcements) {
|
|
158
159
|
if (!announcements || announcements.length === 0) return "";
|
|
159
160
|
const count = announcements.length;
|
|
@@ -164,27 +165,42 @@ function formatInboxAnnouncements(announcements) {
|
|
|
164
165
|
const target = a.targetAgentName ? ` \u2192 ${a.targetAgentName}` : " (broadcast)";
|
|
165
166
|
lines.push(` [${a.fromAgentName}${target}] ${a.body}`);
|
|
166
167
|
}
|
|
168
|
+
lines.push(REPLY_ROUTING_HINT);
|
|
167
169
|
return lines.join("\n");
|
|
168
170
|
}
|
|
171
|
+
function nativeWorkspacePath(root) {
|
|
172
|
+
const win = /^\/([A-Za-z]:[/\\].*)$/.exec(root);
|
|
173
|
+
return win ? win[1] : root;
|
|
174
|
+
}
|
|
169
175
|
function buildHookOutput(rawStdin, inboxDir, drain = drainInbox, nudge = buildLinkNudge) {
|
|
170
176
|
let input;
|
|
171
177
|
try {
|
|
172
|
-
input = JSON.parse(rawStdin);
|
|
178
|
+
input = JSON.parse(rawStdin.replace(/^\uFEFF/, ""));
|
|
173
179
|
} catch {
|
|
174
180
|
return "";
|
|
175
181
|
}
|
|
176
|
-
if (!input || typeof input
|
|
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 "";
|
|
177
187
|
const parts = [];
|
|
178
188
|
const nudgeText = nudge(
|
|
179
|
-
|
|
189
|
+
cwd,
|
|
180
190
|
typeof input.tool_name === "string" ? input.tool_name : void 0
|
|
181
191
|
);
|
|
182
192
|
if (nudgeText) parts.push(nudgeText);
|
|
183
193
|
if (inboxDir) {
|
|
184
|
-
const text = formatInboxAnnouncements(drain(inboxFilePath(inboxDir,
|
|
194
|
+
const text = formatInboxAnnouncements(drain(inboxFilePath(inboxDir, cwd)));
|
|
185
195
|
if (text) parts.push(text);
|
|
186
196
|
}
|
|
187
197
|
if (parts.length === 0) return "";
|
|
198
|
+
if (isCursor) {
|
|
199
|
+
return JSON.stringify({
|
|
200
|
+
continue: true,
|
|
201
|
+
additionalContext: parts.join("\n\n")
|
|
202
|
+
});
|
|
203
|
+
}
|
|
188
204
|
return JSON.stringify({
|
|
189
205
|
hookSpecificOutput: {
|
|
190
206
|
hookEventName: input.hook_event_name || "PreToolUse",
|
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({
|
|
@@ -1036,6 +1054,7 @@ function drainInbox(filePath) {
|
|
|
1036
1054
|
}
|
|
1037
1055
|
return out;
|
|
1038
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.)";
|
|
1039
1058
|
function mergeAnnouncements(...lists) {
|
|
1040
1059
|
const byId = /* @__PURE__ */ new Map();
|
|
1041
1060
|
for (const list of lists) {
|
|
@@ -1047,6 +1066,125 @@ function mergeAnnouncements(...lists) {
|
|
|
1047
1066
|
return [...byId.values()].sort((x, y) => x.id - y.id);
|
|
1048
1067
|
}
|
|
1049
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
|
+
|
|
1050
1188
|
// src/tools.ts
|
|
1051
1189
|
function classifyJoinFailure(err) {
|
|
1052
1190
|
if (err instanceof HubUnreachable) return "unreachable";
|
|
@@ -1116,6 +1254,7 @@ function formatLandscape(landscape) {
|
|
|
1116
1254
|
const target = a.targetAgentName ? ` \u2192 ${a.targetAgentName}` : " (broadcast)";
|
|
1117
1255
|
lines.push(` [${a.fromAgentName}${target}] ${a.body}`);
|
|
1118
1256
|
}
|
|
1257
|
+
lines.push(REPLY_ROUTING_HINT);
|
|
1119
1258
|
} else {
|
|
1120
1259
|
lines.push("ANNOUNCEMENTS: none");
|
|
1121
1260
|
}
|
|
@@ -1128,6 +1267,7 @@ function formatAnnouncements(announcements) {
|
|
|
1128
1267
|
const target = a.targetAgentName ? ` \u2192 ${a.targetAgentName}` : " (broadcast)";
|
|
1129
1268
|
lines.push(` [${a.fromAgentName}${target}] ${a.body}`);
|
|
1130
1269
|
}
|
|
1270
|
+
lines.push(REPLY_ROUTING_HINT);
|
|
1131
1271
|
return lines.join("\n");
|
|
1132
1272
|
}
|
|
1133
1273
|
function relativeAge(iso) {
|
|
@@ -1210,13 +1350,28 @@ function registerTools(server, deps) {
|
|
|
1210
1350
|
const dormant = !context.linked || selfHostMismatch;
|
|
1211
1351
|
let linked = context.linked;
|
|
1212
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;
|
|
1213
1365
|
function rememberDecline() {
|
|
1214
1366
|
if (repoRoot !== null) setDeclined(repoRoot, declinedDir);
|
|
1215
1367
|
declined = true;
|
|
1368
|
+
tripwire?.stop();
|
|
1369
|
+
syncToolSurface();
|
|
1216
1370
|
}
|
|
1217
1371
|
function forgetDecline() {
|
|
1218
1372
|
if (repoRoot !== null) clearDeclined(repoRoot, declinedDir);
|
|
1219
1373
|
declined = false;
|
|
1374
|
+
syncToolSurface();
|
|
1220
1375
|
}
|
|
1221
1376
|
let joinFailure = null;
|
|
1222
1377
|
let joinInFlight = Promise.resolve();
|
|
@@ -1347,7 +1502,7 @@ ${body}` : body;
|
|
|
1347
1502
|
|
|
1348
1503
|
${section}` : body;
|
|
1349
1504
|
}
|
|
1350
|
-
server.registerTool(
|
|
1505
|
+
const workTool = server.registerTool(
|
|
1351
1506
|
"work",
|
|
1352
1507
|
{
|
|
1353
1508
|
title: "Claim a unit of work",
|
|
@@ -1384,7 +1539,7 @@ You hold this claim until you call done (workItemId: ${result.workItemId}) or it
|
|
|
1384
1539
|
}
|
|
1385
1540
|
}
|
|
1386
1541
|
);
|
|
1387
|
-
server.registerTool(
|
|
1542
|
+
const doneTool = server.registerTool(
|
|
1388
1543
|
"done",
|
|
1389
1544
|
{
|
|
1390
1545
|
title: "Release a work claim",
|
|
@@ -1416,11 +1571,11 @@ ${msgs}` : base }
|
|
|
1416
1571
|
}
|
|
1417
1572
|
}
|
|
1418
1573
|
);
|
|
1419
|
-
server.registerTool(
|
|
1574
|
+
const announceTool = server.registerTool(
|
|
1420
1575
|
"announce",
|
|
1421
1576
|
{
|
|
1422
|
-
title: "
|
|
1423
|
-
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.",
|
|
1424
1579
|
inputSchema: AnnounceAgentInput.shape
|
|
1425
1580
|
},
|
|
1426
1581
|
async (args) => {
|
|
@@ -1448,7 +1603,7 @@ ${msgs}` : base }
|
|
|
1448
1603
|
}
|
|
1449
1604
|
}
|
|
1450
1605
|
);
|
|
1451
|
-
server.registerTool(
|
|
1606
|
+
const syncTool = server.registerTool(
|
|
1452
1607
|
"sync",
|
|
1453
1608
|
{
|
|
1454
1609
|
title: "Sync team landscape",
|
|
@@ -1481,10 +1636,16 @@ ${msgs}` : base }
|
|
|
1481
1636
|
function advisory(text) {
|
|
1482
1637
|
return { content: [{ type: "text", text }] };
|
|
1483
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
|
+
}
|
|
1484
1644
|
async function linkAndActivate(slug) {
|
|
1485
1645
|
writeMarker(markerCwd, slug);
|
|
1486
1646
|
forgetDecline();
|
|
1487
1647
|
linked = true;
|
|
1648
|
+
tripwire?.stop();
|
|
1488
1649
|
const result = await activate(slug);
|
|
1489
1650
|
if (result.ok) {
|
|
1490
1651
|
return advisory(`Linked this repo to \`${slug}\` \u2014 coordinating in \`${slug}\` now.`);
|
|
@@ -1520,8 +1681,7 @@ ${msgs}` : base }
|
|
|
1520
1681
|
}
|
|
1521
1682
|
let slugs;
|
|
1522
1683
|
try {
|
|
1523
|
-
|
|
1524
|
-
slugs = (res.workspaces ?? []).map((w) => w.slug).filter((s) => typeof s === "string" && s.length > 0);
|
|
1684
|
+
slugs = await linkableSlugs();
|
|
1525
1685
|
} catch (err) {
|
|
1526
1686
|
const detail = hubErrorDetail(err);
|
|
1527
1687
|
return advisory(
|
|
@@ -1549,7 +1709,7 @@ ${msgs}` : base }
|
|
|
1549
1709
|
return linkAndActivate(requested);
|
|
1550
1710
|
}
|
|
1551
1711
|
);
|
|
1552
|
-
server.registerTool(
|
|
1712
|
+
const unlinkTool = server.registerTool(
|
|
1553
1713
|
"unlink",
|
|
1554
1714
|
{
|
|
1555
1715
|
title: "Unlink this repo from its Shepherd workspace",
|
|
@@ -1560,6 +1720,7 @@ ${msgs}` : base }
|
|
|
1560
1720
|
removeMarker(markerCwd);
|
|
1561
1721
|
rememberDecline();
|
|
1562
1722
|
linked = false;
|
|
1723
|
+
syncToolSurface();
|
|
1563
1724
|
if (sessionId !== null) {
|
|
1564
1725
|
heartbeat.stop();
|
|
1565
1726
|
await leave();
|
|
@@ -1571,7 +1732,7 @@ ${msgs}` : base }
|
|
|
1571
1732
|
);
|
|
1572
1733
|
}
|
|
1573
1734
|
);
|
|
1574
|
-
server.registerTool(
|
|
1735
|
+
const declineTool = server.registerTool(
|
|
1575
1736
|
"decline",
|
|
1576
1737
|
{
|
|
1577
1738
|
title: "Decline Shepherd coordination for this repo",
|
|
@@ -1601,8 +1762,64 @@ ${msgs}` : base }
|
|
|
1601
1762
|
);
|
|
1602
1763
|
}
|
|
1603
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
|
+
}
|
|
1604
1811
|
return { ready: joinInFlight, leave };
|
|
1605
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
|
+
}
|
|
1606
1823
|
|
|
1607
1824
|
// src/identityCache.ts
|
|
1608
1825
|
import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
|
|
@@ -1741,11 +1958,25 @@ function createHeartbeat({
|
|
|
1741
1958
|
}
|
|
1742
1959
|
|
|
1743
1960
|
// src/instructions.ts
|
|
1744
|
-
|
|
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.
|
|
1965
|
+
|
|
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}
|
|
1745
1971
|
|
|
1746
|
-
|
|
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.
|
|
1747
1977
|
|
|
1748
|
-
|
|
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:
|
|
1749
1980
|
|
|
1750
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.
|
|
1751
1982
|
|
|
@@ -1753,7 +1984,7 @@ Once linked, follow this procedure on every session, proactively and without bei
|
|
|
1753
1984
|
|
|
1754
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.)
|
|
1755
1986
|
|
|
1756
|
-
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.
|
|
1757
1988
|
|
|
1758
1989
|
5. Call \`sync\` when you resume, start a new task, or before large changes, to refresh who is doing what.
|
|
1759
1990
|
|
|
@@ -1761,6 +1992,209 @@ Skip \`work\` entirely for read-only exploration \u2014 reading, searching, or t
|
|
|
1761
1992
|
|
|
1762
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.`;
|
|
1763
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
|
+
|
|
1764
2198
|
// src/index.ts
|
|
1765
2199
|
async function main() {
|
|
1766
2200
|
const config = loadConfig();
|
|
@@ -1787,10 +2221,16 @@ async function main() {
|
|
|
1787
2221
|
});
|
|
1788
2222
|
const server = new McpServer(
|
|
1789
2223
|
{ name: "shepherd", version: "0.1.0" },
|
|
1790
|
-
{ instructions:
|
|
2224
|
+
{ instructions: buildInstructions(context.linkState, context.workspace) }
|
|
1791
2225
|
);
|
|
1792
2226
|
const tools = registerTools(server, { hubClient, config, context, heartbeat, inboxFile });
|
|
1793
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
|
+
};
|
|
1794
2234
|
let shuttingDown = false;
|
|
1795
2235
|
const shutdown = async () => {
|
|
1796
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",
|