@korso/shepherd 0.4.4 → 0.4.5
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 +11 -2
- package/dist/index.js +126 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -55,12 +55,21 @@ override only to replace what's detected:
|
|
|
55
55
|
| `REPO` | `git remote origin` → `owner/repo`, else repo folder name, else `unknown-repo` | `Korsoai/shepherd` |
|
|
56
56
|
| `BRANCH` | `git rev-parse --abbrev-ref HEAD`, else `HEAD` | `main` |
|
|
57
57
|
| `BASE_BRANCH` | `origin/HEAD`, else `origin/main` / `origin/master` (used for the change-awareness heads-up) | `origin/main` |
|
|
58
|
-
| `HUMAN` | git `user.name`, else local-part of `user.email`, else a generated name | `daichi` |
|
|
58
|
+
| `HUMAN` | git `user.name`, else local-part of `user.email`, else this device's **cached** last-detected name, else a generated name | `daichi` |
|
|
59
59
|
| `PROGRAM` | defaults to `claude-code` | `codex` |
|
|
60
60
|
| `MODEL` | omitted — **never auto-detected**, so set it if you want it shown | `claude-sonnet-4-6` |
|
|
61
61
|
| `HEARTBEAT_INTERVAL_SECONDS` | defaults to `60` | `30` |
|
|
62
62
|
| `SHEPHERD_INBOX_DIR` | defaults to `~/.shepherd/inbox`. Override only to relocate the **announcement-push** inbox (see below); the background heartbeat writes incoming announcements here. If you set it, point your client hook/extension at the **same** dir | `~/.shepherd/inbox` |
|
|
63
63
|
|
|
64
|
+
**Device-identity cache.** Whenever `HUMAN` is unset and git **does** detect a
|
|
65
|
+
name, that name is cached for your OS user at `~/.shepherd/identity.json`. A
|
|
66
|
+
later launch from a directory where git can't be read (e.g. a multi-repo
|
|
67
|
+
workspace root) then reuses the cached name instead of inventing a fresh random
|
|
68
|
+
one each time. The cache refreshes automatically the next time git reports a
|
|
69
|
+
different name, and an explicit `HUMAN` override always wins and never touches
|
|
70
|
+
the cache. It is best-effort: if the file can't be read or written, resolution
|
|
71
|
+
just falls back to a generated name.
|
|
72
|
+
|
|
64
73
|
---
|
|
65
74
|
|
|
66
75
|
## Announcement push (on by default)
|
|
@@ -322,6 +331,6 @@ npm publish --workspace=@korso/shepherd # prepublishOnly runs tsup automatical
|
|
|
322
331
|
|---|---|---|
|
|
323
332
|
| `Configuration error — missing or invalid env vars` | `HUB_URL` or `TEAM_TOKEN` is absent (only these two are required) | Add the missing var(s) to your client's `env` block |
|
|
324
333
|
| Tools report "session not ready … proceeding uncoordinated" | Startup auto-join rejected — usually a stale `TEAM_TOKEN`, or a `WORKSPACE` override the hub doesn't allow | Re-check `TEAM_TOKEN`; leave `WORKSPACE` unset (→ `default`) or match the hub's `ALLOWED_WORKSPACE`; restart |
|
|
325
|
-
| Agent shows up under a surprising name/repo/branch | Identity auto-detected from git | Override with `HUMAN`/`REPO`/`BRANCH`/`MODEL` env vars (§2) |
|
|
334
|
+
| Agent shows up under a surprising name/repo/branch | Identity auto-detected from git, or reused from the device-identity cache when launched outside a git work tree | Override with `HUMAN`/`REPO`/`BRANCH`/`MODEL` env vars (§2); a correct git `user.name` on the next in-repo launch refreshes the cache, or delete `~/.shepherd/identity.json` to clear it |
|
|
326
335
|
| `npm error 404 … @korso/shepherd` | Package not published yet, or name typo | `npm view @korso/shepherd version` to confirm it's live |
|
|
327
336
|
| Process exits immediately with no error | Rare; check for node version incompatibility | Requires Node 18+ (ESM support) |
|
package/dist/index.js
CHANGED
|
@@ -469,13 +469,22 @@ var HeartbeatRequest = z2.object({
|
|
|
469
469
|
// only when it next calls work/sync). Processed presence-style: it refreshes
|
|
470
470
|
// change records but, like the rest of heartbeat, does NOT renew claim TTLs.
|
|
471
471
|
changeReport: ChangeReport.optional(),
|
|
472
|
-
// Opt-in: when set, the heartbeat
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
// (
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
|
|
472
|
+
// Opt-in: when set, the heartbeat returns any pending announcements for the
|
|
473
|
+
// caller in the response. Delivery is now TWO-PHASE and crash-safe: this fetch
|
|
474
|
+
// phase does NOT mark them delivered — the client persists them to its
|
|
475
|
+
// model-visible sink (the local inbox file drained by a hook) FIRST, then acks
|
|
476
|
+
// via `ackAnnouncementIds` so the hub records the delivery only after the local
|
|
477
|
+
// write is confirmed. The MCP client only sets this when it actually has such a
|
|
478
|
+
// sink. Absent for older clients, so default behaviour (no delivery) is
|
|
479
|
+
// unchanged.
|
|
480
|
+
deliverAnnouncements: z2.boolean().optional(),
|
|
481
|
+
// Phase-two ack of a previous `deliverAnnouncements` fetch: the ids the client
|
|
482
|
+
// has now durably written to its model-visible sink. The hub marks exactly
|
|
483
|
+
// these delivered to the caller's session. Decoupling the mark from the fetch
|
|
484
|
+
// guarantees a message is never recorded delivered before the client holds it
|
|
485
|
+
// (a lost response or a failed local append simply leaves it pending for the
|
|
486
|
+
// next beat). Absent on a plain presence/fetch beat.
|
|
487
|
+
ackAnnouncementIds: z2.array(DbId).optional()
|
|
479
488
|
});
|
|
480
489
|
var HeartbeatResponse = z2.object({
|
|
481
490
|
ok: z2.literal(true),
|
|
@@ -818,6 +827,29 @@ function mergeAnnouncements(...lists) {
|
|
|
818
827
|
}
|
|
819
828
|
|
|
820
829
|
// src/tools.ts
|
|
830
|
+
function classifyJoinFailure(err) {
|
|
831
|
+
if (err instanceof HubUnreachable) return "unreachable";
|
|
832
|
+
if (err instanceof HubRequestError) {
|
|
833
|
+
if (err.status === 401) return "auth";
|
|
834
|
+
if (err.status === 400) return "validation";
|
|
835
|
+
return "unknown";
|
|
836
|
+
}
|
|
837
|
+
return "unknown";
|
|
838
|
+
}
|
|
839
|
+
function joinFailureCause(reason) {
|
|
840
|
+
switch (reason) {
|
|
841
|
+
case "unreachable":
|
|
842
|
+
return "hub unreachable at startup";
|
|
843
|
+
case "auth":
|
|
844
|
+
return "hub rejected the team token (check SHEPHERD/TEAM token)";
|
|
845
|
+
case "validation":
|
|
846
|
+
return "hub rejected the join (workspace/branch not allowed, or returned an invalid response)";
|
|
847
|
+
case "unknown":
|
|
848
|
+
return "join failed with an unexpected error";
|
|
849
|
+
default:
|
|
850
|
+
return "coordination session not established yet";
|
|
851
|
+
}
|
|
852
|
+
}
|
|
821
853
|
function formatLandscape(landscape) {
|
|
822
854
|
const lines = [];
|
|
823
855
|
if (landscape.conflicts.length > 0) {
|
|
@@ -939,6 +971,7 @@ function registerTools(server, deps) {
|
|
|
939
971
|
const { hubClient, config, context, heartbeat, inboxFile } = deps;
|
|
940
972
|
let sessionId = null;
|
|
941
973
|
let agentName = null;
|
|
974
|
+
let joinFailure = null;
|
|
942
975
|
const joinBody = {
|
|
943
976
|
workspace: context.workspace,
|
|
944
977
|
repo: context.repo,
|
|
@@ -949,11 +982,23 @@ function registerTools(server, deps) {
|
|
|
949
982
|
if (context.model !== void 0) {
|
|
950
983
|
joinBody.model = context.model;
|
|
951
984
|
}
|
|
952
|
-
const joinInFlight = hubClient.post("/join", joinBody).then((
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
985
|
+
const joinInFlight = hubClient.post("/join", joinBody).then((raw) => {
|
|
986
|
+
const parsed = JoinResponse.safeParse(raw);
|
|
987
|
+
if (!parsed.success || !parsed.data.sessionId) {
|
|
988
|
+
joinFailure = "validation";
|
|
989
|
+
console.error(
|
|
990
|
+
"[shepherd] join failed (validation): hub returned a malformed join response (no usable sessionId)"
|
|
991
|
+
);
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
sessionId = parsed.data.sessionId;
|
|
995
|
+
agentName = parsed.data.agentName;
|
|
996
|
+
heartbeat.start(parsed.data.sessionId);
|
|
997
|
+
}).catch((err) => {
|
|
998
|
+
joinFailure = classifyJoinFailure(err);
|
|
999
|
+
console.error(
|
|
1000
|
+
`[shepherd] join failed (${joinFailure}): ${err instanceof Error ? err.message : String(err)}`
|
|
1001
|
+
);
|
|
957
1002
|
});
|
|
958
1003
|
async function awaitJoin() {
|
|
959
1004
|
await joinInFlight;
|
|
@@ -963,7 +1008,7 @@ function registerTools(server, deps) {
|
|
|
963
1008
|
content: [
|
|
964
1009
|
{
|
|
965
1010
|
type: "text",
|
|
966
|
-
text:
|
|
1011
|
+
text: `Shepherd coordination session not ready (${joinFailureCause(joinFailure)}) \u2014 proceeding uncoordinated.`
|
|
967
1012
|
}
|
|
968
1013
|
]
|
|
969
1014
|
};
|
|
@@ -1152,11 +1197,52 @@ ${msgs}` : base }
|
|
|
1152
1197
|
return { ready: joinInFlight, leave };
|
|
1153
1198
|
}
|
|
1154
1199
|
|
|
1200
|
+
// src/identityCache.ts
|
|
1201
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
1202
|
+
import { homedir as homedir2, tmpdir as tmpdir2 } from "os";
|
|
1203
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
1204
|
+
function defaultIdentityCachePath() {
|
|
1205
|
+
let base = "";
|
|
1206
|
+
try {
|
|
1207
|
+
base = homedir2();
|
|
1208
|
+
} catch {
|
|
1209
|
+
base = "";
|
|
1210
|
+
}
|
|
1211
|
+
if (!base) base = tmpdir2();
|
|
1212
|
+
return join2(base, ".shepherd", "identity.json");
|
|
1213
|
+
}
|
|
1214
|
+
function readCachedHuman(filePath = defaultIdentityCachePath()) {
|
|
1215
|
+
let raw;
|
|
1216
|
+
try {
|
|
1217
|
+
raw = readFileSync2(filePath, "utf8");
|
|
1218
|
+
} catch {
|
|
1219
|
+
return null;
|
|
1220
|
+
}
|
|
1221
|
+
try {
|
|
1222
|
+
const parsed = JSON.parse(raw);
|
|
1223
|
+
const human = typeof parsed?.human === "string" ? parsed.human.trim() : "";
|
|
1224
|
+
return human.length > 0 ? human : null;
|
|
1225
|
+
} catch {
|
|
1226
|
+
return null;
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
function writeCachedHuman(human, filePath = defaultIdentityCachePath()) {
|
|
1230
|
+
if (typeof human !== "string" || human.trim().length === 0) return;
|
|
1231
|
+
try {
|
|
1232
|
+
mkdirSync2(dirname2(filePath), { recursive: true });
|
|
1233
|
+
const payload = JSON.stringify({ human });
|
|
1234
|
+
writeFileSync(filePath, payload + "\n", "utf8");
|
|
1235
|
+
} catch {
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1155
1239
|
// src/resolveContext.ts
|
|
1156
1240
|
var defaultDeps = {
|
|
1157
1241
|
detectRepo,
|
|
1158
1242
|
detectBranch,
|
|
1159
|
-
detectHuman
|
|
1243
|
+
detectHuman,
|
|
1244
|
+
readCachedHuman,
|
|
1245
|
+
writeCachedHuman
|
|
1160
1246
|
};
|
|
1161
1247
|
var DEFAULT_WORKSPACE = "default";
|
|
1162
1248
|
async function resolveContext(config, cwd = process.cwd(), deps = defaultDeps) {
|
|
@@ -1164,20 +1250,30 @@ async function resolveContext(config, cwd = process.cwd(), deps = defaultDeps) {
|
|
|
1164
1250
|
config.REPO ?? deps.detectRepo(cwd) ?? "unknown-repo"
|
|
1165
1251
|
);
|
|
1166
1252
|
const branch = config.BRANCH ?? deps.detectBranch(cwd) ?? "HEAD";
|
|
1167
|
-
const human = config
|
|
1253
|
+
const human = resolveHuman(config, cwd, deps);
|
|
1168
1254
|
const program = config.PROGRAM ?? "claude-code";
|
|
1169
1255
|
const model = config.MODEL ?? void 0;
|
|
1170
1256
|
const workspace = config.WORKSPACE ?? DEFAULT_WORKSPACE;
|
|
1171
1257
|
return { workspace, repo, branch, human, program, model };
|
|
1172
1258
|
}
|
|
1259
|
+
function resolveHuman(config, cwd, deps) {
|
|
1260
|
+
if (config.HUMAN) return config.HUMAN;
|
|
1261
|
+
const detected = deps.detectHuman(cwd);
|
|
1262
|
+
if (detected) {
|
|
1263
|
+
deps.writeCachedHuman(detected);
|
|
1264
|
+
return detected;
|
|
1265
|
+
}
|
|
1266
|
+
const cached = deps.readCachedHuman();
|
|
1267
|
+
if (cached) return cached;
|
|
1268
|
+
return generateName();
|
|
1269
|
+
}
|
|
1173
1270
|
|
|
1174
1271
|
// src/heartbeat.ts
|
|
1175
1272
|
function createHeartbeat({
|
|
1176
1273
|
hubClient,
|
|
1177
1274
|
intervalSeconds,
|
|
1178
1275
|
buildReport,
|
|
1179
|
-
|
|
1180
|
-
onAnnouncements
|
|
1276
|
+
announcementSink
|
|
1181
1277
|
}) {
|
|
1182
1278
|
let timer = null;
|
|
1183
1279
|
function stop() {
|
|
@@ -1197,17 +1293,22 @@ function createHeartbeat({
|
|
|
1197
1293
|
}
|
|
1198
1294
|
const body = { sessionId };
|
|
1199
1295
|
if (changeReport) body.changeReport = changeReport;
|
|
1200
|
-
if (
|
|
1296
|
+
if (announcementSink) body.deliverAnnouncements = true;
|
|
1201
1297
|
const response = await hubClient.post("/heartbeat", body);
|
|
1202
1298
|
const delivered = response?.announcements;
|
|
1203
|
-
if (
|
|
1299
|
+
if (announcementSink && Array.isArray(delivered) && delivered.length > 0) {
|
|
1204
1300
|
try {
|
|
1205
|
-
|
|
1301
|
+
announcementSink(delivered);
|
|
1206
1302
|
} catch (err) {
|
|
1207
1303
|
console.error(
|
|
1208
|
-
`[shepherd] inbox delivery failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1304
|
+
`[shepherd] inbox delivery failed (not acking, will retry): ${err instanceof Error ? err.message : String(err)}`
|
|
1209
1305
|
);
|
|
1306
|
+
return;
|
|
1210
1307
|
}
|
|
1308
|
+
await hubClient.post("/heartbeat", {
|
|
1309
|
+
sessionId,
|
|
1310
|
+
ackAnnouncementIds: delivered.map((a) => a.id)
|
|
1311
|
+
});
|
|
1211
1312
|
}
|
|
1212
1313
|
}
|
|
1213
1314
|
function start(sessionId) {
|
|
@@ -1262,8 +1363,10 @@ async function main() {
|
|
|
1262
1363
|
return void 0;
|
|
1263
1364
|
}
|
|
1264
1365
|
},
|
|
1265
|
-
|
|
1266
|
-
|
|
1366
|
+
// A model-visible sink (this working dir's inbox file). Its presence opts
|
|
1367
|
+
// the heartbeat into two-phase announcement delivery: append locally, then
|
|
1368
|
+
// ack the hub. appendAnnouncements is itself fail-open.
|
|
1369
|
+
announcementSink: (announcements) => appendAnnouncements(inboxFile, announcements)
|
|
1267
1370
|
});
|
|
1268
1371
|
const server = new McpServer(
|
|
1269
1372
|
{ name: "shepherd", version: "0.1.0" },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@korso/shepherd",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
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",
|