@korso/shepherd 0.6.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -99,9 +99,22 @@ It delivers to an agent **while it's active**; an idle agent picks messages up t
99
99
  moment it next does anything. (Waking a fully-idle agent is out of scope — for
100
100
  Claude Code that needs Channels; Codex/Pi have no equivalent.)
101
101
 
102
- ### Claude Code — `PreToolUse` hook
103
-
104
- `PreToolUse` fires before every tool, giving the most frequent passive delivery.
102
+ **The hook also carries the unlinked-repo nudge.** The server instructions tell
103
+ the agent to ask about linking on its first write in a new repo, but instructions
104
+ sitting passively in context don't trigger themselves — an agent focused on the
105
+ task can skip straight to editing. So on every invocation the hook also checks
106
+ this repo's link state and, when the repo is **neither linked (`.shepherd`
107
+ marker) nor declined**, injects a reminder to run `link`/`decline` — on
108
+ `SessionStart` (front-loads the ask), right before a file-writing tool
109
+ (`Edit`/`Write`/`MultiEdit`/`NotebookEdit`; read-only tools never nudge), and on
110
+ tool-less events like Codex's `UserPromptSubmit` or a Pi turn. The
111
+ nudge is advisory and self-extinguishing: the moment the repo is linked or
112
+ declined it goes quiet, and like everything else here it fails open.
113
+
114
+ ### Claude Code — `PreToolUse` + `SessionStart` hooks
115
+
116
+ `PreToolUse` fires before every tool, giving the most frequent passive delivery;
117
+ `SessionStart` surfaces the link ask at the top of a session in an unlinked repo.
105
118
  The hook needs no arguments — it resolves the same default inbox dir the server
106
119
  uses (override both with `SHEPHERD_INBOX_DIR` if you relocated it):
107
120
 
@@ -118,6 +131,13 @@ uses (override both with `SHEPHERD_INBOX_DIR` if you relocated it):
118
131
  }
119
132
  },
