@korso/shepherd 0.9.0 → 0.10.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/LICENSE +6 -0
- package/README.md +66 -36
- package/dist/inboxExtension.js +195 -22
- package/dist/inboxHook.js +330 -20
- package/dist/index.js +470 -118
- package/package.json +4 -3
package/dist/index.js
CHANGED
|
@@ -9,7 +9,9 @@ import { z } from "zod";
|
|
|
9
9
|
var DEFAULT_WORKSPACE = "default";
|
|
10
10
|
var ConfigSchema = z.object({
|
|
11
11
|
// Hard-required: Hub endpoint.
|
|
12
|
-
HUB_URL: z.string({ required_error: "HUB_URL is required" }).url(
|
|
12
|
+
HUB_URL: z.string({ required_error: "HUB_URL is required" }).url(
|
|
13
|
+
"HUB_URL must be a full URL, e.g. https://your-shepherd-hub.example.com"
|
|
14
|
+
),
|
|
13
15
|
// Auth credentials. Exactly one form is needed (enforced by the refine below):
|
|
14
16
|
// - SHEPHERD_TOKEN: the hosted Hub credential (carries its own workspace).
|
|
15
17
|
// - TEAM_TOKEN: the self-host credential.
|
|
@@ -67,33 +69,51 @@ function parseConfig(env) {
|
|
|
67
69
|
function loadConfig(env = process.env) {
|
|
68
70
|
try {
|
|
69
71
|
const config = parseConfig(env);
|
|
70
|
-
|
|
72
|
+
assertHubUrlAllowed(config.HUB_URL, env);
|
|
71
73
|
return config;
|
|
72
74
|
} catch (err) {
|
|
73
75
|
if (err instanceof z.ZodError) {
|
|
74
76
|
const messages = err.issues.map((e) => ` ${e.path.join(".")}: ${e.message}`).join("\n");
|
|
75
|
-
process.stderr.write(
|
|
77
|
+
process.stderr.write(
|
|
78
|
+
`[shepherd] Configuration error \u2014 missing or invalid env vars:
|
|
76
79
|
${messages}
|
|
80
|
+
`
|
|
81
|
+
);
|
|
82
|
+
} else if (err instanceof Error) {
|
|
83
|
+
process.stderr.write(`[shepherd] Configuration error: ${err.message}
|
|
77
84
|
`);
|
|
78
85
|
} else {
|
|
79
|
-
process.stderr.write(
|
|
80
|
-
`)
|
|
86
|
+
process.stderr.write(
|
|
87
|
+
`[shepherd] Unexpected configuration error: ${String(err)}
|
|
88
|
+
`
|
|
89
|
+
);
|
|
81
90
|
}
|
|
82
91
|
process.exit(1);
|
|
83
92
|
}
|
|
84
93
|
}
|
|
85
|
-
function
|
|
94
|
+
function assertHubUrlAllowed(hubUrl, env = process.env) {
|
|
95
|
+
let url;
|
|
86
96
|
try {
|
|
87
|
-
|
|
88
|
-
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "[::1]";
|
|
89
|
-
if (url.protocol === "http:" && !loopback) {
|
|
90
|
-
process.stderr.write(
|
|
91
|
-
`[shepherd] WARNING: HUB_URL (${hubUrl}) uses plain http to a non-local host \u2014 the team token and all coordination traffic travel unencrypted. Use https.
|
|
92
|
-
`
|
|
93
|
-
);
|
|
94
|
-
}
|
|
97
|
+
url = new URL(hubUrl);
|
|
95
98
|
} catch {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (url.protocol !== "http:") return;
|
|
102
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "[::1]";
|
|
103
|
+
if (loopback) return;
|
|
104
|
+
const allowInsecure = ["1", "true", "yes"].includes(
|
|
105
|
+
(env["SHEPHERD_ALLOW_INSECURE_HTTP"] ?? "").toLowerCase()
|
|
106
|
+
);
|
|
107
|
+
if (allowInsecure) {
|
|
108
|
+
process.stderr.write(
|
|
109
|
+
`[shepherd] WARNING: HUB_URL (${hubUrl}) uses plain http to a non-local host \u2014 the team token and all coordination traffic travel unencrypted (permitted via SHEPHERD_ALLOW_INSECURE_HTTP). Use https.
|
|
110
|
+
`
|
|
111
|
+
);
|
|
112
|
+
return;
|
|
96
113
|
}
|
|
114
|
+
throw new Error(
|
|
115
|
+
`HUB_URL (${hubUrl}) uses plain http to a non-local host \u2014 the team token would travel unencrypted. Use https, or set SHEPHERD_ALLOW_INSECURE_HTTP=1 to permit cleartext to a private-network hub (not recommended).`
|
|
116
|
+
);
|
|
97
117
|
}
|
|
98
118
|
|
|
99
119
|
// src/hubClient.ts
|
|
@@ -125,7 +145,7 @@ function createHubClient({
|
|
|
125
145
|
const controller = new AbortController();
|
|
126
146
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
127
147
|
const headers = {
|
|
128
|
-
|
|
148
|
+
Authorization: `Bearer ${token}`
|
|
129
149
|
};
|
|
130
150
|
if (method === "POST") {
|
|
131
151
|
headers["Content-Type"] = "application/json";
|
|
@@ -520,7 +540,10 @@ var SyncRequest = z2.object({
|
|
|
520
540
|
var SyncResponse = z2.object({
|
|
521
541
|
landscape: Landscape
|
|
522
542
|
});
|
|
523
|
-
var WorkAgentInput = WorkRequest.omit({
|
|
543
|
+
var WorkAgentInput = WorkRequest.omit({
|
|
544
|
+
sessionId: true,
|
|
545
|
+
changeReport: true
|
|
546
|
+
});
|
|
524
547
|
var AnnounceAgentInput = AnnounceRequest.omit({ sessionId: true });
|
|
525
548
|
var DoneAgentInput = DoneRequest.omit({ sessionId: true });
|
|
526
549
|
var JoinAgentInput = z2.object({});
|
|
@@ -576,7 +599,10 @@ var WorkspaceSummary = z2.object({
|
|
|
576
599
|
isOwner: z2.boolean()
|
|
577
600
|
});
|
|
578
601
|
var CreateWorkspaceRequest = z2.object({
|
|
579
|
-
name
|
|
602
|
+
// Cap the name like every other persisted string field (256), so a workspace
|
|
603
|
+
// name (and the slug candidate derived from it) can't be inflated toward the
|
|
604
|
+
// request body limit. min(1) keeps the "non-empty" contract.
|
|
605
|
+
name: z2.string().min(1).max(256)
|
|
580
606
|
});
|
|
581
607
|
var ListWorkspacesResponse = z2.object({
|
|
582
608
|
workspaces: z2.array(WorkspaceSummary)
|
|
@@ -670,15 +696,50 @@ var TransferOwnershipResponse = z2.object({
|
|
|
670
696
|
ok: z2.literal(true)
|
|
671
697
|
});
|
|
672
698
|
var FeedbackType = z2.enum(["bug", "suggestion", "other"]);
|
|
699
|
+
var FeedbackContext = z2.object({
|
|
700
|
+
route: z2.string().max(256).optional(),
|
|
701
|
+
appVersion: z2.string().max(256).optional(),
|
|
702
|
+
userAgent: z2.string().max(512).optional(),
|
|
703
|
+
viewport: z2.string().max(256).optional()
|
|
704
|
+
});
|
|
673
705
|
var FeedbackRequest = z2.object({
|
|
674
706
|
type: FeedbackType,
|
|
675
|
-
body: z2.string().trim().min(1).max(4e3)
|
|
707
|
+
body: z2.string().trim().min(1).max(4e3),
|
|
708
|
+
context: FeedbackContext.optional()
|
|
676
709
|
});
|
|
677
710
|
var FeedbackResponse = z2.object({
|
|
678
711
|
ok: z2.literal(true),
|
|
679
712
|
// uuid PK (the feedback table, like workspaces, uses gen_random_uuid()).
|
|
680
713
|
id: z2.string()
|
|
681
714
|
});
|
|
715
|
+
var NullableCap = z2.number().int().positive().nullable();
|
|
716
|
+
var EntitlementLimits = z2.object({
|
|
717
|
+
seatsLimit: NullableCap,
|
|
718
|
+
reposLimit: NullableCap,
|
|
719
|
+
retentionDays: NullableCap
|
|
720
|
+
});
|
|
721
|
+
var LimitExceededErrorBody = z2.object({
|
|
722
|
+
error: z2.string(),
|
|
723
|
+
code: z2.literal("limit_exceeded"),
|
|
724
|
+
limit: z2.enum(["seats", "repos"]),
|
|
725
|
+
current: z2.number().int(),
|
|
726
|
+
max: z2.number().int()
|
|
727
|
+
});
|
|
728
|
+
var WorkspaceEntitlements = EntitlementLimits.extend({
|
|
729
|
+
expiresAt: IsoTimestamp.nullable(),
|
|
730
|
+
updatedAt: IsoTimestamp
|
|
731
|
+
});
|
|
732
|
+
var PutEntitlementsRequest = EntitlementLimits.extend({
|
|
733
|
+
expiresAt: IsoTimestamp.nullable()
|
|
734
|
+
});
|
|
735
|
+
var EntitlementsStatusResponse = z2.object({
|
|
736
|
+
record: WorkspaceEntitlements.nullable(),
|
|
737
|
+
effective: EntitlementLimits,
|
|
738
|
+
usage: z2.object({
|
|
739
|
+
seatsUsed: z2.number().int(),
|
|
740
|
+
reposUsed: z2.number().int()
|
|
741
|
+
})
|
|
742
|
+
});
|
|
682
743
|
var TrendPoint = z2.object({
|
|
683
744
|
// `YYYY-MM-DD` (UTC day).
|
|
684
745
|
date: z2.string(),
|
|
@@ -723,9 +784,10 @@ var ShepherdAnalyticsResponse = z2.object({
|
|
|
723
784
|
});
|
|
724
785
|
|
|
725
786
|
// src/marker.ts
|
|
726
|
-
import * as fs from "fs";
|
|
727
|
-
import * as path from "path";
|
|
787
|
+
import * as fs from "node:fs";
|
|
788
|
+
import * as path from "node:path";
|
|
728
789
|
var MARKER_FILENAME = ".shepherd";
|
|
790
|
+
var WORKSPACE_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
729
791
|
function findRepoRoot(cwd) {
|
|
730
792
|
let dir = path.resolve(cwd);
|
|
731
793
|
for (; ; ) {
|
|
@@ -750,8 +812,12 @@ function readMarker(cwd = process.cwd()) {
|
|
|
750
812
|
}
|
|
751
813
|
try {
|
|
752
814
|
const parsed = JSON.parse(raw);
|
|
753
|
-
if (parsed !== null && typeof parsed === "object" && typeof parsed.workspace === "string"
|
|
754
|
-
|
|
815
|
+
if (parsed !== null && typeof parsed === "object" && typeof parsed.workspace === "string") {
|
|
816
|
+
const workspace = parsed.workspace;
|
|
817
|
+
if (WORKSPACE_SLUG_PATTERN.test(workspace)) {
|
|
818
|
+
return { workspace };
|
|
819
|
+
}
|
|
820
|
+
return null;
|
|
755
821
|
}
|
|
756
822
|
return null;
|
|
757
823
|
} catch {
|
|
@@ -761,7 +827,9 @@ function readMarker(cwd = process.cwd()) {
|
|
|
761
827
|
function writeMarker(cwd = process.cwd(), slug) {
|
|
762
828
|
const file = markerPath(cwd);
|
|
763
829
|
if (file === null) {
|
|
764
|
-
throw new Error(
|
|
830
|
+
throw new Error(
|
|
831
|
+
"not inside a git repository \u2014 cannot write .shepherd marker"
|
|
832
|
+
);
|
|
765
833
|
}
|
|
766
834
|
fs.writeFileSync(file, JSON.stringify({ workspace: slug }) + "\n", "utf8");
|
|
767
835
|
}
|
|
@@ -775,10 +843,16 @@ function removeMarker(cwd = process.cwd()) {
|
|
|
775
843
|
}
|
|
776
844
|
|
|
777
845
|
// src/declined.ts
|
|
778
|
-
import { createHash } from "crypto";
|
|
779
|
-
import {
|
|
780
|
-
|
|
781
|
-
|
|
846
|
+
import { createHash } from "node:crypto";
|
|
847
|
+
import {
|
|
848
|
+
existsSync as existsSync2,
|
|
849
|
+
mkdirSync,
|
|
850
|
+
readFileSync as readFileSync2,
|
|
851
|
+
rmSync as rmSync2,
|
|
852
|
+
writeFileSync as writeFileSync2
|
|
853
|
+
} from "node:fs";
|
|
854
|
+
import { homedir, tmpdir } from "node:os";
|
|
855
|
+
import { dirname as dirname2, join as join2, resolve as resolve2 } from "node:path";
|
|
782
856
|
function defaultDeclinedDir() {
|
|
783
857
|
let base = "";
|
|
784
858
|
try {
|
|
@@ -814,7 +888,9 @@ function setDeclined(repoRoot, dir = defaultDeclinedDir()) {
|
|
|
814
888
|
const file = declinedFilePath(repoRoot, dir);
|
|
815
889
|
try {
|
|
816
890
|
mkdirSync(dirname2(file), { recursive: true });
|
|
817
|
-
const payload = JSON.stringify({
|
|
891
|
+
const payload = JSON.stringify({
|
|
892
|
+
declinedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
893
|
+
});
|
|
818
894
|
writeFileSync2(file, payload + "\n", "utf8");
|
|
819
895
|
} catch (err) {
|
|
820
896
|
console.error(
|
|
@@ -835,8 +911,8 @@ function clearDeclined(repoRoot, dir = defaultDeclinedDir()) {
|
|
|
835
911
|
}
|
|
836
912
|
|
|
837
913
|
// src/gitContext.ts
|
|
838
|
-
import { execFileSync } from "child_process";
|
|
839
|
-
import * as path2 from "path";
|
|
914
|
+
import { execFileSync } from "node:child_process";
|
|
915
|
+
import * as path2 from "node:path";
|
|
840
916
|
var GIT_TIMEOUT_MS = 2e3;
|
|
841
917
|
var MAX_COMMITS = 100;
|
|
842
918
|
var MAX_PATHS_PER_COMMIT = 500;
|
|
@@ -911,13 +987,22 @@ function detectHuman(cwd = process.cwd()) {
|
|
|
911
987
|
return null;
|
|
912
988
|
}
|
|
913
989
|
function detectBaseBranch(cwd = process.cwd()) {
|
|
914
|
-
const symref = runGit(cwd, [
|
|
990
|
+
const symref = runGit(cwd, [
|
|
991
|
+
"symbolic-ref",
|
|
992
|
+
"--quiet",
|
|
993
|
+
"refs/remotes/origin/HEAD"
|
|
994
|
+
]);
|
|
915
995
|
if (symref) {
|
|
916
996
|
const stripped = symref.replace(/^refs\/remotes\//, "");
|
|
917
997
|
if (stripped) return stripped;
|
|
918
998
|
}
|
|
919
999
|
for (const candidate of ["origin/main", "origin/master"]) {
|
|
920
|
-
if (runGitExitOk(cwd, [
|
|
1000
|
+
if (runGitExitOk(cwd, [
|
|
1001
|
+
"rev-parse",
|
|
1002
|
+
"--verify",
|
|
1003
|
+
"--quiet",
|
|
1004
|
+
`refs/remotes/${candidate}`
|
|
1005
|
+
])) {
|
|
921
1006
|
return candidate;
|
|
922
1007
|
}
|
|
923
1008
|
}
|
|
@@ -970,7 +1055,12 @@ function unlandedCommits(cwd = process.cwd(), baseBranch) {
|
|
|
970
1055
|
return { commits, truncated };
|
|
971
1056
|
}
|
|
972
1057
|
function dirtyPaths(cwd = process.cwd()) {
|
|
973
|
-
const out = runGit(cwd, [
|
|
1058
|
+
const out = runGit(cwd, [
|
|
1059
|
+
"status",
|
|
1060
|
+
"--porcelain",
|
|
1061
|
+
"-z",
|
|
1062
|
+
"--untracked-files=all"
|
|
1063
|
+
]);
|
|
974
1064
|
if (out === null) {
|
|
975
1065
|
return { paths: [], truncated: false };
|
|
976
1066
|
}
|
|
@@ -1082,17 +1172,19 @@ async function buildChangeReport(cwd, config) {
|
|
|
1082
1172
|
}
|
|
1083
1173
|
|
|
1084
1174
|
// src/inbox.ts
|
|
1085
|
-
import { createHash as createHash2 } from "crypto";
|
|
1086
1175
|
import {
|
|
1087
1176
|
appendFileSync,
|
|
1088
1177
|
mkdirSync as mkdirSync2,
|
|
1089
1178
|
readFileSync as readFileSync3,
|
|
1179
|
+
readdirSync,
|
|
1090
1180
|
renameSync,
|
|
1091
1181
|
rmSync as rmSync3,
|
|
1182
|
+
statSync,
|
|
1183
|
+
writeFileSync as writeFileSync3,
|
|
1092
1184
|
existsSync as existsSync3
|
|
1093
|
-
} from "fs";
|
|
1094
|
-
import { homedir as homedir2, tmpdir as tmpdir2 } from "os";
|
|
1095
|
-
import { dirname as dirname3, join as join3, resolve as resolve3 } from "path";
|
|
1185
|
+
} from "node:fs";
|
|
1186
|
+
import { homedir as homedir2, tmpdir as tmpdir2 } from "node:os";
|
|
1187
|
+
import { dirname as dirname3, join as join3, resolve as resolve3 } from "node:path";
|
|
1096
1188
|
function defaultInboxDir() {
|
|
1097
1189
|
let base = "";
|
|
1098
1190
|
try {
|
|
@@ -1103,11 +1195,37 @@ function defaultInboxDir() {
|
|
|
1103
1195
|
if (!base) base = tmpdir2();
|
|
1104
1196
|
return join3(base, ".shepherd", "inbox");
|
|
1105
1197
|
}
|
|
1106
|
-
|
|
1198
|
+
var MAILBOX_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
1199
|
+
var MAILBOX_FRESH_MS = 15 * 60 * 1e3;
|
|
1200
|
+
function sessionMailboxPath(dir, serverPid) {
|
|
1201
|
+
return join3(dir, `agent-${serverPid}.jsonl`);
|
|
1202
|
+
}
|
|
1203
|
+
function sessionMetaPath(dir, serverPid) {
|
|
1204
|
+
return join3(dir, `agent-${serverPid}.json`);
|
|
1205
|
+
}
|
|
1206
|
+
function normalizeCwd(cwd) {
|
|
1107
1207
|
let normalized = resolve3(cwd);
|
|
1108
1208
|
if (process.platform === "win32") normalized = normalized.toLowerCase();
|
|
1109
|
-
|
|
1110
|
-
|
|
1209
|
+
return normalized;
|
|
1210
|
+
}
|
|
1211
|
+
function writeMailboxMeta(dir, serverPid, meta) {
|
|
1212
|
+
try {
|
|
1213
|
+
mkdirSync2(dir, { recursive: true });
|
|
1214
|
+
const dest = sessionMetaPath(dir, serverPid);
|
|
1215
|
+
const tmp = `${dest}.tmp`;
|
|
1216
|
+
writeFileSync3(
|
|
1217
|
+
tmp,
|
|
1218
|
+
JSON.stringify({ v: 1, cwd: normalizeCwd(meta.cwd), chain: meta.chain })
|
|
1219
|
+
);
|
|
1220
|
+
renameSync(tmp, dest);
|
|
1221
|
+
} catch {
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
function removeMailboxMeta(dir, serverPid) {
|
|
1225
|
+
try {
|
|
1226
|
+
rmSync3(sessionMetaPath(dir, serverPid), { force: true });
|
|
1227
|
+
} catch {
|
|
1228
|
+
}
|
|
1111
1229
|
}
|
|
1112
1230
|
function appendAnnouncements(filePath, announcements) {
|
|
1113
1231
|
if (!announcements || announcements.length === 0) return;
|
|
@@ -1159,6 +1277,19 @@ function oneLine(text) {
|
|
|
1159
1277
|
function indentContinuation(text) {
|
|
1160
1278
|
return text.replace(/\r?\n/g, "\n ");
|
|
1161
1279
|
}
|
|
1280
|
+
function relativeAge(iso) {
|
|
1281
|
+
const then = Date.parse(iso);
|
|
1282
|
+
if (Number.isNaN(then)) return "recently";
|
|
1283
|
+
const ms = Date.now() - then;
|
|
1284
|
+
if (ms < 0) return "just now";
|
|
1285
|
+
const mins = Math.floor(ms / 6e4);
|
|
1286
|
+
if (mins < 1) return "just now";
|
|
1287
|
+
if (mins < 60) return `${mins}m ago`;
|
|
1288
|
+
const hours = Math.floor(mins / 60);
|
|
1289
|
+
if (hours < 24) return `${hours}h ago`;
|
|
1290
|
+
const days = Math.floor(hours / 24);
|
|
1291
|
+
return `${days}d ago`;
|
|
1292
|
+
}
|
|
1162
1293
|
function mergeAnnouncements(...lists) {
|
|
1163
1294
|
const byId = /* @__PURE__ */ new Map();
|
|
1164
1295
|
for (const list of lists) {
|
|
@@ -1171,7 +1302,7 @@ function mergeAnnouncements(...lists) {
|
|
|
1171
1302
|
}
|
|
1172
1303
|
|
|
1173
1304
|
// src/editTripwire.ts
|
|
1174
|
-
import { execFile } from "child_process";
|
|
1305
|
+
import { execFile } from "node:child_process";
|
|
1175
1306
|
function createEditTripwire({
|
|
1176
1307
|
cwd,
|
|
1177
1308
|
intervalMs = 3e4,
|
|
@@ -1325,7 +1456,7 @@ function formatLandscape(landscape) {
|
|
|
1325
1456
|
lines.push("CONFLICTS (files overlapping with your claim):");
|
|
1326
1457
|
for (const c of landscape.conflicts) {
|
|
1327
1458
|
lines.push(
|
|
1328
|
-
` [${c.agentName} / ${c.human}] "${oneLine(c.intent)}" \u2014 globs: ${oneLine(c.pathGlobs.join(", "))}`
|
|
1459
|
+
` [${oneLine(c.agentName)} / ${oneLine(c.human)}] "${oneLine(c.intent)}" \u2014 globs: ${oneLine(c.pathGlobs.join(", "))}`
|
|
1329
1460
|
);
|
|
1330
1461
|
}
|
|
1331
1462
|
} else {
|
|
@@ -1335,7 +1466,7 @@ function formatLandscape(landscape) {
|
|
|
1335
1466
|
lines.push("ACTIVE CLAIMS (other agents currently working):");
|
|
1336
1467
|
for (const c of landscape.activeClaims) {
|
|
1337
1468
|
lines.push(
|
|
1338
|
-
` [${c.agentName} / ${c.human}] "${oneLine(c.intent)}" \u2014 globs: ${oneLine(c.pathGlobs.join(", "))}`
|
|
1469
|
+
` [${oneLine(c.agentName)} / ${oneLine(c.human)}] "${oneLine(c.intent)}" \u2014 globs: ${oneLine(c.pathGlobs.join(", "))}`
|
|
1339
1470
|
);
|
|
1340
1471
|
}
|
|
1341
1472
|
} else {
|
|
@@ -1355,8 +1486,10 @@ function formatLandscape(landscape) {
|
|
|
1355
1486
|
if (landscape.announcements.length > 0) {
|
|
1356
1487
|
lines.push("ANNOUNCEMENTS:");
|
|
1357
1488
|
for (const a of landscape.announcements) {
|
|
1358
|
-
const target = a.targetAgentName ? ` \u2192 ${a.targetAgentName}` : " (broadcast)";
|
|
1359
|
-
lines.push(
|
|
1489
|
+
const target = a.targetAgentName ? ` \u2192 ${oneLine(a.targetAgentName)}` : " (broadcast)";
|
|
1490
|
+
lines.push(
|
|
1491
|
+
` [${oneLine(a.fromAgentName)}${target}, ${relativeAge(a.createdAt)}] ${indentContinuation(a.body)}`
|
|
1492
|
+
);
|
|
1360
1493
|
}
|
|
1361
1494
|
lines.push(REPLY_ROUTING_HINT);
|
|
1362
1495
|
} else {
|
|
@@ -1368,25 +1501,14 @@ function formatAnnouncements(announcements) {
|
|
|
1368
1501
|
if (!announcements || announcements.length === 0) return "";
|
|
1369
1502
|
const lines = ["Messages for you:"];
|
|
1370
1503
|
for (const a of announcements) {
|
|
1371
|
-
const target = a.targetAgentName ? ` \u2192 ${a.targetAgentName}` : " (broadcast)";
|
|
1372
|
-
lines.push(
|
|
1504
|
+
const target = a.targetAgentName ? ` \u2192 ${oneLine(a.targetAgentName)}` : " (broadcast)";
|
|
1505
|
+
lines.push(
|
|
1506
|
+
` [${oneLine(a.fromAgentName)}${target}, ${relativeAge(a.createdAt)}] ${indentContinuation(a.body)}`
|
|
1507
|
+
);
|
|
1373
1508
|
}
|
|
1374
1509
|
lines.push(REPLY_ROUTING_HINT);
|
|
1375
1510
|
return lines.join("\n");
|
|
1376
1511
|
}
|
|
1377
|
-
function relativeAge(iso) {
|
|
1378
|
-
const then = Date.parse(iso);
|
|
1379
|
-
if (Number.isNaN(then)) return "recently";
|
|
1380
|
-
const ms = Date.now() - then;
|
|
1381
|
-
if (ms < 0) return "just now";
|
|
1382
|
-
const mins = Math.floor(ms / 6e4);
|
|
1383
|
-
if (mins < 1) return "just now";
|
|
1384
|
-
if (mins < 60) return `${mins}m ago`;
|
|
1385
|
-
const hours = Math.floor(mins / 60);
|
|
1386
|
-
if (hours < 24) return `${hours}h ago`;
|
|
1387
|
-
const days = Math.floor(hours / 24);
|
|
1388
|
-
return `${days}d ago`;
|
|
1389
|
-
}
|
|
1390
1512
|
function presence(rec) {
|
|
1391
1513
|
return rec.authorIsLive ? "active now" : `offline, last seen ${relativeAge(rec.authorLastActiveAt)}`;
|
|
1392
1514
|
}
|
|
@@ -1402,7 +1524,7 @@ function formatChangeRecords(records, cwd = process.cwd()) {
|
|
|
1402
1524
|
const state = present ? "landed, not yet in your branch \u2014 pull/rebase" : "not yet on your base \u2014 unpushed, coordinate";
|
|
1403
1525
|
const intent = oneLine(rec.message ?? "(work in progress)");
|
|
1404
1526
|
lines.push(
|
|
1405
|
-
` ${rec.agentName} / ${rec.human} (${presence(rec)}) \u2014 committed (${state}): "${intent}"`
|
|
1527
|
+
` ${oneLine(rec.agentName)} / ${oneLine(rec.human)} (${presence(rec)}) \u2014 committed (${state}): "${intent}"`
|
|
1406
1528
|
);
|
|
1407
1529
|
lines.push(` files: ${oneLine(rec.paths.join(", "))}`);
|
|
1408
1530
|
if (sha && present && lineRangeBudget > 0) {
|
|
@@ -1410,7 +1532,9 @@ function formatChangeRecords(records, cwd = process.cwd()) {
|
|
|
1410
1532
|
lineRangeBudget -= budgetedPaths.length;
|
|
1411
1533
|
const ranges = changedLineRanges(cwd, sha, budgetedPaths);
|
|
1412
1534
|
for (const p of Object.keys(ranges)) {
|
|
1413
|
-
const spans = ranges[p].map(
|
|
1535
|
+
const spans = ranges[p].map(
|
|
1536
|
+
(r) => r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`
|
|
1537
|
+
);
|
|
1414
1538
|
if (spans.length > 0) {
|
|
1415
1539
|
lines.push(` ${p}: lines ${spans.join(", ")} (for context)`);
|
|
1416
1540
|
}
|
|
@@ -1419,7 +1543,7 @@ function formatChangeRecords(records, cwd = process.cwd()) {
|
|
|
1419
1543
|
} else {
|
|
1420
1544
|
const claim = oneLine(rec.message ?? "uncommitted edits in progress");
|
|
1421
1545
|
lines.push(
|
|
1422
|
-
` ${rec.agentName} / ${rec.human} (${presence(rec)}) \u2014 ${claim} (uncommitted, may change)`
|
|
1546
|
+
` ${oneLine(rec.agentName)} / ${oneLine(rec.human)} (${presence(rec)}) \u2014 ${claim} (uncommitted, may change)`
|
|
1423
1547
|
);
|
|
1424
1548
|
lines.push(` files: ${oneLine(rec.paths.join(", "))}`);
|
|
1425
1549
|
}
|
|
@@ -1441,6 +1565,19 @@ function degradedResult(err) {
|
|
|
1441
1565
|
]
|
|
1442
1566
|
};
|
|
1443
1567
|
}
|
|
1568
|
+
function malformedResponseResult(endpoint) {
|
|
1569
|
+
console.error(
|
|
1570
|
+
`[shepherd] ${endpoint} returned a response that failed contract validation \u2014 proceeding uncoordinated.`
|
|
1571
|
+
);
|
|
1572
|
+
return {
|
|
1573
|
+
content: [
|
|
1574
|
+
{
|
|
1575
|
+
type: "text",
|
|
1576
|
+
text: "Coordination hub returned an invalid response \u2014 proceeding uncoordinated."
|
|
1577
|
+
}
|
|
1578
|
+
]
|
|
1579
|
+
};
|
|
1580
|
+
}
|
|
1444
1581
|
function registerTools(server, deps) {
|
|
1445
1582
|
const { hubClient, config, context, heartbeat, inboxFile } = deps;
|
|
1446
1583
|
const markerCwd = deps.cwd ?? process.cwd();
|
|
@@ -1448,6 +1585,7 @@ function registerTools(server, deps) {
|
|
|
1448
1585
|
const repoRoot = findRepoRoot(markerCwd);
|
|
1449
1586
|
let sessionId = null;
|
|
1450
1587
|
let agentName = null;
|
|
1588
|
+
let activeWorkspaceSlug = null;
|
|
1451
1589
|
const isHosted = Boolean(config.SHEPHERD_TOKEN);
|
|
1452
1590
|
const selfHostMismatch = !isHosted && context.linked && config.WORKSPACE !== void 0 && config.WORKSPACE !== context.workspace;
|
|
1453
1591
|
let hostedWorkspaceRejected = false;
|
|
@@ -1480,6 +1618,9 @@ function registerTools(server, deps) {
|
|
|
1480
1618
|
let joinFailure = null;
|
|
1481
1619
|
let joinInFlight = Promise.resolve();
|
|
1482
1620
|
async function activate(workspaceSlug) {
|
|
1621
|
+
if (sessionId !== null && activeWorkspaceSlug === workspaceSlug) {
|
|
1622
|
+
return { ok: true };
|
|
1623
|
+
}
|
|
1483
1624
|
const joinBody = {
|
|
1484
1625
|
workspace: workspaceSlug,
|
|
1485
1626
|
repo: context.repo,
|
|
@@ -1505,6 +1646,7 @@ function registerTools(server, deps) {
|
|
|
1505
1646
|
heartbeat.start(newSessionId);
|
|
1506
1647
|
sessionId = newSessionId;
|
|
1507
1648
|
agentName = parsed.data.agentName;
|
|
1649
|
+
activeWorkspaceSlug = workspaceSlug;
|
|
1508
1650
|
linked = true;
|
|
1509
1651
|
hostedWorkspaceRejected = false;
|
|
1510
1652
|
joinFailure = null;
|
|
@@ -1598,7 +1740,10 @@ ${body}` : body;
|
|
|
1598
1740
|
function withChangeRecords(landscape, body) {
|
|
1599
1741
|
let section = "";
|
|
1600
1742
|
try {
|
|
1601
|
-
section = formatChangeRecords(
|
|
1743
|
+
section = formatChangeRecords(
|
|
1744
|
+
landscape.changeRecords ?? [],
|
|
1745
|
+
process.cwd()
|
|
1746
|
+
);
|
|
1602
1747
|
} catch {
|
|
1603
1748
|
section = "";
|
|
1604
1749
|
}
|
|
@@ -1618,8 +1763,16 @@ ${section}` : body;
|
|
|
1618
1763
|
if (gated) return gated;
|
|
1619
1764
|
try {
|
|
1620
1765
|
const changeReport = await changeReportForBody();
|
|
1621
|
-
const body = {
|
|
1622
|
-
|
|
1766
|
+
const body = {
|
|
1767
|
+
sessionId,
|
|
1768
|
+
...args,
|
|
1769
|
+
...changeReport ? { changeReport } : {}
|
|
1770
|
+
};
|
|
1771
|
+
const parsed = WorkResponse.safeParse(
|
|
1772
|
+
await hubClient.post("/work", body)
|
|
1773
|
+
);
|
|
1774
|
+
if (!parsed.success) return malformedResponseResult("/work");
|
|
1775
|
+
const result = parsed.data;
|
|
1623
1776
|
result.landscape.announcements = mergeAnnouncements(
|
|
1624
1777
|
result.landscape.announcements,
|
|
1625
1778
|
drainLocalInbox()
|
|
@@ -1655,17 +1808,19 @@ You hold this claim until you call done (workItemId: ${result.workItemId}) or it
|
|
|
1655
1808
|
if (gated) return gated;
|
|
1656
1809
|
try {
|
|
1657
1810
|
const body = { sessionId, ...args };
|
|
1658
|
-
const
|
|
1811
|
+
const parsed = DoneResponse.safeParse(
|
|
1812
|
+
await hubClient.post("/done", body)
|
|
1813
|
+
);
|
|
1814
|
+
if (!parsed.success) return malformedResponseResult("/done");
|
|
1815
|
+
const result = parsed.data;
|
|
1659
1816
|
const base = "Work item released. Call work again before your next edit in a new area.";
|
|
1660
1817
|
const msgs = formatAnnouncements(
|
|
1661
1818
|
mergeAnnouncements(result.announcements, drainLocalInbox())
|
|
1662
1819
|
);
|
|
1663
1820
|
return {
|
|
1664
|
-
content: [
|
|
1665
|
-
{ type: "text", text: msgs ? `${base}
|
|
1821
|
+
content: [{ type: "text", text: msgs ? `${base}
|
|
1666
1822
|
|
|
1667
|
-
${msgs}` : base }
|
|
1668
|
-
]
|
|
1823
|
+
${msgs}` : base }]
|
|
1669
1824
|
};
|
|
1670
1825
|
} catch (err) {
|
|
1671
1826
|
if (err instanceof HubUnreachable || err instanceof HubRequestError) {
|
|
@@ -1687,17 +1842,19 @@ ${msgs}` : base }
|
|
|
1687
1842
|
if (gated) return gated;
|
|
1688
1843
|
try {
|
|
1689
1844
|
const body = { sessionId, ...args };
|
|
1690
|
-
const
|
|
1845
|
+
const parsed = AnnounceResponse.safeParse(
|
|
1846
|
+
await hubClient.post("/announce", body)
|
|
1847
|
+
);
|
|
1848
|
+
if (!parsed.success) return malformedResponseResult("/announce");
|
|
1849
|
+
const result = parsed.data;
|
|
1691
1850
|
const base = `Announcement sent (id: ${result.announcementId}).`;
|
|
1692
1851
|
const msgs = formatAnnouncements(
|
|
1693
1852
|
mergeAnnouncements(result.announcements, drainLocalInbox())
|
|
1694
1853
|
);
|
|
1695
1854
|
return {
|
|
1696
|
-
content: [
|
|
1697
|
-
{ type: "text", text: msgs ? `${base}
|
|
1855
|
+
content: [{ type: "text", text: msgs ? `${base}
|
|
1698
1856
|
|
|
1699
|
-
${msgs}` : base }
|
|
1700
|
-
]
|
|
1857
|
+
${msgs}` : base }]
|
|
1701
1858
|
};
|
|
1702
1859
|
} catch (err) {
|
|
1703
1860
|
if (err instanceof HubUnreachable || err instanceof HubRequestError) {
|
|
@@ -1720,13 +1877,20 @@ ${msgs}` : base }
|
|
|
1720
1877
|
try {
|
|
1721
1878
|
const changeReport = await changeReportForBody();
|
|
1722
1879
|
const body = { sessionId, ...changeReport ? { changeReport } : {} };
|
|
1723
|
-
const
|
|
1880
|
+
const parsed = SyncResponse.safeParse(
|
|
1881
|
+
await hubClient.post("/sync", body)
|
|
1882
|
+
);
|
|
1883
|
+
if (!parsed.success) return malformedResponseResult("/sync");
|
|
1884
|
+
const result = parsed.data;
|
|
1724
1885
|
result.landscape.announcements = mergeAnnouncements(
|
|
1725
1886
|
result.landscape.announcements,
|
|
1726
1887
|
drainLocalInbox()
|
|
1727
1888
|
);
|
|
1728
1889
|
const text = withIdentity(
|
|
1729
|
-
withChangeRecords(
|
|
1890
|
+
withChangeRecords(
|
|
1891
|
+
result.landscape,
|
|
1892
|
+
formatLandscape(result.landscape)
|
|
1893
|
+
)
|
|
1730
1894
|
);
|
|
1731
1895
|
return { content: [{ type: "text", text }] };
|
|
1732
1896
|
} catch (err) {
|
|
@@ -1752,7 +1916,9 @@ ${msgs}` : base }
|
|
|
1752
1916
|
tripwire?.stop();
|
|
1753
1917
|
const result = await activate(slug);
|
|
1754
1918
|
if (result.ok) {
|
|
1755
|
-
return advisory(
|
|
1919
|
+
return advisory(
|
|
1920
|
+
`Linked this repo to \`${slug}\` \u2014 coordinating in \`${slug}\` now.`
|
|
1921
|
+
);
|
|
1756
1922
|
}
|
|
1757
1923
|
return advisory(
|
|
1758
1924
|
`Linked this repo to \`${slug}\`, but coordination couldn't start just now (${joinFailureCause(joinFailure)}). It'll connect on your next tool call or session.`
|
|
@@ -1764,7 +1930,9 @@ ${msgs}` : base }
|
|
|
1764
1930
|
title: "Link this repo to a Shepherd workspace",
|
|
1765
1931
|
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.",
|
|
1766
1932
|
inputSchema: z3.object({
|
|
1767
|
-
workspace: z3.string().min(1).optional().describe(
|
|
1933
|
+
workspace: z3.string().min(1).optional().describe(
|
|
1934
|
+
"The workspace slug to link this repo to. Omit to auto-pick or list choices."
|
|
1935
|
+
)
|
|
1768
1936
|
}).shape
|
|
1769
1937
|
},
|
|
1770
1938
|
async (args) => {
|
|
@@ -1825,6 +1993,7 @@ ${msgs}` : base }
|
|
|
1825
1993
|
await leave();
|
|
1826
1994
|
sessionId = null;
|
|
1827
1995
|
agentName = null;
|
|
1996
|
+
activeWorkspaceSlug = null;
|
|
1828
1997
|
}
|
|
1829
1998
|
return advisory(
|
|
1830
1999
|
"Unlinked \u2014 this repo will stay uncoordinated and won't ask again. Run `link` to re-enable."
|
|
@@ -1861,7 +2030,14 @@ ${msgs}` : base }
|
|
|
1861
2030
|
);
|
|
1862
2031
|
}
|
|
1863
2032
|
}
|
|
1864
|
-
gatedTools.push(
|
|
2033
|
+
gatedTools.push(
|
|
2034
|
+
workTool,
|
|
2035
|
+
doneTool,
|
|
2036
|
+
announceTool,
|
|
2037
|
+
syncTool,
|
|
2038
|
+
unlinkTool,
|
|
2039
|
+
declineTool
|
|
2040
|
+
);
|
|
1865
2041
|
surfaceVisible = true;
|
|
1866
2042
|
syncToolSurface();
|
|
1867
2043
|
async function runFirstRunAsk() {
|
|
@@ -1889,7 +2065,9 @@ ${msgs}` : base }
|
|
|
1889
2065
|
appendAnnouncements(inboxFile, [postLinkGuidance(workspace ?? "")]);
|
|
1890
2066
|
}
|
|
1891
2067
|
if (outcome !== "unanswered") {
|
|
1892
|
-
console.error(
|
|
2068
|
+
console.error(
|
|
2069
|
+
`[shepherd] first-run ask answered by the user: ${outcome}`
|
|
2070
|
+
);
|
|
1893
2071
|
}
|
|
1894
2072
|
} catch (err) {
|
|
1895
2073
|
console.error(
|
|
@@ -1910,20 +2088,21 @@ ${msgs}` : base }
|
|
|
1910
2088
|
return { ready: joinInFlight, leave };
|
|
1911
2089
|
}
|
|
1912
2090
|
function postLinkGuidance(workspace) {
|
|
2091
|
+
const safeWorkspace = workspace.replace(/\s+/g, " ").slice(0, 64);
|
|
1913
2092
|
return {
|
|
1914
2093
|
id: -Date.now(),
|
|
1915
2094
|
fromAgentName: "shepherd",
|
|
1916
2095
|
fromHuman: "shepherd",
|
|
1917
2096
|
targetAgentName: null,
|
|
1918
2097
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1919
|
-
body: `The user just linked this repository to the \`${
|
|
2098
|
+
body: `The user just linked this repository to the \`${safeWorkspace}\` workspace \u2014 coordination is NOW ACTIVE. Standing procedure from here on: call \`work\` (one-line intent + pathGlobs) BEFORE changing files in an area; \`done\` when that unit of work is complete; \`announce\` anything teammates need to know; \`sync\` when you resume or switch tasks. Start by calling \`work\` for the files you're editing right now.`
|
|
1920
2099
|
};
|
|
1921
2100
|
}
|
|
1922
2101
|
|
|
1923
2102
|
// src/identityCache.ts
|
|
1924
|
-
import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as
|
|
1925
|
-
import { homedir as homedir3, tmpdir as tmpdir3 } from "os";
|
|
1926
|
-
import { dirname as dirname4, join as join4 } from "path";
|
|
2103
|
+
import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "node:fs";
|
|
2104
|
+
import { homedir as homedir3, tmpdir as tmpdir3 } from "node:os";
|
|
2105
|
+
import { dirname as dirname4, join as join4 } from "node:path";
|
|
1927
2106
|
function defaultIdentityCachePath() {
|
|
1928
2107
|
let base = "";
|
|
1929
2108
|
try {
|
|
@@ -1954,7 +2133,7 @@ function writeCachedHuman(human, filePath = defaultIdentityCachePath()) {
|
|
|
1954
2133
|
try {
|
|
1955
2134
|
mkdirSync3(dirname4(filePath), { recursive: true });
|
|
1956
2135
|
const payload = JSON.stringify({ human });
|
|
1957
|
-
|
|
2136
|
+
writeFileSync4(filePath, payload + "\n", "utf8");
|
|
1958
2137
|
} catch {
|
|
1959
2138
|
}
|
|
1960
2139
|
}
|
|
@@ -1984,7 +2163,17 @@ async function resolveContext(config, cwd = process.cwd(), deps = defaultDeps) {
|
|
|
1984
2163
|
const repoRoot = deps.findRepoRoot(cwd);
|
|
1985
2164
|
const declined = repoRoot !== null ? deps.isDeclined(repoRoot) : false;
|
|
1986
2165
|
const linkState = linked ? "linked" : declined ? "declined" : "unanswered";
|
|
1987
|
-
return {
|
|
2166
|
+
return {
|
|
2167
|
+
workspace,
|
|
2168
|
+
repo,
|
|
2169
|
+
branch,
|
|
2170
|
+
human,
|
|
2171
|
+
program,
|
|
2172
|
+
model,
|
|
2173
|
+
linked,
|
|
2174
|
+
declined,
|
|
2175
|
+
linkState
|
|
2176
|
+
};
|
|
1988
2177
|
}
|
|
1989
2178
|
function resolveHuman(config, cwd, deps) {
|
|
1990
2179
|
if (config.HUMAN) return config.HUMAN;
|
|
@@ -2003,13 +2192,20 @@ function createHeartbeat({
|
|
|
2003
2192
|
hubClient,
|
|
2004
2193
|
intervalSeconds,
|
|
2005
2194
|
buildReport,
|
|
2006
|
-
announcementSink
|
|
2195
|
+
announcementSink,
|
|
2196
|
+
liveness
|
|
2007
2197
|
}) {
|
|
2008
2198
|
let timer = null;
|
|
2009
2199
|
function stop() {
|
|
2010
2200
|
if (timer !== null) {
|
|
2011
2201
|
clearInterval(timer);
|
|
2012
2202
|
timer = null;
|
|
2203
|
+
if (liveness) {
|
|
2204
|
+
try {
|
|
2205
|
+
liveness.remove();
|
|
2206
|
+
} catch {
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2013
2209
|
}
|
|
2014
2210
|
}
|
|
2015
2211
|
async function beat(sessionId) {
|
|
@@ -2023,9 +2219,22 @@ function createHeartbeat({
|
|
|
2023
2219
|
}
|
|
2024
2220
|
const body = { sessionId };
|
|
2025
2221
|
if (changeReport) body.changeReport = changeReport;
|
|
2222
|
+
if (liveness) {
|
|
2223
|
+
try {
|
|
2224
|
+
liveness.refresh();
|
|
2225
|
+
} catch {
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2026
2228
|
if (announcementSink) body.deliverAnnouncements = true;
|
|
2027
|
-
const
|
|
2028
|
-
|
|
2229
|
+
const parsed = HeartbeatResponse.safeParse(
|
|
2230
|
+
await hubClient.post("/heartbeat", body)
|
|
2231
|
+
);
|
|
2232
|
+
if (!parsed.success) {
|
|
2233
|
+
console.error(
|
|
2234
|
+
"[shepherd] heartbeat returned a response that failed contract validation \u2014 ignoring this beat's announcements."
|
|
2235
|
+
);
|
|
2236
|
+
}
|
|
2237
|
+
const delivered = parsed.success ? parsed.data.announcements : [];
|
|
2029
2238
|
if (announcementSink && Array.isArray(delivered) && delivered.length > 0) {
|
|
2030
2239
|
try {
|
|
2031
2240
|
announcementSink(delivered);
|
|
@@ -2043,6 +2252,12 @@ function createHeartbeat({
|
|
|
2043
2252
|
}
|
|
2044
2253
|
function start(sessionId) {
|
|
2045
2254
|
stop();
|
|
2255
|
+
if (liveness) {
|
|
2256
|
+
try {
|
|
2257
|
+
liveness.refresh();
|
|
2258
|
+
} catch {
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2046
2261
|
timer = setInterval(() => {
|
|
2047
2262
|
void beat(sessionId).catch((err) => {
|
|
2048
2263
|
console.error(
|
|
@@ -2056,10 +2271,13 @@ function createHeartbeat({
|
|
|
2056
2271
|
}
|
|
2057
2272
|
|
|
2058
2273
|
// src/instructions.ts
|
|
2274
|
+
function sanitizeWorkspace(workspace) {
|
|
2275
|
+
return workspace.replace(/\s+/g, " ").slice(0, 64);
|
|
2276
|
+
}
|
|
2059
2277
|
function buildInstructions(state, workspace) {
|
|
2060
2278
|
switch (state) {
|
|
2061
2279
|
case "linked":
|
|
2062
|
-
return `${INTRO} This repository is linked to the \`${workspace
|
|
2280
|
+
return `${INTRO} This repository is linked to the \`${workspace ? sanitizeWorkspace(workspace) : "team"}\` workspace, so coordination is active.
|
|
2063
2281
|
|
|
2064
2282
|
${PROCEDURE}`;
|
|
2065
2283
|
case "declined":
|
|
@@ -2090,14 +2308,108 @@ Skip \`work\` entirely for read-only exploration \u2014 reading, searching, or t
|
|
|
2090
2308
|
|
|
2091
2309
|
Commit work-in-progress as you go rather than sitting on a large dirty tree: committed work becomes a precise, presence-independent signal to teammates (with line-level detail and automatic resolution once it lands), whereas uncommitted edits are only a best-effort, decaying hint.`;
|
|
2092
2310
|
|
|
2311
|
+
// src/processTree.ts
|
|
2312
|
+
import { execFile as execFile2 } from "node:child_process";
|
|
2313
|
+
import { promisify } from "node:util";
|
|
2314
|
+
var execFileAsync = promisify(execFile2);
|
|
2315
|
+
function pidChainFromMap(startPid, parentOf, maxDepth = 32) {
|
|
2316
|
+
const chain = [];
|
|
2317
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2318
|
+
let pid = startPid;
|
|
2319
|
+
while (chain.length < maxDepth && pid > 0 && !seen.has(pid)) {
|
|
2320
|
+
chain.push(pid);
|
|
2321
|
+
seen.add(pid);
|
|
2322
|
+
const parent = parentOf.get(pid);
|
|
2323
|
+
if (parent === void 0) break;
|
|
2324
|
+
pid = parent;
|
|
2325
|
+
}
|
|
2326
|
+
return chain;
|
|
2327
|
+
}
|
|
2328
|
+
function quickChain() {
|
|
2329
|
+
return [process.pid, process.ppid];
|
|
2330
|
+
}
|
|
2331
|
+
function parseWmicProcessList(text) {
|
|
2332
|
+
const map = /* @__PURE__ */ new Map();
|
|
2333
|
+
const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
|
2334
|
+
if (lines.length === 0) return map;
|
|
2335
|
+
const header = lines[0].trimStart();
|
|
2336
|
+
let pidFirst;
|
|
2337
|
+
if (header.startsWith("ParentProcessId")) pidFirst = false;
|
|
2338
|
+
else if (header.startsWith("ProcessId")) pidFirst = true;
|
|
2339
|
+
else return map;
|
|
2340
|
+
for (const line of lines.slice(1)) {
|
|
2341
|
+
const nums = line.trim().split(/\s+/).map(Number);
|
|
2342
|
+
if (nums.length !== 2 || nums.some((n) => !Number.isInteger(n))) continue;
|
|
2343
|
+
const [a, b] = nums;
|
|
2344
|
+
const [pid, ppid] = pidFirst ? [a, b] : [b, a];
|
|
2345
|
+
map.set(pid, ppid);
|
|
2346
|
+
}
|
|
2347
|
+
return map;
|
|
2348
|
+
}
|
|
2349
|
+
function parsePidPpidLines(text) {
|
|
2350
|
+
const map = /* @__PURE__ */ new Map();
|
|
2351
|
+
for (const line of text.split(/\r?\n/)) {
|
|
2352
|
+
const m = /^\s*(\d+)\s+(\d+)\s*$/.exec(line);
|
|
2353
|
+
if (m) map.set(Number(m[1]), Number(m[2]));
|
|
2354
|
+
}
|
|
2355
|
+
return map;
|
|
2356
|
+
}
|
|
2357
|
+
async function snapshotParentMap() {
|
|
2358
|
+
if (process.platform === "win32") {
|
|
2359
|
+
try {
|
|
2360
|
+
const { stdout: stdout3 } = await execFileAsync(
|
|
2361
|
+
"wmic",
|
|
2362
|
+
["process", "get", "ProcessId,ParentProcessId"],
|
|
2363
|
+
{ windowsHide: true, timeout: 1e4, maxBuffer: 8 * 1024 * 1024 }
|
|
2364
|
+
);
|
|
2365
|
+
const map = parseWmicProcessList(stdout3);
|
|
2366
|
+
if (map.size > 0) return map;
|
|
2367
|
+
} catch {
|
|
2368
|
+
}
|
|
2369
|
+
const { stdout: stdout2 } = await execFileAsync(
|
|
2370
|
+
"powershell.exe",
|
|
2371
|
+
[
|
|
2372
|
+
"-NoProfile",
|
|
2373
|
+
"-NonInteractive",
|
|
2374
|
+
"-Command",
|
|
2375
|
+
'Get-CimInstance -Query "SELECT ProcessId,ParentProcessId FROM Win32_Process" | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId)" }'
|
|
2376
|
+
],
|
|
2377
|
+
{ windowsHide: true, timeout: 15e3, maxBuffer: 8 * 1024 * 1024 }
|
|
2378
|
+
);
|
|
2379
|
+
return parsePidPpidLines(stdout2);
|
|
2380
|
+
}
|
|
2381
|
+
const { stdout } = await execFileAsync(
|
|
2382
|
+
"ps",
|
|
2383
|
+
["-eo", "pid=,ppid="],
|
|
2384
|
+
{ timeout: 1e4, maxBuffer: 8 * 1024 * 1024 }
|
|
2385
|
+
);
|
|
2386
|
+
return parsePidPpidLines(stdout);
|
|
2387
|
+
}
|
|
2388
|
+
async function ancestorChain(maxDepth = 32, snapshot = snapshotParentMap) {
|
|
2389
|
+
try {
|
|
2390
|
+
const map = await snapshot();
|
|
2391
|
+
const chain = pidChainFromMap(process.pid, map, maxDepth);
|
|
2392
|
+
return chain.length >= 2 ? chain : quickChain();
|
|
2393
|
+
} catch {
|
|
2394
|
+
return quickChain();
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2093
2398
|
// src/hookInstall.ts
|
|
2094
|
-
import {
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2399
|
+
import {
|
|
2400
|
+
readFileSync as readFileSync5,
|
|
2401
|
+
writeFileSync as writeFileSync5,
|
|
2402
|
+
mkdirSync as mkdirSync4,
|
|
2403
|
+
copyFileSync,
|
|
2404
|
+
existsSync as existsSync4,
|
|
2405
|
+
renameSync as renameSync2
|
|
2406
|
+
} from "node:fs";
|
|
2407
|
+
import { homedir as homedir4 } from "node:os";
|
|
2408
|
+
import { dirname as dirname5, join as join5 } from "node:path";
|
|
2409
|
+
import { fileURLToPath } from "node:url";
|
|
2098
2410
|
|
|
2099
2411
|
// src/version.ts
|
|
2100
|
-
import { createRequire } from "module";
|
|
2412
|
+
import { createRequire } from "node:module";
|
|
2101
2413
|
var PACKAGE_VERSION = (() => {
|
|
2102
2414
|
try {
|
|
2103
2415
|
const req = createRequire(import.meta.url);
|
|
@@ -2130,7 +2442,7 @@ function ensureHookScript(homeDir, hookScriptSource) {
|
|
|
2130
2442
|
if (current === null || !current.equals(next)) {
|
|
2131
2443
|
mkdirSync4(dirname5(dest), { recursive: true });
|
|
2132
2444
|
const tmp = dest + ".tmp";
|
|
2133
|
-
|
|
2445
|
+
writeFileSync5(tmp, next);
|
|
2134
2446
|
renameSync2(tmp, dest);
|
|
2135
2447
|
}
|
|
2136
2448
|
return dest;
|
|
@@ -2139,7 +2451,7 @@ function ensureHookScript(homeDir, hookScriptSource) {
|
|
|
2139
2451
|
}
|
|
2140
2452
|
}
|
|
2141
2453
|
function hookCommandFor(scriptPath) {
|
|
2142
|
-
return scriptPath === null ? HOOK_COMMAND : `node "${scriptPath}"`;
|
|
2454
|
+
return scriptPath === null ? HOOK_COMMAND : `node "${scriptPath.replace(/\\/g, "/")}"`;
|
|
2143
2455
|
}
|
|
2144
2456
|
function codexHookBlock(scriptPath) {
|
|
2145
2457
|
const command = scriptPath === null ? `["npx", "-y", "--package=@korso/shepherd@${PACKAGE_VERSION}", "shepherd-inbox-hook"]` : `["node", ${JSON.stringify(scriptPath)}]`;
|
|
@@ -2179,7 +2491,7 @@ async function autoInstallHooks({
|
|
|
2179
2491
|
status = installPi(homeDir, extensionSource, log);
|
|
2180
2492
|
}
|
|
2181
2493
|
mkdirSync4(dirname5(recordFile), { recursive: true });
|
|
2182
|
-
|
|
2494
|
+
writeFileSync5(
|
|
2183
2495
|
recordFile,
|
|
2184
2496
|
JSON.stringify({ status, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2) + "\n",
|
|
2185
2497
|
"utf8"
|
|
@@ -2221,14 +2533,18 @@ function installClaude(homeDir, scriptPath, log) {
|
|
|
2221
2533
|
}
|
|
2222
2534
|
const hooks = settings["hooks"] ??= {};
|
|
2223
2535
|
if (typeof hooks !== "object" || hooks === null || Array.isArray(hooks)) {
|
|
2224
|
-
log(
|
|
2536
|
+
log(
|
|
2537
|
+
`[shepherd] ${settingsFile} has an unexpected "hooks" shape \u2014 not touching it.`
|
|
2538
|
+
);
|
|
2225
2539
|
return "skipped";
|
|
2226
2540
|
}
|
|
2227
2541
|
const hooksObj = hooks;
|
|
2228
2542
|
for (const event of ["SessionStart", "PreToolUse"]) {
|
|
2229
2543
|
const existing = hooksObj[event] ??= [];
|
|
2230
2544
|
if (!Array.isArray(existing)) {
|
|
2231
|
-
log(
|
|
2545
|
+
log(
|
|
2546
|
+
`[shepherd] ${settingsFile} has an unexpected hooks.${event} shape \u2014 not touching it.`
|
|
2547
|
+
);
|
|
2232
2548
|
return "skipped";
|
|
2233
2549
|
}
|
|
2234
2550
|
}
|
|
@@ -2241,7 +2557,7 @@ function installClaude(homeDir, scriptPath, log) {
|
|
|
2241
2557
|
hooks: [{ type: "command", command }]
|
|
2242
2558
|
});
|
|
2243
2559
|
mkdirSync4(dirname5(settingsFile), { recursive: true });
|
|
2244
|
-
|
|
2560
|
+
writeFileSync5(settingsFile, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
2245
2561
|
return "installed";
|
|
2246
2562
|
}
|
|
2247
2563
|
function installCodex(homeDir, scriptPath, log) {
|
|
@@ -2250,7 +2566,7 @@ function installCodex(homeDir, scriptPath, log) {
|
|
|
2250
2566
|
const hookBlock = codexHookBlock(scriptPath);
|
|
2251
2567
|
if (!existsSync4(configFile)) {
|
|
2252
2568
|
mkdirSync4(dirname5(configFile), { recursive: true });
|
|
2253
|
-
|
|
2569
|
+
writeFileSync5(configFile, `[features]
|
|
2254
2570
|
hooks = true
|
|
2255
2571
|
${hookBlock}`, "utf8");
|
|
2256
2572
|
return "installed";
|
|
@@ -2258,13 +2574,17 @@ ${hookBlock}`, "utf8");
|
|
|
2258
2574
|
const toml = readFileSync5(configFile, "utf8");
|
|
2259
2575
|
if (toml.includes(HOOK_MARKER)) return "already-present";
|
|
2260
2576
|
if (/^\s*\[hooks\.UserPromptSubmit\]\s*$/m.test(toml)) {
|
|
2261
|
-
log(
|
|
2577
|
+
log(
|
|
2578
|
+
`[shepherd] ${configFile} defines [hooks.UserPromptSubmit] \u2014 not touching it. ${manualHint}`
|
|
2579
|
+
);
|
|
2262
2580
|
return "skipped";
|
|
2263
2581
|
}
|
|
2264
2582
|
if (/^\s*\[features\]/m.test(toml)) {
|
|
2265
2583
|
const hooksKey = /^\s*hooks\s*=\s*(.+)$/m.exec(toml);
|
|
2266
2584
|
if (hooksKey && hooksKey[1].trim() !== "true") {
|
|
2267
|
-
log(
|
|
2585
|
+
log(
|
|
2586
|
+
`[shepherd] ${configFile} sets hooks = ${hooksKey[1].trim()} \u2014 respecting it. ${manualHint}`
|
|
2587
|
+
);
|
|
2268
2588
|
return "skipped";
|
|
2269
2589
|
}
|
|
2270
2590
|
let updated = toml;
|
|
@@ -2272,13 +2592,17 @@ ${hookBlock}`, "utf8");
|
|
|
2272
2592
|
updated = toml.replace(/^(\s*\[features\]\s*)$/m, `$1
|
|
2273
2593
|
hooks = true`);
|
|
2274
2594
|
}
|
|
2275
|
-
|
|
2595
|
+
writeFileSync5(configFile, updated + hookBlock, "utf8");
|
|
2276
2596
|
return "installed";
|
|
2277
2597
|
}
|
|
2278
|
-
|
|
2598
|
+
writeFileSync5(
|
|
2599
|
+
configFile,
|
|
2600
|
+
`${toml}
|
|
2279
2601
|
[features]
|
|
2280
2602
|
hooks = true
|
|
2281
|
-
${hookBlock}`,
|
|
2603
|
+
${hookBlock}`,
|
|
2604
|
+
"utf8"
|
|
2605
|
+
);
|
|
2282
2606
|
return "installed";
|
|
2283
2607
|
}
|
|
2284
2608
|
function installCursor(homeDir, scriptPath, log) {
|
|
@@ -2306,7 +2630,9 @@ function installCursor(homeDir, scriptPath, log) {
|
|
|
2306
2630
|
config["version"] ??= 1;
|
|
2307
2631
|
const hooks = config["hooks"] ??= {};
|
|
2308
2632
|
if (typeof hooks !== "object" || hooks === null || Array.isArray(hooks)) {
|
|
2309
|
-
log(
|
|
2633
|
+
log(
|
|
2634
|
+
`[shepherd] ${hooksFile} has an unexpected "hooks" shape \u2014 not touching it.`
|
|
2635
|
+
);
|
|
2310
2636
|
return "skipped";
|
|
2311
2637
|
}
|
|
2312
2638
|
const hooksObj = hooks;
|
|
@@ -2319,7 +2645,7 @@ function installCursor(homeDir, scriptPath, log) {
|
|
|
2319
2645
|
}
|
|
2320
2646
|
entries.push({ command: hookCommandFor(scriptPath) });
|
|
2321
2647
|
mkdirSync4(dirname5(hooksFile), { recursive: true });
|
|
2322
|
-
|
|
2648
|
+
writeFileSync5(hooksFile, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
2323
2649
|
return "installed";
|
|
2324
2650
|
}
|
|
2325
2651
|
function installPi(homeDir, extensionSource, log) {
|
|
@@ -2327,7 +2653,9 @@ function installPi(homeDir, extensionSource, log) {
|
|
|
2327
2653
|
const dest = join5(homeDir, ".pi", "agent", "extensions", "shepherd-inbox.js");
|
|
2328
2654
|
if (existsSync4(dest)) return "already-present";
|
|
2329
2655
|
if (!existsSync4(source)) {
|
|
2330
|
-
log(
|
|
2656
|
+
log(
|
|
2657
|
+
`[shepherd] bundled Pi extension not found at ${source} \u2014 skipping auto-install.`
|
|
2658
|
+
);
|
|
2331
2659
|
return "skipped";
|
|
2332
2660
|
}
|
|
2333
2661
|
mkdirSync4(dirname5(dest), { recursive: true });
|
|
@@ -2338,10 +2666,27 @@ function installPi(homeDir, extensionSource, log) {
|
|
|
2338
2666
|
// src/index.ts
|
|
2339
2667
|
async function main() {
|
|
2340
2668
|
const config = loadConfig();
|
|
2341
|
-
const hubClient = createHubClient({
|
|
2669
|
+
const hubClient = createHubClient({
|
|
2670
|
+
hubUrl: config.HUB_URL,
|
|
2671
|
+
token: config.authToken
|
|
2672
|
+
});
|
|
2342
2673
|
const context = await resolveContext(config);
|
|
2343
2674
|
const inboxDir = config.SHEPHERD_INBOX_DIR ?? defaultInboxDir();
|
|
2344
|
-
const inboxFile =
|
|
2675
|
+
const inboxFile = sessionMailboxPath(inboxDir, process.pid);
|
|
2676
|
+
const launchCwd = process.cwd();
|
|
2677
|
+
let serverChain = quickChain();
|
|
2678
|
+
const liveness = {
|
|
2679
|
+
refresh: () => writeMailboxMeta(inboxDir, process.pid, {
|
|
2680
|
+
cwd: launchCwd,
|
|
2681
|
+
chain: serverChain
|
|
2682
|
+
}),
|
|
2683
|
+
remove: () => removeMailboxMeta(inboxDir, process.pid)
|
|
2684
|
+
};
|
|
2685
|
+
void ancestorChain().then((chain) => {
|
|
2686
|
+
serverChain = chain;
|
|
2687
|
+
liveness.refresh();
|
|
2688
|
+
}).catch(() => {
|
|
2689
|
+
});
|
|
2345
2690
|
const heartbeat = createHeartbeat({
|
|
2346
2691
|
hubClient,
|
|
2347
2692
|
intervalSeconds: config.HEARTBEAT_INTERVAL_SECONDS,
|
|
@@ -2354,16 +2699,23 @@ async function main() {
|
|
|
2354
2699
|
return void 0;
|
|
2355
2700
|
}
|
|
2356
2701
|
},
|
|
2357
|
-
// A model-visible sink (this
|
|
2358
|
-
//
|
|
2702
|
+
// A model-visible sink (this session's mailbox). Its presence opts the
|
|
2703
|
+
// heartbeat into two-phase announcement delivery: append locally, then
|
|
2359
2704
|
// ack the hub. appendAnnouncements is itself fail-open.
|
|
2360
|
-
announcementSink: (announcements) => appendAnnouncements(inboxFile, announcements)
|
|
2705
|
+
announcementSink: (announcements) => appendAnnouncements(inboxFile, announcements),
|
|
2706
|
+
liveness
|
|
2361
2707
|
});
|
|
2362
2708
|
const server = new McpServer(
|
|
2363
2709
|
{ name: "shepherd", version: PACKAGE_VERSION },
|
|
2364
2710
|
{ instructions: buildInstructions(context.linkState, context.workspace) }
|
|
2365
2711
|
);
|
|
2366
|
-
const tools = registerTools(server, {
|
|
2712
|
+
const tools = registerTools(server, {
|
|
2713
|
+
hubClient,
|
|
2714
|
+
config,
|
|
2715
|
+
context,
|
|
2716
|
+
heartbeat,
|
|
2717
|
+
inboxFile
|
|
2718
|
+
});
|
|
2367
2719
|
const transport = new StdioServerTransport();
|
|
2368
2720
|
server.server.oninitialized = () => {
|
|
2369
2721
|
void autoInstallHooks({
|