@korso/shepherd 0.4.5 → 0.6.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.
Files changed (3) hide show
  1. package/LICENSE +661 -0
  2. package/dist/index.js +526 -116
  3. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -7,11 +7,18 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
7
7
  // src/config.ts
8
8
  import { z } from "zod";
9
9
  var ConfigSchema = z.object({
10
- // Hard-required: connection credentials.
10
+ // Hard-required: Hub endpoint.
11
11
  HUB_URL: z.string().min(1, "HUB_URL is required"),
12
- TEAM_TOKEN: z.string().min(1, "TEAM_TOKEN is required"),
12
+ // Auth credentials. Exactly one form is needed (enforced by the refine below):
13
+ // - SHEPHERD_TOKEN: the hosted Hub credential (carries its own workspace).
14
+ // - TEAM_TOKEN: the self-host credential.
15
+ // SHEPHERD_TOKEN wins when both are present (see the derived `authToken`).
16
+ SHEPHERD_TOKEN: z.string().min(1).optional(),
17
+ TEAM_TOKEN: z.string().min(1).optional(),
13
18
  // Optional overrides — resolveContext will apply defaults for any that are absent.
14
19
  // WORKSPACE default is applied in resolveContext (auto-detected from cwd basename).
20
+ // NOTE: WORKSPACE is IGNORED by the hosted Hub — the SHEPHERD_TOKEN carries the
21
+ // workspace identity. It remains meaningful only for self-host (TEAM_TOKEN) setups.
15
22
  WORKSPACE: z.string().min(1).optional(),
16
23
  REPO: z.string().min(1).optional(),
17
24
  BRANCH: z.string().min(1).optional(),
@@ -28,10 +35,14 @@ var ConfigSchema = z.object({
28
35
  // work/sync/done/announce tool results as before. Both the MCP server and the
29
36
  // hook must agree on this path.
30
37
  SHEPHERD_INBOX_DIR: z.string().min(1).optional()
38
+ }).refine((c) => Boolean(c.SHEPHERD_TOKEN || c.TEAM_TOKEN), {
39
+ message: "Either SHEPHERD_TOKEN or TEAM_TOKEN is required",
40
+ path: ["SHEPHERD_TOKEN"]
31
41
  });
32
42
  function parseConfig(env) {
33
- return ConfigSchema.parse({
43
+ const parsed = ConfigSchema.parse({
34
44
  HUB_URL: env["HUB_URL"],
45
+ SHEPHERD_TOKEN: env["SHEPHERD_TOKEN"],
35
46
  TEAM_TOKEN: env["TEAM_TOKEN"],
36
47
  WORKSPACE: env["WORKSPACE"],
37
48
  REPO: env["REPO"],
@@ -43,6 +54,8 @@ function parseConfig(env) {
43
54
  HEARTBEAT_INTERVAL_SECONDS: env["HEARTBEAT_INTERVAL_SECONDS"],
44
55
  SHEPHERD_INBOX_DIR: env["SHEPHERD_INBOX_DIR"]
45
56
  });
57
+ const authToken = parsed.SHEPHERD_TOKEN ?? parsed.TEAM_TOKEN;
58
+ return { ...parsed, authToken };
46
59
  }
47
60
  function loadConfig(env = process.env) {
48
61
  try {
@@ -82,51 +95,63 @@ var HubRequestError = class extends Error {
82
95
  };
83
96
  function createHubClient({
84
97
  hubUrl,
85
- teamToken,
98
+ token,
86
99
  timeoutMs = DEFAULT_TIMEOUT_MS
87
100
  }) {
88
101
  const baseUrl = hubUrl.replace(/\/$/, "");
89
- return {
90
- async post(path2, body) {
91
- const controller = new AbortController();
92
- const timer = setTimeout(() => controller.abort(), timeoutMs);
93
- let response;
102
+ async function request(method, path3, body) {
103
+ const controller = new AbortController();
104
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
105
+ const headers = {
106
+ "Authorization": `Bearer ${token}`
107
+ };
108
+ if (method === "POST") {
109
+ headers["Content-Type"] = "application/json";
110
+ }
111
+ let response;
112
+ try {
113
+ response = await fetch(`${baseUrl}${path3}`, {
114
+ method,
115
+ headers,
116
+ ...method === "POST" ? { body: JSON.stringify(body) } : {},
117
+ signal: controller.signal
118
+ });
119
+ } catch (err) {
120
+ clearTimeout(timer);
121
+ const message = err instanceof DOMException && err.name === "AbortError" ? `Hub request timed out after ${timeoutMs}ms (${path3})` : `Hub unreachable at ${baseUrl}${path3}: ${String(err)}`;
122
+ throw new HubUnreachable(message, err);
123
+ } finally {
124
+ clearTimeout(timer);
125
+ }
126
+ if (!response.ok) {
127
+ let detail = "";
94
128
  try {
95
- response = await fetch(`${baseUrl}${path2}`, {
96
- method: "POST",
97
- headers: {
98
- "Authorization": `Bearer ${teamToken}`,
99
- "Content-Type": "application/json"
100
- },
101
- body: JSON.stringify(body),
102
- signal: controller.signal
103
- });
104
- } catch (err) {
105
- clearTimeout(timer);
106
- const message = err instanceof DOMException && err.name === "AbortError" ? `Hub request timed out after ${timeoutMs}ms (${path2})` : `Hub unreachable at ${baseUrl}${path2}: ${String(err)}`;
107
- throw new HubUnreachable(message, err);
108
- } finally {
109
- clearTimeout(timer);
110
- }
111
- if (!response.ok) {
112
- let detail = "";
113
- try {
114
- const data = await response.json();
115
- if (data && typeof data === "object" && "error" in data && typeof data.error === "string") {
116
- detail = `: ${data.error}`;
117
- }
118
- } catch {
129
+ const data = await response.json();
130
+ if (data && typeof data === "object" && "error" in data && typeof data.error === "string") {
131
+ detail = `: ${data.error}`;
119
132
  }
120
- throw new HubRequestError(
121
- response.status,
122
- `Hub returned HTTP ${response.status} for ${path2}${detail}`
123
- );
133
+ } catch {
124
134
  }
125
- return response.json();
135
+ throw new HubRequestError(
136
+ response.status,
137
+ `Hub returned HTTP ${response.status} for ${path3}${detail}`
138
+ );
139
+ }
140
+ return response.json();
141
+ }
142
+ return {
143
+ post(path3, body) {
144
+ return request("POST", path3, body);
145
+ },
146
+ get(path3) {
147
+ return request("GET", path3);
126
148
  }
127
149
  };
128
150
  }
129
151
 
152
+ // src/tools.ts
153
+ import { z as z3 } from "zod";
154
+
130
155
  // ../shared/dist/names.js
131
156
  var adjectives = [
132
157
  "Able",
@@ -499,10 +524,199 @@ var LeaveRequest = z2.object({
499
524
  var LeaveResponse = z2.object({
500
525
  ok: z2.literal(true)
501
526
  });
527
+ var Role = z2.enum(["admin", "member"]);
528
+ var WorkspaceSummary = z2.object({
529
+ id: z2.string(),
530
+ slug: z2.string(),
531
+ name: z2.string(),
532
+ role: Role
533
+ });
534
+ var CreateWorkspaceRequest = z2.object({
535
+ name: z2.string().min(1)
536
+ });
537
+ var ListWorkspacesResponse = z2.object({
538
+ workspaces: z2.array(WorkspaceSummary)
539
+ });
540
+ var MintTokenRequest = z2.object({
541
+ name: z2.string().min(1).optional()
542
+ });
543
+ var MintTokenResponse = z2.object({
544
+ // The raw shp_ token, shown once at creation and never returned again.
545
+ token: z2.string(),
546
+ id: z2.string()
547
+ });
548
+ var TokenSummary = z2.object({
549
+ id: z2.string(),
550
+ name: z2.string().nullable(),
551
+ // ISO timestamp string (see IsoTimestamp note above) or null when unused / not revoked.
552
+ lastUsedAt: IsoTimestamp.nullable(),
553
+ createdAt: IsoTimestamp,
554
+ revokedAt: IsoTimestamp.nullable()
555
+ });
556
+ var ListTokensResponse = z2.object({
557
+ tokens: z2.array(TokenSummary)
558
+ });
559
+ var CreateInviteRequest = z2.object({
560
+ expiresInDays: z2.number().int().positive().optional(),
561
+ // Omitted = unlimited, redeemable until explicitly revoked. Pass a positive
562
+ // integer to cap it instead.
563
+ maxUses: z2.number().int().positive().optional()
564
+ });
565
+ var InviteResponse = z2.object({
566
+ code: z2.string(),
567
+ // ISO timestamp string, or null when the invite never expires.
568
+ expiresAt: IsoTimestamp.nullable(),
569
+ // null = unlimited (redeemable until revoked).
570
+ maxUses: z2.number().int().positive().nullable(),
571
+ useCount: z2.number().int().nonnegative()
572
+ });
573
+ var InviteByEmailRequest = z2.object({
574
+ email: z2.string().email()
575
+ });
576
+ var InviteByEmailResponse = z2.object({
577
+ email: z2.string(),
578
+ sentAt: IsoTimestamp
579
+ });
580
+ var RedeemInviteResponse = z2.object({
581
+ // The workspace the caller just joined.
582
+ workspace: WorkspaceSummary
583
+ });
584
+ var MemberSummary = z2.object({
585
+ accountId: z2.string(),
586
+ displayName: z2.string().nullable(),
587
+ githubLogin: z2.string().nullable(),
588
+ avatarUrl: z2.string().nullable(),
589
+ role: Role
590
+ });
591
+ var ListMembersResponse = z2.object({
592
+ members: z2.array(MemberSummary)
593
+ });
594
+ var FeedbackType = z2.enum(["bug", "suggestion", "other"]);
595
+ var FeedbackRequest = z2.object({
596
+ type: FeedbackType,
597
+ body: z2.string().trim().min(1).max(4e3)
598
+ });
599
+ var FeedbackResponse = z2.object({
600
+ ok: z2.literal(true),
601
+ // uuid PK (the feedback table, like workspaces, uses gen_random_uuid()).
602
+ id: z2.string()
603
+ });
604
+
605
+ // src/marker.ts
606
+ import * as fs from "fs";
607
+ import * as path from "path";
608
+ var MARKER_FILENAME = ".shepherd";
609
+ function findRepoRoot(cwd) {
610
+ let dir = path.resolve(cwd);
611
+ for (; ; ) {
612
+ if (fs.existsSync(path.join(dir, ".git"))) return dir;
613
+ const parent = path.dirname(dir);
614
+ if (parent === dir) return null;
615
+ dir = parent;
616
+ }
617
+ }
618
+ function markerPath(cwd) {
619
+ const root = findRepoRoot(cwd);
620
+ return root === null ? null : path.join(root, MARKER_FILENAME);
621
+ }
622
+ function readMarker(cwd = process.cwd()) {
623
+ const file = markerPath(cwd);
624
+ if (file === null) return null;
625
+ let raw;
626
+ try {
627
+ raw = fs.readFileSync(file, "utf8");
628
+ } catch {
629
+ return null;
630
+ }
631
+ try {
632
+ const parsed = JSON.parse(raw);
633
+ if (parsed !== null && typeof parsed === "object" && typeof parsed.workspace === "string" && parsed.workspace.length > 0) {
634
+ return { workspace: parsed.workspace };
635
+ }
636
+ return null;
637
+ } catch {
638
+ return null;
639
+ }
640
+ }
641
+ function writeMarker(cwd = process.cwd(), slug) {
642
+ const file = markerPath(cwd);
643
+ if (file === null) {
644
+ throw new Error("not inside a git repository \u2014 cannot write .shepherd marker");
645
+ }
646
+ fs.writeFileSync(file, JSON.stringify({ workspace: slug }) + "\n", "utf8");
647
+ }
648
+ function removeMarker(cwd = process.cwd()) {
649
+ const file = markerPath(cwd);
650
+ if (file === null) return;
651
+ try {
652
+ fs.rmSync(file, { force: true });
653
+ } catch {
654
+ }
655
+ }
656
+
657
+ // src/declined.ts
658
+ import { createHash } from "crypto";
659
+ import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
660
+ import { homedir, tmpdir } from "os";
661
+ import { dirname as dirname2, join as join2, resolve as resolve2 } from "path";
662
+ function defaultDeclinedDir() {
663
+ let base = "";
664
+ try {
665
+ base = homedir();
666
+ } catch {
667
+ base = "";
668
+ }
669
+ if (!base) base = tmpdir();
670
+ return join2(base, ".shepherd", "declined");
671
+ }
672
+ function declinedFilePath(repoRoot, dir = defaultDeclinedDir()) {
673
+ let normalized = resolve2(repoRoot);
674
+ if (process.platform === "win32") normalized = normalized.toLowerCase();
675
+ const hash = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
676
+ return join2(dir, hash);
677
+ }
678
+ function isDeclined(repoRoot, dir = defaultDeclinedDir()) {
679
+ const file = declinedFilePath(repoRoot, dir);
680
+ let raw;
681
+ try {
682
+ raw = readFileSync2(file, "utf8");
683
+ } catch {
684
+ return false;
685
+ }
686
+ try {
687
+ const parsed = JSON.parse(raw);
688
+ return typeof parsed?.declinedAt === "string";
689
+ } catch {
690
+ return false;
691
+ }
692
+ }
693
+ function setDeclined(repoRoot, dir = defaultDeclinedDir()) {
694
+ const file = declinedFilePath(repoRoot, dir);
695
+ try {
696
+ mkdirSync(dirname2(file), { recursive: true });
697
+ const payload = JSON.stringify({ declinedAt: (/* @__PURE__ */ new Date()).toISOString() });
698
+ writeFileSync2(file, payload + "\n", "utf8");
699
+ } catch (err) {
700
+ console.error(
701
+ `[shepherd] declined-state write failed: ${err instanceof Error ? err.message : String(err)}`
702
+ );
703
+ }
704
+ }
705
+ function clearDeclined(repoRoot, dir = defaultDeclinedDir()) {
706
+ const file = declinedFilePath(repoRoot, dir);
707
+ if (!existsSync2(file)) return;
708
+ try {
709
+ rmSync2(file, { force: true });
710
+ } catch (err) {
711
+ console.error(
712
+ `[shepherd] declined-state clear failed: ${err instanceof Error ? err.message : String(err)}`
713
+ );
714
+ }
715
+ }
502
716
 
503
717
  // src/gitContext.ts
504
718
  import { execFileSync } from "child_process";
505
- import * as path from "path";
719
+ import * as path2 from "path";
506
720
  var GIT_TIMEOUT_MS = 2e3;
507
721
  var MAX_COMMITS = 100;
508
722
  var MAX_PATHS_PER_COMMIT = 500;
@@ -550,7 +764,7 @@ function detectRepo(cwd = process.cwd()) {
550
764
  }
551
765
  const top = runGit(cwd, ["rev-parse", "--show-toplevel"]);
552
766
  if (top) {
553
- const base = path.basename(top);
767
+ const base = path2.basename(top);
554
768
  if (base) return base;
555
769
  }
556
770
  return null;
@@ -745,37 +959,37 @@ async function buildChangeReport(cwd, config) {
745
959
  }
746
960
 
747
961
  // src/inbox.ts
748
- import { createHash } from "crypto";
962
+ import { createHash as createHash2 } from "crypto";
749
963
  import {
750
964
  appendFileSync,
751
- mkdirSync,
752
- readFileSync,
965
+ mkdirSync as mkdirSync2,
966
+ readFileSync as readFileSync3,
753
967
  renameSync,
754
- rmSync,
755
- existsSync
968
+ rmSync as rmSync3,
969
+ existsSync as existsSync3
756
970
  } from "fs";
757
- import { homedir, tmpdir } from "os";
758
- import { dirname, join, resolve } from "path";
971
+ import { homedir as homedir2, tmpdir as tmpdir2 } from "os";
972
+ import { dirname as dirname3, join as join3, resolve as resolve3 } from "path";
759
973
  function defaultInboxDir() {
760
974
  let base = "";
761
975
  try {
762
- base = homedir();
976
+ base = homedir2();
763
977
  } catch {
764
978
  base = "";
765
979
  }
766
- if (!base) base = tmpdir();
767
- return join(base, ".shepherd", "inbox");
980
+ if (!base) base = tmpdir2();
981
+ return join3(base, ".shepherd", "inbox");
768
982
  }
769
983
  function inboxFilePath(dir, cwd) {
770
- let normalized = resolve(cwd);
984
+ let normalized = resolve3(cwd);
771
985
  if (process.platform === "win32") normalized = normalized.toLowerCase();
772
- const hash = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
773
- return join(dir, `${hash}.jsonl`);
986
+ const hash = createHash2("sha256").update(normalized).digest("hex").slice(0, 16);
987
+ return join3(dir, `${hash}.jsonl`);
774
988
  }
775
989
  function appendAnnouncements(filePath, announcements) {
776
990
  if (!announcements || announcements.length === 0) return;
777
991
  try {
778
- mkdirSync(dirname(filePath), { recursive: true });
992
+ mkdirSync2(dirname3(filePath), { recursive: true });
779
993
  const payload = announcements.map((a) => JSON.stringify(a)).join("\n") + "\n";
780
994
  appendFileSync(filePath, payload, "utf8");
781
995
  } catch {
@@ -785,17 +999,17 @@ function drainInbox(filePath) {
785
999
  const tmp = `${filePath}.draining`;
786
1000
  let raw = "";
787
1001
  try {
788
- if (existsSync(tmp)) {
789
- raw += readFileSync(tmp, "utf8");
790
- rmSync(tmp, { force: true });
1002
+ if (existsSync3(tmp)) {
1003
+ raw += readFileSync3(tmp, "utf8");
1004
+ rmSync3(tmp, { force: true });
791
1005
  }
792
1006
  } catch {
793
1007
  }
794
1008
  try {
795
- if (existsSync(filePath)) {
1009
+ if (existsSync3(filePath)) {
796
1010
  renameSync(filePath, tmp);
797
- raw += readFileSync(tmp, "utf8");
798
- rmSync(tmp, { force: true });
1011
+ raw += readFileSync3(tmp, "utf8");
1012
+ rmSync3(tmp, { force: true });
799
1013
  }
800
1014
  } catch {
801
1015
  }
@@ -836,6 +1050,12 @@ function classifyJoinFailure(err) {
836
1050
  }
837
1051
  return "unknown";
838
1052
  }
1053
+ function classifyActivateFailure(err) {
1054
+ if (err instanceof HubRequestError && (err.status === 403 || err.status === 404)) {
1055
+ return "workspaceRejected";
1056
+ }
1057
+ return classifyJoinFailure(err);
1058
+ }
839
1059
  function joinFailureCause(reason) {
840
1060
  switch (reason) {
841
1061
  case "unreachable":
@@ -956,8 +1176,11 @@ function formatChangeRecords(records, cwd = process.cwd()) {
956
1176
  if (lines.length === 0) return "";
957
1177
  return "Unlanded changes touching your area (awareness only \u2014 these are not blockers):\n" + lines.join("\n");
958
1178
  }
1179
+ function hubErrorDetail(err) {
1180
+ return err instanceof HubUnreachable || err instanceof HubRequestError ? err.message : String(err);
1181
+ }
959
1182
  function degradedResult(err) {
960
- const detail = err instanceof HubUnreachable || err instanceof HubRequestError ? err.message : String(err);
1183
+ const detail = hubErrorDetail(err);
961
1184
  return {
962
1185
  content: [
963
1186
  {
@@ -969,37 +1192,89 @@ function degradedResult(err) {
969
1192
  }
970
1193
  function registerTools(server, deps) {
971
1194
  const { hubClient, config, context, heartbeat, inboxFile } = deps;
1195
+ const markerCwd = deps.cwd ?? process.cwd();
1196
+ const declinedDir = deps.declinedDir;
1197
+ const repoRoot = findRepoRoot(markerCwd);
972
1198
  let sessionId = null;
973
1199
  let agentName = null;
974
- let joinFailure = null;
975
- const joinBody = {
976
- workspace: context.workspace,
977
- repo: context.repo,
978
- branch: context.branch,
979
- human: context.human,
980
- program: context.program
981
- };
982
- if (context.model !== void 0) {
983
- joinBody.model = context.model;
1200
+ const isHosted = Boolean(config.SHEPHERD_TOKEN);
1201
+ const selfHostMismatch = !isHosted && context.linked && config.WORKSPACE !== void 0 && config.WORKSPACE !== context.workspace;
1202
+ let hostedWorkspaceRejected = false;
1203
+ const dormant = !context.linked || selfHostMismatch;
1204
+ let linked = context.linked;
1205
+ let declined = context.declined;
1206
+ function rememberDecline() {
1207
+ if (repoRoot !== null) setDeclined(repoRoot, declinedDir);
1208
+ declined = true;
984
1209
  }
985
- const joinInFlight = hubClient.post("/join", joinBody).then((raw) => {
986
- const parsed = JoinResponse.safeParse(raw);
987
- if (!parsed.success || !parsed.data.sessionId) {
988
- joinFailure = "validation";
989
- console.error(
990
- "[shepherd] join failed (validation): hub returned a malformed join response (no usable sessionId)"
991
- );
992
- return;
1210
+ function forgetDecline() {
1211
+ if (repoRoot !== null) clearDeclined(repoRoot, declinedDir);
1212
+ declined = false;
1213
+ }
1214
+ let joinFailure = null;
1215
+ let joinInFlight = Promise.resolve();
1216
+ async function activate(workspaceSlug) {
1217
+ const joinBody = {
1218
+ workspace: workspaceSlug,
1219
+ repo: context.repo,
1220
+ branch: context.branch,
1221
+ human: context.human,
1222
+ program: context.program
1223
+ };
1224
+ if (context.model !== void 0) {
1225
+ joinBody.model = context.model;
993
1226
  }
994
- sessionId = parsed.data.sessionId;
995
- agentName = parsed.data.agentName;
996
- heartbeat.start(parsed.data.sessionId);
997
- }).catch((err) => {
998
- joinFailure = classifyJoinFailure(err);
1227
+ const attempt = (async () => {
1228
+ try {
1229
+ const raw = await hubClient.post("/join", joinBody);
1230
+ const parsed = JoinResponse.safeParse(raw);
1231
+ if (!parsed.success || !parsed.data.sessionId) {
1232
+ joinFailure = "validation";
1233
+ console.error(
1234
+ "[shepherd] join failed (validation): hub returned a malformed join response (no usable sessionId)"
1235
+ );
1236
+ return { ok: false, reason: "validation" };
1237
+ }
1238
+ const newSessionId = parsed.data.sessionId;
1239
+ heartbeat.start(newSessionId);
1240
+ sessionId = newSessionId;
1241
+ agentName = parsed.data.agentName;
1242
+ linked = true;
1243
+ hostedWorkspaceRejected = false;
1244
+ joinFailure = null;
1245
+ return { ok: true };
1246
+ } catch (err) {
1247
+ const reason = classifyActivateFailure(err);
1248
+ joinFailure = classifyJoinFailure(err);
1249
+ if (reason === "workspaceRejected") {
1250
+ hostedWorkspaceRejected = true;
1251
+ console.error(
1252
+ `[shepherd] This repo is linked to workspace "${workspaceSlug}" but your configured token is for a different workspace \u2014 coordination disabled.`
1253
+ );
1254
+ } else {
1255
+ console.error(
1256
+ `[shepherd] join failed (${reason}): ${err instanceof Error ? err.message : String(err)}`
1257
+ );
1258
+ }
1259
+ return { ok: false, reason };
1260
+ }
1261
+ })();
1262
+ joinInFlight = attempt.then(() => void 0);
1263
+ return attempt;
1264
+ }
1265
+ if (!context.linked) {
999
1266
  console.error(
1000
- `[shepherd] join failed (${joinFailure}): ${err instanceof Error ? err.message : String(err)}`
1267
+ context.declined ? "[shepherd] This repo was declined \u2014 staying uncoordinated. Run `link` to change your mind." : "[shepherd] This repo isn't linked to a Shepherd workspace \u2014 staying uncoordinated. Run `link` to choose one."
1001
1268
  );
1002
- });
1269
+ } else if (selfHostMismatch) {
1270
+ console.error(
1271
+ `[shepherd] This repo is linked to workspace "${context.workspace}" but your configured token is for a different workspace \u2014 coordination disabled.`
1272
+ );
1273
+ }
1274
+ if (!dormant) {
1275
+ forgetDecline();
1276
+ void activate(context.workspace);
1277
+ }
1003
1278
  async function awaitJoin() {
1004
1279
  await joinInFlight;
1005
1280
  }
@@ -1013,6 +1288,27 @@ function registerTools(server, deps) {
1013
1288
  ]
1014
1289
  };
1015
1290
  }
1291
+ function notLinked() {
1292
+ const text = declined ? "Not coordinating this repo \u2014 you declined. Run `link` anytime to change your mind." : "This repo isn't linked to a Shepherd workspace \u2014 run `link` to choose one, or `decline` to stay uncoordinated and not be asked again.";
1293
+ return { content: [{ type: "text", text }] };
1294
+ }
1295
+ function workspaceMismatch() {
1296
+ return {
1297
+ content: [
1298
+ {
1299
+ type: "text",
1300
+ text: `This repo is linked to \`${context.workspace}\` but your configured token is for a different workspace \u2014 coordination disabled.`
1301
+ }
1302
+ ]
1303
+ };
1304
+ }
1305
+ async function coordinationGate() {
1306
+ await awaitJoin();
1307
+ if (!linked) return notLinked();
1308
+ if (selfHostMismatch || hostedWorkspaceRejected) return workspaceMismatch();
1309
+ if (sessionId === null) return sessionNotReady();
1310
+ return null;
1311
+ }
1016
1312
  function withIdentity(body) {
1017
1313
  return agentName ? `You are ${agentName}.
1018
1314
 
@@ -1052,10 +1348,8 @@ ${section}` : body;
1052
1348
  inputSchema: WorkAgentInput.shape
1053
1349
  },
1054
1350
  async (args) => {
1055
- await awaitJoin();
1056
- if (sessionId === null) {
1057
- return sessionNotReady();
1058
- }
1351
+ const gated = await coordinationGate();
1352
+ if (gated) return gated;
1059
1353
  try {
1060
1354
  const changeReport = await changeReportForBody();
1061
1355
  const body = { sessionId, ...args, ...changeReport ? { changeReport } : {} };
@@ -1091,10 +1385,8 @@ You hold this claim until you call done (workItemId: ${result.workItemId}) or it
1091
1385
  inputSchema: DoneAgentInput.shape
1092
1386
  },
1093
1387
  async (args) => {
1094
- await awaitJoin();
1095
- if (sessionId === null) {
1096
- return sessionNotReady();
1097
- }
1388
+ const gated = await coordinationGate();
1389
+ if (gated) return gated;
1098
1390
  try {
1099
1391
  const body = { sessionId, ...args };
1100
1392
  const result = await hubClient.post("/done", body);
@@ -1125,10 +1417,8 @@ ${msgs}` : base }
1125
1417
  inputSchema: AnnounceAgentInput.shape
1126
1418
  },
1127
1419
  async (args) => {
1128
- await awaitJoin();
1129
- if (sessionId === null) {
1130
- return sessionNotReady();
1131
- }
1420
+ const gated = await coordinationGate();
1421
+ if (gated) return gated;
1132
1422
  try {
1133
1423
  const body = { sessionId, ...args };
1134
1424
  const result = await hubClient.post("/announce", body);
@@ -1159,10 +1449,8 @@ ${msgs}` : base }
1159
1449
  inputSchema: SyncAgentInput.shape
1160
1450
  },
1161
1451
  async (_args) => {
1162
- await awaitJoin();
1163
- if (sessionId === null) {
1164
- return sessionNotReady();
1165
- }
1452
+ const gated = await coordinationGate();
1453
+ if (gated) return gated;
1166
1454
  try {
1167
1455
  const changeReport = await changeReportForBody();
1168
1456
  const body = { sessionId, ...changeReport ? { changeReport } : {} };
@@ -1183,6 +1471,118 @@ ${msgs}` : base }
1183
1471
  }
1184
1472
  }
1185
1473
  );
1474
+ function advisory(text) {
1475
+ return { content: [{ type: "text", text }] };
1476
+ }
1477
+ async function linkAndActivate(slug) {
1478
+ writeMarker(markerCwd, slug);
1479
+ forgetDecline();
1480
+ linked = true;
1481
+ const result = await activate(slug);
1482
+ if (result.ok) {
1483
+ return advisory(`Linked this repo to \`${slug}\` \u2014 coordinating in \`${slug}\` now.`);
1484
+ }
1485
+ return advisory(
1486
+ `Linked this repo to \`${slug}\`, but coordination couldn't start just now (${joinFailureCause(joinFailure)}). It'll connect on your next tool call or session.`
1487
+ );
1488
+ }
1489
+ server.registerTool(
1490
+ "link",
1491
+ {
1492
+ title: "Link this repo to a Shepherd workspace",
1493
+ description: "Opt this repository into Shepherd coordination by writing a committed `.shepherd` marker naming the workspace. Call with no argument: if you belong to exactly one workspace it is linked and coordination starts immediately; if you belong to several, the choices are listed for you to confirm one with the agent's user, then call `link` again with that `workspace`. You can only link to a workspace you are a member of. Takes effect immediately \u2014 no restart. Use `unlink` to opt out, or `decline` to stay uncoordinated without linking.",
1494
+ inputSchema: z3.object({
1495
+ workspace: z3.string().min(1).optional().describe("The workspace slug to link this repo to. Omit to auto-pick or list choices.")
1496
+ }).shape
1497
+ },
1498
+ async (args) => {
1499
+ const requested = args.workspace;
1500
+ if (!isHosted) {
1501
+ const allowed = config.WORKSPACE;
1502
+ if (!allowed) {
1503
+ return advisory(
1504
+ "Self-host mode has no configured workspace (WORKSPACE is unset) \u2014 cannot link."
1505
+ );
1506
+ }
1507
+ if (requested !== void 0 && requested !== allowed) {
1508
+ return advisory(
1509
+ `This self-host deployment only serves the workspace \`${allowed}\`; you asked for \`${requested}\`. Choose: ${allowed}`
1510
+ );
1511
+ }
1512
+ return linkAndActivate(allowed);
1513
+ }
1514
+ let slugs;
1515
+ try {
1516
+ const res = await hubClient.get("/workspaces");
1517
+ slugs = (res.workspaces ?? []).map((w) => w.slug).filter((s) => typeof s === "string" && s.length > 0);
1518
+ } catch (err) {
1519
+ const detail = hubErrorDetail(err);
1520
+ return advisory(
1521
+ `Couldn't reach the coordination hub to list your workspaces \u2014 link not changed. ${detail}`
1522
+ );
1523
+ }
1524
+ if (slugs.length === 0) {
1525
+ return advisory(
1526
+ "Your account isn't a member of any workspaces yet \u2014 nothing to link to. Create one in the Shepherd dashboard, then run `link` again."
1527
+ );
1528
+ }
1529
+ if (requested === void 0) {
1530
+ if (slugs.length === 1) {
1531
+ return linkAndActivate(slugs[0]);
1532
+ }
1533
+ return advisory(
1534
+ "You're a member of multiple Shepherd workspaces:\n" + slugs.map((s) => ` - ${s}`).join("\n") + "\n\nAsk the user which one to use for this repo, then call `link` again with it as the `workspace` argument."
1535
+ );
1536
+ }
1537
+ if (!slugs.includes(requested)) {
1538
+ return advisory(
1539
+ `You're not a member of \`${requested}\`; choose one of: ${slugs.join(", ")}`
1540
+ );
1541
+ }
1542
+ return linkAndActivate(requested);
1543
+ }
1544
+ );
1545
+ server.registerTool(
1546
+ "unlink",
1547
+ {
1548
+ title: "Unlink this repo from its Shepherd workspace",
1549
+ description: "Opt this repository OUT of Shepherd coordination by removing its `.shepherd` marker. Also records a local decline so you aren't re-prompted to link on the next session. The repo stays uncoordinated (no claims, no presence) until you `link` it again.",
1550
+ inputSchema: z3.object({}).shape
1551
+ },
1552
+ async () => {
1553
+ removeMarker(markerCwd);
1554
+ rememberDecline();
1555
+ linked = false;
1556
+ if (sessionId !== null) {
1557
+ heartbeat.stop();
1558
+ await leave();
1559
+ sessionId = null;
1560
+ agentName = null;
1561
+ }
1562
+ return advisory(
1563
+ "Unlinked \u2014 this repo will stay uncoordinated and won't ask again. Run `link` to re-enable."
1564
+ );
1565
+ }
1566
+ );
1567
+ server.registerTool(
1568
+ "decline",
1569
+ {
1570
+ title: "Decline Shepherd coordination for this repo",
1571
+ description: "Opt out of Shepherd for this repo WITHOUT linking: records a local, per-user 'don't ask again' so this repo stays uncoordinated and you aren't prompted to link on future sessions. This is local only \u2014 it is never committed, so a teammate on the same repo can still link it. Run `link` anytime to change your mind.",
1572
+ inputSchema: z3.object({}).shape
1573
+ },
1574
+ async () => {
1575
+ if (linked) {
1576
+ return advisory(
1577
+ `Already coordinating \`${context.workspace}\` \u2014 run \`unlink\` to stop coordinating this repo.`
1578
+ );
1579
+ }
1580
+ rememberDecline();
1581
+ return advisory(
1582
+ "Won't coordinate this repo or ask again. Run `link` anytime to change your mind."
1583
+ );
1584
+ }
1585
+ );
1186
1586
  async function leave() {
1187
1587
  try {
1188
1588
  await joinInFlight;
@@ -1198,23 +1598,23 @@ ${msgs}` : base }
1198
1598
  }
1199
1599
 
1200
1600
  // src/identityCache.ts
1201
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync } from "fs";
1202
- import { homedir as homedir2, tmpdir as tmpdir2 } from "os";
1203
- import { dirname as dirname2, join as join2 } from "path";
1601
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
1602
+ import { homedir as homedir3, tmpdir as tmpdir3 } from "os";
1603
+ import { dirname as dirname4, join as join4 } from "path";
1204
1604
  function defaultIdentityCachePath() {
1205
1605
  let base = "";
1206
1606
  try {
1207
- base = homedir2();
1607
+ base = homedir3();
1208
1608
  } catch {
1209
1609
  base = "";
1210
1610
  }
1211
- if (!base) base = tmpdir2();
1212
- return join2(base, ".shepherd", "identity.json");
1611
+ if (!base) base = tmpdir3();
1612
+ return join4(base, ".shepherd", "identity.json");
1213
1613
  }
1214
1614
  function readCachedHuman(filePath = defaultIdentityCachePath()) {
1215
1615
  let raw;
1216
1616
  try {
1217
- raw = readFileSync2(filePath, "utf8");
1617
+ raw = readFileSync4(filePath, "utf8");
1218
1618
  } catch {
1219
1619
  return null;
1220
1620
  }
@@ -1229,9 +1629,9 @@ function readCachedHuman(filePath = defaultIdentityCachePath()) {
1229
1629
  function writeCachedHuman(human, filePath = defaultIdentityCachePath()) {
1230
1630
  if (typeof human !== "string" || human.trim().length === 0) return;
1231
1631
  try {
1232
- mkdirSync2(dirname2(filePath), { recursive: true });
1632
+ mkdirSync3(dirname4(filePath), { recursive: true });
1233
1633
  const payload = JSON.stringify({ human });
1234
- writeFileSync(filePath, payload + "\n", "utf8");
1634
+ writeFileSync3(filePath, payload + "\n", "utf8");
1235
1635
  } catch {
1236
1636
  }
1237
1637
  }
@@ -1241,6 +1641,9 @@ var defaultDeps = {
1241
1641
  detectRepo,
1242
1642
  detectBranch,
1243
1643
  detectHuman,
1644
+ readMarker,
1645
+ findRepoRoot,
1646
+ isDeclined: (repoRoot) => isDeclined(repoRoot),
1244
1647
  readCachedHuman,
1245
1648
  writeCachedHuman
1246
1649
  };
@@ -1253,8 +1656,13 @@ async function resolveContext(config, cwd = process.cwd(), deps = defaultDeps) {
1253
1656
  const human = resolveHuman(config, cwd, deps);
1254
1657
  const program = config.PROGRAM ?? "claude-code";
1255
1658
  const model = config.MODEL ?? void 0;
1256
- const workspace = config.WORKSPACE ?? DEFAULT_WORKSPACE;
1257
- return { workspace, repo, branch, human, program, model };
1659
+ const marker = deps.readMarker(cwd);
1660
+ const linked = marker !== null;
1661
+ const workspace = marker?.workspace ?? config.WORKSPACE ?? DEFAULT_WORKSPACE;
1662
+ const repoRoot = deps.findRepoRoot(cwd);
1663
+ const declined = repoRoot !== null ? deps.isDeclined(repoRoot) : false;
1664
+ const linkState = linked ? "linked" : declined ? "declined" : "unanswered";
1665
+ return { workspace, repo, branch, human, program, model, linked, declined, linkState };
1258
1666
  }
1259
1667
  function resolveHuman(config, cwd, deps) {
1260
1668
  if (config.HUMAN) return config.HUMAN;
@@ -1326,9 +1734,11 @@ function createHeartbeat({
1326
1734
  }
1327
1735
 
1328
1736
  // src/instructions.ts
1329
- 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. You have ALREADY been joined to the workspace automatically \u2014 there is no setup step, and you must not ask the user how to connect.
1737
+ 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.
1738
+
1739
+ 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.
1330
1740
 
1331
- Follow this procedure on every session, proactively and without being asked:
1741
+ Once linked, follow this procedure on every session, proactively and without being asked:
1332
1742
 
1333
1743
  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.
1334
1744
 
@@ -1347,7 +1757,7 @@ Commit work-in-progress as you go rather than sitting on a large dirty tree: com
1347
1757
  // src/index.ts
1348
1758
  async function main() {
1349
1759
  const config = loadConfig();
1350
- const hubClient = createHubClient({ hubUrl: config.HUB_URL, teamToken: config.TEAM_TOKEN });
1760
+ const hubClient = createHubClient({ hubUrl: config.HUB_URL, token: config.authToken });
1351
1761
  const context = await resolveContext(config);
1352
1762
  const inboxDir = config.SHEPHERD_INBOX_DIR ?? defaultInboxDir();
1353
1763
  const inboxFile = inboxFilePath(inboxDir, process.cwd());