@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.
Files changed (3) hide show
  1. package/README.md +11 -2
  2. package/dist/index.js +126 -23
  3. 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 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()
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((r) => {
953
- sessionId = r.sessionId;
954
- agentName = r.agentName;
955
- heartbeat.start(r.sessionId);
956
- }).catch(() => {
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: "Shepherd coordination session not ready (hub unreachable at startup) \u2014 proceeding uncoordinated."
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.HUMAN ?? deps.detectHuman(cwd) ?? generateName();
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
- deliverAnnouncements = false,
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 (deliverAnnouncements) body.deliverAnnouncements = true;
1296
+ if (announcementSink) body.deliverAnnouncements = true;
1201
1297
  const response = await hubClient.post("/heartbeat", body);
1202
1298
  const delivered = response?.announcements;
1203
- if (onAnnouncements && Array.isArray(delivered) && delivered.length > 0) {
1299
+ if (announcementSink && Array.isArray(delivered) && delivered.length > 0) {
1204
1300
  try {
1205
- onAnnouncements(delivered);
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
- deliverAnnouncements: true,
1266
- onAnnouncements: (announcements) => appendAnnouncements(inboxFile, announcements)
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.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",