120
133
  "hooks": {
134
+ "SessionStart": [
135
+ {
136
+ "hooks": [
137
+ { "type": "command", "command": "npx -y --package=@korso/shepherd shepherd-inbox-hook" }
138
+ ]
139
+ }
140
+ ],
121
141
  "PreToolUse": [
122
142
  {
123
143
  "matcher": "*",
@@ -161,8 +181,8 @@ cp "$(npm root -g)/@korso/shepherd/dist/inboxExtension.js" ~/.pi/agent/extension
161
181
  ```
162
182
 
163
183
  It runs on every user turn (`before_agent_start`), drains the same inbox, and
164
- injects pending announcements. (Or load it ad hoc with
165
- `pi -e /abs/path/to/dist/inboxExtension.js`.)
184
+ injects pending announcements plus the unlinked-repo nudge. (Or load it ad hoc
185
+ with `pi -e /abs/path/to/dist/inboxExtension.js`.)
166
186
 
167
187
  ### Notes
168
188
 
@@ -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
  }
@@ -80,7 +172,7 @@ function shepherdInbox(pi) {
80
172
  const dir = process.env["SHEPHERD_INBOX_DIR"] || defaultInboxDir();
81
173
  const cwd = ctx?.cwd ?? process.cwd();
82
174
  const announcements = drainInbox(inboxFilePath(dir, cwd));
83
- const content = formatInboxAnnouncements(announcements);
175
+ const content = [buildLinkNudge(cwd), formatInboxAnnouncements(announcements)].filter(Boolean).join("\n\n");
84
176
  if (!content) return void 0;
85
177
  return { message: { customType: "shepherd-inbox", content, display: true } };
86
178
  } catch {
package/dist/inboxHook.js CHANGED
@@ -1,18 +1,61 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/inbox.ts
4
- import { createHash } from "crypto";
4
+ import { createHash as createHash2 } from "crypto";
5
5
  import {
6
6
  appendFileSync,
7
- mkdirSync,
8
- readFileSync,
7
+ mkdirSync as mkdirSync2,
8
+ readFileSync as readFileSync3,
9
9
  renameSync,
10
- rmSync,
11
- existsSync
10
+ rmSync as rmSync3,
11
+ existsSync as existsSync3
12
12
  } from "fs";
13
+ import { homedir as homedir2, tmpdir as tmpdir2 } from "os";
14
+ import { dirname as dirname3, join as join3, resolve as resolve3 } from "path";
15
+
16
+ // src/marker.ts
17
+ import * as fs from "fs";
18
+ import * as path from "path";
19
+ var MARKER_FILENAME = ".shepherd";
20
+ function findRepoRoot(cwd) {
21
+ let dir = path.resolve(cwd);
22
+ for (; ; ) {
23
+ if (fs.existsSync(path.join(dir, ".git"))) return dir;
24
+ const parent = path.dirname(dir);
25
+ if (parent === dir) return null;
26
+ dir = parent;
27
+ }
28
+ }
29
+ function markerPath(cwd) {
30
+ const root = findRepoRoot(cwd);
31
+ return root === null ? null : path.join(root, MARKER_FILENAME);
32
+ }
33
+ function readMarker(cwd = process.cwd()) {
34
+ const file = markerPath(cwd);
35
+ if (file === null) return null;
36
+ let raw;
37
+ try {
38
+ raw = fs.readFileSync(file, "utf8");
39
+ } catch {
40
+ return null;
41
+ }
42
+ try {
43
+ const parsed = JSON.parse(raw);
44
+ if (parsed !== null && typeof parsed === "object" && typeof parsed.workspace === "string" && parsed.workspace.length > 0) {
45
+ return { workspace: parsed.workspace };
46
+ }
47
+ return null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ // src/declined.ts
54
+ import { createHash } from "crypto";
55
+ import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
13
56
  import { homedir, tmpdir } from "os";
14
- import { dirname, join, resolve } from "path";
15
- function 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
  }
@@ -74,8 +166,7 @@ function formatInboxAnnouncements(announcements) {
74
166
  }
75
167
  return lines.join("\n");
76
168
  }
77
- function buildHookOutput(rawStdin, inboxDir, drain = drainInbox) {
78
- if (!inboxDir) return "";
169
+ function buildHookOutput(rawStdin, inboxDir, drain = drainInbox, nudge = buildLinkNudge) {
79
170
  let input;
80
171
  try {
81
172
  input = JSON.parse(rawStdin);
@@ -83,13 +174,21 @@ function buildHookOutput(rawStdin, inboxDir, drain = drainInbox) {
83
174
  return "";
84
175
  }
85
176
  if (!input || typeof input.cwd !== "string" || input.cwd.length === 0) return "";
86
- const announcements = drain(inboxFilePath(inboxDir, input.cwd));
87
- const text = formatInboxAnnouncements(announcements);
88
- if (!text) return "";
177
+ const parts = [];
178
+ const nudgeText = nudge(
179
+ input.cwd,
180
+ typeof input.tool_name === "string" ? input.tool_name : void 0
181
+ );
182
+ if (nudgeText) parts.push(nudgeText);
183
+ if (inboxDir) {
184
+ const text = formatInboxAnnouncements(drain(inboxFilePath(inboxDir, input.cwd)));
185
+ if (text) parts.push(text);
186
+ }
187
+ if (parts.length === 0) return "";
89
188
  return JSON.stringify({
90
189
  hookSpecificOutput: {
91
190
  hookEventName: input.hook_event_name || "PreToolUse",
92
- additionalContext: text
191
+ additionalContext: parts.join("\n\n")
93
192
  }
94
193
  });
95
194
  }
package/dist/index.js CHANGED
@@ -783,7 +783,10 @@ function detectHuman(cwd = process.cwd()) {
783
783
  return null;
784
784
  }
785
785
  const name = runGit(cwd, ["config", "user.name"]);
786
- if (name) return name;
786
+ if (name) {
787
+ const local = name.includes("@") ? name.split("@")[0] : name;
788
+ if (local) return local;
789
+ }
787
790
  const email = runGit(cwd, ["config", "user.email"]);
788
791
  if (email) {
789
792
  const local = email.split("@")[0];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@korso/shepherd",
3
- "version": "0.6.3",
3
+ "version": "0.7.0",
4
4
  "description": "Shepherd MCP server — gives any MCP-capable agent (Claude Code, Codex, etc.) four advisory cross-session coordination tools (work/done/announce/sync) backed by the shared Shepherd hub. Joins the workspace automatically and ships standing instructions so the agent self-coordinates.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",