@korso/shepherd 0.2.0 → 0.3.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/dist/index.js +589 -29
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -7,14 +7,20 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
7
7
|
// src/config.ts
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
var ConfigSchema = z.object({
|
|
10
|
+
// Hard-required: connection credentials.
|
|
10
11
|
HUB_URL: z.string().min(1, "HUB_URL is required"),
|
|
11
12
|
TEAM_TOKEN: z.string().min(1, "TEAM_TOKEN is required"),
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
// Optional overrides — resolveContext will apply defaults for any that are absent.
|
|
14
|
+
// WORKSPACE default is applied in resolveContext (auto-detected from cwd basename).
|
|
15
|
+
WORKSPACE: z.string().min(1).optional(),
|
|
16
|
+
REPO: z.string().min(1).optional(),
|
|
17
|
+
BRANCH: z.string().min(1).optional(),
|
|
18
|
+
BASE_BRANCH: z.string().min(1).optional(),
|
|
19
|
+
HUMAN: z.string().min(1).optional(),
|
|
20
|
+
PROGRAM: z.string().min(1).optional(),
|
|
21
|
+
MODEL: z.string().min(1).optional(),
|
|
22
|
+
// Heartbeat cadence in seconds; coerced from string env var.
|
|
23
|
+
HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().int().positive().default(60)
|
|
18
24
|
});
|
|
19
25
|
function parseConfig(env) {
|
|
20
26
|
return ConfigSchema.parse({
|
|
@@ -23,9 +29,11 @@ function parseConfig(env) {
|
|
|
23
29
|
WORKSPACE: env["WORKSPACE"],
|
|
24
30
|
REPO: env["REPO"],
|
|
25
31
|
BRANCH: env["BRANCH"],
|
|
32
|
+
BASE_BRANCH: env["BASE_BRANCH"],
|
|
26
33
|
HUMAN: env["HUMAN"],
|
|
27
34
|
PROGRAM: env["PROGRAM"],
|
|
28
|
-
MODEL: env["MODEL"]
|
|
35
|
+
MODEL: env["MODEL"],
|
|
36
|
+
HEARTBEAT_INTERVAL_SECONDS: env["HEARTBEAT_INTERVAL_SECONDS"]
|
|
29
37
|
});
|
|
30
38
|
}
|
|
31
39
|
function loadConfig(env = process.env) {
|
|
@@ -71,12 +79,12 @@ function createHubClient({
|
|
|
71
79
|
}) {
|
|
72
80
|
const baseUrl = hubUrl.replace(/\/$/, "");
|
|
73
81
|
return {
|
|
74
|
-
async post(
|
|
82
|
+
async post(path2, body) {
|
|
75
83
|
const controller = new AbortController();
|
|
76
84
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
77
85
|
let response;
|
|
78
86
|
try {
|
|
79
|
-
response = await fetch(`${baseUrl}${
|
|
87
|
+
response = await fetch(`${baseUrl}${path2}`, {
|
|
80
88
|
method: "POST",
|
|
81
89
|
headers: {
|
|
82
90
|
"Authorization": `Bearer ${teamToken}`,
|
|
@@ -87,7 +95,7 @@ function createHubClient({
|
|
|
87
95
|
});
|
|
88
96
|
} catch (err) {
|
|
89
97
|
clearTimeout(timer);
|
|
90
|
-
const message = err instanceof DOMException && err.name === "AbortError" ? `Hub request timed out after ${timeoutMs}ms (${
|
|
98
|
+
const message = err instanceof DOMException && err.name === "AbortError" ? `Hub request timed out after ${timeoutMs}ms (${path2})` : `Hub unreachable at ${baseUrl}${path2}: ${String(err)}`;
|
|
91
99
|
throw new HubUnreachable(message, err);
|
|
92
100
|
} finally {
|
|
93
101
|
clearTimeout(timer);
|
|
@@ -95,7 +103,7 @@ function createHubClient({
|
|
|
95
103
|
if (!response.ok) {
|
|
96
104
|
throw new HubRequestError(
|
|
97
105
|
response.status,
|
|
98
|
-
`Hub returned HTTP ${response.status} for ${
|
|
106
|
+
`Hub returned HTTP ${response.status} for ${path2}`
|
|
99
107
|
);
|
|
100
108
|
}
|
|
101
109
|
return response.json();
|
|
@@ -103,10 +111,145 @@ function createHubClient({
|
|
|
103
111
|
};
|
|
104
112
|
}
|
|
105
113
|
|
|
114
|
+
// ../shared/dist/names.js
|
|
115
|
+
var adjectives = [
|
|
116
|
+
"Able",
|
|
117
|
+
"Agile",
|
|
118
|
+
"Artful",
|
|
119
|
+
"Avid",
|
|
120
|
+
"Balanced",
|
|
121
|
+
"Brave",
|
|
122
|
+
"Bright",
|
|
123
|
+
"Brisk",
|
|
124
|
+
"Calm",
|
|
125
|
+
"Clear",
|
|
126
|
+
"Clever",
|
|
127
|
+
"Crisp",
|
|
128
|
+
"Daring",
|
|
129
|
+
"Diligent",
|
|
130
|
+
"Deft",
|
|
131
|
+
"Deep",
|
|
132
|
+
"Dynamic",
|
|
133
|
+
"Eager",
|
|
134
|
+
"Earnest",
|
|
135
|
+
"Elegant",
|
|
136
|
+
"Energetic",
|
|
137
|
+
"Fair",
|
|
138
|
+
"Faithful",
|
|
139
|
+
"Fertile",
|
|
140
|
+
"Fierce",
|
|
141
|
+
"Firm",
|
|
142
|
+
"Fleet",
|
|
143
|
+
"Frank",
|
|
144
|
+
"Fresh",
|
|
145
|
+
"Friendly",
|
|
146
|
+
"Frisky",
|
|
147
|
+
"Gentle",
|
|
148
|
+
"Giant",
|
|
149
|
+
"Gifted",
|
|
150
|
+
"Global",
|
|
151
|
+
"Golden",
|
|
152
|
+
"Good",
|
|
153
|
+
"Grace",
|
|
154
|
+
"Grand",
|
|
155
|
+
"Green"
|
|
156
|
+
];
|
|
157
|
+
var nouns = [
|
|
158
|
+
"Anchor",
|
|
159
|
+
"Arrow",
|
|
160
|
+
"Beacon",
|
|
161
|
+
"Bear",
|
|
162
|
+
"Beast",
|
|
163
|
+
"Bell",
|
|
164
|
+
"Blade",
|
|
165
|
+
"Blaze",
|
|
166
|
+
"Bridge",
|
|
167
|
+
"Bronze",
|
|
168
|
+
"Brook",
|
|
169
|
+
"Builder",
|
|
170
|
+
"Buzz",
|
|
171
|
+
"Castle",
|
|
172
|
+
"Cedar",
|
|
173
|
+
"Chain",
|
|
174
|
+
"Charm",
|
|
175
|
+
"Chase",
|
|
176
|
+
"Cliff",
|
|
177
|
+
"Cloud",
|
|
178
|
+
"Coast",
|
|
179
|
+
"Compass",
|
|
180
|
+
"Crown",
|
|
181
|
+
"Crystal",
|
|
182
|
+
"Current",
|
|
183
|
+
"Eagle",
|
|
184
|
+
"Earth",
|
|
185
|
+
"Echo",
|
|
186
|
+
"Edge",
|
|
187
|
+
"Element",
|
|
188
|
+
"Ember",
|
|
189
|
+
"Engine",
|
|
190
|
+
"Fable",
|
|
191
|
+
"Falcon",
|
|
192
|
+
"Fate",
|
|
193
|
+
"Fawn",
|
|
194
|
+
"Feather",
|
|
195
|
+
"Fiber",
|
|
196
|
+
"Field",
|
|
197
|
+
"Fire",
|
|
198
|
+
"Fisher",
|
|
199
|
+
"Flame",
|
|
200
|
+
"Flash",
|
|
201
|
+
"Fleet",
|
|
202
|
+
"Flight",
|
|
203
|
+
"Flint",
|
|
204
|
+
"Flood",
|
|
205
|
+
"Flow"
|
|
206
|
+
];
|
|
207
|
+
function generateName() {
|
|
208
|
+
const randomAdj = adjectives[Math.floor(Math.random() * adjectives.length)];
|
|
209
|
+
const randomNoun = nouns[Math.floor(Math.random() * nouns.length)];
|
|
210
|
+
return randomAdj + randomNoun;
|
|
211
|
+
}
|
|
212
|
+
|
|
106
213
|
// ../shared/dist/contract.js
|
|
107
214
|
import { z as z2 } from "zod";
|
|
108
215
|
var IsoTimestamp = z2.string();
|
|
109
216
|
var DbId = z2.number();
|
|
217
|
+
var ChangeRecord = z2.object({
|
|
218
|
+
agentName: z2.string(),
|
|
219
|
+
human: z2.string(),
|
|
220
|
+
branch: z2.string(),
|
|
221
|
+
kind: z2.enum(["committed", "uncommitted"]),
|
|
222
|
+
commitSha: z2.string().nullable(),
|
|
223
|
+
message: z2.string().nullable(),
|
|
224
|
+
paths: z2.array(z2.string()).min(1),
|
|
225
|
+
authorIsLive: z2.boolean(),
|
|
226
|
+
authorLastActiveAt: IsoTimestamp,
|
|
227
|
+
updatedAt: IsoTimestamp
|
|
228
|
+
});
|
|
229
|
+
var ChangeReportEntry = z2.object({
|
|
230
|
+
kind: z2.enum(["committed", "uncommitted"]),
|
|
231
|
+
// A git object id (lowercase hex, 4–64 chars) for `committed` entries, or null
|
|
232
|
+
// for `uncommitted`. This value is forwarded by the hub to OTHER clients, which
|
|
233
|
+
// feed it straight into local `git` argument vectors (isAncestor/hasCommit/
|
|
234
|
+
// changedLineRanges). Validating the shape at the wire boundary stops an
|
|
235
|
+
// attacker-controlled, flag-like value (e.g. "--output=...") from being parsed
|
|
236
|
+
// by git as an option on a teammate's machine (argument injection). gitContext
|
|
237
|
+
// re-validates defensively as well.
|
|
238
|
+
sha: z2.string().regex(/^[0-9a-f]{4,64}$/).nullable(),
|
|
239
|
+
message: z2.string().nullable(),
|
|
240
|
+
paths: z2.array(z2.string()).min(1).max(500)
|
|
241
|
+
});
|
|
242
|
+
var ChangeReport = z2.object({
|
|
243
|
+
branch: z2.string(),
|
|
244
|
+
baseBranch: z2.string(),
|
|
245
|
+
head: z2.string(),
|
|
246
|
+
truncated: z2.boolean().default(false),
|
|
247
|
+
// The only producer (gitContext.unlandedCommits) emits at most MAX_COMMITS
|
|
248
|
+
// (100) committed entries + 1 uncommitted, so this ceiling is generous. If
|
|
249
|
+
// MAX_COMMITS is ever raised above ~599, raise this in lockstep or the hub
|
|
250
|
+
// will start 400-rejecting otherwise-valid reports.
|
|
251
|
+
entries: z2.array(ChangeReportEntry).max(600)
|
|
252
|
+
});
|
|
110
253
|
var Claim = z2.object({
|
|
111
254
|
workItemId: z2.string().uuid(),
|
|
112
255
|
agentName: z2.string(),
|
|
@@ -134,7 +277,9 @@ var Landscape = z2.object({
|
|
|
134
277
|
// claim is live. Optional with a default so an older client talking to a
|
|
135
278
|
// newer hub (or vice-versa) never fails validation on its absence.
|
|
136
279
|
yourClaims: z2.array(Claim).default([]),
|
|
137
|
-
announcements: z2.array(Announcement)
|
|
280
|
+
announcements: z2.array(Announcement),
|
|
281
|
+
// Per-agent change records for the workspace. Defaulted for version-skew safety.
|
|
282
|
+
changeRecords: z2.array(ChangeRecord).default([])
|
|
138
283
|
});
|
|
139
284
|
var JoinRequest = z2.object({
|
|
140
285
|
workspace: z2.string().min(1),
|
|
@@ -142,7 +287,7 @@ var JoinRequest = z2.object({
|
|
|
142
287
|
branch: z2.string().min(1),
|
|
143
288
|
human: z2.string().min(1),
|
|
144
289
|
program: z2.string().min(1),
|
|
145
|
-
model: z2.string().min(1)
|
|
290
|
+
model: z2.string().min(1).optional()
|
|
146
291
|
});
|
|
147
292
|
var JoinResponse = z2.object({
|
|
148
293
|
agentName: z2.string(),
|
|
@@ -152,7 +297,8 @@ var WorkRequest = z2.object({
|
|
|
152
297
|
sessionId: z2.string().uuid(),
|
|
153
298
|
intent: z2.string().min(1).max(2048),
|
|
154
299
|
pathGlobs: z2.array(z2.string().min(1).max(512)).min(1).max(64),
|
|
155
|
-
ttlSeconds: z2.number().int().positive().optional()
|
|
300
|
+
ttlSeconds: z2.number().int().positive().optional(),
|
|
301
|
+
changeReport: ChangeReport.optional()
|
|
156
302
|
});
|
|
157
303
|
var WorkResponse = z2.object({
|
|
158
304
|
workItemId: z2.string().uuid(),
|
|
@@ -177,16 +323,288 @@ var AnnounceResponse = z2.object({
|
|
|
177
323
|
announcementId: DbId
|
|
178
324
|
});
|
|
179
325
|
var SyncRequest = z2.object({
|
|
180
|
-
sessionId: z2.string().uuid()
|
|
326
|
+
sessionId: z2.string().uuid(),
|
|
327
|
+
changeReport: ChangeReport.optional()
|
|
181
328
|
});
|
|
182
329
|
var SyncResponse = z2.object({
|
|
183
330
|
landscape: Landscape
|
|
184
331
|
});
|
|
185
|
-
var WorkAgentInput = WorkRequest.omit({ sessionId: true });
|
|
332
|
+
var WorkAgentInput = WorkRequest.omit({ sessionId: true, changeReport: true });
|
|
186
333
|
var AnnounceAgentInput = AnnounceRequest.omit({ sessionId: true });
|
|
187
334
|
var DoneAgentInput = DoneRequest.omit({ sessionId: true });
|
|
188
335
|
var JoinAgentInput = z2.object({});
|
|
189
336
|
var SyncAgentInput = z2.object({});
|
|
337
|
+
var HeartbeatRequest = z2.object({
|
|
338
|
+
sessionId: z2.string().uuid()
|
|
339
|
+
});
|
|
340
|
+
var HeartbeatResponse = z2.object({
|
|
341
|
+
ok: z2.literal(true)
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// src/gitContext.ts
|
|
345
|
+
import { execFileSync } from "child_process";
|
|
346
|
+
import * as path from "path";
|
|
347
|
+
var GIT_TIMEOUT_MS = 2e3;
|
|
348
|
+
var MAX_COMMITS = 100;
|
|
349
|
+
var MAX_PATHS_PER_COMMIT = 500;
|
|
350
|
+
var MAX_DIRTY_PATHS = 500;
|
|
351
|
+
var MAX_LINE_RANGE_PATHS = 50;
|
|
352
|
+
function runGit(cwd, args) {
|
|
353
|
+
try {
|
|
354
|
+
const out = execFileSync("git", args, {
|
|
355
|
+
cwd,
|
|
356
|
+
encoding: "utf8",
|
|
357
|
+
timeout: GIT_TIMEOUT_MS,
|
|
358
|
+
// Keep git from prompting for credentials/editors and hanging the timeout.
|
|
359
|
+
windowsHide: true,
|
|
360
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
361
|
+
// Capture stdout; silence stderr so failures stay quiet (we fail open).
|
|
362
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
363
|
+
});
|
|
364
|
+
return out.trim();
|
|
365
|
+
} catch {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function runGitExitOk(cwd, args) {
|
|
370
|
+
try {
|
|
371
|
+
execFileSync("git", args, {
|
|
372
|
+
cwd,
|
|
373
|
+
encoding: "utf8",
|
|
374
|
+
timeout: GIT_TIMEOUT_MS,
|
|
375
|
+
windowsHide: true,
|
|
376
|
+
stdio: "ignore"
|
|
377
|
+
});
|
|
378
|
+
return true;
|
|
379
|
+
} catch {
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
function isValidSha(sha) {
|
|
384
|
+
return /^[0-9a-f]{4,64}$/.test(sha);
|
|
385
|
+
}
|
|
386
|
+
function normalizeRemoteUrl(url) {
|
|
387
|
+
let s = url.trim();
|
|
388
|
+
if (!s) return null;
|
|
389
|
+
s = s.replace(/\.git$/, "");
|
|
390
|
+
const scp = s.match(/^[^/@]+@[^:]+:(.+)$/);
|
|
391
|
+
if (scp) {
|
|
392
|
+
s = scp[1];
|
|
393
|
+
} else {
|
|
394
|
+
s = s.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "");
|
|
395
|
+
const slash = s.indexOf("/");
|
|
396
|
+
if (slash !== -1) {
|
|
397
|
+
s = s.slice(slash + 1);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
s = s.replace(/^\/+|\/+$/g, "");
|
|
401
|
+
const segments = s.split("/").filter(Boolean);
|
|
402
|
+
if (segments.length < 2) return null;
|
|
403
|
+
const owner = segments[segments.length - 2];
|
|
404
|
+
const repo = segments[segments.length - 1];
|
|
405
|
+
return `${owner}/${repo}`;
|
|
406
|
+
}
|
|
407
|
+
function detectRepo(cwd = process.cwd()) {
|
|
408
|
+
const origin = runGit(cwd, ["config", "--get", "remote.origin.url"]);
|
|
409
|
+
if (origin) {
|
|
410
|
+
const normalized = normalizeRemoteUrl(origin);
|
|
411
|
+
if (normalized) return normalized;
|
|
412
|
+
}
|
|
413
|
+
const top = runGit(cwd, ["rev-parse", "--show-toplevel"]);
|
|
414
|
+
if (top) {
|
|
415
|
+
const base = path.basename(top);
|
|
416
|
+
if (base) return base;
|
|
417
|
+
}
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
function detectBranch(cwd = process.cwd()) {
|
|
421
|
+
const branch = runGit(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
422
|
+
if (branch === null || branch === "") return null;
|
|
423
|
+
return branch;
|
|
424
|
+
}
|
|
425
|
+
function detectHuman(cwd = process.cwd()) {
|
|
426
|
+
if (!runGitExitOk(cwd, ["rev-parse", "--is-inside-work-tree"])) {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
const name = runGit(cwd, ["config", "user.name"]);
|
|
430
|
+
if (name) return name;
|
|
431
|
+
const email = runGit(cwd, ["config", "user.email"]);
|
|
432
|
+
if (email) {
|
|
433
|
+
const local = email.split("@")[0];
|
|
434
|
+
if (local) return local;
|
|
435
|
+
}
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
function detectBaseBranch(cwd = process.cwd()) {
|
|
439
|
+
const symref = runGit(cwd, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]);
|
|
440
|
+
if (symref) {
|
|
441
|
+
const stripped = symref.replace(/^refs\/remotes\//, "");
|
|
442
|
+
if (stripped) return stripped;
|
|
443
|
+
}
|
|
444
|
+
for (const candidate of ["origin/main", "origin/master"]) {
|
|
445
|
+
if (runGitExitOk(cwd, ["rev-parse", "--verify", "--quiet", `refs/remotes/${candidate}`])) {
|
|
446
|
+
return candidate;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
function headSha(cwd = process.cwd()) {
|
|
452
|
+
const sha = runGit(cwd, ["rev-parse", "HEAD"]);
|
|
453
|
+
if (sha === null || sha === "") return null;
|
|
454
|
+
return sha;
|
|
455
|
+
}
|
|
456
|
+
function unlandedCommits(cwd = process.cwd(), baseBranch) {
|
|
457
|
+
if (!baseBranch || baseBranch.startsWith("-")) {
|
|
458
|
+
return { commits: [], truncated: false };
|
|
459
|
+
}
|
|
460
|
+
const out = runGit(cwd, [
|
|
461
|
+
"log",
|
|
462
|
+
`${baseBranch}..HEAD`,
|
|
463
|
+
"--name-only",
|
|
464
|
+
`--max-count=${MAX_COMMITS}`,
|
|
465
|
+
"--format=%x01%H%x00%s"
|
|
466
|
+
]);
|
|
467
|
+
if (out === null) {
|
|
468
|
+
return { commits: [], truncated: false };
|
|
469
|
+
}
|
|
470
|
+
if (out === "") {
|
|
471
|
+
return { commits: [], truncated: false };
|
|
472
|
+
}
|
|
473
|
+
let truncated = false;
|
|
474
|
+
const commits = [];
|
|
475
|
+
const records = out.split("").filter((r) => r.length > 0);
|
|
476
|
+
for (const record of records) {
|
|
477
|
+
const newlineIdx = record.indexOf("\n");
|
|
478
|
+
const header = newlineIdx === -1 ? record : record.slice(0, newlineIdx);
|
|
479
|
+
const rest = newlineIdx === -1 ? "" : record.slice(newlineIdx + 1);
|
|
480
|
+
const nulIdx = header.indexOf("\0");
|
|
481
|
+
const sha = (nulIdx === -1 ? header : header.slice(0, nulIdx)).trim();
|
|
482
|
+
const message = nulIdx === -1 ? "" : header.slice(nulIdx + 1);
|
|
483
|
+
if (!sha) continue;
|
|
484
|
+
let paths = rest.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
|
|
485
|
+
if (paths.length > MAX_PATHS_PER_COMMIT) {
|
|
486
|
+
paths = paths.slice(0, MAX_PATHS_PER_COMMIT);
|
|
487
|
+
truncated = true;
|
|
488
|
+
}
|
|
489
|
+
if (paths.length === 0) continue;
|
|
490
|
+
commits.push({ sha, message, paths });
|
|
491
|
+
}
|
|
492
|
+
if (commits.length >= MAX_COMMITS) {
|
|
493
|
+
truncated = true;
|
|
494
|
+
}
|
|
495
|
+
return { commits, truncated };
|
|
496
|
+
}
|
|
497
|
+
function dirtyPaths(cwd = process.cwd()) {
|
|
498
|
+
const out = runGit(cwd, ["status", "--porcelain", "-z", "--untracked-files=all"]);
|
|
499
|
+
if (out === null) {
|
|
500
|
+
return { paths: [], truncated: false };
|
|
501
|
+
}
|
|
502
|
+
const seen = /* @__PURE__ */ new Set();
|
|
503
|
+
const fields = out.split("\0").filter((f) => f.length > 0);
|
|
504
|
+
for (let i = 0; i < fields.length; i++) {
|
|
505
|
+
const field = fields[i];
|
|
506
|
+
const status = field.slice(0, 2);
|
|
507
|
+
const rest = field.slice(2).replace(/^\s+/, "");
|
|
508
|
+
if (rest) seen.add(rest);
|
|
509
|
+
if (/[RC]/.test(status)) {
|
|
510
|
+
const src = fields[i + 1];
|
|
511
|
+
if (src) {
|
|
512
|
+
seen.add(src);
|
|
513
|
+
i++;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
let paths = Array.from(seen);
|
|
518
|
+
let truncated = false;
|
|
519
|
+
if (paths.length > MAX_DIRTY_PATHS) {
|
|
520
|
+
paths = paths.slice(0, MAX_DIRTY_PATHS);
|
|
521
|
+
truncated = true;
|
|
522
|
+
}
|
|
523
|
+
return { paths, truncated };
|
|
524
|
+
}
|
|
525
|
+
function isAncestor(cwd = process.cwd(), sha) {
|
|
526
|
+
if (!isValidSha(sha)) return false;
|
|
527
|
+
return runGitExitOk(cwd, ["merge-base", "--is-ancestor", sha, "HEAD"]);
|
|
528
|
+
}
|
|
529
|
+
function hasCommit(cwd = process.cwd(), sha) {
|
|
530
|
+
if (!isValidSha(sha)) return false;
|
|
531
|
+
return runGitExitOk(cwd, ["cat-file", "-e", `${sha}^{commit}`]);
|
|
532
|
+
}
|
|
533
|
+
function parseHunkRanges(diff) {
|
|
534
|
+
const ranges = [];
|
|
535
|
+
const re = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm;
|
|
536
|
+
let m;
|
|
537
|
+
while ((m = re.exec(diff)) !== null) {
|
|
538
|
+
const start = parseInt(m[1], 10);
|
|
539
|
+
const count = m[2] === void 0 ? 1 : parseInt(m[2], 10);
|
|
540
|
+
if (count <= 0) {
|
|
541
|
+
ranges.push({ start, end: start });
|
|
542
|
+
} else {
|
|
543
|
+
ranges.push({ start, end: start + count - 1 });
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return ranges;
|
|
547
|
+
}
|
|
548
|
+
function changedLineRanges(cwd = process.cwd(), sha, paths) {
|
|
549
|
+
if (!isValidSha(sha) || !paths || paths.length === 0) return {};
|
|
550
|
+
const result = {};
|
|
551
|
+
const capped = paths.length > MAX_LINE_RANGE_PATHS ? paths.slice(0, MAX_LINE_RANGE_PATHS) : paths;
|
|
552
|
+
for (const p of capped) {
|
|
553
|
+
let diff = runGit(cwd, ["diff", "--unified=0", `${sha}~1`, sha, "--", p]);
|
|
554
|
+
if (diff === null) {
|
|
555
|
+
diff = runGit(cwd, ["show", "--unified=0", "--format=", sha, "--", p]);
|
|
556
|
+
}
|
|
557
|
+
if (diff === null || diff === "") continue;
|
|
558
|
+
const ranges = parseHunkRanges(diff);
|
|
559
|
+
if (ranges.length > 0) {
|
|
560
|
+
result[p] = ranges;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return result;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// src/changeReport.ts
|
|
567
|
+
var UNRESOLVED_BASE = "(unknown)";
|
|
568
|
+
async function buildChangeReport(cwd, config) {
|
|
569
|
+
const branch = detectBranch(cwd);
|
|
570
|
+
const head = headSha(cwd);
|
|
571
|
+
if (branch === null && head === null) {
|
|
572
|
+
return void 0;
|
|
573
|
+
}
|
|
574
|
+
const base = config.BASE_BRANCH ?? detectBaseBranch(cwd);
|
|
575
|
+
const entries = [];
|
|
576
|
+
let truncated = false;
|
|
577
|
+
const dirty = dirtyPaths(cwd);
|
|
578
|
+
if (dirty.truncated) truncated = true;
|
|
579
|
+
if (dirty.paths.length > 0) {
|
|
580
|
+
entries.push({
|
|
581
|
+
kind: "uncommitted",
|
|
582
|
+
sha: null,
|
|
583
|
+
message: null,
|
|
584
|
+
paths: dirty.paths
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
if (base) {
|
|
588
|
+
const unlanded = unlandedCommits(cwd, base);
|
|
589
|
+
if (unlanded.truncated) truncated = true;
|
|
590
|
+
for (const c of unlanded.commits) {
|
|
591
|
+
if (c.paths.length === 0) continue;
|
|
592
|
+
entries.push({
|
|
593
|
+
kind: "committed",
|
|
594
|
+
sha: c.sha,
|
|
595
|
+
message: c.message,
|
|
596
|
+
paths: c.paths
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return {
|
|
601
|
+
branch: branch ?? "HEAD",
|
|
602
|
+
baseBranch: base ?? UNRESOLVED_BASE,
|
|
603
|
+
head: head ?? "",
|
|
604
|
+
truncated,
|
|
605
|
+
entries
|
|
606
|
+
};
|
|
607
|
+
}
|
|
190
608
|
|
|
191
609
|
// src/tools.ts
|
|
192
610
|
function formatLandscape(landscape) {
|
|
@@ -233,6 +651,56 @@ function formatLandscape(landscape) {
|
|
|
233
651
|
}
|
|
234
652
|
return lines.join("\n");
|
|
235
653
|
}
|
|
654
|
+
function relativeAge(iso) {
|
|
655
|
+
const then = Date.parse(iso);
|
|
656
|
+
if (Number.isNaN(then)) return "recently";
|
|
657
|
+
const ms = Date.now() - then;
|
|
658
|
+
if (ms < 0) return "just now";
|
|
659
|
+
const mins = Math.floor(ms / 6e4);
|
|
660
|
+
if (mins < 1) return "just now";
|
|
661
|
+
if (mins < 60) return `${mins}m ago`;
|
|
662
|
+
const hours = Math.floor(mins / 60);
|
|
663
|
+
if (hours < 24) return `${hours}h ago`;
|
|
664
|
+
const days = Math.floor(hours / 24);
|
|
665
|
+
return `${days}d ago`;
|
|
666
|
+
}
|
|
667
|
+
function presence(rec) {
|
|
668
|
+
return rec.authorIsLive ? "active now" : `offline, last seen ${relativeAge(rec.authorLastActiveAt)}`;
|
|
669
|
+
}
|
|
670
|
+
function formatChangeRecords(records, cwd = process.cwd()) {
|
|
671
|
+
if (!records || records.length === 0) return "";
|
|
672
|
+
let lineRangeBudget = MAX_LINE_RANGE_PATHS;
|
|
673
|
+
const lines = [];
|
|
674
|
+
for (const rec of records) {
|
|
675
|
+
if (rec.kind === "committed") {
|
|
676
|
+
if (rec.commitSha && isAncestor(cwd, rec.commitSha)) continue;
|
|
677
|
+
const intent = rec.message ?? "(work in progress)";
|
|
678
|
+
lines.push(
|
|
679
|
+
` ${rec.agentName} / ${rec.human} (${presence(rec)}) \u2014 committed (not yet on your base): "${intent}"`
|
|
680
|
+
);
|
|
681
|
+
lines.push(` files: ${rec.paths.join(", ")}`);
|
|
682
|
+
if (rec.commitSha && lineRangeBudget > 0 && hasCommit(cwd, rec.commitSha)) {
|
|
683
|
+
const budgetedPaths = rec.paths.slice(0, lineRangeBudget);
|
|
684
|
+
lineRangeBudget -= budgetedPaths.length;
|
|
685
|
+
const ranges = changedLineRanges(cwd, rec.commitSha, budgetedPaths);
|
|
686
|
+
for (const p of Object.keys(ranges)) {
|
|
687
|
+
const spans = ranges[p].map((r) => r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`);
|
|
688
|
+
if (spans.length > 0) {
|
|
689
|
+
lines.push(` ${p}: lines ${spans.join(", ")} (for context)`);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
} else {
|
|
694
|
+
const claim = rec.message ?? "uncommitted edits in progress";
|
|
695
|
+
lines.push(
|
|
696
|
+
` ${rec.agentName} / ${rec.human} (${presence(rec)}) \u2014 ${claim} (uncommitted, may change)`
|
|
697
|
+
);
|
|
698
|
+
lines.push(` files: ${rec.paths.join(", ")}`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
if (lines.length === 0) return "";
|
|
702
|
+
return "Unlanded changes touching your area (awareness only \u2014 these are not blockers):\n" + lines.join("\n");
|
|
703
|
+
}
|
|
236
704
|
function degradedResult(err) {
|
|
237
705
|
const detail = err instanceof HubUnreachable || err instanceof HubRequestError ? err.message : String(err);
|
|
238
706
|
return {
|
|
@@ -245,20 +713,23 @@ function degradedResult(err) {
|
|
|
245
713
|
};
|
|
246
714
|
}
|
|
247
715
|
function registerTools(server, deps) {
|
|
248
|
-
const { hubClient, config } = deps;
|
|
716
|
+
const { hubClient, config, context, heartbeat } = deps;
|
|
249
717
|
let sessionId = null;
|
|
250
718
|
let agentName = null;
|
|
251
719
|
const joinBody = {
|
|
252
|
-
workspace:
|
|
253
|
-
repo:
|
|
254
|
-
branch:
|
|
255
|
-
human:
|
|
256
|
-
program:
|
|
257
|
-
model: config.MODEL
|
|
720
|
+
workspace: context.workspace,
|
|
721
|
+
repo: context.repo,
|
|
722
|
+
branch: context.branch,
|
|
723
|
+
human: context.human,
|
|
724
|
+
program: context.program
|
|
258
725
|
};
|
|
726
|
+
if (context.model !== void 0) {
|
|
727
|
+
joinBody.model = context.model;
|
|
728
|
+
}
|
|
259
729
|
const joinInFlight = hubClient.post("/join", joinBody).then((r) => {
|
|
260
730
|
sessionId = r.sessionId;
|
|
261
731
|
agentName = r.agentName;
|
|
732
|
+
heartbeat.start(r.sessionId);
|
|
262
733
|
}).catch(() => {
|
|
263
734
|
});
|
|
264
735
|
async function awaitJoin() {
|
|
@@ -278,6 +749,24 @@ function registerTools(server, deps) {
|
|
|
278
749
|
return agentName ? `You are ${agentName}.
|
|
279
750
|
|
|
280
751
|
${body}` : body;
|
|
752
|
+
}
|
|
753
|
+
async function changeReportForBody() {
|
|
754
|
+
try {
|
|
755
|
+
return await buildChangeReport(process.cwd(), config) ?? void 0;
|
|
756
|
+
} catch {
|
|
757
|
+
return void 0;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
function withChangeRecords(landscape, body) {
|
|
761
|
+
let section = "";
|
|
762
|
+
try {
|
|
763
|
+
section = formatChangeRecords(landscape.changeRecords ?? [], process.cwd());
|
|
764
|
+
} catch {
|
|
765
|
+
section = "";
|
|
766
|
+
}
|
|
767
|
+
return section ? `${body}
|
|
768
|
+
|
|
769
|
+
${section}` : body;
|
|
281
770
|
}
|
|
282
771
|
server.registerTool(
|
|
283
772
|
"work",
|
|
@@ -292,14 +781,18 @@ ${body}` : body;
|
|
|
292
781
|
return sessionNotReady();
|
|
293
782
|
}
|
|
294
783
|
try {
|
|
295
|
-
const
|
|
784
|
+
const changeReport = await changeReportForBody();
|
|
785
|
+
const body = { sessionId, ...args, ...changeReport ? { changeReport } : {} };
|
|
296
786
|
const result = await hubClient.post("/work", body);
|
|
297
787
|
const text = withIdentity(
|
|
298
|
-
|
|
788
|
+
withChangeRecords(
|
|
789
|
+
result.landscape,
|
|
790
|
+
`Work claimed (workItemId: ${result.workItemId})
|
|
299
791
|
|
|
300
792
|
` + formatLandscape(result.landscape) + `
|
|
301
793
|
|
|
302
794
|
You hold this claim until you call done (workItemId: ${result.workItemId}) or it expires (~30 min). Calling work or sync renews it.`
|
|
795
|
+
)
|
|
303
796
|
);
|
|
304
797
|
return { content: [{ type: "text", text }] };
|
|
305
798
|
} catch (err) {
|
|
@@ -385,9 +878,12 @@ You hold this claim until you call done (workItemId: ${result.workItemId}) or it
|
|
|
385
878
|
return sessionNotReady();
|
|
386
879
|
}
|
|
387
880
|
try {
|
|
388
|
-
const
|
|
881
|
+
const changeReport = await changeReportForBody();
|
|
882
|
+
const body = { sessionId, ...changeReport ? { changeReport } : {} };
|
|
389
883
|
const result = await hubClient.post("/sync", body);
|
|
390
|
-
const text = withIdentity(
|
|
884
|
+
const text = withIdentity(
|
|
885
|
+
withChangeRecords(result.landscape, formatLandscape(result.landscape))
|
|
886
|
+
);
|
|
391
887
|
return { content: [{ type: "text", text }] };
|
|
392
888
|
} catch (err) {
|
|
393
889
|
if (err instanceof HubUnreachable || err instanceof HubRequestError) {
|
|
@@ -400,6 +896,51 @@ You hold this claim until you call done (workItemId: ${result.workItemId}) or it
|
|
|
400
896
|
return { ready: joinInFlight };
|
|
401
897
|
}
|
|
402
898
|
|
|
899
|
+
// src/resolveContext.ts
|
|
900
|
+
var defaultDeps = {
|
|
901
|
+
detectRepo,
|
|
902
|
+
detectBranch,
|
|
903
|
+
detectHuman
|
|
904
|
+
};
|
|
905
|
+
var DEFAULT_WORKSPACE = "default";
|
|
906
|
+
async function resolveContext(config, cwd = process.cwd(), deps = defaultDeps) {
|
|
907
|
+
const repo = config.REPO ?? deps.detectRepo(cwd) ?? "unknown-repo";
|
|
908
|
+
const branch = config.BRANCH ?? deps.detectBranch(cwd) ?? "HEAD";
|
|
909
|
+
const human = config.HUMAN ?? deps.detectHuman(cwd) ?? generateName();
|
|
910
|
+
const program = config.PROGRAM ?? "claude-code";
|
|
911
|
+
const model = config.MODEL ?? void 0;
|
|
912
|
+
const workspace = config.WORKSPACE ?? DEFAULT_WORKSPACE;
|
|
913
|
+
return { workspace, repo, branch, human, program, model };
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// src/heartbeat.ts
|
|
917
|
+
function createHeartbeat({
|
|
918
|
+
hubClient,
|
|
919
|
+
intervalSeconds
|
|
920
|
+
}) {
|
|
921
|
+
let timer = null;
|
|
922
|
+
function stop() {
|
|
923
|
+
if (timer !== null) {
|
|
924
|
+
clearInterval(timer);
|
|
925
|
+
timer = null;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
function start(sessionId) {
|
|
929
|
+
stop();
|
|
930
|
+
timer = setInterval(() => {
|
|
931
|
+
void Promise.resolve(hubClient.post("/heartbeat", { sessionId })).catch(
|
|
932
|
+
(err) => {
|
|
933
|
+
console.error(
|
|
934
|
+
`[shepherd] heartbeat failed: ${err instanceof Error ? err.message : String(err)}`
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
);
|
|
938
|
+
}, intervalSeconds * 1e3);
|
|
939
|
+
timer.unref();
|
|
940
|
+
}
|
|
941
|
+
return { start, stop };
|
|
942
|
+
}
|
|
943
|
+
|
|
403
944
|
// src/instructions.ts
|
|
404
945
|
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. You have ALREADY been joined to the workspace automatically \u2014 there is no setup step, and you must not ask the user how to connect.
|
|
405
946
|
|
|
@@ -415,18 +956,37 @@ Follow this procedure on every session, proactively and without being asked:
|
|
|
415
956
|
|
|
416
957
|
5. Call \`sync\` when you resume, start a new task, or before large changes, to refresh who is doing what.
|
|
417
958
|
|
|
418
|
-
Skip \`work\` entirely for read-only exploration. These tools are advisory and degrade gracefully if the hub is unreachable \u2014 never block your real work on them
|
|
959
|
+
Skip \`work\` entirely for read-only exploration. These tools are advisory and degrade gracefully if the hub is unreachable \u2014 never block your real work on them.
|
|
960
|
+
|
|
961
|
+
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.`;
|
|
419
962
|
|
|
420
963
|
// src/index.ts
|
|
421
964
|
async function main() {
|
|
422
965
|
const config = loadConfig();
|
|
423
966
|
const hubClient = createHubClient({ hubUrl: config.HUB_URL, teamToken: config.TEAM_TOKEN });
|
|
967
|
+
const context = await resolveContext(config);
|
|
968
|
+
const heartbeat = createHeartbeat({
|
|
969
|
+
hubClient,
|
|
970
|
+
intervalSeconds: config.HEARTBEAT_INTERVAL_SECONDS
|
|
971
|
+
});
|
|
424
972
|
const server = new McpServer(
|
|
425
973
|
{ name: "shepherd", version: "0.1.0" },
|
|
426
974
|
{ instructions: SHEPHERD_INSTRUCTIONS }
|
|
427
975
|
);
|
|
428
|
-
registerTools(server, { hubClient, config });
|
|
976
|
+
registerTools(server, { hubClient, config, context, heartbeat });
|
|
429
977
|
const transport = new StdioServerTransport();
|
|
978
|
+
const shutdown = () => {
|
|
979
|
+
heartbeat.stop();
|
|
980
|
+
};
|
|
981
|
+
process.once("SIGINT", () => {
|
|
982
|
+
shutdown();
|
|
983
|
+
process.exit(0);
|
|
984
|
+
});
|
|
985
|
+
process.once("SIGTERM", () => {
|
|
986
|
+
shutdown();
|
|
987
|
+
process.exit(0);
|
|
988
|
+
});
|
|
989
|
+
transport.onclose = shutdown;
|
|
430
990
|
await server.connect(transport);
|
|
431
991
|
}
|
|
432
992
|
main().catch((err) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@korso/shepherd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|