@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/README.md +336 -327
- package/dist/inboxHook.js +0 -0
- package/dist/index.js +462 -97
- package/package.json +1 -1
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:
|
|
10
|
+
// Hard-required: Hub endpoint.
|
|
11
11
|
HUB_URL: z.string().min(1, "HUB_URL is required"),
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
+
token,
|
|
86
99
|
timeoutMs = DEFAULT_TIMEOUT_MS
|
|
87
100
|
}) {
|
|
88
101
|
const baseUrl = hubUrl.replace(/\/$/, "");
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
121
|
-
response.status,
|
|
122
|
-
`Hub returned HTTP ${response.status} for ${path2}${detail}`
|
|
123
|
-
);
|
|
133
|
+
} catch {
|
|
124
134
|
}
|
|
125
|
-
|
|
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
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
// (
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
|
|
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
|
|
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 =
|
|
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
|
|
901
|
+
return join2(base, ".shepherd", "inbox");
|
|
759
902
|
}
|
|
760
903
|
function inboxFilePath(dir, cwd) {
|
|
761
|
-
let normalized =
|
|
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
|
|
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(
|
|
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 (
|
|
780
|
-
raw +=
|
|
781
|
-
|
|
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 (
|
|
929
|
+
if (existsSync2(filePath)) {
|
|
787
930
|
renameSync(filePath, tmp);
|
|
788
|
-
raw +=
|
|
789
|
-
|
|
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 =
|
|
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
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
}
|
|
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:
|
|
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
|
|
1011
|
-
if (
|
|
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
|
|
1050
|
-
if (
|
|
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
|
|
1084
|
-
if (
|
|
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
|
|
1118
|
-
if (
|
|
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
|
|
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
|
|
1171
|
-
|
|
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
|
-
|
|
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 (
|
|
1558
|
+
if (announcementSink) body.deliverAnnouncements = true;
|
|
1201
1559
|
const response = await hubClient.post("/heartbeat", body);
|
|
1202
1560
|
const delivered = response?.announcements;
|
|
1203
|
-
if (
|
|
1561
|
+
if (announcementSink && Array.isArray(delivered) && delivered.length > 0) {
|
|
1204
1562
|
try {
|
|
1205
|
-
|
|
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,
|
|
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
|
-
|
|
1266
|
-
|
|
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" },
|