@korso/shepherd 0.5.0 → 0.6.1
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/LICENSE +661 -0
- package/README.md +336 -336
- package/dist/inboxHook.js +0 -0
- package/dist/index.js +247 -98
- package/package.json +3 -3
package/dist/inboxHook.js
CHANGED
|
File without changes
|
package/dist/index.js
CHANGED
|
@@ -558,15 +558,25 @@ var ListTokensResponse = z2.object({
|
|
|
558
558
|
});
|
|
559
559
|
var CreateInviteRequest = z2.object({
|
|
560
560
|
expiresInDays: z2.number().int().positive().optional(),
|
|
561
|
+
// Omitted = unlimited, redeemable until explicitly revoked. Pass a positive
|
|
562
|
+
// integer to cap it instead.
|
|
561
563
|
maxUses: z2.number().int().positive().optional()
|
|
562
564
|
});
|
|
563
565
|
var InviteResponse = z2.object({
|
|
564
566
|
code: z2.string(),
|
|
565
567
|
// ISO timestamp string, or null when the invite never expires.
|
|
566
568
|
expiresAt: IsoTimestamp.nullable(),
|
|
567
|
-
|
|
569
|
+
// null = unlimited (redeemable until revoked).
|
|
570
|
+
maxUses: z2.number().int().positive().nullable(),
|
|
568
571
|
useCount: z2.number().int().nonnegative()
|
|
569
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
|
+
});
|
|
570
580
|
var RedeemInviteResponse = z2.object({
|
|
571
581
|
// The workspace the caller just joined.
|
|
572
582
|
workspace: WorkspaceSummary
|
|
@@ -575,12 +585,23 @@ var MemberSummary = z2.object({
|
|
|
575
585
|
accountId: z2.string(),
|
|
576
586
|
displayName: z2.string().nullable(),
|
|
577
587
|
githubLogin: z2.string().nullable(),
|
|
588
|
+
email: z2.string().nullable(),
|
|
578
589
|
avatarUrl: z2.string().nullable(),
|
|
579
590
|
role: Role
|
|
580
591
|
});
|
|
581
592
|
var ListMembersResponse = z2.object({
|
|
582
593
|
members: z2.array(MemberSummary)
|
|
583
594
|
});
|
|
595
|
+
var FeedbackType = z2.enum(["bug", "suggestion", "other"]);
|
|
596
|
+
var FeedbackRequest = z2.object({
|
|
597
|
+
type: FeedbackType,
|
|
598
|
+
body: z2.string().trim().min(1).max(4e3)
|
|
599
|
+
});
|
|
600
|
+
var FeedbackResponse = z2.object({
|
|
601
|
+
ok: z2.literal(true),
|
|
602
|
+
// uuid PK (the feedback table, like workspaces, uses gen_random_uuid()).
|
|
603
|
+
id: z2.string()
|
|
604
|
+
});
|
|
584
605
|
|
|
585
606
|
// src/marker.ts
|
|
586
607
|
import * as fs from "fs";
|
|
@@ -634,6 +655,66 @@ function removeMarker(cwd = process.cwd()) {
|
|
|
634
655
|
}
|
|
635
656
|
}
|
|
636
657
|
|
|
658
|
+
// src/declined.ts
|
|
659
|
+
import { createHash } from "crypto";
|
|
660
|
+
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
661
|
+
import { homedir, tmpdir } from "os";
|
|
662
|
+
import { dirname as dirname2, join as join2, resolve as resolve2 } from "path";
|
|
663
|
+
function defaultDeclinedDir() {
|
|
664
|
+
let base = "";
|
|
665
|
+
try {
|
|
666
|
+
base = homedir();
|
|
667
|
+
} catch {
|
|
668
|
+
base = "";
|
|
669
|
+
}
|
|
670
|
+
if (!base) base = tmpdir();
|
|
671
|
+
return join2(base, ".shepherd", "declined");
|
|
672
|
+
}
|
|
673
|
+
function declinedFilePath(repoRoot, dir = defaultDeclinedDir()) {
|
|
674
|
+
let normalized = resolve2(repoRoot);
|
|
675
|
+
if (process.platform === "win32") normalized = normalized.toLowerCase();
|
|
676
|
+
const hash = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
|
|
677
|
+
return join2(dir, hash);
|
|
678
|
+
}
|
|
679
|
+
function isDeclined(repoRoot, dir = defaultDeclinedDir()) {
|
|
680
|
+
const file = declinedFilePath(repoRoot, dir);
|
|
681
|
+
let raw;
|
|
682
|
+
try {
|
|
683
|
+
raw = readFileSync2(file, "utf8");
|
|
684
|
+
} catch {
|
|
685
|
+
return false;
|
|
686
|
+
}
|
|
687
|
+
try {
|
|
688
|
+
const parsed = JSON.parse(raw);
|
|
689
|
+
return typeof parsed?.declinedAt === "string";
|
|
690
|
+
} catch {
|
|
691
|
+
return false;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
function setDeclined(repoRoot, dir = defaultDeclinedDir()) {
|
|
695
|
+
const file = declinedFilePath(repoRoot, dir);
|
|
696
|
+
try {
|
|
697
|
+
mkdirSync(dirname2(file), { recursive: true });
|
|
698
|
+
const payload = JSON.stringify({ declinedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
699
|
+
writeFileSync2(file, payload + "\n", "utf8");
|
|
700
|
+
} catch (err) {
|
|
701
|
+
console.error(
|
|
702
|
+
`[shepherd] declined-state write failed: ${err instanceof Error ? err.message : String(err)}`
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
function clearDeclined(repoRoot, dir = defaultDeclinedDir()) {
|
|
707
|
+
const file = declinedFilePath(repoRoot, dir);
|
|
708
|
+
if (!existsSync2(file)) return;
|
|
709
|
+
try {
|
|
710
|
+
rmSync2(file, { force: true });
|
|
711
|
+
} catch (err) {
|
|
712
|
+
console.error(
|
|
713
|
+
`[shepherd] declined-state clear failed: ${err instanceof Error ? err.message : String(err)}`
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
637
718
|
// src/gitContext.ts
|
|
638
719
|
import { execFileSync } from "child_process";
|
|
639
720
|
import * as path2 from "path";
|
|
@@ -879,37 +960,37 @@ async function buildChangeReport(cwd, config) {
|
|
|
879
960
|
}
|
|
880
961
|
|
|
881
962
|
// src/inbox.ts
|
|
882
|
-
import { createHash } from "crypto";
|
|
963
|
+
import { createHash as createHash2 } from "crypto";
|
|
883
964
|
import {
|
|
884
965
|
appendFileSync,
|
|
885
|
-
mkdirSync,
|
|
886
|
-
readFileSync as
|
|
966
|
+
mkdirSync as mkdirSync2,
|
|
967
|
+
readFileSync as readFileSync3,
|
|
887
968
|
renameSync,
|
|
888
|
-
rmSync as
|
|
889
|
-
existsSync as
|
|
969
|
+
rmSync as rmSync3,
|
|
970
|
+
existsSync as existsSync3
|
|
890
971
|
} from "fs";
|
|
891
|
-
import { homedir, tmpdir } from "os";
|
|
892
|
-
import { dirname as
|
|
972
|
+
import { homedir as homedir2, tmpdir as tmpdir2 } from "os";
|
|
973
|
+
import { dirname as dirname3, join as join3, resolve as resolve3 } from "path";
|
|
893
974
|
function defaultInboxDir() {
|
|
894
975
|
let base = "";
|
|
895
976
|
try {
|
|
896
|
-
base =
|
|
977
|
+
base = homedir2();
|
|
897
978
|
} catch {
|
|
898
979
|
base = "";
|
|
899
980
|
}
|
|
900
|
-
if (!base) base =
|
|
901
|
-
return
|
|
981
|
+
if (!base) base = tmpdir2();
|
|
982
|
+
return join3(base, ".shepherd", "inbox");
|
|
902
983
|
}
|
|
903
984
|
function inboxFilePath(dir, cwd) {
|
|
904
|
-
let normalized =
|
|
985
|
+
let normalized = resolve3(cwd);
|
|
905
986
|
if (process.platform === "win32") normalized = normalized.toLowerCase();
|
|
906
|
-
const hash =
|
|
907
|
-
return
|
|
987
|
+
const hash = createHash2("sha256").update(normalized).digest("hex").slice(0, 16);
|
|
988
|
+
return join3(dir, `${hash}.jsonl`);
|
|
908
989
|
}
|
|
909
990
|
function appendAnnouncements(filePath, announcements) {
|
|
910
991
|
if (!announcements || announcements.length === 0) return;
|
|
911
992
|
try {
|
|
912
|
-
|
|
993
|
+
mkdirSync2(dirname3(filePath), { recursive: true });
|
|
913
994
|
const payload = announcements.map((a) => JSON.stringify(a)).join("\n") + "\n";
|
|
914
995
|
appendFileSync(filePath, payload, "utf8");
|
|
915
996
|
} catch {
|
|
@@ -919,17 +1000,17 @@ function drainInbox(filePath) {
|
|
|
919
1000
|
const tmp = `${filePath}.draining`;
|
|
920
1001
|
let raw = "";
|
|
921
1002
|
try {
|
|
922
|
-
if (
|
|
923
|
-
raw +=
|
|
924
|
-
|
|
1003
|
+
if (existsSync3(tmp)) {
|
|
1004
|
+
raw += readFileSync3(tmp, "utf8");
|
|
1005
|
+
rmSync3(tmp, { force: true });
|
|
925
1006
|
}
|
|
926
1007
|
} catch {
|
|
927
1008
|
}
|
|
928
1009
|
try {
|
|
929
|
-
if (
|
|
1010
|
+
if (existsSync3(filePath)) {
|
|
930
1011
|
renameSync(filePath, tmp);
|
|
931
|
-
raw +=
|
|
932
|
-
|
|
1012
|
+
raw += readFileSync3(tmp, "utf8");
|
|
1013
|
+
rmSync3(tmp, { force: true });
|
|
933
1014
|
}
|
|
934
1015
|
} catch {
|
|
935
1016
|
}
|
|
@@ -970,6 +1051,12 @@ function classifyJoinFailure(err) {
|
|
|
970
1051
|
}
|
|
971
1052
|
return "unknown";
|
|
972
1053
|
}
|
|
1054
|
+
function classifyActivateFailure(err) {
|
|
1055
|
+
if (err instanceof HubRequestError && (err.status === 403 || err.status === 404)) {
|
|
1056
|
+
return "workspaceRejected";
|
|
1057
|
+
}
|
|
1058
|
+
return classifyJoinFailure(err);
|
|
1059
|
+
}
|
|
973
1060
|
function joinFailureCause(reason) {
|
|
974
1061
|
switch (reason) {
|
|
975
1062
|
case "unreachable":
|
|
@@ -1107,57 +1194,88 @@ function degradedResult(err) {
|
|
|
1107
1194
|
function registerTools(server, deps) {
|
|
1108
1195
|
const { hubClient, config, context, heartbeat, inboxFile } = deps;
|
|
1109
1196
|
const markerCwd = deps.cwd ?? process.cwd();
|
|
1197
|
+
const declinedDir = deps.declinedDir;
|
|
1198
|
+
const repoRoot = findRepoRoot(markerCwd);
|
|
1110
1199
|
let sessionId = null;
|
|
1111
1200
|
let agentName = null;
|
|
1112
1201
|
const isHosted = Boolean(config.SHEPHERD_TOKEN);
|
|
1113
1202
|
const selfHostMismatch = !isHosted && context.linked && config.WORKSPACE !== void 0 && config.WORKSPACE !== context.workspace;
|
|
1114
1203
|
let hostedWorkspaceRejected = false;
|
|
1115
1204
|
const dormant = !context.linked || selfHostMismatch;
|
|
1205
|
+
let linked = context.linked;
|
|
1206
|
+
let declined = context.declined;
|
|
1207
|
+
function rememberDecline() {
|
|
1208
|
+
if (repoRoot !== null) setDeclined(repoRoot, declinedDir);
|
|
1209
|
+
declined = true;
|
|
1210
|
+
}
|
|
1211
|
+
function forgetDecline() {
|
|
1212
|
+
if (repoRoot !== null) clearDeclined(repoRoot, declinedDir);
|
|
1213
|
+
declined = false;
|
|
1214
|
+
}
|
|
1116
1215
|
let joinFailure = null;
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1216
|
+
let joinInFlight = Promise.resolve();
|
|
1217
|
+
async function activate(workspaceSlug) {
|
|
1218
|
+
const joinBody = {
|
|
1219
|
+
workspace: workspaceSlug,
|
|
1220
|
+
repo: context.repo,
|
|
1221
|
+
branch: context.branch,
|
|
1222
|
+
human: context.human,
|
|
1223
|
+
program: context.program
|
|
1224
|
+
};
|
|
1225
|
+
if (context.model !== void 0) {
|
|
1226
|
+
joinBody.model = context.model;
|
|
1227
|
+
}
|
|
1228
|
+
const attempt = (async () => {
|
|
1229
|
+
try {
|
|
1230
|
+
const raw = await hubClient.post("/join", joinBody);
|
|
1231
|
+
const parsed = JoinResponse.safeParse(raw);
|
|
1232
|
+
if (!parsed.success || !parsed.data.sessionId) {
|
|
1233
|
+
joinFailure = "validation";
|
|
1234
|
+
console.error(
|
|
1235
|
+
"[shepherd] join failed (validation): hub returned a malformed join response (no usable sessionId)"
|
|
1236
|
+
);
|
|
1237
|
+
return { ok: false, reason: "validation" };
|
|
1238
|
+
}
|
|
1239
|
+
const newSessionId = parsed.data.sessionId;
|
|
1240
|
+
heartbeat.start(newSessionId);
|
|
1241
|
+
sessionId = newSessionId;
|
|
1242
|
+
agentName = parsed.data.agentName;
|
|
1243
|
+
linked = true;
|
|
1244
|
+
hostedWorkspaceRejected = false;
|
|
1245
|
+
joinFailure = null;
|
|
1246
|
+
return { ok: true };
|
|
1247
|
+
} catch (err) {
|
|
1248
|
+
const reason = classifyActivateFailure(err);
|
|
1249
|
+
joinFailure = classifyJoinFailure(err);
|
|
1250
|
+
if (reason === "workspaceRejected") {
|
|
1251
|
+
hostedWorkspaceRejected = true;
|
|
1252
|
+
console.error(
|
|
1253
|
+
`[shepherd] This repo is linked to workspace "${workspaceSlug}" but your configured token is for a different workspace \u2014 coordination disabled.`
|
|
1254
|
+
);
|
|
1255
|
+
} else {
|
|
1256
|
+
console.error(
|
|
1257
|
+
`[shepherd] join failed (${reason}): ${err instanceof Error ? err.message : String(err)}`
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
return { ok: false, reason };
|
|
1261
|
+
}
|
|
1262
|
+
})();
|
|
1263
|
+
joinInFlight = attempt.then(() => void 0);
|
|
1264
|
+
return attempt;
|
|
1126
1265
|
}
|
|
1127
1266
|
if (!context.linked) {
|
|
1128
1267
|
console.error(
|
|
1129
|
-
"[shepherd] This repo isn't linked to a Shepherd workspace \u2014 staying uncoordinated. Run `link` to choose one."
|
|
1268
|
+
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."
|
|
1130
1269
|
);
|
|
1131
1270
|
} else if (selfHostMismatch) {
|
|
1132
1271
|
console.error(
|
|
1133
1272
|
`[shepherd] This repo is linked to workspace "${context.workspace}" but your configured token is for a different workspace \u2014 coordination disabled.`
|
|
1134
1273
|
);
|
|
1135
1274
|
}
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
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
|
-
}
|
|
1160
|
-
});
|
|
1275
|
+
if (!dormant) {
|
|
1276
|
+
forgetDecline();
|
|
1277
|
+
void activate(context.workspace);
|
|
1278
|
+
}
|
|
1161
1279
|
async function awaitJoin() {
|
|
1162
1280
|
await joinInFlight;
|
|
1163
1281
|
}
|
|
@@ -1172,14 +1290,8 @@ function registerTools(server, deps) {
|
|
|
1172
1290
|
};
|
|
1173
1291
|
}
|
|
1174
1292
|
function notLinked() {
|
|
1175
|
-
|
|
1176
|
-
|
|
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
|
-
};
|
|
1293
|
+
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.";
|
|
1294
|
+
return { content: [{ type: "text", text }] };
|
|
1183
1295
|
}
|
|
1184
1296
|
function workspaceMismatch() {
|
|
1185
1297
|
return {
|
|
@@ -1193,7 +1305,7 @@ function registerTools(server, deps) {
|
|
|
1193
1305
|
}
|
|
1194
1306
|
async function coordinationGate() {
|
|
1195
1307
|
await awaitJoin();
|
|
1196
|
-
if (!
|
|
1308
|
+
if (!linked) return notLinked();
|
|
1197
1309
|
if (selfHostMismatch || hostedWorkspaceRejected) return workspaceMismatch();
|
|
1198
1310
|
if (sessionId === null) return sessionNotReady();
|
|
1199
1311
|
return null;
|
|
@@ -1363,13 +1475,25 @@ ${msgs}` : base }
|
|
|
1363
1475
|
function advisory(text) {
|
|
1364
1476
|
return { content: [{ type: "text", text }] };
|
|
1365
1477
|
}
|
|
1478
|
+
async function linkAndActivate(slug) {
|
|
1479
|
+
writeMarker(markerCwd, slug);
|
|
1480
|
+
forgetDecline();
|
|
1481
|
+
linked = true;
|
|
1482
|
+
const result = await activate(slug);
|
|
1483
|
+
if (result.ok) {
|
|
1484
|
+
return advisory(`Linked this repo to \`${slug}\` \u2014 coordinating in \`${slug}\` now.`);
|
|
1485
|
+
}
|
|
1486
|
+
return advisory(
|
|
1487
|
+
`Linked this repo to \`${slug}\`, but coordination couldn't start just now (${joinFailureCause(joinFailure)}). It'll connect on your next tool call or session.`
|
|
1488
|
+
);
|
|
1489
|
+
}
|
|
1366
1490
|
server.registerTool(
|
|
1367
1491
|
"link",
|
|
1368
1492
|
{
|
|
1369
1493
|
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
|
|
1494
|
+
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.",
|
|
1371
1495
|
inputSchema: z3.object({
|
|
1372
|
-
workspace: z3.string().min(1).optional().describe("The workspace slug to link this repo to. Omit to list
|
|
1496
|
+
workspace: z3.string().min(1).optional().describe("The workspace slug to link this repo to. Omit to auto-pick or list choices.")
|
|
1373
1497
|
}).shape
|
|
1374
1498
|
},
|
|
1375
1499
|
async (args) => {
|
|
@@ -1381,21 +1505,12 @@ ${msgs}` : base }
|
|
|
1381
1505
|
"Self-host mode has no configured workspace (WORKSPACE is unset) \u2014 cannot link."
|
|
1382
1506
|
);
|
|
1383
1507
|
}
|
|
1384
|
-
if (requested
|
|
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) {
|
|
1508
|
+
if (requested !== void 0 && requested !== allowed) {
|
|
1391
1509
|
return advisory(
|
|
1392
1510
|
`This self-host deployment only serves the workspace \`${allowed}\`; you asked for \`${requested}\`. Choose: ${allowed}`
|
|
1393
1511
|
);
|
|
1394
1512
|
}
|
|
1395
|
-
|
|
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
|
-
);
|
|
1513
|
+
return linkAndActivate(allowed);
|
|
1399
1514
|
}
|
|
1400
1515
|
let slugs;
|
|
1401
1516
|
try {
|
|
@@ -1409,12 +1524,15 @@ Run \`link\` again with workspace "${allowed}" to opt this repo in.`
|
|
|
1409
1524
|
}
|
|
1410
1525
|
if (slugs.length === 0) {
|
|
1411
1526
|
return advisory(
|
|
1412
|
-
"Your account isn't a member of any workspaces yet \u2014 nothing to link to. Create
|
|
1527
|
+
"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."
|
|
1413
1528
|
);
|
|
1414
1529
|
}
|
|
1415
1530
|
if (requested === void 0) {
|
|
1531
|
+
if (slugs.length === 1) {
|
|
1532
|
+
return linkAndActivate(slugs[0]);
|
|
1533
|
+
}
|
|
1416
1534
|
return advisory(
|
|
1417
|
-
"You
|
|
1535
|
+
"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."
|
|
1418
1536
|
);
|
|
1419
1537
|
}
|
|
1420
1538
|
if (!slugs.includes(requested)) {
|
|
@@ -1422,23 +1540,47 @@ Run \`link\` again with workspace "${allowed}" to opt this repo in.`
|
|
|
1422
1540
|
`You're not a member of \`${requested}\`; choose one of: ${slugs.join(", ")}`
|
|
1423
1541
|
);
|
|
1424
1542
|
}
|
|
1425
|
-
|
|
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
|
-
);
|
|
1543
|
+
return linkAndActivate(requested);
|
|
1429
1544
|
}
|
|
1430
1545
|
);
|
|
1431
1546
|
server.registerTool(
|
|
1432
1547
|
"unlink",
|
|
1433
1548
|
{
|
|
1434
1549
|
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.",
|
|
1550
|
+
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.",
|
|
1436
1551
|
inputSchema: z3.object({}).shape
|
|
1437
1552
|
},
|
|
1438
1553
|
async () => {
|
|
1439
1554
|
removeMarker(markerCwd);
|
|
1555
|
+
rememberDecline();
|
|
1556
|
+
linked = false;
|
|
1557
|
+
if (sessionId !== null) {
|
|
1558
|
+
heartbeat.stop();
|
|
1559
|
+
await leave();
|
|
1560
|
+
sessionId = null;
|
|
1561
|
+
agentName = null;
|
|
1562
|
+
}
|
|
1440
1563
|
return advisory(
|
|
1441
|
-
"Unlinked \u2014 this repo will stay uncoordinated
|
|
1564
|
+
"Unlinked \u2014 this repo will stay uncoordinated and won't ask again. Run `link` to re-enable."
|
|
1565
|
+
);
|
|
1566
|
+
}
|
|
1567
|
+
);
|
|
1568
|
+
server.registerTool(
|
|
1569
|
+
"decline",
|
|
1570
|
+
{
|
|
1571
|
+
title: "Decline Shepherd coordination for this repo",
|
|
1572
|
+
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.",
|
|
1573
|
+
inputSchema: z3.object({}).shape
|
|
1574
|
+
},
|
|
1575
|
+
async () => {
|
|
1576
|
+
if (linked) {
|
|
1577
|
+
return advisory(
|
|
1578
|
+
`Already coordinating \`${context.workspace}\` \u2014 run \`unlink\` to stop coordinating this repo.`
|
|
1579
|
+
);
|
|
1580
|
+
}
|
|
1581
|
+
rememberDecline();
|
|
1582
|
+
return advisory(
|
|
1583
|
+
"Won't coordinate this repo or ask again. Run `link` anytime to change your mind."
|
|
1442
1584
|
);
|
|
1443
1585
|
}
|
|
1444
1586
|
);
|
|
@@ -1457,23 +1599,23 @@ Run \`link\` again with workspace "${allowed}" to opt this repo in.`
|
|
|
1457
1599
|
}
|
|
1458
1600
|
|
|
1459
1601
|
// src/identityCache.ts
|
|
1460
|
-
import { mkdirSync as
|
|
1461
|
-
import { homedir as
|
|
1462
|
-
import { dirname as
|
|
1602
|
+
import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
|
|
1603
|
+
import { homedir as homedir3, tmpdir as tmpdir3 } from "os";
|
|
1604
|
+
import { dirname as dirname4, join as join4 } from "path";
|
|
1463
1605
|
function defaultIdentityCachePath() {
|
|
1464
1606
|
let base = "";
|
|
1465
1607
|
try {
|
|
1466
|
-
base =
|
|
1608
|
+
base = homedir3();
|
|
1467
1609
|
} catch {
|
|
1468
1610
|
base = "";
|
|
1469
1611
|
}
|
|
1470
|
-
if (!base) base =
|
|
1471
|
-
return
|
|
1612
|
+
if (!base) base = tmpdir3();
|
|
1613
|
+
return join4(base, ".shepherd", "identity.json");
|
|
1472
1614
|
}
|
|
1473
1615
|
function readCachedHuman(filePath = defaultIdentityCachePath()) {
|
|
1474
1616
|
let raw;
|
|
1475
1617
|
try {
|
|
1476
|
-
raw =
|
|
1618
|
+
raw = readFileSync4(filePath, "utf8");
|
|
1477
1619
|
} catch {
|
|
1478
1620
|
return null;
|
|
1479
1621
|
}
|
|
@@ -1488,9 +1630,9 @@ function readCachedHuman(filePath = defaultIdentityCachePath()) {
|
|
|
1488
1630
|
function writeCachedHuman(human, filePath = defaultIdentityCachePath()) {
|
|
1489
1631
|
if (typeof human !== "string" || human.trim().length === 0) return;
|
|
1490
1632
|
try {
|
|
1491
|
-
|
|
1633
|
+
mkdirSync3(dirname4(filePath), { recursive: true });
|
|
1492
1634
|
const payload = JSON.stringify({ human });
|
|
1493
|
-
|
|
1635
|
+
writeFileSync3(filePath, payload + "\n", "utf8");
|
|
1494
1636
|
} catch {
|
|
1495
1637
|
}
|
|
1496
1638
|
}
|
|
@@ -1501,6 +1643,8 @@ var defaultDeps = {
|
|
|
1501
1643
|
detectBranch,
|
|
1502
1644
|
detectHuman,
|
|
1503
1645
|
readMarker,
|
|
1646
|
+
findRepoRoot,
|
|
1647
|
+
isDeclined: (repoRoot) => isDeclined(repoRoot),
|
|
1504
1648
|
readCachedHuman,
|
|
1505
1649
|
writeCachedHuman
|
|
1506
1650
|
};
|
|
@@ -1516,7 +1660,10 @@ async function resolveContext(config, cwd = process.cwd(), deps = defaultDeps) {
|
|
|
1516
1660
|
const marker = deps.readMarker(cwd);
|
|
1517
1661
|
const linked = marker !== null;
|
|
1518
1662
|
const workspace = marker?.workspace ?? config.WORKSPACE ?? DEFAULT_WORKSPACE;
|
|
1519
|
-
|
|
1663
|
+
const repoRoot = deps.findRepoRoot(cwd);
|
|
1664
|
+
const declined = repoRoot !== null ? deps.isDeclined(repoRoot) : false;
|
|
1665
|
+
const linkState = linked ? "linked" : declined ? "declined" : "unanswered";
|
|
1666
|
+
return { workspace, repo, branch, human, program, model, linked, declined, linkState };
|
|
1520
1667
|
}
|
|
1521
1668
|
function resolveHuman(config, cwd, deps) {
|
|
1522
1669
|
if (config.HUMAN) return config.HUMAN;
|
|
@@ -1588,9 +1735,11 @@ function createHeartbeat({
|
|
|
1588
1735
|
}
|
|
1589
1736
|
|
|
1590
1737
|
// src/instructions.ts
|
|
1591
|
-
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
|
+
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.
|
|
1739
|
+
|
|
1740
|
+
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.
|
|
1592
1741
|
|
|
1593
|
-
|
|
1742
|
+
Once linked, follow this procedure on every session, proactively and without being asked:
|
|
1594
1743
|
|
|
1595
1744
|
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.
|
|
1596
1745
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@korso/shepherd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Shepherd MCP server — gives any MCP-capable agent (Claude Code, Codex, etc.) four advisory cross-session coordination tools (work/done/announce/sync) backed by the shared Shepherd hub. Joins the workspace automatically and ships standing instructions so the agent self-coordinates.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
"engines": {
|
|
16
16
|
"node": ">=18"
|
|
17
17
|
},
|
|
18
|
-
"license": "
|
|
18
|
+
"license": "AGPL-3.0-only",
|
|
19
19
|
"repository": {
|
|
20
20
|
"type": "git",
|
|
21
|
-
"url": "git+https://github.com/
|
|
21
|
+
"url": "git+https://github.com/Korso-AI/shepherd.git",
|
|
22
22
|
"directory": "packages/mcp-server"
|
|
23
23
|
},
|
|
24
24
|
"keywords": [
|