@korso/shepherd 0.4.4 → 0.5.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/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",
@@ -469,13 +494,22 @@ var HeartbeatRequest = z2.object({
469
494
  // only when it next calls work/sync). Processed presence-style: it refreshes
470
495
  // change records but, like the rest of heartbeat, does NOT renew claim TTLs.
471
496
  changeReport: ChangeReport.optional(),
472
- // Opt-in: when set, the heartbeat ALSO delivers (and marks delivered) any
473
- // pending announcements for the caller, returned in the response. The MCP
474
- // client only sets this when it has somewhere model-visible to surface them
475
- // (a local inbox file drained by a hook) — otherwise the long-standing
476
- // invariant holds: heartbeat must NOT consume announcements the model can't
477
- // see. Absent for older clients, so default delivery is unchanged.
478
- deliverAnnouncements: z2.boolean().optional()
497
+ // Opt-in: when set, the heartbeat returns any pending announcements for the
498
+ // caller in the response. Delivery is now TWO-PHASE and crash-safe: this fetch
499
+ // phase does NOT mark them delivered — the client persists them to its
500
+ // model-visible sink (the local inbox file drained by a hook) FIRST, then acks
501
+ // via `ackAnnouncementIds` so the hub records the delivery only after the local
502
+ // write is confirmed. The MCP client only sets this when it actually has such a
503
+ // sink. Absent for older clients, so default behaviour (no delivery) is
504
+ // unchanged.
505
+ deliverAnnouncements: z2.boolean().optional(),
506
+ // Phase-two ack of a previous `deliverAnnouncements` fetch: the ids the client
507
+ // has now durably written to its model-visible sink. The hub marks exactly
508
+ // these delivered to the caller's session. Decoupling the mark from the fetch
509
+ // guarantees a message is never recorded delivered before the client holds it
510
+ // (a lost response or a failed local append simply leaves it pending for the
511
+ // next beat). Absent on a plain presence/fetch beat.
512
+ ackAnnouncementIds: z2.array(DbId).optional()
479
513
  });
