@korso/shepherd 0.2.0 → 0.4.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/README.md +69 -50
- package/dist/index.js +720 -43
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,9 +8,9 @@ Shepherd's stdio MCP server. Gives any MCP-capable agent (Claude Code, Codex, et
|
|
|
8
8
|
|
|
9
9
|
## CRITICAL: WORKSPACE must match the hub exactly
|
|
10
10
|
|
|
11
|
-
>
|
|
11
|
+
> **`WORKSPACE` defaults to `default`. If you override it, the value must equal the hub's `ALLOWED_WORKSPACE` env var exactly.**
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
The server fires an automatic `join` call to the hub at startup. If the workspace it sends does not match the hub's `ALLOWED_WORKSPACE`, that call returns HTTP 400 and coordination degrades: every tool reports "session not ready … proceeding uncoordinated" instead of a landscape. The safe default is to **leave `WORKSPACE` unset** so it resolves to `default` — only set it when a maintainer points you at a different workspace. If your agent never sees teammates, check `WORKSPACE` (and `TEAM_TOKEN`) first.
|
|
14
14
|
|
|
15
15
|
---
|
|
16
16
|
|
|
@@ -32,22 +32,33 @@ first fetch, and `@korso/shepherd@latest` picks up updates automatically.
|
|
|
32
32
|
|
|
33
33
|
---
|
|
34
34
|
|
|
35
|
-
## 2.
|
|
35
|
+
## 2. Environment variables
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
**Only two are required:**
|
|
38
38
|
|
|
39
39
|
| Variable | Description | Example |
|
|
40
40
|
|---|---|---|
|
|
41
41
|
| `HUB_URL` | Base URL of the deployed hub | `https://shepherd.example.com` |
|
|
42
42
|
| `TEAM_TOKEN` | Shared bearer token accepted by the hub | `tok_abc123` |
|
|
43
|
-
| `WORKSPACE` | **Must match hub's `ALLOWED_WORKSPACE` exactly** | `shepherd` |
|
|
44
|
-
| `REPO` | Repository slug (used for scoping claims) | `shepherd` |
|
|
45
|
-
| `BRANCH` | Git branch name | `main` |
|
|
46
|
-
| `HUMAN` | Founder name — identifies you in the presence feed | `daichi` |
|
|
47
|
-
| `PROGRAM` | Agent program name | `claude-code` |
|
|
48
|
-
| `MODEL` | Model ID being used | `claude-sonnet-4-6` |
|
|
49
43
|
|
|
50
|
-
|
|
44
|
+
Missing either causes an immediate startup failure with a clear error on stderr
|
|
45
|
+
listing which vars are absent. (No other var triggers this.)
|
|
46
|
+
|
|
47
|
+
**Everything else is optional** — each identity field is resolved at startup as
|
|
48
|
+
**env var → git detection → fallback**, so a plain `npx -y @korso/shepherd` with
|
|
49
|
+
just the two required vars produces a valid, fully-identified session. Set an
|
|
50
|
+
override only to replace what's detected:
|
|
51
|
+
|
|
52
|
+
| Variable | If omitted | Example |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| `WORKSPACE` | defaults to `default` (**must match hub's `ALLOWED_WORKSPACE` if overridden**) | `shepherd` |
|
|
55
|
+
| `REPO` | `git remote origin` → `owner/repo`, else repo folder name, else `unknown-repo` | `Korsoai/shepherd` |
|
|
56
|
+
| `BRANCH` | `git rev-parse --abbrev-ref HEAD`, else `HEAD` | `main` |
|
|
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` |
|
|
59
|
+
| `PROGRAM` | defaults to `claude-code` | `codex` |
|
|
60
|
+
| `MODEL` | omitted — **never auto-detected**, so set it if you want it shown | `claude-sonnet-4-6` |
|
|
61
|
+
| `HEARTBEAT_INTERVAL_SECONDS` | defaults to `60` | `30` |
|
|
51
62
|
|
|
52
63
|
---
|
|
53
64
|
|
|
@@ -60,19 +71,19 @@ All eight are required. Missing any one causes an immediate startup failure with
|
|
|
60
71
|
> everywhere) or a project-root `.mcp.json`. Confirm with `claude mcp list`,
|
|
61
72
|
> which should show `shepherd … ✔ Connected`.
|
|
62
73
|
|
|
63
|
-
Recommended — register once at user scope
|
|
74
|
+
Recommended — register once at user scope. Written as a **single line** so it
|
|
75
|
+
pastes cleanly into PowerShell, cmd, bash, and zsh (on PowerShell the bash `\`
|
|
76
|
+
line-continuation does not work). Minimal: just the two required vars (identity
|
|
77
|
+
is auto-detected from git):
|
|
64
78
|
|
|
65
|
-
```
|
|
66
|
-
claude mcp add shepherd -s user
|
|
67
|
-
-e HUB_URL=https://shepherd.example.com \
|
|
68
|
-
-e TEAM_TOKEN=tok_abc123 \
|
|
69
|
-
-e WORKSPACE=shepherd \
|
|
70
|
-
-e REPO=shepherd -e BRANCH=main -e HUMAN=daichi \
|
|
71
|
-
-e PROGRAM=claude-code -e MODEL=claude-sonnet-4-6 \
|
|
72
|
-
-- npx -y @korso/shepherd
|
|
79
|
+
```powershell
|
|
80
|
+
claude mcp add shepherd -s user -e HUB_URL=https://shepherd.example.com -e TEAM_TOKEN=tok_abc123 -- npx -y @korso/shepherd
|
|
73
81
|
```
|
|
74
82
|
|
|
75
|
-
|
|
83
|
+
Add any optional overrides from §2 with extra `-e` flags (e.g. `-e MODEL=claude-sonnet-4-6 -e HUMAN=daichi`).
|
|
84
|
+
|
|
85
|
+
Alternative — a `.mcp.json` at the **root of the repo you're working in**
|
|
86
|
+
(optional overrides shown commented-style; drop the ones you don't need):
|
|
76
87
|
|
|
77
88
|
```json
|
|
78
89
|
{
|
|
@@ -83,11 +94,6 @@ Alternative — a `.mcp.json` at the **root of the repo you're working in**:
|
|
|
83
94
|
"env": {
|
|
84
95
|
"HUB_URL": "https://shepherd.example.com",
|
|
85
96
|
"TEAM_TOKEN": "tok_abc123",
|
|
86
|
-
"WORKSPACE": "shepherd",
|
|
87
|
-
"REPO": "shepherd",
|
|
88
|
-
"BRANCH": "main",
|
|
89
|
-
"HUMAN": "daichi",
|
|
90
|
-
"PROGRAM": "claude-code",
|
|
91
97
|
"MODEL": "claude-sonnet-4-6"
|
|
92
98
|
}
|
|
93
99
|
}
|
|
@@ -100,9 +106,29 @@ Alternative — a `.mcp.json` at the **root of the repo you're working in**:
|
|
|
100
106
|
> on Linux (Postgres), so the Windows-native durability concerns from the spike
|
|
101
107
|
> don't apply to clients.
|
|
102
108
|
|
|
103
|
-
### Codex (`~/.codex/config.
|
|
109
|
+
### Codex (`~/.codex/config.toml`)
|
|
110
|
+
|
|
111
|
+
Codex uses the same MCP stdio protocol but configures it in **TOML**, not JSON —
|
|
112
|
+
at `~/.codex/config.toml` (global) or `.codex/config.toml` in a trusted project.
|
|
113
|
+
The table is `mcp_servers` with an **underscore** (`mcp-servers`/`mcpServers` are
|
|
114
|
+
silently ignored). Either run `codex mcp add`:
|
|
104
115
|
|
|
105
|
-
|
|
116
|
+
```sh
|
|
117
|
+
codex mcp add shepherd --env HUB_URL=https://shepherd.example.com --env TEAM_TOKEN=tok_abc123 --env PROGRAM=codex -- npx -y @korso/shepherd
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
…or add the table directly:
|
|
121
|
+
|
|
122
|
+
```toml
|
|
123
|
+
[mcp_servers.shepherd]
|
|
124
|
+
command = "npx"
|
|
125
|
+
args = ["-y", "@korso/shepherd"]
|
|
126
|
+
env = { HUB_URL = "https://shepherd.example.com", TEAM_TOKEN = "tok_abc123", PROGRAM = "codex", MODEL = "o4-mini" }
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Pi (`~/.pi/agent/mcp.json` or `.pi/mcp.json`)
|
|
130
|
+
|
|
131
|
+
Pi uses a JSON `mcpServers` block (project config overrides global):
|
|
106
132
|
|
|
107
133
|
```json
|
|
108
134
|
{
|
|
@@ -113,12 +139,7 @@ Codex uses the same MCP stdio protocol. Add a server entry under `mcpServers`:
|
|
|
113
139
|
"env": {
|
|
114
140
|
"HUB_URL": "https://shepherd.example.com",
|
|
115
141
|
"TEAM_TOKEN": "tok_abc123",
|
|
116
|
-
"
|
|
117
|
-
"REPO": "shepherd",
|
|
118
|
-
"BRANCH": "main",
|
|
119
|
-
"HUMAN": "alex",
|
|
120
|
-
"PROGRAM": "codex",
|
|
121
|
-
"MODEL": "o4-mini"
|
|
142
|
+
"PROGRAM": "pi"
|
|
122
143
|
}
|
|
123
144
|
}
|
|
124
145
|
}
|
|
@@ -129,34 +150,31 @@ Codex uses the same MCP stdio protocol. Add a server entry under `mcpServers`:
|
|
|
129
150
|
|
|
130
151
|
## 4. Verify the server starts (quick smoke test)
|
|
131
152
|
|
|
132
|
-
Run with
|
|
153
|
+
Run with the two required vars set to confirm it connects and idles on stdin.
|
|
154
|
+
PowerShell (set env vars, then run):
|
|
133
155
|
|
|
134
|
-
```
|
|
135
|
-
HUB_URL=https://shepherd.example.com
|
|
136
|
-
TEAM_TOKEN=tok_abc123
|
|
137
|
-
WORKSPACE=shepherd \
|
|
138
|
-
REPO=shepherd \
|
|
139
|
-
BRANCH=main \
|
|
140
|
-
HUMAN=daichi \
|
|
141
|
-
PROGRAM=claude-code \
|
|
142
|
-
MODEL=claude-sonnet-4-6 \
|
|
156
|
+
```powershell
|
|
157
|
+
$env:HUB_URL = "https://shepherd.example.com"
|
|
158
|
+
$env:TEAM_TOKEN = "tok_abc123"
|
|
143
159
|
npx -y @korso/shepherd
|
|
144
160
|
```
|
|
145
161
|
|
|
162
|
+
bash/zsh: `HUB_URL=https://shepherd.example.com TEAM_TOKEN=tok_abc123 npx -y @korso/shepherd`
|
|
163
|
+
|
|
146
164
|
No stderr output and the process blocking on stdin = healthy. Press Ctrl+C to exit.
|
|
147
165
|
|
|
148
|
-
**Missing env vars:** if you
|
|
166
|
+
**Missing env vars:** if you omit `HUB_URL` or `TEAM_TOKEN`, you will see:
|
|
149
167
|
|
|
150
168
|
```
|
|
151
169
|
[shepherd] Configuration error — missing or invalid env vars:
|
|
152
170
|
HUB_URL: HUB_URL is required
|
|
153
171
|
TEAM_TOKEN: TEAM_TOKEN is required
|
|
154
|
-
...
|
|
155
172
|
```
|
|
156
173
|
|
|
157
|
-
and the process exits 1 immediately. This is by design.
|
|
174
|
+
and the process exits 1 immediately. This is by design. The optional identity
|
|
175
|
+
vars never cause this — they fall back to git detection / defaults.
|
|
158
176
|
|
|
159
|
-
**Wrong WORKSPACE:** the server starts and connects
|
|
177
|
+
**Wrong WORKSPACE:** if you override `WORKSPACE` to a value the hub doesn't allow, the server starts and connects but the startup auto-join is rejected (400), so every tool call (`work`, `sync`, etc.) reports "session not ready … proceeding uncoordinated". Either leave `WORKSPACE` unset (resolves to `default`) or set it to exactly match the hub's `ALLOWED_WORKSPACE`, then restart.
|
|
160
178
|
|
|
161
179
|
---
|
|
162
180
|
|
|
@@ -197,7 +215,8 @@ npm publish --workspace=@korso/shepherd # prepublishOnly runs tsup automatical
|
|
|
197
215
|
|
|
198
216
|
| Symptom | Likely cause | Fix |
|
|
199
217
|
|---|---|---|
|
|
200
|
-
| `Configuration error — missing or invalid env vars` |
|
|
201
|
-
| Tools report "session not ready … proceeding uncoordinated" | Startup auto-join rejected — usually `WORKSPACE`
|
|
218
|
+
| `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 |
|
|
219
|
+
| 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 |
|
|
220
|
+
| Agent shows up under a surprising name/repo/branch | Identity auto-detected from git | Override with `HUMAN`/`REPO`/`BRANCH`/`MODEL` env vars (§2) |
|
|
202
221
|
| `npm error 404 … @korso/shepherd` | Package not published yet, or name typo | `npm view @korso/shepherd version` to confirm it's live |
|
|
203
222
|
| Process exits immediately with no error | Rare; check for node version incompatibility | Requires Node 18+ (ESM support) |
|
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,50 @@ 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([])
|
|
283
|
+
});
|
|
284
|
+
var WorkspaceAgent = z2.object({
|
|
285
|
+
name: z2.string(),
|
|
286
|
+
human: z2.string(),
|
|
287
|
+
program: z2.string(),
|
|
288
|
+
// model is nullable in the DB (may be unknown when an agent first joins).
|
|
289
|
+
model: z2.string().nullable(),
|
|
290
|
+
// repo/branch/lastHeartbeatAt come from the agent's most-recent session and
|
|
291
|
+
// are null when the agent has no session yet.
|
|
292
|
+
repo: z2.string().nullable(),
|
|
293
|
+
branch: z2.string().nullable(),
|
|
294
|
+
lastHeartbeatAt: IsoTimestamp.nullable(),
|
|
295
|
+
presence: z2.enum(["live", "offline"])
|
|
296
|
+
});
|
|
297
|
+
var TaskStatus = z2.enum(["active", "done", "dropped"]);
|
|
298
|
+
var WorkspaceTask = z2.object({
|
|
299
|
+
agentName: z2.string(),
|
|
300
|
+
program: z2.string(),
|
|
301
|
+
model: z2.string().nullable(),
|
|
302
|
+
repo: z2.string(),
|
|
303
|
+
intent: z2.string(),
|
|
304
|
+
pathGlobs: z2.array(z2.string()),
|
|
305
|
+
status: TaskStatus,
|
|
306
|
+
createdAt: IsoTimestamp,
|
|
307
|
+
endedAt: IsoTimestamp.nullable()
|
|
308
|
+
});
|
|
309
|
+
var WorkspaceAnnouncement = z2.object({
|
|
310
|
+
fromAgentName: z2.string(),
|
|
311
|
+
fromHuman: z2.string(),
|
|
312
|
+
body: z2.string(),
|
|
313
|
+
targetAgentName: z2.string().nullable(),
|
|
314
|
+
repo: z2.string(),
|
|
315
|
+
createdAt: IsoTimestamp
|
|
316
|
+
});
|
|
317
|
+
var WorkspaceLandscapeResponse = z2.object({
|
|
318
|
+
agents: z2.array(WorkspaceAgent),
|
|
319
|
+
tasks: z2.array(WorkspaceTask),
|
|
320
|
+
announcements: z2.array(WorkspaceAnnouncement),
|
|
321
|
+
// The server's clock, so the client computes "expires in / last seen" against
|
|
322
|
+
// the hub rather than the (possibly skewed) browser clock.
|
|
323
|
+
serverTime: IsoTimestamp
|
|
138
324
|
});
|
|
139
325
|
var JoinRequest = z2.object({
|
|
140
326
|
workspace: z2.string().min(1),
|
|
@@ -142,7 +328,7 @@ var JoinRequest = z2.object({
|
|
|
142
328
|
branch: z2.string().min(1),
|
|
143
329
|
human: z2.string().min(1),
|
|
144
330
|
program: z2.string().min(1),
|
|
145
|
-
model: z2.string().min(1)
|
|
331
|
+
model: z2.string().min(1).optional()
|
|
146
332
|
});
|
|
147
333
|
var JoinResponse = z2.object({
|
|
148
334
|
agentName: z2.string(),
|
|
@@ -152,7 +338,8 @@ var WorkRequest = z2.object({
|
|
|
152
338
|
sessionId: z2.string().uuid(),
|
|
153
339
|
intent: z2.string().min(1).max(2048),
|
|
154
340
|
pathGlobs: z2.array(z2.string().min(1).max(512)).min(1).max(64),
|
|
155
|
-
ttlSeconds: z2.number().int().positive().optional()
|
|
341
|
+
ttlSeconds: z2.number().int().positive().optional(),
|
|
342
|
+
changeReport: ChangeReport.optional()
|
|
156
343
|
});
|
|
157
344
|
var WorkResponse = z2.object({
|
|
158
345
|
workItemId: z2.string().uuid(),
|
|
@@ -163,7 +350,11 @@ var DoneRequest = z2.object({
|
|
|
163
350
|
workItemId: z2.string().uuid()
|
|
164
351
|
});
|
|
165
352
|
var DoneResponse = z2.object({
|
|
166
|
-
ok: z2.literal(true)
|
|
353
|
+
ok: z2.literal(true),
|
|
354
|
+
// Pending announcements for the caller, delivered as a side effect of done so
|
|
355
|
+
// a message lands the moment a teammate finishes a unit of work (not only on
|
|
356
|
+
// their next work/sync). Defaulted for version-skew safety with older hubs.
|
|
357
|
+
announcements: z2.array(Announcement).default([])
|
|
167
358
|
});
|
|
168
359
|
var AnnounceRequest = z2.object({
|
|
169
360
|
sessionId: z2.string().uuid(),
|
|
@@ -174,19 +365,312 @@ var AnnounceRequest = z2.object({
|
|
|
174
365
|
var AnnounceResponse = z2.object({
|
|
175
366
|
ok: z2.literal(true),
|
|
176
367
|
// bigint PK serialised as number; see DbId note above
|
|
177
|
-
announcementId: DbId
|
|
368
|
+
announcementId: DbId,
|
|
369
|
+
// Pending announcements for the caller, delivered as a side effect of announce
|
|
370
|
+
// (a turn where the agent is already reading hub output) so inbound messages
|
|
371
|
+
// surface promptly. Excludes the just-sent one. Defaulted for version skew.
|
|
372
|
+
announcements: z2.array(Announcement).default([])
|
|
178
373
|
});
|
|
179
374
|
var SyncRequest = z2.object({
|
|
180
|
-
sessionId: z2.string().uuid()
|
|
375
|
+
sessionId: z2.string().uuid(),
|
|
376
|
+
changeReport: ChangeReport.optional()
|
|
181
377
|
});
|
|
182
378
|
var SyncResponse = z2.object({
|
|
183
379
|
landscape: Landscape
|
|
184
380
|
});
|
|
185
|
-
var WorkAgentInput = WorkRequest.omit({ sessionId: true });
|
|
381
|
+
var WorkAgentInput = WorkRequest.omit({ sessionId: true, changeReport: true });
|
|
186
382
|
var AnnounceAgentInput = AnnounceRequest.omit({ sessionId: true });
|
|
187
383
|
var DoneAgentInput = DoneRequest.omit({ sessionId: true });
|
|
188
384
|
var JoinAgentInput = z2.object({});
|
|
189
385
|
var SyncAgentInput = z2.object({});
|
|
386
|
+
var HeartbeatRequest = z2.object({
|
|
387
|
+
sessionId: z2.string().uuid(),
|
|
388
|
+
// Optional change report so the BACKGROUND heartbeat keeps an agent's durable
|
|
389
|
+
// change records fresh (commits surface within ~one heartbeat interval, not
|
|
390
|
+
// only when it next calls work/sync). Processed presence-style: it refreshes
|
|
391
|
+
// change records but, like the rest of heartbeat, does NOT renew claim TTLs.
|
|
392
|
+
changeReport: ChangeReport.optional()
|
|
393
|
+
});
|
|
394
|
+
var HeartbeatResponse = z2.object({
|
|
395
|
+
ok: z2.literal(true)
|
|
396
|
+
});
|
|
397
|
+
var LeaveRequest = z2.object({
|
|
398
|
+
sessionId: z2.string().uuid()
|
|
399
|
+
});
|
|
400
|
+
var LeaveResponse = z2.object({
|
|
401
|
+
ok: z2.literal(true)
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// src/gitContext.ts
|
|
405
|
+
import { execFileSync } from "child_process";
|
|
406
|
+
import * as path from "path";
|
|
407
|
+
var GIT_TIMEOUT_MS = 2e3;
|
|
408
|
+
var MAX_COMMITS = 100;
|
|
409
|
+
var MAX_PATHS_PER_COMMIT = 500;
|
|
410
|
+
var MAX_DIRTY_PATHS = 500;
|
|
411
|
+
var MAX_LINE_RANGE_PATHS = 50;
|
|
412
|
+
function runGit(cwd, args) {
|
|
413
|
+
try {
|
|
414
|
+
const out = execFileSync("git", args, {
|
|
415
|
+
cwd,
|
|
416
|
+
encoding: "utf8",
|
|
417
|
+
timeout: GIT_TIMEOUT_MS,
|
|
418
|
+
// Keep git from prompting for credentials/editors and hanging the timeout.
|
|
419
|
+
windowsHide: true,
|
|
420
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
421
|
+
// Capture stdout; silence stderr so failures stay quiet (we fail open).
|
|
422
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
423
|
+
});
|
|
424
|
+
return out.trim();
|
|
425
|
+
} catch {
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
function runGitExitOk(cwd, args) {
|
|
430
|
+
try {
|
|
431
|
+
execFileSync("git", args, {
|
|
432
|
+
cwd,
|
|
433
|
+
encoding: "utf8",
|
|
434
|
+
timeout: GIT_TIMEOUT_MS,
|
|
435
|
+
windowsHide: true,
|
|
436
|
+
stdio: "ignore"
|
|
437
|
+
});
|
|
438
|
+
return true;
|
|
439
|
+
} catch {
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
function isValidSha(sha) {
|
|
444
|
+
return /^[0-9a-f]{4,64}$/.test(sha);
|
|
445
|
+
}
|
|
446
|
+
function normalizeRemoteUrl(url) {
|
|
447
|
+
let s = url.trim();
|
|
448
|
+
if (!s) return null;
|
|
449
|
+
s = s.replace(/\.git$/, "");
|
|
450
|
+
const scp = s.match(/^[^/@]+@[^:]+:(.+)$/);
|
|
451
|
+
if (scp) {
|
|
452
|
+
s = scp[1];
|
|
453
|
+
} else {
|
|
454
|
+
s = s.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "");
|
|
455
|
+
const slash = s.indexOf("/");
|
|
456
|
+
if (slash !== -1) {
|
|
457
|
+
s = s.slice(slash + 1);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
s = s.replace(/^\/+|\/+$/g, "");
|
|
461
|
+
const segments = s.split("/").filter(Boolean);
|
|
462
|
+
if (segments.length < 2) return null;
|
|
463
|
+
const owner = segments[segments.length - 2];
|
|
464
|
+
const repo = segments[segments.length - 1];
|
|
465
|
+
return `${owner}/${repo}`;
|
|
466
|
+
}
|
|
467
|
+
function canonicalizeRepo(input) {
|
|
468
|
+
const s = input.trim();
|
|
469
|
+
const looksLikeUrl = /:\/\//.test(s) || /^[^/@]+@[^:]+:/.test(s);
|
|
470
|
+
const base = looksLikeUrl ? normalizeRemoteUrl(s) ?? s : s.replace(/\.git$/, "").replace(/^\/+|\/+$/g, "");
|
|
471
|
+
return base.toLowerCase();
|
|
472
|
+
}
|
|
473
|
+
function detectRepo(cwd = process.cwd()) {
|
|
474
|
+
const origin = runGit(cwd, ["config", "--get", "remote.origin.url"]);
|
|
475
|
+
if (origin) {
|
|
476
|
+
const normalized = normalizeRemoteUrl(origin);
|
|
477
|
+
if (normalized) return normalized;
|
|
478
|
+
}
|
|
479
|
+
const top = runGit(cwd, ["rev-parse", "--show-toplevel"]);
|
|
480
|
+
if (top) {
|
|
481
|
+
const base = path.basename(top);
|
|
482
|
+
if (base) return base;
|
|
483
|
+
}
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
function detectBranch(cwd = process.cwd()) {
|
|
487
|
+
const branch = runGit(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
488
|
+
if (branch === null || branch === "") return null;
|
|
489
|
+
return branch;
|
|
490
|
+
}
|
|
491
|
+
function detectHuman(cwd = process.cwd()) {
|
|
492
|
+
if (!runGitExitOk(cwd, ["rev-parse", "--is-inside-work-tree"])) {
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
const name = runGit(cwd, ["config", "user.name"]);
|
|
496
|
+
if (name) return name;
|
|
497
|
+
const email = runGit(cwd, ["config", "user.email"]);
|
|
498
|
+
if (email) {
|
|
499
|
+
const local = email.split("@")[0];
|
|
500
|
+
if (local) return local;
|
|
501
|
+
}
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
function detectBaseBranch(cwd = process.cwd()) {
|
|
505
|
+
const symref = runGit(cwd, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]);
|
|
506
|
+
if (symref) {
|
|
507
|
+
const stripped = symref.replace(/^refs\/remotes\//, "");
|
|
508
|
+
if (stripped) return stripped;
|
|
509
|
+
}
|
|
510
|
+
for (const candidate of ["origin/main", "origin/master"]) {
|
|
511
|
+
if (runGitExitOk(cwd, ["rev-parse", "--verify", "--quiet", `refs/remotes/${candidate}`])) {
|
|
512
|
+
return candidate;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
function headSha(cwd = process.cwd()) {
|
|
518
|
+
const sha = runGit(cwd, ["rev-parse", "HEAD"]);
|
|
519
|
+
if (sha === null || sha === "") return null;
|
|
520
|
+
return sha;
|
|
521
|
+
}
|
|
522
|
+
function unlandedCommits(cwd = process.cwd(), baseBranch) {
|
|
523
|
+
if (!baseBranch || baseBranch.startsWith("-")) {
|
|
524
|
+
return { commits: [], truncated: false };
|
|
525
|
+
}
|
|
526
|
+
const out = runGit(cwd, [
|
|
527
|
+
"log",
|
|
528
|
+
`${baseBranch}..HEAD`,
|
|
529
|
+
"--name-only",
|
|
530
|
+
`--max-count=${MAX_COMMITS}`,
|
|
531
|
+
"--format=%x01%H%x00%s"
|
|
532
|
+
]);
|
|
533
|
+
if (out === null) {
|
|
534
|
+
return { commits: [], truncated: false };
|
|
535
|
+
}
|
|
536
|
+
if (out === "") {
|
|
537
|
+
return { commits: [], truncated: false };
|
|
538
|
+
}
|
|
539
|
+
let truncated = false;
|
|
540
|
+
const commits = [];
|
|
541
|
+
const records = out.split("").filter((r) => r.length > 0);
|
|
542
|
+
for (const record of records) {
|
|
543
|
+
const newlineIdx = record.indexOf("\n");
|
|
544
|
+
const header = newlineIdx === -1 ? record : record.slice(0, newlineIdx);
|
|
545
|
+
const rest = newlineIdx === -1 ? "" : record.slice(newlineIdx + 1);
|
|
546
|
+
const nulIdx = header.indexOf("\0");
|
|
547
|
+
const sha = (nulIdx === -1 ? header : header.slice(0, nulIdx)).trim();
|
|
548
|
+
const message = nulIdx === -1 ? "" : header.slice(nulIdx + 1);
|
|
549
|
+
if (!sha) continue;
|
|
550
|
+
let paths = rest.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
|
|
551
|
+
if (paths.length > MAX_PATHS_PER_COMMIT) {
|
|
552
|
+
paths = paths.slice(0, MAX_PATHS_PER_COMMIT);
|
|
553
|
+
truncated = true;
|
|
554
|
+
}
|
|
555
|
+
if (paths.length === 0) continue;
|
|
556
|
+
commits.push({ sha, message, paths });
|
|
557
|
+
}
|
|
558
|
+
if (commits.length >= MAX_COMMITS) {
|
|
559
|
+
truncated = true;
|
|
560
|
+
}
|
|
561
|
+
return { commits, truncated };
|
|
562
|
+
}
|
|
563
|
+
function dirtyPaths(cwd = process.cwd()) {
|
|
564
|
+
const out = runGit(cwd, ["status", "--porcelain", "-z", "--untracked-files=all"]);
|
|
565
|
+
if (out === null) {
|
|
566
|
+
return { paths: [], truncated: false };
|
|
567
|
+
}
|
|
568
|
+
const seen = /* @__PURE__ */ new Set();
|
|
569
|
+
const fields = out.split("\0").filter((f) => f.length > 0);
|
|
570
|
+
for (let i = 0; i < fields.length; i++) {
|
|
571
|
+
const field = fields[i];
|
|
572
|
+
const status = field.slice(0, 2);
|
|
573
|
+
const rest = field.slice(2).replace(/^\s+/, "");
|
|
574
|
+
if (rest) seen.add(rest);
|
|
575
|
+
if (/[RC]/.test(status)) {
|
|
576
|
+
const src = fields[i + 1];
|
|
577
|
+
if (src) {
|
|
578
|
+
seen.add(src);
|
|
579
|
+
i++;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
let paths = Array.from(seen);
|
|
584
|
+
let truncated = false;
|
|
585
|
+
if (paths.length > MAX_DIRTY_PATHS) {
|
|
586
|
+
paths = paths.slice(0, MAX_DIRTY_PATHS);
|
|
587
|
+
truncated = true;
|
|
588
|
+
}
|
|
589
|
+
return { paths, truncated };
|
|
590
|
+
}
|
|
591
|
+
function isAncestor(cwd = process.cwd(), sha) {
|
|
592
|
+
if (!isValidSha(sha)) return false;
|
|
593
|
+
return runGitExitOk(cwd, ["merge-base", "--is-ancestor", sha, "HEAD"]);
|
|
594
|
+
}
|
|
595
|
+
function hasCommit(cwd = process.cwd(), sha) {
|
|
596
|
+
if (!isValidSha(sha)) return false;
|
|
597
|
+
return runGitExitOk(cwd, ["cat-file", "-e", `${sha}^{commit}`]);
|
|
598
|
+
}
|
|
599
|
+
function parseHunkRanges(diff) {
|
|
600
|
+
const ranges = [];
|
|
601
|
+
const re = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm;
|
|
602
|
+
let m;
|
|
603
|
+
while ((m = re.exec(diff)) !== null) {
|
|
604
|
+
const start = parseInt(m[1], 10);
|
|
605
|
+
const count = m[2] === void 0 ? 1 : parseInt(m[2], 10);
|
|
606
|
+
if (count <= 0) {
|
|
607
|
+
ranges.push({ start, end: start });
|
|
608
|
+
} else {
|
|
609
|
+
ranges.push({ start, end: start + count - 1 });
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return ranges;
|
|
613
|
+
}
|
|
614
|
+
function changedLineRanges(cwd = process.cwd(), sha, paths) {
|
|
615
|
+
if (!isValidSha(sha) || !paths || paths.length === 0) return {};
|
|
616
|
+
const result = {};
|
|
617
|
+
const capped = paths.length > MAX_LINE_RANGE_PATHS ? paths.slice(0, MAX_LINE_RANGE_PATHS) : paths;
|
|
618
|
+
for (const p of capped) {
|
|
619
|
+
let diff = runGit(cwd, ["diff", "--unified=0", `${sha}~1`, sha, "--", p]);
|
|
620
|
+
if (diff === null) {
|
|
621
|
+
diff = runGit(cwd, ["show", "--unified=0", "--format=", sha, "--", p]);
|
|
622
|
+
}
|
|
623
|
+
if (diff === null || diff === "") continue;
|
|
624
|
+
const ranges = parseHunkRanges(diff);
|
|
625
|
+
if (ranges.length > 0) {
|
|
626
|
+
result[p] = ranges;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return result;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// src/changeReport.ts
|
|
633
|
+
var UNRESOLVED_BASE = "(unknown)";
|
|
634
|
+
async function buildChangeReport(cwd, config) {
|
|
635
|
+
const branch = detectBranch(cwd);
|
|
636
|
+
const head = headSha(cwd);
|
|
637
|
+
if (branch === null && head === null) {
|
|
638
|
+
return void 0;
|
|
639
|
+
}
|
|
640
|
+
const base = config.BASE_BRANCH ?? detectBaseBranch(cwd);
|
|
641
|
+
const entries = [];
|
|
642
|
+
let truncated = false;
|
|
643
|
+
const dirty = dirtyPaths(cwd);
|
|
644
|
+
if (dirty.truncated) truncated = true;
|
|
645
|
+
if (dirty.paths.length > 0) {
|
|
646
|
+
entries.push({
|
|
647
|
+
kind: "uncommitted",
|
|
648
|
+
sha: null,
|
|
649
|
+
message: null,
|
|
650
|
+
paths: dirty.paths
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
if (base) {
|
|
654
|
+
const unlanded = unlandedCommits(cwd, base);
|
|
655
|
+
if (unlanded.truncated) truncated = true;
|
|
656
|
+
for (const c of unlanded.commits) {
|
|
657
|
+
if (c.paths.length === 0) continue;
|
|
658
|
+
entries.push({
|
|
659
|
+
kind: "committed",
|
|
660
|
+
sha: c.sha,
|
|
661
|
+
message: c.message,
|
|
662
|
+
paths: c.paths
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return {
|
|
667
|
+
branch: branch ?? "HEAD",
|
|
668
|
+
baseBranch: base ?? UNRESOLVED_BASE,
|
|
669
|
+
head: head ?? "",
|
|
670
|
+
truncated,
|
|
671
|
+
entries
|
|
672
|
+
};
|
|
673
|
+
}
|
|
190
674
|
|
|
191
675
|
// src/tools.ts
|
|
192
676
|
function formatLandscape(landscape) {
|
|
@@ -233,6 +717,68 @@ function formatLandscape(landscape) {
|
|
|
233
717
|
}
|
|
234
718
|
return lines.join("\n");
|
|
235
719
|
}
|
|
720
|
+
function formatAnnouncements(announcements) {
|
|
721
|
+
if (!announcements || announcements.length === 0) return "";
|
|
722
|
+
const lines = ["Messages for you:"];
|
|
723
|
+
for (const a of announcements) {
|
|
724
|
+
const target = a.targetAgentName ? ` \u2192 ${a.targetAgentName}` : " (broadcast)";
|
|
725
|
+
lines.push(` [${a.fromAgentName}${target}] ${a.body}`);
|
|
726
|
+
}
|
|
727
|
+
return lines.join("\n");
|
|
728
|
+
}
|
|
729
|
+
function relativeAge(iso) {
|
|
730
|
+
const then = Date.parse(iso);
|
|
731
|
+
if (Number.isNaN(then)) return "recently";
|
|
732
|
+
const ms = Date.now() - then;
|
|
733
|
+
if (ms < 0) return "just now";
|
|
734
|
+
const mins = Math.floor(ms / 6e4);
|
|
735
|
+
if (mins < 1) return "just now";
|
|
736
|
+
if (mins < 60) return `${mins}m ago`;
|
|
737
|
+
const hours = Math.floor(mins / 60);
|
|
738
|
+
if (hours < 24) return `${hours}h ago`;
|
|
739
|
+
const days = Math.floor(hours / 24);
|
|
740
|
+
return `${days}d ago`;
|
|
741
|
+
}
|
|
742
|
+
function presence(rec) {
|
|
743
|
+
return rec.authorIsLive ? "active now" : `offline, last seen ${relativeAge(rec.authorLastActiveAt)}`;
|
|
744
|
+
}
|
|
745
|
+
function formatChangeRecords(records, cwd = process.cwd()) {
|
|
746
|
+
if (!records || records.length === 0) return "";
|
|
747
|
+
let lineRangeBudget = MAX_LINE_RANGE_PATHS;
|
|
748
|
+
const lines = [];
|
|
749
|
+
for (const rec of records) {
|
|
750
|
+
if (rec.kind === "committed") {
|
|
751
|
+
const sha = rec.commitSha;
|
|
752
|
+
if (sha && isAncestor(cwd, sha)) continue;
|
|
753
|
+
const present = sha ? hasCommit(cwd, sha) : false;
|
|
754
|
+
const state = present ? "landed, not yet in your branch \u2014 pull/rebase" : "not yet on your base \u2014 unpushed, coordinate";
|
|
755
|
+
const intent = rec.message ?? "(work in progress)";
|
|
756
|
+
lines.push(
|
|
757
|
+
` ${rec.agentName} / ${rec.human} (${presence(rec)}) \u2014 committed (${state}): "${intent}"`
|
|
758
|
+
);
|
|
759
|
+
lines.push(` files: ${rec.paths.join(", ")}`);
|
|
760
|
+
if (sha && present && lineRangeBudget > 0) {
|
|
761
|
+
const budgetedPaths = rec.paths.slice(0, lineRangeBudget);
|
|
762
|
+
lineRangeBudget -= budgetedPaths.length;
|
|
763
|
+
const ranges = changedLineRanges(cwd, sha, budgetedPaths);
|
|
764
|
+
for (const p of Object.keys(ranges)) {
|
|
765
|
+
const spans = ranges[p].map((r) => r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`);
|
|
766
|
+
if (spans.length > 0) {
|
|
767
|
+
lines.push(` ${p}: lines ${spans.join(", ")} (for context)`);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
} else {
|
|
772
|
+
const claim = rec.message ?? "uncommitted edits in progress";
|
|
773
|
+
lines.push(
|
|
774
|
+
` ${rec.agentName} / ${rec.human} (${presence(rec)}) \u2014 ${claim} (uncommitted, may change)`
|
|
775
|
+
);
|
|
776
|
+
lines.push(` files: ${rec.paths.join(", ")}`);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
if (lines.length === 0) return "";
|
|
780
|
+
return "Unlanded changes touching your area (awareness only \u2014 these are not blockers):\n" + lines.join("\n");
|
|
781
|
+
}
|
|
236
782
|
function degradedResult(err) {
|
|
237
783
|
const detail = err instanceof HubUnreachable || err instanceof HubRequestError ? err.message : String(err);
|
|
238
784
|
return {
|
|
@@ -245,20 +791,23 @@ function degradedResult(err) {
|
|
|
245
791
|
};
|
|
246
792
|
}
|
|
247
793
|
function registerTools(server, deps) {
|
|
248
|
-
const { hubClient, config } = deps;
|
|
794
|
+
const { hubClient, config, context, heartbeat } = deps;
|
|
249
795
|
let sessionId = null;
|
|
250
796
|
let agentName = null;
|
|
251
797
|
const joinBody = {
|
|
252
|
-
workspace:
|
|
253
|
-
repo:
|
|
254
|
-
branch:
|
|
255
|
-
human:
|
|
256
|
-
program:
|
|
257
|
-
model: config.MODEL
|
|
798
|
+
workspace: context.workspace,
|
|
799
|
+
repo: context.repo,
|
|
800
|
+
branch: context.branch,
|
|
801
|
+
human: context.human,
|
|
802
|
+
program: context.program
|
|
258
803
|
};
|
|
804
|
+
if (context.model !== void 0) {
|
|
805
|
+
joinBody.model = context.model;
|
|
806
|
+
}
|
|
259
807
|
const joinInFlight = hubClient.post("/join", joinBody).then((r) => {
|
|
260
808
|
sessionId = r.sessionId;
|
|
261
809
|
agentName = r.agentName;
|
|
810
|
+
heartbeat.start(r.sessionId);
|
|
262
811
|
}).catch(() => {
|
|
263
812
|
});
|
|
264
813
|
async function awaitJoin() {
|
|
@@ -278,12 +827,30 @@ function registerTools(server, deps) {
|
|
|
278
827
|
return agentName ? `You are ${agentName}.
|
|
279
828
|
|
|
280
829
|
${body}` : body;
|
|
830
|
+
}
|
|
831
|
+
async function changeReportForBody() {
|
|
832
|
+
try {
|
|
833
|
+
return await buildChangeReport(process.cwd(), config) ?? void 0;
|
|
834
|
+
} catch {
|
|
835
|
+
return void 0;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
function withChangeRecords(landscape, body) {
|
|
839
|
+
let section = "";
|
|
840
|
+
try {
|
|
841
|
+
section = formatChangeRecords(landscape.changeRecords ?? [], process.cwd());
|
|
842
|
+
} catch {
|
|
843
|
+
section = "";
|
|
844
|
+
}
|
|
845
|
+
return section ? `${body}
|
|
846
|
+
|
|
847
|
+
${section}` : body;
|
|
281
848
|
}
|
|
282
849
|
server.registerTool(
|
|
283
850
|
"work",
|
|
284
851
|
{
|
|
285
852
|
title: "Claim a unit of work",
|
|
286
|
-
description: 'Claim a unit of work BEFORE you start
|
|
853
|
+
description: 'Claim a unit of work BEFORE you start producing or changing files in an area of the codebase \u2014 source OR a plan/design doc (per unit of work, NOT per edit). Authoring a plan counts: claim the doc\'s path before you write it. Pass a one-line `intent` and the `pathGlobs` covering the files you expect to touch \u2014 scope them as specifically as you reasonably can (e.g. ["src/auth/**"], not ["src/**"] and not a single file). It atomically checks whether a teammate is already in those files and claims them for you, returning any conflicts and what others are working on. Hold one claim across all edits in that area; don\'t re-claim per file.',
|
|
287
854
|
inputSchema: WorkAgentInput.shape
|
|
288
855
|
},
|
|
289
856
|
async (args) => {
|
|
@@ -292,14 +859,18 @@ ${body}` : body;
|
|
|
292
859
|
return sessionNotReady();
|
|
293
860
|
}
|
|
294
861
|
try {
|
|
295
|
-
const
|
|
862
|
+
const changeReport = await changeReportForBody();
|
|
863
|
+
const body = { sessionId, ...args, ...changeReport ? { changeReport } : {} };
|
|
296
864
|
const result = await hubClient.post("/work", body);
|
|
297
865
|
const text = withIdentity(
|
|
298
|
-
|
|
866
|
+
withChangeRecords(
|
|
867
|
+
result.landscape,
|
|
868
|
+
`Work claimed (workItemId: ${result.workItemId})
|
|
299
869
|
|
|
300
870
|
` + formatLandscape(result.landscape) + `
|
|
301
871
|
|
|
302
872
|
You hold this claim until you call done (workItemId: ${result.workItemId}) or it expires (~30 min). Calling work or sync renews it.`
|
|
873
|
+
)
|
|
303
874
|
);
|
|
304
875
|
return { content: [{ type: "text", text }] };
|
|
305
876
|
} catch (err) {
|
|
@@ -324,13 +895,14 @@ You hold this claim until you call done (workItemId: ${result.workItemId}) or it
|
|
|
324
895
|
}
|
|
325
896
|
try {
|
|
326
897
|
const body = { sessionId, ...args };
|
|
327
|
-
await hubClient.post("/done", body);
|
|
898
|
+
const result = await hubClient.post("/done", body);
|
|
899
|
+
const base = "Work item released. Call work again before your next edit in a new area.";
|
|
900
|
+
const msgs = formatAnnouncements(result.announcements ?? []);
|
|
328
901
|
return {
|
|
329
902
|
content: [
|
|
330
|
-
{
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
}
|
|
903
|
+
{ type: "text", text: msgs ? `${base}
|
|
904
|
+
|
|
905
|
+
${msgs}` : base }
|
|
334
906
|
]
|
|
335
907
|
};
|
|
336
908
|
} catch (err) {
|
|
@@ -356,12 +928,13 @@ You hold this claim until you call done (workItemId: ${result.workItemId}) or it
|
|
|
356
928
|
try {
|
|
357
929
|
const body = { sessionId, ...args };
|
|
358
930
|
const result = await hubClient.post("/announce", body);
|
|
931
|
+
const base = `Announcement sent (id: ${result.announcementId}).`;
|
|
932
|
+
const msgs = formatAnnouncements(result.announcements ?? []);
|
|
359
933
|
return {
|
|
360
934
|
content: [
|
|
361
|
-
{
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
}
|
|
935
|
+
{ type: "text", text: msgs ? `${base}
|
|
936
|
+
|
|
937
|
+
${msgs}` : base }
|
|
365
938
|
]
|
|
366
939
|
};
|
|
367
940
|
} catch (err) {
|
|
@@ -385,9 +958,12 @@ You hold this claim until you call done (workItemId: ${result.workItemId}) or it
|
|
|
385
958
|
return sessionNotReady();
|
|
386
959
|
}
|
|
387
960
|
try {
|
|
388
|
-
const
|
|
961
|
+
const changeReport = await changeReportForBody();
|
|
962
|
+
const body = { sessionId, ...changeReport ? { changeReport } : {} };
|
|
389
963
|
const result = await hubClient.post("/sync", body);
|
|
390
|
-
const text = withIdentity(
|
|
964
|
+
const text = withIdentity(
|
|
965
|
+
withChangeRecords(result.landscape, formatLandscape(result.landscape))
|
|
966
|
+
);
|
|
391
967
|
return { content: [{ type: "text", text }] };
|
|
392
968
|
} catch (err) {
|
|
393
969
|
if (err instanceof HubUnreachable || err instanceof HubRequestError) {
|
|
@@ -397,7 +973,76 @@ You hold this claim until you call done (workItemId: ${result.workItemId}) or it
|
|
|
397
973
|
}
|
|
398
974
|
}
|
|
399
975
|
);
|
|
400
|
-
|
|
976
|
+
async function leave() {
|
|
977
|
+
try {
|
|
978
|
+
await joinInFlight;
|
|
979
|
+
if (!sessionId) return;
|
|
980
|
+
await hubClient.post("/leave", { sessionId });
|
|
981
|
+
} catch (err) {
|
|
982
|
+
console.error(
|
|
983
|
+
`[shepherd] leave failed: ${err instanceof Error ? err.message : String(err)}`
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
return { ready: joinInFlight, leave };
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// src/resolveContext.ts
|
|
991
|
+
var defaultDeps = {
|
|
992
|
+
detectRepo,
|
|
993
|
+
detectBranch,
|
|
994
|
+
detectHuman
|
|
995
|
+
};
|
|
996
|
+
var DEFAULT_WORKSPACE = "default";
|
|
997
|
+
async function resolveContext(config, cwd = process.cwd(), deps = defaultDeps) {
|
|
998
|
+
const repo = canonicalizeRepo(
|
|
999
|
+
config.REPO ?? deps.detectRepo(cwd) ?? "unknown-repo"
|
|
1000
|
+
);
|
|
1001
|
+
const branch = config.BRANCH ?? deps.detectBranch(cwd) ?? "HEAD";
|
|
1002
|
+
const human = config.HUMAN ?? deps.detectHuman(cwd) ?? generateName();
|
|
1003
|
+
const program = config.PROGRAM ?? "claude-code";
|
|
1004
|
+
const model = config.MODEL ?? void 0;
|
|
1005
|
+
const workspace = config.WORKSPACE ?? DEFAULT_WORKSPACE;
|
|
1006
|
+
return { workspace, repo, branch, human, program, model };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// src/heartbeat.ts
|
|
1010
|
+
function createHeartbeat({
|
|
1011
|
+
hubClient,
|
|
1012
|
+
intervalSeconds,
|
|
1013
|
+
buildReport
|
|
1014
|
+
}) {
|
|
1015
|
+
let timer = null;
|
|
1016
|
+
function stop() {
|
|
1017
|
+
if (timer !== null) {
|
|
1018
|
+
clearInterval(timer);
|
|
1019
|
+
timer = null;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
async function beat(sessionId) {
|
|
1023
|
+
let changeReport;
|
|
1024
|
+
if (buildReport) {
|
|
1025
|
+
try {
|
|
1026
|
+
changeReport = await buildReport();
|
|
1027
|
+
} catch {
|
|
1028
|
+
changeReport = void 0;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
const body = changeReport ? { sessionId, changeReport } : { sessionId };
|
|
1032
|
+
await hubClient.post("/heartbeat", body);
|
|
1033
|
+
}
|
|
1034
|
+
function start(sessionId) {
|
|
1035
|
+
stop();
|
|
1036
|
+
timer = setInterval(() => {
|
|
1037
|
+
void beat(sessionId).catch((err) => {
|
|
1038
|
+
console.error(
|
|
1039
|
+
`[shepherd] heartbeat failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1040
|
+
);
|
|
1041
|
+
});
|
|
1042
|
+
}, intervalSeconds * 1e3);
|
|
1043
|
+
timer.unref();
|
|
1044
|
+
}
|
|
1045
|
+
return { start, stop };
|
|
401
1046
|
}
|
|
402
1047
|
|
|
403
1048
|
// src/instructions.ts
|
|
@@ -405,7 +1050,7 @@ var SHEPHERD_INSTRUCTIONS = `You are connected to Shepherd, the shared coordinat
|
|
|
405
1050
|
|
|
406
1051
|
Follow this procedure on every session, proactively and without being asked:
|
|
407
1052
|
|
|
408
|
-
1. Before you start
|
|
1053
|
+
1. Before you start producing or changing files in an AREA of the codebase, call \`work\` ONCE. This includes authoring a plan or design doc: claim the doc's path (e.g. ["docs/plans/auth.md"], or the directory you'll write into) BEFORE you write it \u2014 a plan you're about to author counts as a unit of work, not exploration. Pass a one-line \`intent\` and the \`pathGlobs\` covering the files you expect to touch. Scope the globs as specifically as you reasonably can \u2014 tight enough to avoid colliding with unrelated work, broad enough to cover the task (e.g. ["src/auth/**"], not ["src/**"] and not a single file). Hold that one claim across all your edits in that area; do NOT re-claim per file. If it reports a conflict, coordinate or pick different work \u2014 never silently collide.
|
|
409
1054
|
|
|
410
1055
|
2. Call \`done\` when that unit of work is complete, using its \`workItemId\`, so teammates see the files freed.
|
|
411
1056
|
|
|
@@ -415,18 +1060,50 @@ Follow this procedure on every session, proactively and without being asked:
|
|
|
415
1060
|
|
|
416
1061
|
5. Call \`sync\` when you resume, start a new task, or before large changes, to refresh who is doing what.
|
|
417
1062
|
|
|
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
|
|
1063
|
+
Skip \`work\` entirely for read-only exploration \u2014 reading, searching, or thinking that produces no file. The moment you're going to WRITE something, source or doc, claim it first. These tools are advisory and degrade gracefully if the hub is unreachable \u2014 never block your real work on them.
|
|
1064
|
+
|
|
1065
|
+
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
1066
|
|
|
420
1067
|
// src/index.ts
|
|
421
1068
|
async function main() {
|
|
422
1069
|
const config = loadConfig();
|
|
423
1070
|
const hubClient = createHubClient({ hubUrl: config.HUB_URL, teamToken: config.TEAM_TOKEN });
|
|
1071
|
+
const context = await resolveContext(config);
|
|
1072
|
+
const heartbeat = createHeartbeat({
|
|
1073
|
+
hubClient,
|
|
1074
|
+
intervalSeconds: config.HEARTBEAT_INTERVAL_SECONDS,
|
|
1075
|
+
// Attach a best-effort change report to each beat so commits surface to
|
|
1076
|
+
// teammates within ~one interval. Fail-open: any git error → presence-only.
|
|
1077
|
+
buildReport: async () => {
|
|
1078
|
+
try {
|
|
1079
|
+
return await buildChangeReport(process.cwd(), config) ?? void 0;
|
|
1080
|
+
} catch {
|
|
1081
|
+
return void 0;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
});
|
|
424
1085
|
const server = new McpServer(
|
|
425
1086
|
{ name: "shepherd", version: "0.1.0" },
|
|
426
1087
|
{ instructions: SHEPHERD_INSTRUCTIONS }
|
|
427
1088
|
);
|
|
428
|
-
registerTools(server, { hubClient, config });
|
|
1089
|
+
const tools = registerTools(server, { hubClient, config, context, heartbeat });
|
|
429
1090
|
const transport = new StdioServerTransport();
|
|
1091
|
+
let shuttingDown = false;
|
|
1092
|
+
const shutdown = async () => {
|
|
1093
|
+
if (shuttingDown) return;
|
|
1094
|
+
shuttingDown = true;
|
|
1095
|
+
heartbeat.stop();
|
|
1096
|
+
await tools.leave();
|
|
1097
|
+
};
|
|
1098
|
+
process.once("SIGINT", () => {
|
|
1099
|
+
void shutdown().finally(() => process.exit(0));
|
|
1100
|
+
});
|
|
1101
|
+
process.once("SIGTERM", () => {
|
|
1102
|
+
void shutdown().finally(() => process.exit(0));
|
|
1103
|
+
});
|
|
1104
|
+
transport.onclose = () => {
|
|
1105
|
+
void shutdown();
|
|
1106
|
+
};
|
|
430
1107
|
await server.connect(transport);
|
|
431
1108
|
}
|
|
432
1109
|
main().catch((err) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@korso/shepherd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|