480
514
  var HeartbeatResponse = z2.object({
481
515
  ok: z2.literal(true),
@@ -490,10 +524,119 @@ var LeaveRequest = z2.object({
490
524
  var LeaveResponse = z2.object({
491
525
  ok: z2.literal(true)
492
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
+ maxUses: z2.number().int().positive().optional()
562
+ });
563
+ var InviteResponse = z2.object({
564
+ code: z2.string(),
565
+ // ISO timestamp string, or null when the invite never expires.
566
+ expiresAt: IsoTimestamp.nullable(),
567
+ maxUses: z2.number().int().positive(),
568
+ useCount: z2.number().int().nonnegative()
569
+ });
570
+ var RedeemInviteResponse = z2.object({
571
+ // The workspace the caller just joined.
572
+ workspace: WorkspaceSummary
573
+ });
574
+ var MemberSummary = z2.object({
575
+ accountId: z2.string(),
576
+ displayName: z2.string().nullable(),
577
+ githubLogin: z2.string().nullable(),
578
+ avatarUrl: z2.string().nullable(),
579
+ role: Role
580
+ });
581
+ var ListMembersResponse = z2.object({
582
+ members: z2.array(MemberSummary)
583
+ });
584
+
585
+ // src/marker.ts
586
+ import * as fs from "fs";
587
+ import * as path from "path";
588
+ var MARKER_FILENAME = ".shepherd";
589
+ function findRepoRoot(cwd) {
590
+ let dir = path.resolve(cwd);
591
+ for (; ; ) {
592
+ if (fs.existsSync(path.join(dir, ".git"))) return dir;
593
+ const parent = path.dirname(dir);
594
+ if (parent === dir) return null;
595
+ dir = parent;
596
+ }
597
+ }
598
+ function markerPath(cwd) {
599
+ const root = findRepoRoot(cwd);
600
+ return root === null ? null : path.join(root, MARKER_FILENAME);
601
+ }
602
+ function readMarker(cwd = process.cwd()) {
603
+ const file = markerPath(cwd);
604
+ if (file === null) return null;
605
+ let raw;
606
+ try {
607
+ raw = fs.readFileSync(file, "utf8");
608
+ } catch {
609
+ return null;
610
+ }
611
+ try {
612
+ const parsed = JSON.parse(raw);
613
+ if (parsed !== null && typeof parsed === "object" && typeof parsed.workspace === "string" && parsed.workspace.length > 0) {
614
+ return { workspace: parsed.workspace };
615
+ }
616
+ return null;
617
+ } catch {
618
+ return null;
619
+ }
620
+ }
621
+ function writeMarker(cwd = process.cwd(), slug) {
622
+ const file = markerPath(cwd);
623
+ if (file === null) {
624
+ throw new Error("not inside a git repository \u2014 cannot write .shepherd marker");
625
+ }
626
+ fs.writeFileSync(file, JSON.stringify({ workspace: slug }) + "\n", "utf8");
627
+ }
628
+ function removeMarker(cwd = process.cwd()) {
629
+ const file = markerPath(cwd);
630
+ if (file === null) return;
631
+ try {
632
+ fs.rmSync(file, { force: true });
633
+ } catch {
634
+ }
635
+ }
493
636
 
494
637
  // src/gitContext.ts
495
638
  import { execFileSync } from "child_process";
496
- import * as path from "path";
639
+ import * as path2 from "path";
497
640
  var GIT_TIMEOUT_MS = 2e3;
498
641
  var MAX_COMMITS = 100;
499
642
  var MAX_PATHS_PER_COMMIT = 500;
@@ -541,7 +684,7 @@ function detectRepo(cwd = process.cwd()) {
541
684
  }
542
685
  const top = runGit(cwd, ["rev-parse", "--show-toplevel"]);
543
686
  if (top) {
544
- const base = path.basename(top);
687
+ const base = path2.basename(top);
545
688
  if (base) return base;
546
689
  }
547
690
  return null;
@@ -740,13 +883,13 @@ import { createHash } from "crypto";
740
883
  import {
741
884
  appendFileSync,
742
885
  mkdirSync,
743
- readFileSync,
886
+ readFileSync as readFileSync2,
744
887
  renameSync,
745
- rmSync,
746
- existsSync
888
+ rmSync as rmSync2,
889
+ existsSync as existsSync2
747
890
  } from "fs";
748
891
  import { homedir, tmpdir } from "os";
749
- import { dirname, join, resolve } from "path";
892
+ import { dirname as dirname2, join as join2, resolve as resolve2 } from "path";
750
893
  function defaultInboxDir() {
751
894
  let base = "";
752
895
  try {
@@ -755,18 +898,18 @@ function defaultInboxDir() {
755
898
  base = "";
756
899
  }
757
900
  if (!base) base = tmpdir();
758
- return join(base, ".shepherd", "inbox");
901
+ return join2(base, ".shepherd", "inbox");
759
902
  }
760
903
  function inboxFilePath(dir, cwd) {
761
- let normalized = resolve(cwd);
904
+ let normalized = resolve2(cwd);
762
905
  if (process.platform === "win32") normalized = normalized.toLowerCase();
763
906
  const hash = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
764
- return join(dir, `${hash}.jsonl`);
907
+ return join2(dir, `${hash}.jsonl`);
765
908
  }
766
909
  function appendAnnouncements(filePath, announcements) {
767
910
  if (!announcements || announcements.length === 0) return;
768
911
  try {
769
- mkdirSync(dirname(filePath), { recursive: true });
912
+ mkdirSync(dirname2(filePath), { recursive: true });
770
913
  const payload = announcements.map((a) => JSON.stringify(a)).join("\n") + "\n";
771
914
  appendFileSync(filePath, payload, "utf8");
772
915
  } catch {
@@ -776,17 +919,17 @@ function drainInbox(filePath) {
776
919
  const tmp = `${filePath}.draining`;
777
920
  let raw = "";
778
921
  try {
779
- if (existsSync(tmp)) {
780
- raw += readFileSync(tmp, "utf8");
781
- rmSync(tmp, { force: true });
922
+ if (existsSync2(tmp)) {
923
+ raw += readFileSync2(tmp, "utf8");
924
+ rmSync2(tmp, { force: true });
782
925
  }
783
926
  } catch {
784
927
  }
785
928
  try {
786
- if (existsSync(filePath)) {
929
+ if (existsSync2(filePath)) {
787
930
  renameSync(filePath, tmp);
788
- raw += readFileSync(tmp, "utf8");
789
- rmSync(tmp, { force: true });
931
+ raw += readFileSync2(tmp, "utf8");
932
+ rmSync2(tmp, { force: true });
790
933
  }
791
934
  } catch {
792
935
  }
@@ -818,6 +961,29 @@ function mergeAnnouncements(...lists) {
818
961
  }
819
962
 
820
963
  // src/tools.ts
964
+ function classifyJoinFailure(err) {
965
+ if (err instanceof HubUnreachable) return "unreachable";
966
+ if (err instanceof HubRequestError) {
967
+ if (err.status === 401) return "auth";
968
+ if (err.status === 400) return "validation";
969
+ return "unknown";
970
+ }
971
+ return "unknown";
972
+ }
973
+ function joinFailureCause(reason) {
974
+ switch (reason) {
975
+ case "unreachable":
976
+ return "hub unreachable at startup";
977
+ case "auth":
978
+ return "hub rejected the team token (check SHEPHERD/TEAM token)";
979
+ case "validation":
980
+ return "hub rejected the join (workspace/branch not allowed, or returned an invalid response)";
981
+ case "unknown":
982
+ return "join failed with an unexpected error";
983
+ default:
984
+ return "coordination session not established yet";
985
+ }
986
+ }
821
987
  function formatLandscape(landscape) {
822
988
  const lines = [];
823
989
  if (landscape.conflicts.length > 0) {
@@ -924,8 +1090,11 @@ function formatChangeRecords(records, cwd = process.cwd()) {
924
1090
  if (lines.length === 0) return "";
925
1091
  return "Unlanded changes touching your area (awareness only \u2014 these are not blockers):\n" + lines.join("\n");
926
1092
  }
1093
+ function hubErrorDetail(err) {
1094
+ return err instanceof HubUnreachable || err instanceof HubRequestError ? err.message : String(err);
1095
+ }
927
1096
  function degradedResult(err) {
928
- const detail = err instanceof HubUnreachable || err instanceof HubRequestError ? err.message : String(err);
1097
+ const detail = hubErrorDetail(err);
929
1098
  return {
930
1099
  content: [
931
1100
  {
@@ -937,8 +1106,14 @@ function degradedResult(err) {
937
1106
  }
938
1107
  function registerTools(server, deps) {
939
1108
  const { hubClient, config, context, heartbeat, inboxFile } = deps;
1109
+ const markerCwd = deps.cwd ?? process.cwd();
940
1110
  let sessionId = null;
941
1111
  let agentName = null;
1112
+ const isHosted = Boolean(config.SHEPHERD_TOKEN);
1113
+ const selfHostMismatch = !isHosted && context.linked && config.WORKSPACE !== void 0 && config.WORKSPACE !== context.workspace;
1114
+ let hostedWorkspaceRejected = false;
1115
+ const dormant = !context.linked || selfHostMismatch;
1116
+ let joinFailure = null;
942
1117
  const joinBody = {
943
1118
  workspace: context.workspace,
944
1119
  repo: context.repo,
@@ -949,11 +1124,39 @@ function registerTools(server, deps) {
949
1124
  if (context.model !== void 0) {
950
1125
  joinBody.model = context.model;
951
1126
  }
952
- const joinInFlight = hubClient.post("/join", joinBody).then((r) => {
953
- sessionId = r.sessionId;
954
- agentName = r.agentName;
955
- heartbeat.start(r.sessionId);
956
- }).catch(() => {
1127
+ if (!context.linked) {
1128
+ console.error(
1129
+ "[shepherd] This repo isn't linked to a Shepherd workspace \u2014 staying uncoordinated. Run `link` to choose one."
1130
+ );
1131
+ } else if (selfHostMismatch) {
1132
+ console.error(
1133
+ `[shepherd] This repo is linked to workspace "${context.workspace}" but your configured token is for a different workspace \u2014 coordination disabled.`
1134
+ );
1135
+ }
1136
+ const joinInFlight = dormant ? Promise.resolve() : hubClient.post("/join", joinBody).then((raw) => {
1137
+ const parsed = JoinResponse.safeParse(raw);
1138
+ if (!parsed.success || !parsed.data.sessionId) {
1139
+ joinFailure = "validation";
1140
+ console.error(
1141
+ "[shepherd] join failed (validation): hub returned a malformed join response (no usable sessionId)"
1142
+ );
1143
+ return;
1144
+ }
1145
+ sessionId = parsed.data.sessionId;
1146
+ agentName = parsed.data.agentName;
1147
+ heartbeat.start(parsed.data.sessionId);
1148
+ }).catch((err) => {
1149
+ joinFailure = classifyJoinFailure(err);
1150
+ if (err instanceof HubRequestError && (err.status === 403 || err.status === 404)) {
1151
+ hostedWorkspaceRejected = true;
1152
+ console.error(
1153
+ `[shepherd] This repo is linked to workspace "${context.workspace}" but your configured token is for a different workspace \u2014 coordination disabled.`
1154
+ );
1155
+ } else {
1156
+ console.error(
1157
+ `[shepherd] join failed (${joinFailure}): ${err instanceof Error ? err.message : String(err)}`
1158
+ );
1159
+ }
957
1160
  });
958
1161
  async function awaitJoin() {
959
1162
  await joinInFlight;
@@ -963,11 +1166,38 @@ function registerTools(server, deps) {
963
1166
  content: [
964
1167
  {
965
1168
  type: "text",
966
- text: "Shepherd coordination session not ready (hub unreachable at startup) \u2014 proceeding uncoordinated."
1169
+ text: `Shepherd coordination session not ready (${joinFailureCause(joinFailure)}) \u2014 proceeding uncoordinated.`
967
1170
  }
968
1171
  ]
969
1172
  };
970
1173
  }
1174
+ function notLinked() {
1175
+ return {
1176
+ content: [
1177
+ {
1178
+ type: "text",
1179
+ text: "This repo isn't linked to a Shepherd workspace \u2014 run `link` to choose one, or ignore to stay uncoordinated."
1180
+ }
1181
+ ]
1182
+ };
1183
+ }
1184
+ function workspaceMismatch() {
1185
+ return {
1186
+ content: [
1187
+ {
1188
+ type: "text",
1189
+ text: `This repo is linked to \`${context.workspace}\` but your configured token is for a different workspace \u2014 coordination disabled.`
1190
+ }
1191
+ ]
1192
+ };
1193
+ }
1194
+ async function coordinationGate() {
1195
+ await awaitJoin();
1196
+ if (!context.linked) return notLinked();
1197
+ if (selfHostMismatch || hostedWorkspaceRejected) return workspaceMismatch();
1198
+ if (sessionId === null) return sessionNotReady();
1199
+ return null;
1200
+ }
971
1201
  function withIdentity(body) {
972
1202
  return agentName ? `You are ${agentName}.
973
1203
 
@@ -1007,10 +1237,8 @@ ${section}` : body;
1007
1237
  inputSchema: WorkAgentInput.shape
1008
1238
  },
1009
1239
  async (args) => {
1010
- await awaitJoin();
1011
- if (sessionId === null) {
1012
- return sessionNotReady();
1013
- }
1240
+ const gated = await coordinationGate();
1241
+ if (gated) return gated;
1014
1242
  try {
1015
1243
  const changeReport = await changeReportForBody();
1016
1244
  const body = { sessionId, ...args, ...changeReport ? { changeReport } : {} };
@@ -1046,10 +1274,8 @@ You hold this claim until you call done (workItemId: ${result.workItemId}) or it
1046
1274
  inputSchema: DoneAgentInput.shape
1047
1275
  },
1048
1276
  async (args) => {
1049
- await awaitJoin();
1050
- if (sessionId === null) {
1051
- return sessionNotReady();
1052
- }
1277
+ const gated = await coordinationGate();
1278
+ if (gated) return gated;
1053
1279
  try {
1054
1280
  const body = { sessionId, ...args };
1055
1281
  const result = await hubClient.post("/done", body);
@@ -1080,10 +1306,8 @@ ${msgs}` : base }
1080
1306
  inputSchema: AnnounceAgentInput.shape
1081
1307
  },
1082
1308
  async (args) => {
1083
- await awaitJoin();
1084
- if (sessionId === null) {
1085
- return sessionNotReady();
1086
- }
1309
+ const gated = await coordinationGate();
1310
+ if (gated) return gated;
1087
1311
  try {
1088
1312
  const body = { sessionId, ...args };
1089
1313
  const result = await hubClient.post("/announce", body);
@@ -1114,10 +1338,8 @@ ${msgs}` : base }
1114
1338
  inputSchema: SyncAgentInput.shape
1115
1339
  },
1116
1340
  async (_args) => {
1117
- await awaitJoin();
1118
- if (sessionId === null) {
1119
- return sessionNotReady();
1120
- }
1341
+ const gated = await coordinationGate();
1342
+ if (gated) return gated;
1121
1343
  try {
1122
1344
  const changeReport = await changeReportForBody();
1123
1345
  const body = { sessionId, ...changeReport ? { changeReport } : {} };
@@ -1138,6 +1360,88 @@ ${msgs}` : base }
1138
1360
  }
1139
1361
  }
1140
1362
  );
1363
+ function advisory(text) {
1364
+ return { content: [{ type: "text", text }] };
1365
+ }
1366
+ server.registerTool(
1367
+ "link",
1368
+ {
1369
+ title: "Link this repo to a Shepherd workspace",
1370
+ description: "Opt this repository into Shepherd coordination by writing a committed `.shepherd` marker naming the workspace. Call with no argument to see the workspaces you can link to, then call again with one. You can only link to a workspace you are a member of. Takes effect on the next session (restart to coordinate now). Use `unlink` to opt out.",
1371
+ inputSchema: z3.object({
1372
+ workspace: z3.string().min(1).optional().describe("The workspace slug to link this repo to. Omit to list your choices.")
1373
+ }).shape
1374
+ },
1375
+ async (args) => {
1376
+ const requested = args.workspace;
1377
+ if (!isHosted) {
1378
+ const allowed = config.WORKSPACE;
1379
+ if (!allowed) {
1380
+ return advisory(
1381
+ "Self-host mode has no configured workspace (WORKSPACE is unset) \u2014 cannot link."
1382
+ );
1383
+ }
1384
+ if (requested === void 0) {
1385
+ return advisory(
1386
+ `This is a self-host deployment with a single workspace: \`${allowed}\`.
1387
+ Run \`link\` again with workspace "${allowed}" to opt this repo in.`
1388
+ );
1389
+ }
1390
+ if (requested !== allowed) {
1391
+ return advisory(
1392
+ `This self-host deployment only serves the workspace \`${allowed}\`; you asked for \`${requested}\`. Choose: ${allowed}`
1393
+ );
1394
+ }
1395
+ writeMarker(markerCwd, allowed);
1396
+ return advisory(
1397
+ `Linked this repo to \`${allowed}\` \u2014 restart the session for it to take effect (it applies on the next launch).`
1398
+ );
1399
+ }
1400
+ let slugs;
1401
+ try {
1402
+ const res = await hubClient.get("/workspaces");
1403
+ slugs = (res.workspaces ?? []).map((w) => w.slug).filter((s) => typeof s === "string" && s.length > 0);
1404
+ } catch (err) {
1405
+ const detail = hubErrorDetail(err);
1406
+ return advisory(
1407
+ `Couldn't reach the coordination hub to list your workspaces \u2014 link not changed. ${detail}`
1408
+ );
1409
+ }
1410
+ if (slugs.length === 0) {
1411
+ return advisory(
1412
+ "Your account isn't a member of any workspaces yet \u2014 nothing to link to. Create or join a workspace first, then run `link` again."
1413
+ );
1414
+ }
1415
+ if (requested === void 0) {
1416
+ return advisory(
1417
+ "You can link this repo to one of these workspaces:\n" + slugs.map((s) => ` - ${s}`).join("\n") + "\n\nRun `link` again with one of them as the `workspace` argument."
1418
+ );
1419
+ }
1420
+ if (!slugs.includes(requested)) {
1421
+ return advisory(
1422
+ `You're not a member of \`${requested}\`; choose one of: ${slugs.join(", ")}`
1423
+ );
1424
+ }
1425
+ writeMarker(markerCwd, requested);
1426
+ return advisory(
1427
+ `Linked this repo to \`${requested}\` \u2014 restart the session for it to take effect (it applies on the next launch).`
1428
+ );
1429
+ }
1430
+ );
1431
+ server.registerTool(
1432
+ "unlink",
1433
+ {
1434
+ title: "Unlink this repo from its Shepherd workspace",
1435
+ description: "Opt this repository OUT of Shepherd coordination by removing its `.shepherd` marker. The repo stays uncoordinated (no claims, no presence) until you `link` it again.",
1436
+ inputSchema: z3.object({}).shape
1437
+ },
1438
+ async () => {
1439
+ removeMarker(markerCwd);
1440
+ return advisory(
1441
+ "Unlinked \u2014 this repo will stay uncoordinated until re-linked."
1442
+ );
1443
+ }
1444
+ );
1141
1445
  async function leave() {
1142
1446
  try {
1143
1447
  await joinInFlight;
@@ -1152,11 +1456,53 @@ ${msgs}` : base }
1152
1456
  return { ready: joinInFlight, leave };
1153
1457
  }
1154
1458
 
1459
+ // src/identityCache.ts
1460
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
1461
+ import { homedir as homedir2, tmpdir as tmpdir2 } from "os";
1462
+ import { dirname as dirname3, join as join3 } from "path";
1463
+ function defaultIdentityCachePath() {
1464
+ let base = "";
1465
+ try {
1466
+ base = homedir2();
1467
+ } catch {
1468
+ base = "";
1469
+ }
1470
+ if (!base) base = tmpdir2();
1471
+ return join3(base, ".shepherd", "identity.json");
1472
+ }
1473
+ function readCachedHuman(filePath = defaultIdentityCachePath()) {
1474
+ let raw;
1475
+ try {
1476
+ raw = readFileSync3(filePath, "utf8");
1477
+ } catch {
1478
+ return null;
1479
+ }
1480
+ try {
1481
+ const parsed = JSON.parse(raw);
1482
+ const human = typeof parsed?.human === "string" ? parsed.human.trim() : "";
1483
+ return human.length > 0 ? human : null;
1484
+ } catch {
1485
+ return null;
1486
+ }
1487
+ }
1488
+ function writeCachedHuman(human, filePath = defaultIdentityCachePath()) {
1489
+ if (typeof human !== "string" || human.trim().length === 0) return;
1490
+ try {
1491
+ mkdirSync2(dirname3(filePath), { recursive: true });
1492
+ const payload = JSON.stringify({ human });
1493
+ writeFileSync2(filePath, payload + "\n", "utf8");
1494
+ } catch {
1495
+ }
1496
+ }
1497
+
1155
1498
  // src/resolveContext.ts
1156
1499
  var defaultDeps = {
1157
1500
  detectRepo,
1158
1501
  detectBranch,
1159
- detectHuman
1502
+ detectHuman,
1503
+ readMarker,
1504
+ readCachedHuman,
1505
+ writeCachedHuman
1160
1506
  };
1161
1507
  var DEFAULT_WORKSPACE = "default";
1162
1508
  async function resolveContext(config, cwd = process.cwd(), deps = defaultDeps) {
@@ -1164,11 +1510,24 @@ async function resolveContext(config, cwd = process.cwd(), deps = defaultDeps) {
1164
1510
  config.REPO ?? deps.detectRepo(cwd) ?? "unknown-repo"
1165
1511
  );
1166
1512
  const branch = config.BRANCH ?? deps.detectBranch(cwd) ?? "HEAD";
1167
- const human = config.HUMAN ?? deps.detectHuman(cwd) ?? generateName();
1513
+ const human = resolveHuman(config, cwd, deps);
1168
1514
  const program = config.PROGRAM ?? "claude-code";
1169
1515
  const model = config.MODEL ?? void 0;
1170
- const workspace = config.WORKSPACE ?? DEFAULT_WORKSPACE;
1171
- return { workspace, repo, branch, human, program, model };
1516
+ const marker = deps.readMarker(cwd);
1517
+ const linked = marker !== null;
1518
+ const workspace = marker?.workspace ?? config.WORKSPACE ?? DEFAULT_WORKSPACE;
1519
+ return { workspace, repo, branch, human, program, model, linked };
1520
+ }
1521
+ function resolveHuman(config, cwd, deps) {
1522
+ if (config.HUMAN) return config.HUMAN;
1523
+ const detected = deps.detectHuman(cwd);
1524
+ if (detected) {
1525
+ deps.writeCachedHuman(detected);
1526
+ return detected;
1527
+ }
1528
+ const cached = deps.readCachedHuman();
1529
+ if (cached) return cached;
1530
+ return generateName();
1172
1531
  }
1173
1532
 
1174
1533
  // src/heartbeat.ts
@@ -1176,8 +1535,7 @@ function createHeartbeat({
1176
1535
  hubClient,
1177
1536
  intervalSeconds,
1178
1537
  buildReport,
1179
- deliverAnnouncements = false,
1180
- onAnnouncements
1538
+ announcementSink
1181
1539
  }) {
1182
1540
  let timer = null;
1183
1541
  function stop() {
@@ -1197,17 +1555,22 @@ function createHeartbeat({
1197
1555
  }
1198
1556
  const body = { sessionId };
1199
1557
  if (changeReport) body.changeReport = changeReport;
1200
- if (deliverAnnouncements) body.deliverAnnouncements = true;
1558
+ if (announcementSink) body.deliverAnnouncements = true;
1201
1559
  const response = await hubClient.post("/heartbeat", body);
1202
1560
  const delivered = response?.announcements;
1203
- if (onAnnouncements && Array.isArray(delivered) && delivered.length > 0) {
1561
+ if (announcementSink && Array.isArray(delivered) && delivered.length > 0) {
1204
1562
  try {
1205
- onAnnouncements(delivered);
1563
+ announcementSink(delivered);
1206
1564
  } catch (err) {
1207
1565
  console.error(
1208
- `[shepherd] inbox delivery failed: ${err instanceof Error ? err.message : String(err)}`
1566
+ `[shepherd] inbox delivery failed (not acking, will retry): ${err instanceof Error ? err.message : String(err)}`
1209
1567
  );
1568
+ return;
1210
1569
  }
1570
+ await hubClient.post("/heartbeat", {
1571
+ sessionId,
1572
+ ackAnnouncementIds: delivered.map((a) => a.id)
1573
+ });
1211
1574
  }
1212
1575
  }
1213
1576
  function start(sessionId) {
@@ -1246,7 +1609,7 @@ Commit work-in-progress as you go rather than sitting on a large dirty tree: com
1246
1609
  // src/index.ts
1247
1610
  async function main() {
1248
1611
  const config = loadConfig();
1249
- const hubClient = createHubClient({ hubUrl: config.HUB_URL, teamToken: config.TEAM_TOKEN });
1612
+ const hubClient = createHubClient({ hubUrl: config.HUB_URL, token: config.authToken });
1250
1613
  const context = await resolveContext(config);
1251
1614
  const inboxDir = config.SHEPHERD_INBOX_DIR ?? defaultInboxDir();
1252
1615
  const inboxFile = inboxFilePath(inboxDir, process.cwd());
@@ -1262,8 +1625,10 @@ async function main() {
1262
1625
  return void 0;
1263
1626
  }
1264
1627
  },
1265
- deliverAnnouncements: true,
1266
- onAnnouncements: (announcements) => appendAnnouncements(inboxFile, announcements)
1628
+ // A model-visible sink (this working dir's inbox file). Its presence opts
1629
+ // the heartbeat into two-phase announcement delivery: append locally, then
1630
+ // ack the hub. appendAnnouncements is itself fail-open.
1631
+ announcementSink: (announcements) => appendAnnouncements(inboxFile, announcements)
1267
1632
  });
1268
1633
  const server = new McpServer(
1269
1634
  { name: "shepherd", version: "0.1.0" },