syncstaff-mcp 0.2.3
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 +86 -0
- package/dist/lib/agent-state.js +119 -0
- package/dist/lib/blast.js +462 -0
- package/dist/lib/client-config.js +81 -0
- package/dist/lib/env-compat.js +66 -0
- package/dist/lib/globs.js +0 -0
- package/dist/lib/ids.js +24 -0
- package/dist/lib/index/aliases.js +244 -0
- package/dist/lib/index/call-sites.js +178 -0
- package/dist/lib/index/checker-resolver.js +257 -0
- package/dist/lib/index/context-card.js +140 -0
- package/dist/lib/index/coverage.js +218 -0
- package/dist/lib/index/delivery.js +66 -0
- package/dist/lib/index/discovery.js +90 -0
- package/dist/lib/index/embedding.js +110 -0
- package/dist/lib/index/file-index.js +222 -0
- package/dist/lib/index/fingerprint.js +0 -0
- package/dist/lib/index/git-history.js +136 -0
- package/dist/lib/index/graph.js +234 -0
- package/dist/lib/index/impact.js +174 -0
- package/dist/lib/index/incremental.js +332 -0
- package/dist/lib/index/lexical.js +462 -0
- package/dist/lib/index/order.js +43 -0
- package/dist/lib/index/pages.js +357 -0
- package/dist/lib/index/persistence.js +233 -0
- package/dist/lib/index/pipeline.js +527 -0
- package/dist/lib/index/registry.js +106 -0
- package/dist/lib/index/resolve.js +280 -0
- package/dist/lib/index/semantic.js +381 -0
- package/dist/lib/index/surfaces.js +27 -0
- package/dist/lib/index/symbols.js +426 -0
- package/dist/lib/index/transformers-embedder.js +73 -0
- package/dist/lib/index/typescript-parser.js +532 -0
- package/dist/lib/index/vector-cache.js +176 -0
- package/dist/lib/index/verification.js +58 -0
- package/dist/lib/mcp-compaction.js +241 -0
- package/dist/lib/model-roles.js +206 -0
- package/dist/lib/path-warnings.js +90 -0
- package/dist/lib/protocol.js +95 -0
- package/dist/lib/types.js +69 -0
- package/dist/lib/version.js +21 -0
- package/dist/lib/worktree.js +211 -0
- package/dist/mcp/approval.js +0 -0
- package/dist/mcp/cloud-connector.js +99 -0
- package/dist/mcp/daemon-client.js +156 -0
- package/dist/mcp/daemon-protocol.js +100 -0
- package/dist/mcp/escalation-waiter.js +183 -0
- package/dist/mcp/graph-ops.js +169 -0
- package/dist/mcp/index.js +1151 -0
- package/dist/mcp/login.js +169 -0
- package/dist/mcp/setup.js +90 -0
- package/package.json +42 -0
|
@@ -0,0 +1,1151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* keel-mcp — MCP adapter exposing the coordination tool surface (§6.4).
|
|
4
|
+
*
|
|
5
|
+
* Connection settings come from KEEL_URL / KEEL_PROJECT / KEEL_VENDOR /
|
|
6
|
+
* KEEL_TOKEN first, then the repo-local config written by `keel login`.
|
|
7
|
+
* KEEL_URL defaults to http://localhost:7377. Set
|
|
8
|
+
* KEEL_BLAST_INCLUDE_CONTEXT=0 to omit source snippets from client-side
|
|
9
|
+
* call-site reports. Retrieval rollout is local and fail-closed: use
|
|
10
|
+
* KEEL_RETRIEVAL_MODE=stable (default), candidate, or shadow. Set
|
|
11
|
+
* KEEL_RETRIEVAL_VOCABULARY=1 only for candidate/shadow testing of the
|
|
12
|
+
* bounded parser-derived lexical vocabulary; stable mode ignores it. Set
|
|
13
|
+
* KEEL_RETRIEVAL_ROUTING=1 to test candidate-only intent-specific query
|
|
14
|
+
* routing; stable mode ignores it.
|
|
15
|
+
* KEEL_RETRIEVAL_VERIFY=1 to run the local TypeScript checker against
|
|
16
|
+
* candidate-mode results and promote only candidates with call-site evidence;
|
|
17
|
+
* stable mode ignores it. Verification is budgeted and degrades to the
|
|
18
|
+
* unverified ranking when the checkout cannot be checked.
|
|
19
|
+
* KEEL_API_TIMEOUT_SECONDS (default 45, 0 disables) bounds every HTTP call
|
|
20
|
+
* this adapter makes to the Sync server, so a stalled request fails with a
|
|
21
|
+
* clear error instead of hanging for as long as the calling harness allows.
|
|
22
|
+
* KEEL_MCP_DAEMON=1 shares the local graph index (sync_impact /
|
|
23
|
+
* sync_context_card / sync_retrieve) across every agent CLI on this checkout
|
|
24
|
+
* through one background process instead of one per adapter process. Off by
|
|
25
|
+
* default — unset, this adapter's behavior is unchanged from before daemon
|
|
26
|
+
* mode existed. See daemon.ts for KEEL_MCP_DAEMON_IDLE_MINUTES (default 15)
|
|
27
|
+
* and KEEL_MCP_DAEMON_TIMEOUT_SECONDS (default 20).
|
|
28
|
+
*
|
|
29
|
+
* The agent is registered lazily on the first tool call. When a lifecycle
|
|
30
|
+
* hook registered the session first, the MCP process adopts that same id so
|
|
31
|
+
* lease checks and lease acquisition refer to one agent.
|
|
32
|
+
*/
|
|
33
|
+
// FIRST import, deliberately: it aliases KEEL_* ⇄ CHARTER_* on load, and the
|
|
34
|
+
// module-scope reads below (and in agent-state) would otherwise run first and
|
|
35
|
+
// see whichever prefix the caller happened not to set.
|
|
36
|
+
import "../lib/env-compat.js";
|
|
37
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
38
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
39
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
40
|
+
import { spawn } from "node:child_process";
|
|
41
|
+
import { hostname } from "node:os";
|
|
42
|
+
import { join } from "node:path";
|
|
43
|
+
import { z } from "zod";
|
|
44
|
+
import { loadAgentState, repoRoot, resolveStateFile, saveAgentState, stateFile, vendorSessionId, } from "../lib/agent-state.js";
|
|
45
|
+
import { attachClientCallSiteReports } from "../lib/blast.js";
|
|
46
|
+
import { discoverFiles } from "../lib/index/discovery.js";
|
|
47
|
+
import { loadClientConfig } from "../lib/client-config.js";
|
|
48
|
+
import { PROTOCOL_INSTRUCTIONS } from "../lib/protocol.js";
|
|
49
|
+
import { globMatches, isGlob, pathsOverlap } from "../lib/globs.js";
|
|
50
|
+
import { declaredPathWarnings } from "../lib/path-warnings.js";
|
|
51
|
+
import { BOARD_KINDS, BOARD_STATUSES } from "../lib/types.js";
|
|
52
|
+
import { adapterRequestHeaders, KEEL_ADAPTER_PROTOCOL_VERSION, KEEL_RELEASE_VERSION, } from "../lib/version.js";
|
|
53
|
+
import { DEFAULT_IGNORES, findUnleasedWrites, fingerprintWrites, listWorktreeChanges, listWorktreeChangesStrict, upstreamLandingReport, } from "../lib/worktree.js";
|
|
54
|
+
import { createApprovalGate } from "./approval.js";
|
|
55
|
+
import { runLogin } from "./login.js";
|
|
56
|
+
import { runSetup } from "./setup.js";
|
|
57
|
+
import { waitForEscalation } from "./escalation-waiter.js";
|
|
58
|
+
import { startCloudConnector } from "./cloud-connector.js";
|
|
59
|
+
import { buildLocalGraph, runContextCard, runImpact, runRetrieve } from "./graph-ops.js";
|
|
60
|
+
import { ensureDaemonSpawned, tryDaemon } from "./daemon-client.js";
|
|
61
|
+
import { boardWriteReceipt, compactBoardItem, compactCharter, compactDeclaration, compactIntent, compactLeaseGrant, } from "../lib/mcp-compaction.js";
|
|
62
|
+
const LOGIN_MODE = process.argv[2] === "login";
|
|
63
|
+
const SETUP_MODE = process.argv[2] === "setup";
|
|
64
|
+
const TEST_IMPORT_MODE = process.argv[2] === "__test_import__";
|
|
65
|
+
const CLIENT_CONFIG = LOGIN_MODE ? null : loadClientConfig(repoRoot());
|
|
66
|
+
const SERVER = process.env.KEEL_URL ?? CLIENT_CONFIG?.server ?? "http://localhost:7377";
|
|
67
|
+
const PROJECT = process.env.KEEL_PROJECT ?? CLIENT_CONFIG?.project;
|
|
68
|
+
const VENDOR = process.env.KEEL_VENDOR ?? CLIENT_CONFIG?.vendor ?? "other";
|
|
69
|
+
const TOKEN = process.env.KEEL_TOKEN?.trim() ||
|
|
70
|
+
(CLIENT_CONFIG?.server.replace(/\/+$/, "") === SERVER.replace(/\/+$/, "")
|
|
71
|
+
? CLIENT_CONFIG.token
|
|
72
|
+
: undefined);
|
|
73
|
+
/**
|
|
74
|
+
* The MCP heartbeat makes an agent online, but Board Auto is driven by the
|
|
75
|
+
* repository CLI's separate runner. Starting it here closes the gap for MCP-
|
|
76
|
+
* only registrations; the runner's state-file lock makes repeated starts
|
|
77
|
+
* harmless when a lifecycle hook already launched it.
|
|
78
|
+
*/
|
|
79
|
+
export function localAutoRunnerCommand(root, statePath) {
|
|
80
|
+
const candidates = [
|
|
81
|
+
join(root, "keel", "dist", "src", "cli", "keel-cli.js"),
|
|
82
|
+
join(root, "dist", "src", "cli", "keel-cli.js"),
|
|
83
|
+
];
|
|
84
|
+
const cli = candidates.find((candidate) => existsSync(candidate));
|
|
85
|
+
return cli ? { command: process.execPath, args: [cli, "auto-run", "--state-file", statePath] } : null;
|
|
86
|
+
}
|
|
87
|
+
function ensureLocalAutoRunner(root, statePath) {
|
|
88
|
+
const invocation = localAutoRunnerCommand(root, statePath);
|
|
89
|
+
if (!invocation)
|
|
90
|
+
return false;
|
|
91
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
92
|
+
cwd: root,
|
|
93
|
+
detached: true,
|
|
94
|
+
stdio: "ignore",
|
|
95
|
+
windowsHide: true,
|
|
96
|
+
env: { ...process.env, KEEL_VENDOR: VENDOR },
|
|
97
|
+
});
|
|
98
|
+
child.unref();
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
function envFlag(name, fallback) {
|
|
102
|
+
const value = process.env[name]?.trim().toLowerCase();
|
|
103
|
+
if (value === undefined)
|
|
104
|
+
return fallback;
|
|
105
|
+
return !["0", "false", "off", "no"].includes(value);
|
|
106
|
+
}
|
|
107
|
+
function envSeconds(name, fallback) {
|
|
108
|
+
const value = Number(process.env[name]);
|
|
109
|
+
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
110
|
+
}
|
|
111
|
+
function stateBelongsToAdapter(state, root, expectedProjectId, expectedSessionId) {
|
|
112
|
+
return Boolean(state &&
|
|
113
|
+
state.server.replace(/\/+$/, "") === SERVER.replace(/\/+$/, "") &&
|
|
114
|
+
state.project_id === expectedProjectId &&
|
|
115
|
+
state.repo_root === root &&
|
|
116
|
+
// Only adopt an identity this same client registered. Adopting another
|
|
117
|
+
// client's agent id would collapse two agents into one, and one agent
|
|
118
|
+
// cannot collide with itself — they would stop seeing each other.
|
|
119
|
+
state.vendor === VENDOR &&
|
|
120
|
+
// The same reasoning one level down, and the half that went unenforced:
|
|
121
|
+
// two sessions of one client collapse into a single agent just as
|
|
122
|
+
// completely as two clients do. A state file that records no session is
|
|
123
|
+
// pre-migration and adoptable once — that is the only way an in-flight
|
|
124
|
+
// session keeps its identity across this change. A file that records a
|
|
125
|
+
// *different* session is another live agent's, and adopting it is the
|
|
126
|
+
// bug this guard exists to prevent.
|
|
127
|
+
(state.session_id === undefined || state.session_id === expectedSessionId));
|
|
128
|
+
}
|
|
129
|
+
function apiHeaders() {
|
|
130
|
+
const headers = {
|
|
131
|
+
"content-type": "application/json",
|
|
132
|
+
...adapterRequestHeaders(),
|
|
133
|
+
};
|
|
134
|
+
if (TOKEN)
|
|
135
|
+
headers.authorization = `Bearer ${TOKEN}`;
|
|
136
|
+
return headers;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Every call here used to be unbounded — plain `fetch` with no timeout of its
|
|
140
|
+
* own, so a stalled request rode whatever ceiling the calling harness
|
|
141
|
+
* happened to enforce. That ceiling has been observed at 1800s (see
|
|
142
|
+
* tsk_01M0SWY731SRA8NM83PEZC1PB3): a client blocked that long with "no
|
|
143
|
+
* response or progress" and nothing to act on, no matter which of the 30+
|
|
144
|
+
* routes this function serves was the one that stalled. The arbiter got the
|
|
145
|
+
* same bound for the same reason (commit 47ca50c) — this is the client-side
|
|
146
|
+
* half of the same fix, one level up the call chain.
|
|
147
|
+
*
|
|
148
|
+
* A generous default: legitimate calls finish in low seconds even with the
|
|
149
|
+
* arbiter in the loop. `KEEL_API_TIMEOUT_SECONDS=0` disables it for a slow
|
|
150
|
+
* link where 45s is provably too tight, rather than editing this file.
|
|
151
|
+
*/
|
|
152
|
+
const API_TIMEOUT_MS = envSeconds("KEEL_API_TIMEOUT_SECONDS", 45) * 1000;
|
|
153
|
+
async function api(method, path, body, extraHeaders = {}) {
|
|
154
|
+
const controller = new AbortController();
|
|
155
|
+
const timer = API_TIMEOUT_MS > 0 ? setTimeout(() => controller.abort(), API_TIMEOUT_MS) : undefined;
|
|
156
|
+
let res;
|
|
157
|
+
try {
|
|
158
|
+
res = await fetch(`${SERVER}${path}`, {
|
|
159
|
+
method,
|
|
160
|
+
headers: { ...apiHeaders(), ...extraHeaders },
|
|
161
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
162
|
+
signal: controller.signal,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
if (controller.signal.aborted) {
|
|
167
|
+
// The request may still have landed server-side — an abort here only
|
|
168
|
+
// means this client gave up waiting, not that nothing happened. See
|
|
169
|
+
// tsk_01M0SWY731SRA8NM83PEZC1PB3 for the discovery path: check
|
|
170
|
+
// intent_list_active (or the relevant list tool) for what this call
|
|
171
|
+
// was trying to create before retrying, and reuse an idempotency_key
|
|
172
|
+
// where the tool accepts one rather than declaring blind.
|
|
173
|
+
throw new Error(`${method} ${path} timed out after ${API_TIMEOUT_MS / 1000}s with no response. ` +
|
|
174
|
+
`It may still have landed server-side — check before retrying blind ` +
|
|
175
|
+
`(e.g. intent_list_active for an intents call). Raise KEEL_API_TIMEOUT_SECONDS ` +
|
|
176
|
+
`if this route is legitimately slower than that.`);
|
|
177
|
+
}
|
|
178
|
+
throw e;
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
}
|
|
183
|
+
const data = await res.json().catch(() => ({}));
|
|
184
|
+
if (!res.ok) {
|
|
185
|
+
// 401 has exactly one cause worth naming: this machine has no usable
|
|
186
|
+
// token. It is the first thing a new collaborator hits — the MCP config
|
|
187
|
+
// is committed, so the server and project resolve fine and only the
|
|
188
|
+
// credential is missing — and the raw envelope gives them nothing to act
|
|
189
|
+
// on. Everything else keeps the literal response.
|
|
190
|
+
if (res.status === 401) {
|
|
191
|
+
throw new Error(`${method} ${path} -> 401: this machine has no valid Sync credential.\n` +
|
|
192
|
+
`Run: npm --prefix keel run login -- --server ${SERVER}` +
|
|
193
|
+
(PROJECT ? ` --project ${PROJECT}` : "") +
|
|
194
|
+
` --vendor ${VENDOR}\n` +
|
|
195
|
+
`Then run: node keel/dist/src/cli/keel-cli.js doctor`);
|
|
196
|
+
}
|
|
197
|
+
throw new Error(`${method} ${path} -> ${res.status}: ${JSON.stringify(data)}`);
|
|
198
|
+
}
|
|
199
|
+
return data;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Like `api()`, but for a route that answers with raw bytes rather than
|
|
203
|
+
* JSON — the org-document download route is the only caller today. Kept
|
|
204
|
+
* separate rather than teaching `api()` a content-type branch: every other
|
|
205
|
+
* route on this server is JSON, and `api()`'s error handling (the 401
|
|
206
|
+
* message in particular) is written assuming a parseable body.
|
|
207
|
+
*/
|
|
208
|
+
async function apiRaw(path) {
|
|
209
|
+
const controller = new AbortController();
|
|
210
|
+
const timer = API_TIMEOUT_MS > 0 ? setTimeout(() => controller.abort(), API_TIMEOUT_MS) : undefined;
|
|
211
|
+
let res;
|
|
212
|
+
try {
|
|
213
|
+
res = await fetch(`${SERVER}${path}`, { headers: apiHeaders(), signal: controller.signal });
|
|
214
|
+
}
|
|
215
|
+
catch (e) {
|
|
216
|
+
if (controller.signal.aborted) {
|
|
217
|
+
throw new Error(`GET ${path} timed out after ${API_TIMEOUT_MS / 1000}s with no response.`);
|
|
218
|
+
}
|
|
219
|
+
throw e;
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
clearTimeout(timer);
|
|
223
|
+
}
|
|
224
|
+
if (!res.ok) {
|
|
225
|
+
const body = await res.text().catch(() => "");
|
|
226
|
+
throw new Error(`GET ${path} -> ${res.status}: ${body}`);
|
|
227
|
+
}
|
|
228
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
229
|
+
return { contentType: res.headers.get("content-type") ?? "application/octet-stream", buffer };
|
|
230
|
+
}
|
|
231
|
+
/** Where each vendor wires its hooks, relative to the repo root. */
|
|
232
|
+
const HOOK_CONFIGS = {
|
|
233
|
+
codex: [join(".codex", "hooks.json")],
|
|
234
|
+
claude_code: [join(".claude", "settings.json"), join(".claude", "settings.local.json")],
|
|
235
|
+
};
|
|
236
|
+
/**
|
|
237
|
+
* Does this checkout enforce leases through a preflight hook?
|
|
238
|
+
*
|
|
239
|
+
* If it does, the adapter must be *told* which session it serves, because it
|
|
240
|
+
* cannot work that out for itself and being wrong is silent. The adapter is a
|
|
241
|
+
* long-lived child process: it reads the client's session id from its own
|
|
242
|
+
* environment once, at spawn. When the client starts a new session in the same
|
|
243
|
+
* process tree — `/clear` in Claude Code, a new thread in Codex — the
|
|
244
|
+
* SessionStart hook registers a fresh agent under the *new* session id while
|
|
245
|
+
* the adapter keeps serving under the old one. Nothing errors. The two halves
|
|
246
|
+
* of one session simply hold two agent identities, and since the preflight
|
|
247
|
+
* hook checks leases against the hook's identity and every lease is taken
|
|
248
|
+
* under the adapter's, the agent is refused every edit it correctly leased —
|
|
249
|
+
* with its own intent quoted back to it as the blocking one.
|
|
250
|
+
*
|
|
251
|
+
* That failure is worse than a refusal to start: it punishes the agent that
|
|
252
|
+
* followed the protocol, and it looks like a coordination conflict rather than
|
|
253
|
+
* a misconfiguration, so it is diagnosed as the former.
|
|
254
|
+
*
|
|
255
|
+
* This was enforced for Codex alone, which is why only Codex was immune.
|
|
256
|
+
* The requirement never had anything to do with Codex — it follows from the
|
|
257
|
+
* hook, so it is asked of whichever vendor wires one.
|
|
258
|
+
*/
|
|
259
|
+
function hookBindingRequired(root) {
|
|
260
|
+
for (const relative of HOOK_CONFIGS[VENDOR] ?? []) {
|
|
261
|
+
try {
|
|
262
|
+
const config = readFileSync(join(root, relative), "utf8");
|
|
263
|
+
// Either binary name. The rename briefly broke this: the check looked for
|
|
264
|
+
// "keel-cli" while the config still said "charter-cli", so it returned
|
|
265
|
+
// false and Codex's session-binding requirement silently stopped being
|
|
266
|
+
// enforced — a guard that fails open reads exactly like a guard that passes.
|
|
267
|
+
const wired = config.includes("keel-cli") || config.includes("charter-cli");
|
|
268
|
+
if (wired && config.includes("preflight"))
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
// A vendor may wire only one of its config files; keep looking.
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Serialise a tool result.
|
|
279
|
+
*
|
|
280
|
+
* Compact, not pretty-printed. Nothing reads this but a model, and indentation
|
|
281
|
+
* measured 20% of the bytes across the tool responses in 40 sessions' worth of
|
|
282
|
+
* transcripts — roughly 100k tokens of leading spaces and newlines for a
|
|
283
|
+
* readability nobody was consuming. JSON.stringify with no spacer is still
|
|
284
|
+
* valid JSON; a human debugging a response can pipe it through `jq`.
|
|
285
|
+
*/
|
|
286
|
+
function text(result) {
|
|
287
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
288
|
+
}
|
|
289
|
+
// `instructions` reaches every MCP client at connect time. This is what makes
|
|
290
|
+
// the experience uniform: a client with no hook support and no repo-local doc
|
|
291
|
+
// file (Cursor, Gemini CLI, a homegrown agent) still gets the full protocol
|
|
292
|
+
// before its first tool call.
|
|
293
|
+
/**
|
|
294
|
+
* Report working-tree files changed without a lease.
|
|
295
|
+
*
|
|
296
|
+
* Runs where the checkout is, so it covers every client the same way —
|
|
297
|
+
* including ones that cannot block writes at the tool level, and shell
|
|
298
|
+
* writes (redirects, sed -i) that bypass tool hooks everywhere.
|
|
299
|
+
*
|
|
300
|
+
* This reports what the *worktree* looks like, which in a shared checkout
|
|
301
|
+
* includes other agents' edits. It deliberately does not claim authorship —
|
|
302
|
+
* the server decides whether this agent can be named. Content hashes travel
|
|
303
|
+
* with the report so the server can suppress a file that is merely still
|
|
304
|
+
* dirty while still alerting when it changes again; calling this often is
|
|
305
|
+
* therefore cheap.
|
|
306
|
+
*/
|
|
307
|
+
async function reportUnleasedWrites(projectId, agent) {
|
|
308
|
+
try {
|
|
309
|
+
const root = repoRoot();
|
|
310
|
+
const changes = listWorktreeChanges(root);
|
|
311
|
+
if (changes.length === 0)
|
|
312
|
+
return [];
|
|
313
|
+
const held = (await api("GET", `/leases?project_id=${projectId}`));
|
|
314
|
+
const allLeased = held.flatMap((l) => [...(l.paths ?? []), ...(l.secondary_paths ?? [])]);
|
|
315
|
+
const unleased = findUnleasedWrites(changes, allLeased);
|
|
316
|
+
if (unleased.length === 0)
|
|
317
|
+
return [];
|
|
318
|
+
const writes = fingerprintWrites(root, unleased);
|
|
319
|
+
// `paths` is sent alongside for a server that predates fingerprinting.
|
|
320
|
+
const res = await api("POST", "/unleased-writes", {
|
|
321
|
+
agent_id: agent,
|
|
322
|
+
writes,
|
|
323
|
+
paths: writes.map((w) => w.path),
|
|
324
|
+
});
|
|
325
|
+
return (res.alerted ?? []);
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
// detection is best-effort: never break a tool call over it
|
|
329
|
+
return [];
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Opt-in retry safety (§11). A call whose response never arrived may still
|
|
334
|
+
* have committed; passing the same key on the retry returns the first
|
|
335
|
+
* attempt's answer with `replayed: true` rather than creating a second thing.
|
|
336
|
+
*/
|
|
337
|
+
const IDEMPOTENCY_KEY = z
|
|
338
|
+
.string()
|
|
339
|
+
.min(8)
|
|
340
|
+
.optional()
|
|
341
|
+
.describe("Pass the same value when retrying a call whose response you never saw, so the retry " +
|
|
342
|
+
"returns the original answer instead of creating a duplicate.");
|
|
343
|
+
const interfaceSchema = z.object({
|
|
344
|
+
kind: z.enum(["function", "export", "class", "method", "route", "schema", "config", "env_var", "constant", "dependency"]),
|
|
345
|
+
name: z.string(),
|
|
346
|
+
change: z.enum(["none", "signature", "added", "removed", "behavior"]),
|
|
347
|
+
});
|
|
348
|
+
function withLocalCallSites(subjects, analyzeChanges) {
|
|
349
|
+
const contextSetting = process.env.KEEL_BLAST_INCLUDE_CONTEXT?.toLowerCase();
|
|
350
|
+
const includeContext = !["0", "false", "off", "no"].includes(contextSetting ?? "");
|
|
351
|
+
return attachClientCallSiteReports(repoRoot(), subjects, {
|
|
352
|
+
includeContext,
|
|
353
|
+
analyzeChanges,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
export function createSyncMcpServer(requestContext) {
|
|
357
|
+
async function ensureAgent(extra, sessionIdFromArg) {
|
|
358
|
+
if (!PROJECT)
|
|
359
|
+
throw new Error("KEEL_PROJECT env var is required");
|
|
360
|
+
const root = repoRoot();
|
|
361
|
+
if (!requestContext)
|
|
362
|
+
requestContext = {};
|
|
363
|
+
const metaSessionId = extra?._meta?.session_id;
|
|
364
|
+
if (sessionIdFromArg)
|
|
365
|
+
requestContext.boundSessionId = sessionIdFromArg;
|
|
366
|
+
// Resolved for EVERY vendor, not just Codex. The `VENDOR === "codex"`
|
|
367
|
+
// gate that used to be here handed every other client a null session id,
|
|
368
|
+
// which drops the session segment from the state path — so all sessions of
|
|
369
|
+
// that client in one checkout shared a file, an agent id, and a lease
|
|
370
|
+
// holder. `vendorSessionId()` is the general form of the Codex thread id
|
|
371
|
+
// and needs no cooperation from the agent: every client exports its
|
|
372
|
+
// session to child processes, and the adapter is one.
|
|
373
|
+
//
|
|
374
|
+
// Told, versus inferred. Only the first three are the client speaking; the
|
|
375
|
+
// environment is the adapter guessing from the session it was spawned in,
|
|
376
|
+
// and that guess is what goes stale when the client starts a new session
|
|
377
|
+
// without restarting the adapter. Keeping the two apart is what lets a
|
|
378
|
+
// hook-enforced checkout insist on being told (see hookBindingRequired) —
|
|
379
|
+
// a check against the merged value cannot fire, because the stale guess is
|
|
380
|
+
// never absent.
|
|
381
|
+
const boundSessionId = metaSessionId ?? sessionIdFromArg ?? requestContext.boundSessionId ?? null;
|
|
382
|
+
const requestedSessionId = boundSessionId ?? vendorSessionId() ?? null;
|
|
383
|
+
if (!requestedSessionId) {
|
|
384
|
+
throw new Error("Syncstaff needs this coding task's session id before it can track the agent. " +
|
|
385
|
+
"Call sync_get(session_id=\"<this task's id>\") once, using the id shown by your coding client.");
|
|
386
|
+
}
|
|
387
|
+
if (hookBindingRequired(root) && !boundSessionId) {
|
|
388
|
+
throw new Error(`Sync session binding required: call sync_get(session_id="<this session's id>") before any other Sync tool.\n` +
|
|
389
|
+
"The SessionStart hook prints the id. This checkout enforces leases through a preflight hook, " +
|
|
390
|
+
"and an adapter that guesses its session from the environment can outlive the session it guessed — " +
|
|
391
|
+
"which hands one session two agent identities and refuses the agent its own leases.");
|
|
392
|
+
}
|
|
393
|
+
// Written session-scoped, read with a fallback to any pre-migration file,
|
|
394
|
+
// so a session already running when this shipped is not locked out.
|
|
395
|
+
const sessionStatePath = stateFile(root, requestedSessionId, VENDOR);
|
|
396
|
+
const sharedStateFile = resolveStateFile(root, requestedSessionId, VENDOR);
|
|
397
|
+
const cachedState = loadAgentState(sharedStateFile);
|
|
398
|
+
if (requestContext.projectId &&
|
|
399
|
+
requestContext.agentId &&
|
|
400
|
+
stateBelongsToAdapter(cachedState, root, requestContext.projectId, requestedSessionId) &&
|
|
401
|
+
cachedState.agent_id === requestContext.agentId) {
|
|
402
|
+
return { projectId: requestContext.projectId, agentId: requestContext.agentId };
|
|
403
|
+
}
|
|
404
|
+
const project = await api("GET", `/projects?name=${encodeURIComponent(PROJECT)}`);
|
|
405
|
+
const projectId = project.id;
|
|
406
|
+
let agentId = null;
|
|
407
|
+
const shared = loadAgentState(sharedStateFile);
|
|
408
|
+
if (stateBelongsToAdapter(shared, root, projectId, requestedSessionId)) {
|
|
409
|
+
try {
|
|
410
|
+
await api("POST", `/agents/${shared.agent_id}/heartbeat`, {
|
|
411
|
+
vendor_session_id: requestedSessionId,
|
|
412
|
+
});
|
|
413
|
+
agentId = shared.agent_id;
|
|
414
|
+
}
|
|
415
|
+
catch { }
|
|
416
|
+
}
|
|
417
|
+
if (!agentId) {
|
|
418
|
+
const agent = await api("POST", "/agents", {
|
|
419
|
+
project_id: projectId,
|
|
420
|
+
vendor: VENDOR,
|
|
421
|
+
vendor_session_id: requestedSessionId,
|
|
422
|
+
human_owner: process.env.KEEL_HUMAN ?? process.env.USER ?? "unknown",
|
|
423
|
+
machine_id: hostname(),
|
|
424
|
+
checkout_path: root,
|
|
425
|
+
enforcement_level: "advisory",
|
|
426
|
+
});
|
|
427
|
+
agentId = agent.id;
|
|
428
|
+
saveAgentState({
|
|
429
|
+
server: SERVER,
|
|
430
|
+
project_id: projectId,
|
|
431
|
+
agent_id: agentId,
|
|
432
|
+
repo_root: root,
|
|
433
|
+
vendor: VENDOR,
|
|
434
|
+
...(requestedSessionId ? { session_id: requestedSessionId } : {}),
|
|
435
|
+
},
|
|
436
|
+
// The scoped path, never the one a legacy fallback may have read from:
|
|
437
|
+
// writing back to the shared file is what would re-merge the identities.
|
|
438
|
+
sessionStatePath);
|
|
439
|
+
}
|
|
440
|
+
requestContext.projectId = projectId;
|
|
441
|
+
requestContext.agentId = agentId;
|
|
442
|
+
// Keep the resolved identity in the request context. Heartbeats must not
|
|
443
|
+
// depend on re-reading a process environment that may be absent or may
|
|
444
|
+
// change after the client reconnects.
|
|
445
|
+
requestContext.boundSessionId = requestedSessionId;
|
|
446
|
+
if (!requestContext.autoRunnerStarted) {
|
|
447
|
+
requestContext.autoRunnerStarted = true;
|
|
448
|
+
ensureLocalAutoRunner(root, existsSync(sessionStatePath) ? sessionStatePath : sharedStateFile);
|
|
449
|
+
}
|
|
450
|
+
if (!requestContext.heartbeatStarted) {
|
|
451
|
+
const hb = setInterval(() => {
|
|
452
|
+
const id = requestContext.agentId;
|
|
453
|
+
const pid = requestContext.projectId;
|
|
454
|
+
if (!id || !pid)
|
|
455
|
+
return;
|
|
456
|
+
void api("POST", `/agents/${id}/heartbeat`, {
|
|
457
|
+
vendor_session_id: requestContext.boundSessionId ?? vendorSessionId() ?? null,
|
|
458
|
+
}).catch(() => { });
|
|
459
|
+
void reportUnleasedWrites(pid, id);
|
|
460
|
+
}, 30000);
|
|
461
|
+
hb.unref();
|
|
462
|
+
requestContext.heartbeatStarted = true;
|
|
463
|
+
}
|
|
464
|
+
if (!requestContext.cloudConnectorStarted) {
|
|
465
|
+
requestContext.cloudConnectorStarted = true;
|
|
466
|
+
requestContext.cloudConnector = startCloudConnector({
|
|
467
|
+
api,
|
|
468
|
+
projectId,
|
|
469
|
+
machineId: hostname(),
|
|
470
|
+
displayName: hostname(),
|
|
471
|
+
clientVersion: KEEL_RELEASE_VERSION,
|
|
472
|
+
protocolVersion: String(KEEL_ADAPTER_PROTOCOL_VERSION),
|
|
473
|
+
platform: `${process.platform}-${process.arch}`,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
return { projectId, agentId };
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Human approval, backed by the escalation row rather than by this process.
|
|
480
|
+
*
|
|
481
|
+
* Declared before the server so the server can hand `requestState.verify` to
|
|
482
|
+
* the SDK seam: an echoed state is verified before the handler is entered,
|
|
483
|
+
* so a tool never sees an unverified one. See approval.ts for why the
|
|
484
|
+
* escalation id is the only thing that travels.
|
|
485
|
+
*/
|
|
486
|
+
const approval = createApprovalGate({
|
|
487
|
+
api,
|
|
488
|
+
project: PROJECT ?? "",
|
|
489
|
+
repoRoot: repoRoot(),
|
|
490
|
+
token: TOKEN,
|
|
491
|
+
key: process.env.KEEL_REQUEST_STATE_KEY,
|
|
492
|
+
resolvedBy: process.env.KEEL_HUMAN ?? process.env.USER ?? "unknown",
|
|
493
|
+
// The era belongs to the connection, and `serveStdio` puts it on the
|
|
494
|
+
// context it builds this server with — there is no accessor on the server
|
|
495
|
+
// itself because a modern instance and a legacy one are different
|
|
496
|
+
// instances, not two modes of one.
|
|
497
|
+
era: () => requestContext?.era,
|
|
498
|
+
});
|
|
499
|
+
const mcp = new McpServer({ name: "sync", version: KEEL_RELEASE_VERSION }, {
|
|
500
|
+
instructions: PROTOCOL_INSTRUCTIONS,
|
|
501
|
+
requestState: { verify: approval.verify },
|
|
502
|
+
});
|
|
503
|
+
const registerGet = (name) => {
|
|
504
|
+
mcp.registerTool(name, {
|
|
505
|
+
description: "Fetch the project charter (purpose, non-goals, binding invariants). Call at session start, passing the session_id supplied by the Codex SessionStart hook when present. Re-read after any charter.amended event. If this fails, do not start work.",
|
|
506
|
+
inputSchema: z.object({
|
|
507
|
+
session_id: z
|
|
508
|
+
.string()
|
|
509
|
+
.optional()
|
|
510
|
+
.describe("Codex session id printed by the SessionStart hook."),
|
|
511
|
+
})
|
|
512
|
+
}, async (args, extra) => {
|
|
513
|
+
const { session_id } = args;
|
|
514
|
+
const { projectId } = await ensureAgent(extra, session_id);
|
|
515
|
+
return text(compactCharter(await api("GET", `/charter?project_id=${projectId}`)));
|
|
516
|
+
});
|
|
517
|
+
};
|
|
518
|
+
registerGet("sync_get");
|
|
519
|
+
const registerWhoami = (name) => {
|
|
520
|
+
mcp.registerTool(name, {
|
|
521
|
+
description: "Report your agent id, the git repository root, and the project you are registered to. Call this if you are unsure what a path should be relative to — every path you pass to a Sync tool is relative to the repo_root returned here.",
|
|
522
|
+
inputSchema: z.object({})
|
|
523
|
+
}, async (_args, extra) => {
|
|
524
|
+
const { projectId, agentId } = await ensureAgent(extra);
|
|
525
|
+
return text({
|
|
526
|
+
agent_id: agentId,
|
|
527
|
+
project_id: projectId,
|
|
528
|
+
repo_root: repoRoot(),
|
|
529
|
+
vendor: VENDOR,
|
|
530
|
+
human_owner: process.env.NAIB_HUMAN ?? process.env.KEEL_HUMAN ?? process.env.USER ?? "unknown",
|
|
531
|
+
path_convention: "All paths are relative to repo_root, never to your cwd.",
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
};
|
|
535
|
+
registerWhoami("sync_whoami");
|
|
536
|
+
mcp.registerTool("token_usage_report", {
|
|
537
|
+
description: "Report one hourly token-usage period for this agent. Submit exact provider totals when available; set exact=false only when the host can provide an estimate. Send a final partial-period report when work finishes.",
|
|
538
|
+
inputSchema: z.object({
|
|
539
|
+
provider: z.string().describe("Model provider, for example openai, anthropic, or google."),
|
|
540
|
+
model: z.string().optional(),
|
|
541
|
+
period_start: z.string().describe("ISO-8601 start of this reporting period."),
|
|
542
|
+
period_end: z.string().describe("ISO-8601 end of this reporting period."),
|
|
543
|
+
input_tokens: z.number().int().nonnegative(),
|
|
544
|
+
output_tokens: z.number().int().nonnegative(),
|
|
545
|
+
cached_tokens: z.number().int().nonnegative().default(0),
|
|
546
|
+
reasoning_tokens: z.number().int().nonnegative().default(0),
|
|
547
|
+
intent_id: z.string().optional(),
|
|
548
|
+
task_id: z.string().optional(),
|
|
549
|
+
exact: z.boolean().default(true),
|
|
550
|
+
}),
|
|
551
|
+
}, async (args, extra) => {
|
|
552
|
+
const { agentId } = await ensureAgent(extra);
|
|
553
|
+
return text(await api("POST", "/token-usage", { ...args, agent_id: agentId }));
|
|
554
|
+
});
|
|
555
|
+
mcp.registerTool("lease_check", { description: "Check whether you currently hold a lease covering a specific file, BEFORE writing to it. Returns covered=true/false, and when false, names any other agent holding it. Some clients block unleased edits automatically; if yours does not, call this as your own preflight.", inputSchema: z.object({ path: z.string().describe("Path relative to the git repository root.") }) }, async (args, extra) => {
|
|
556
|
+
const { path } = args;
|
|
557
|
+
const { agentId } = await ensureAgent(extra);
|
|
558
|
+
const result = await api("GET", `/leases/check?agent_id=${agentId}&path=${encodeURIComponent(path)}`);
|
|
559
|
+
return text(result.covered
|
|
560
|
+
? result
|
|
561
|
+
: {
|
|
562
|
+
...result,
|
|
563
|
+
next_step: result.held_by_others?.length > 0
|
|
564
|
+
? "Another agent holds this path. Re-scope, take a partial grant, or pick a different task — do not edit it."
|
|
565
|
+
: "You hold no lease on this path. Call intent_declare then lease_acquire before editing.",
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
const handleProposeAmendment = async (args, extra) => {
|
|
569
|
+
const { projectId, agentId } = await ensureAgent(extra);
|
|
570
|
+
// Round two: the client's human already answered. The escalation id
|
|
571
|
+
// came back sealed in requestState and was verified by the seam, so
|
|
572
|
+
// reaching here means it is the decision this server asked about.
|
|
573
|
+
const pending = await extra?.mcpReq?.requestState?.();
|
|
574
|
+
if (pending)
|
|
575
|
+
return text(await approval.answer(extra?.mcpReq?.inputResponses, pending));
|
|
576
|
+
const proposed = await api("POST", "/charter/propose", {
|
|
577
|
+
project_id: projectId,
|
|
578
|
+
agent_id: agentId,
|
|
579
|
+
...args,
|
|
580
|
+
});
|
|
581
|
+
// The escalation is now durable whatever happens next. Everything
|
|
582
|
+
// below is about who gets asked, not about whether the decision
|
|
583
|
+
// exists — a process that dies here leaves a proposal a human can
|
|
584
|
+
// still answer on the web.
|
|
585
|
+
if (!approval.available(extra))
|
|
586
|
+
return text(proposed);
|
|
587
|
+
const escalations = (await api("GET", `/escalations?project_id=${encodeURIComponent(projectId)}`).catch(() => []));
|
|
588
|
+
const record = escalations.find((row) => row.id === proposed.escalation_id);
|
|
589
|
+
const options = (record?.options ?? []).map((option) => ({
|
|
590
|
+
id: String(option.id),
|
|
591
|
+
label: String(option.label),
|
|
592
|
+
}));
|
|
593
|
+
if (options.length === 0)
|
|
594
|
+
return text(proposed);
|
|
595
|
+
return approval.ask(extra, proposed.escalation_id, projectId, options, `Charter amendment proposed (${args.kind}):\n\n${args.text}\n\n` +
|
|
596
|
+
`Why: ${args.rationale ?? "(none)"}\n\nRatifying changes the binding document every agent reads.`);
|
|
597
|
+
};
|
|
598
|
+
const proposeAmendmentSchema = z.object({
|
|
599
|
+
kind: z.enum(["purpose", "non_goal_add", "invariant_add", "invariant_change", "invariant_remove"]),
|
|
600
|
+
text: z.string(),
|
|
601
|
+
rationale: z.string().optional(),
|
|
602
|
+
invariant_id: z.string().optional(),
|
|
603
|
+
task_id: z.string().optional(),
|
|
604
|
+
});
|
|
605
|
+
const proposeAmendmentDesc = "Propose a charter change when a task requires violating an invariant or the charter is silent on a decision you are about to make. Blocks THIS decision path until a human ratifies — continue other work while waiting.";
|
|
606
|
+
mcp.registerTool("sync_propose_amendment", { description: proposeAmendmentDesc, inputSchema: proposeAmendmentSchema }, handleProposeAmendment);
|
|
607
|
+
/**
|
|
608
|
+
* Do the declared paths exist? Answered here, in the checkout being edited.
|
|
609
|
+
*
|
|
610
|
+
* inv_01KYRCZX78VK0YYMHC4FYEHR5W says every path is git-root-relative, and
|
|
611
|
+
* nothing enforced it: a lease on `keel/src/server/db.ts` when the file is at
|
|
612
|
+
* `keel/src/lib/db.ts` is granted, renewed and released without complaint
|
|
613
|
+
* while the agent edits a file no lease covers.
|
|
614
|
+
*
|
|
615
|
+
* The server used to answer this from its own `repo_path`, which is a
|
|
616
|
+
* different checkout at a different commit and, on a second instance, may not
|
|
617
|
+
* exist at all — so the same declaration warned or stayed silent depending on
|
|
618
|
+
* which process picked it up. This machine has the only copy whose answer is
|
|
619
|
+
* about the files actually being changed.
|
|
620
|
+
*
|
|
621
|
+
* `null` rather than `[]` when the walk finds nothing: "could not look" and
|
|
622
|
+
* "looked, all fine" must stay distinguishable, or the check becomes a machine
|
|
623
|
+
* for crying wolf the first time a path is misconfigured.
|
|
624
|
+
*/
|
|
625
|
+
function localPathWarnings(paths) {
|
|
626
|
+
if (paths.length === 0)
|
|
627
|
+
return [];
|
|
628
|
+
try {
|
|
629
|
+
// `declaredPathWarnings` returns null for an empty file list, which is the
|
|
630
|
+
// "could not look" case; a throw here is the same answer by another route.
|
|
631
|
+
return declaredPathWarnings(paths, discoverFiles(repoRoot()));
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
mcp.registerTool("intent_declare", { description: "Declare what you are about to do BEFORE editing any file: a one-sentence summary, the paths you will touch, and every interface you will add/remove/change. Returns an arbiter verdict and blast radius. If the coordinator escalates the declaration, this call waits briefly (KEEL_ESCALATION_WAIT_SECONDS, default 60) in case the decision is fast or already automated, then returns either way with the escalation_id. On a timeout the escalation is already durable — run `node keel/scripts/await-escalation.mjs <escalation_id>` in the background to keep watching it while you do something else, rather than re-declaring or retrying this call.", inputSchema: z.object({
|
|
638
|
+
summary: z.string(),
|
|
639
|
+
paths: z.array(z.string()),
|
|
640
|
+
interfaces_touched: z.array(interfaceSchema).default([]),
|
|
641
|
+
idempotency_key: IDEMPOTENCY_KEY,
|
|
642
|
+
task_id: z.string().optional(),
|
|
643
|
+
}) }, async (args, extra) => {
|
|
644
|
+
const { agentId, projectId } = await ensureAgent(extra);
|
|
645
|
+
const interfaces = withLocalCallSites(args.interfaces_touched);
|
|
646
|
+
// reportUnleasedWrites reads only project/agent state — never this
|
|
647
|
+
// call's own result — so it doesn't need to wait for the declare to
|
|
648
|
+
// land first. Run both concurrently rather than paying two sequential
|
|
649
|
+
// round trips on every intent_declare call.
|
|
650
|
+
//
|
|
651
|
+
// The heartbeat interval already runs this same check every 30s, but
|
|
652
|
+
// fire-and-forget and its result goes nowhere — so an agent starting
|
|
653
|
+
// work has no way to see, right now, that this checkout already has
|
|
654
|
+
// someone else's uncommitted, unleased changes sitting in it. The
|
|
655
|
+
// server dedupes by content hash, so calling it again here is cheap
|
|
656
|
+
// and never re-alerts on a file that was already reported stale.
|
|
657
|
+
const [declared, foreignWrites] = await Promise.all([
|
|
658
|
+
api("POST", "/intents", {
|
|
659
|
+
agent_id: agentId,
|
|
660
|
+
...args,
|
|
661
|
+
interfaces_touched: interfaces,
|
|
662
|
+
path_warnings: localPathWarnings(args.paths ?? []),
|
|
663
|
+
}),
|
|
664
|
+
reportUnleasedWrites(projectId, agentId),
|
|
665
|
+
]);
|
|
666
|
+
if (foreignWrites.length > 0) {
|
|
667
|
+
declared.checkout_warning =
|
|
668
|
+
`${foreignWrites.length} file${foreignWrites.length === 1 ? "" : "s"} in this checkout ` +
|
|
669
|
+
`${foreignWrites.length === 1 ? "has" : "have"} uncommitted changes not covered by any lease — ` +
|
|
670
|
+
`likely another agent sharing this checkout, not your own in-progress work: ` +
|
|
671
|
+
`${foreignWrites.slice(0, 8).join(", ")}${foreignWrites.length > 8 ? `, +${foreignWrites.length - 8} more` : ""}. ` +
|
|
672
|
+
`Only stage and commit the paths you actually declared.`;
|
|
673
|
+
}
|
|
674
|
+
if (!declared.escalation_id || !envFlag("KEEL_AUTO_WAIT_ESCALATIONS", true)) {
|
|
675
|
+
return text(compactDeclaration(declared));
|
|
676
|
+
}
|
|
677
|
+
const waited = await waitForEscalation({
|
|
678
|
+
escalationId: String(declared.escalation_id),
|
|
679
|
+
projectId,
|
|
680
|
+
server: SERVER,
|
|
681
|
+
headers: apiHeaders(),
|
|
682
|
+
fetchEscalations: async () => (await api("GET", `/escalations?project_id=${encodeURIComponent(projectId)}`)),
|
|
683
|
+
// Was 30*60: a human decision in this system typically takes minutes,
|
|
684
|
+
// not seconds, so the old default meant every escalated declaration
|
|
685
|
+
// surprise-blocked the caller for up to half an hour on the common
|
|
686
|
+
// case, not the rare one. 60s still catches a fast or already-
|
|
687
|
+
// automated resolution inline; anything slower should be watched with
|
|
688
|
+
// scripts/await-escalation.mjs instead of held open here. See
|
|
689
|
+
// tsk_01M17NJ3KKTEVTYEVGPCN957D7.
|
|
690
|
+
timeoutMs: envSeconds("KEEL_ESCALATION_WAIT_SECONDS", 60) * 1000,
|
|
691
|
+
});
|
|
692
|
+
return text({
|
|
693
|
+
...compactDeclaration(declared),
|
|
694
|
+
escalation_wait: waited,
|
|
695
|
+
next_step: waited.status === "resolved"
|
|
696
|
+
? "The durable human decision is applied. Continue based on escalation_wait.resolution and escalation_wait.changes."
|
|
697
|
+
: `The human decision did not arrive within the wait. The escalation is durable — run in the background: node keel/scripts/await-escalation.mjs ${declared.escalation_id}. Do not re-declare the same intent to keep waiting; that creates a duplicate.`,
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
/**
|
|
701
|
+
* Leased paths still uncommitted in this checkout.
|
|
702
|
+
*
|
|
703
|
+
* Reported at completion so the next agent to lease these files learns the
|
|
704
|
+
* work is here but not in git. A released lease otherwise makes a dirty file
|
|
705
|
+
* indistinguishable from a clean one. Runs client-side, where the checkout is.
|
|
706
|
+
*/
|
|
707
|
+
async function dirtyLeasedPaths(projectId, agent, intentId) {
|
|
708
|
+
try {
|
|
709
|
+
const changes = listWorktreeChangesStrict(repoRoot());
|
|
710
|
+
if (changes.length === 0)
|
|
711
|
+
return [];
|
|
712
|
+
const held = (await api("GET", `/leases?project_id=${projectId}`));
|
|
713
|
+
const mine = held
|
|
714
|
+
.filter((l) => l.agent_id === agent && l.intent_id === intentId)
|
|
715
|
+
.flatMap((l) => [...(l.paths ?? []), ...(l.secondary_paths ?? [])]);
|
|
716
|
+
if (mine.length === 0)
|
|
717
|
+
return [];
|
|
718
|
+
return [
|
|
719
|
+
...new Set(changes
|
|
720
|
+
.filter((c) => !DEFAULT_IGNORES.some((i) => i.endsWith("/") ? c.path.startsWith(i) : c.path === i))
|
|
721
|
+
.filter((c) => mine.some((p) => (isGlob(p) ? globMatches(p, c.path) : pathsOverlap(p, c.path))))
|
|
722
|
+
.map((c) => c.path)),
|
|
723
|
+
];
|
|
724
|
+
}
|
|
725
|
+
catch (error) {
|
|
726
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
727
|
+
throw new Error(`Refusing to complete ${intentId}: Sync could not verify that its leased paths are committed. ` +
|
|
728
|
+
`Restore git/worktree access and retry. (${detail})`);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
mcp.registerTool("intent_complete", { description: "Mark your declared intent as landed. Commit first, then call AFTER surface_delta_publish and BEFORE lease_release. Completion fails while any leased path is uncommitted, when git cannot verify the checkout, or when blocking contract breaks remain. A successful completion reports a non-blocking warning when HEAD is ahead of its configured upstream or upstream status cannot be verified.", inputSchema: z.object({ intent_id: z.string() }) }, async (args, extra) => {
|
|
732
|
+
const { intent_id } = args;
|
|
733
|
+
const { projectId, agentId } = await ensureAgent(extra);
|
|
734
|
+
const dirty = await dirtyLeasedPaths(projectId, agentId, intent_id);
|
|
735
|
+
const completed = await api("POST", `/intents/${intent_id}/complete`, {
|
|
736
|
+
dirty_paths: dirty,
|
|
737
|
+
});
|
|
738
|
+
return text({ ...completed, ...upstreamLandingReport(repoRoot()) });
|
|
739
|
+
});
|
|
740
|
+
mcp.registerTool("intent_withdraw", { description: "Withdraw an intent you declared and are not going to act on — after a conflict or escalate verdict, or when you change plan. Releases any leases it holds. Do this instead of walking away: an intent left open is what the arbiter compares the NEXT agent's declaration against, so abandoning one silently blocks whoever declares near your paths later. Only your own intents, and only before they complete.", inputSchema: z.object({
|
|
741
|
+
intent_id: z.string(),
|
|
742
|
+
reason: z.string().optional(),
|
|
743
|
+
}) }, async (args, extra) => {
|
|
744
|
+
const { agentId } = await ensureAgent(extra);
|
|
745
|
+
return text(await api("POST", `/intents/${args.intent_id}/withdraw`, {
|
|
746
|
+
agent_id: agentId,
|
|
747
|
+
reason: args.reason,
|
|
748
|
+
}));
|
|
749
|
+
});
|
|
750
|
+
mcp.registerTool("intent_list_active", { description: "List every intent currently in flight across all agents. Use to understand what others are doing before planning. Call site reports are summarised to their file set and counts; intent_get returns one intent with every call site quoted.", inputSchema: z.object({}) }, async (_args, extra) => {
|
|
751
|
+
const { projectId } = await ensureAgent(extra);
|
|
752
|
+
// One call for both statuses: GET /intents accepts a comma-separated
|
|
753
|
+
// status list, so this used to be two sequential round trips for what
|
|
754
|
+
// is really one question ("what's in flight").
|
|
755
|
+
const inFlight = await api("GET", `/intents?project_id=${projectId}&status=active,declared`);
|
|
756
|
+
return text(inFlight.map(compactIntent));
|
|
757
|
+
});
|
|
758
|
+
mcp.registerTool("intent_get", { description: "Read one intent in full, including every call site behind each declared interface change. intent_list_active summarises those to a file set; call this when you need the exact lines — for example before changing a symbol another agent has already declared.", inputSchema: z.object({ intent_id: z.string() }) }, async (args, extra) => {
|
|
759
|
+
const { intent_id } = args;
|
|
760
|
+
await ensureAgent(extra);
|
|
761
|
+
return text(await api("GET", `/intents/${intent_id}`));
|
|
762
|
+
});
|
|
763
|
+
mcp.registerTool("lease_acquire", { description: "Acquire an advisory lease on the paths from your declared intent. If denied, the response names the blocking agent's intent — take the partial grant, re-scope, or claim a different task. NEVER edit a path you were denied.", inputSchema: z.object({
|
|
764
|
+
intent_id: z.string(),
|
|
765
|
+
paths: z.array(z.string()).optional(),
|
|
766
|
+
ttl_seconds: z.number().optional(),
|
|
767
|
+
idempotency_key: IDEMPOTENCY_KEY,
|
|
768
|
+
}) }, async (args, extra) => {
|
|
769
|
+
await ensureAgent(extra);
|
|
770
|
+
return text(compactLeaseGrant(await api("POST", "/leases", args)));
|
|
771
|
+
});
|
|
772
|
+
mcp.registerTool("lease_renew", { description: "Renew a held lease, optionally choosing a fresh TTL. Leases expire; call this for long tasks (or rely on heartbeats).", inputSchema: z.object({ lease_id: z.string(), ttl_seconds: z.number().positive().optional() }) }, async (args, extra) => {
|
|
773
|
+
const { lease_id, ttl_seconds } = args;
|
|
774
|
+
await ensureAgent(extra);
|
|
775
|
+
return text(await api("POST", `/leases/${lease_id}/renew`, {
|
|
776
|
+
...(ttl_seconds === undefined ? {} : { ttl_seconds }),
|
|
777
|
+
}));
|
|
778
|
+
});
|
|
779
|
+
mcp.registerTool("lease_release", { description: "Release your lease when the work is done. Call AFTER surface_delta_publish and intent_complete. Do not release while leaving known-broken call sites behind.", inputSchema: z.object({ lease_id: z.string() }) }, async (args, extra) => {
|
|
780
|
+
const { lease_id } = args;
|
|
781
|
+
await ensureAgent(extra);
|
|
782
|
+
return text(await api("POST", `/leases/${lease_id}/release`, {}));
|
|
783
|
+
});
|
|
784
|
+
mcp.registerTool("lease_list", { description: "List leases currently held on a project, so you can see what is taken before planning.", inputSchema: z.object({}) }, async (_args, extra) => {
|
|
785
|
+
const { projectId } = await ensureAgent(extra);
|
|
786
|
+
return text(await api("GET", `/leases?project_id=${projectId}`));
|
|
787
|
+
});
|
|
788
|
+
mcp.registerTool("task_find_work", { description: "Ask the coordinator for the best next task instead of inventing one — prefers tasks whose file footprint avoids currently-held leases. Call when idle or after your task completes.", inputSchema: z.object({}) }, async (_args, extra) => {
|
|
789
|
+
const { agentId } = await ensureAgent(extra);
|
|
790
|
+
return text(await api("POST", "/tasks/find-work", { agent_id: agentId }));
|
|
791
|
+
});
|
|
792
|
+
mcp.registerTool("task_claim", { description: "Atomically claim a task. Two agents cannot claim the same one. If claimed=false, check held_by_you first: true means you already hold it (your earlier claim landed and you are retrying), so carry on with the work. Only call task_find_work again when held_by_you is false.", inputSchema: z.object({ task_id: z.string() }) }, async (args, extra) => {
|
|
793
|
+
const { task_id } = args;
|
|
794
|
+
const { agentId } = await ensureAgent(extra);
|
|
795
|
+
return text(await api("POST", `/tasks/${task_id}/claim`, { agent_id: agentId }));
|
|
796
|
+
});
|
|
797
|
+
mcp.registerTool("auto_mode_next", { description: "Continue an operator-enabled Auto mode loop safely. Call this after completing and releasing the current task, before ending your turn. It checks this session's Auto mode switch, finds eligible board work, and atomically claims one item. If continue=true, work that returned task through the normal intent/lease/commit/surface-delta/completion protocol, then call auto_mode_next again. Stop and report the returned reason when continue=false; never bypass a human decision, permission boundary, or protocol gate.", inputSchema: z.object({}) }, async (_args, extra) => {
|
|
798
|
+
const { agentId } = await ensureAgent(extra);
|
|
799
|
+
const mode = await api("GET", `/agents/${agentId}/auto-mode`);
|
|
800
|
+
if (!mode.enabled || !mode.connected) {
|
|
801
|
+
return text({ continue: false, reason: mode.connected ? "auto_mode_off" : "agent_disconnected" });
|
|
802
|
+
}
|
|
803
|
+
// A claim is the concurrency boundary. If another agent wins between
|
|
804
|
+
// find-work and claim, retry a bounded number of times rather than
|
|
805
|
+
// handing the model work it does not own or looping forever.
|
|
806
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
807
|
+
const found = await api("POST", "/tasks/find-work", { agent_id: agentId });
|
|
808
|
+
if (!found.task)
|
|
809
|
+
return text({ continue: false, reason: "no_eligible_work" });
|
|
810
|
+
const claim = await api("POST", `/tasks/${found.task.id}/claim`, { agent_id: agentId });
|
|
811
|
+
if (claim.claimed || claim.held_by_you) {
|
|
812
|
+
return text({
|
|
813
|
+
continue: true,
|
|
814
|
+
task: claim.task ?? found.task,
|
|
815
|
+
estimated_lease_collisions: found.estimated_lease_collisions,
|
|
816
|
+
resumed_existing_claim: Boolean(claim.held_by_you),
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return text({ continue: false, reason: "claim_contention", retry_later: true });
|
|
821
|
+
});
|
|
822
|
+
mcp.registerTool("task_create", { description: "Add a task to the shared DAG — e.g. follow-up work you discovered but should not do inside your current lease (fixing another agent's call sites, deferred refactors).", inputSchema: z.object({
|
|
823
|
+
title: z.string(),
|
|
824
|
+
detail: z.string().optional(),
|
|
825
|
+
depends_on: z.array(z.string()).default([]),
|
|
826
|
+
estimated_paths: z.array(z.string()).default([]),
|
|
827
|
+
idempotency_key: IDEMPOTENCY_KEY,
|
|
828
|
+
}) }, async (args, extra) => {
|
|
829
|
+
const { projectId, agentId } = await ensureAgent(extra);
|
|
830
|
+
return text(await api("POST", "/tasks", { project_id: projectId, created_by: agentId, ...args }));
|
|
831
|
+
});
|
|
832
|
+
mcp.registerTool("task_complete", { description: "Mark a claimed task as completed so dependent tasks unblock.", inputSchema: z.object({ task_id: z.string() }) }, async (args, extra) => {
|
|
833
|
+
const { task_id } = args;
|
|
834
|
+
await ensureAgent(extra);
|
|
835
|
+
return text(await api("POST", `/tasks/${task_id}/complete`, {}));
|
|
836
|
+
});
|
|
837
|
+
// ---------------------------------------------------------------------------
|
|
838
|
+
// M9b — the board (§9). Project-scoped: a board item belongs to one project,
|
|
839
|
+
// and the project comes from this adapter's own registration rather than from
|
|
840
|
+
// a caller-supplied id it could get wrong.
|
|
841
|
+
// ---------------------------------------------------------------------------
|
|
842
|
+
const boardStatusEnum = z.enum([...BOARD_STATUSES]);
|
|
843
|
+
const boardKindEnum = z.enum([...BOARD_KINDS]);
|
|
844
|
+
const boardPrincipal = z.object({
|
|
845
|
+
type: z.enum(["vendor", "account"]),
|
|
846
|
+
id: z.string(),
|
|
847
|
+
});
|
|
848
|
+
mcp.registerTool("board_list", { description: "List board items for this project — phases, work items, debt, decisions and findings. Returns summaries (title, status, owners, an excerpt) rather than full bodies, so listing a large board stays cheap. Filter by status or kind; call board_get for the body of one item.", inputSchema: z.object({
|
|
849
|
+
status: boardStatusEnum.optional().describe("Only items in this status."),
|
|
850
|
+
kind: boardKindEnum.optional().describe("Only items of this kind."),
|
|
851
|
+
parent_id: z.string().optional().describe("Only this item's children. Pass 'root' for top-level items only."),
|
|
852
|
+
flag: z
|
|
853
|
+
.string()
|
|
854
|
+
.optional()
|
|
855
|
+
.describe("Only items carrying this flag. Matched whole, not as a prefix — 'token' does not match 'token saver'."),
|
|
856
|
+
q: z
|
|
857
|
+
.string()
|
|
858
|
+
.optional()
|
|
859
|
+
.describe("Substring search over title and body, case-insensitive, title matches ranked first. Prefer this over listing and reading: it is how you find one item without paying for the rest. Searching includes completed items by default, because that is where finished decisions are recorded."),
|
|
860
|
+
include_done: z
|
|
861
|
+
.boolean()
|
|
862
|
+
.optional()
|
|
863
|
+
.describe("Include completed and abandoned items. Defaults to false for a plain listing — you are usually deciding what to work on next — and to true when q is given. An explicit status filter overrides this."),
|
|
864
|
+
limit: z.number().int().min(1).max(500).optional(),
|
|
865
|
+
full: z
|
|
866
|
+
.boolean()
|
|
867
|
+
.default(false)
|
|
868
|
+
.describe("Return complete bodies instead of excerpts. Expensive on a large board — leave false unless you need every body at once."),
|
|
869
|
+
}) }, async (args, extra) => {
|
|
870
|
+
const { status, kind, parent_id, flag, q, include_done, limit, full } = args;
|
|
871
|
+
const { projectId } = await ensureAgent(extra);
|
|
872
|
+
const params = new URLSearchParams();
|
|
873
|
+
if (status)
|
|
874
|
+
params.set("status", status);
|
|
875
|
+
if (kind)
|
|
876
|
+
params.set("kind", kind);
|
|
877
|
+
if (parent_id)
|
|
878
|
+
params.set("parent", parent_id);
|
|
879
|
+
if (flag)
|
|
880
|
+
params.set("flag", flag);
|
|
881
|
+
if (q)
|
|
882
|
+
params.set("q", q);
|
|
883
|
+
// The HTTP route defaults to including finished work, because the web
|
|
884
|
+
// dashboard renders its history from the same endpoint. An agent
|
|
885
|
+
// listing the board is choosing what to do next, so the tool opts out
|
|
886
|
+
// unless it is searching — a search is usually looking for a decision
|
|
887
|
+
// that has already been made.
|
|
888
|
+
const wantDone = include_done ?? Boolean(q);
|
|
889
|
+
params.set("include_done", wantDone ? "1" : "0");
|
|
890
|
+
if (limit !== undefined)
|
|
891
|
+
params.set("limit", String(limit));
|
|
892
|
+
if (full)
|
|
893
|
+
params.set("full", "1");
|
|
894
|
+
const suffix = params.toString() ? `?${params}` : "";
|
|
895
|
+
const board = await api("GET", `/projects/${projectId}/board${suffix}`);
|
|
896
|
+
return text(Array.isArray(board?.items)
|
|
897
|
+
? { ...board, items: board.items.map(compactBoardItem) }
|
|
898
|
+
: board);
|
|
899
|
+
});
|
|
900
|
+
mcp.registerTool("board_get", { description: "Read one board item in full, including its markdown body and its `etag`. You must pass that etag to board_update — it is what proves you edited the version you actually read.", inputSchema: z.object({ item_id: z.string() }) }, async (args, extra) => {
|
|
901
|
+
const { item_id } = args;
|
|
902
|
+
const { projectId } = await ensureAgent(extra);
|
|
903
|
+
return text(await api("GET", `/projects/${projectId}/board/${item_id}`));
|
|
904
|
+
});
|
|
905
|
+
mcp.registerTool("board_create", { description: "Add an item to the project board: a work item, a piece of debt, a decision that needs making, or a finding worth keeping. Use this for durable project state — task_create is for the coordination DAG you claim work from.", inputSchema: z.object({
|
|
906
|
+
title: z.string(),
|
|
907
|
+
detail: z.string().optional().describe("Markdown body."),
|
|
908
|
+
kind: boardKindEnum.default("item"),
|
|
909
|
+
status: boardStatusEnum.default("available"),
|
|
910
|
+
owners: z
|
|
911
|
+
.array(boardPrincipal)
|
|
912
|
+
.default([])
|
|
913
|
+
.describe("Typed principals, e.g. [{type:'vendor',id:'claude_code'},{type:'account',id:'acc_…'}]. Joint ownership is normal."),
|
|
914
|
+
depends_on: z.array(z.string()).default([]),
|
|
915
|
+
estimated_paths: z.array(z.string()).default([]),
|
|
916
|
+
flags: z
|
|
917
|
+
.array(z.string())
|
|
918
|
+
.optional()
|
|
919
|
+
.describe("Short labels for cross-cutting concerns that are not status, kind, or owner — e.g. [\"token saver\"]. Canonicalised to lowercase with collapsed whitespace; letters, digits, spaces and _ . / - only, starting with a letter or digit. Max 16 flags, 48 characters each."),
|
|
920
|
+
parent_id: z.string().optional().describe("Nest under this item — e.g. the phase this step belongs to."),
|
|
921
|
+
position: z.number().int().optional().describe("Explicit order among siblings. Omit for unordered."),
|
|
922
|
+
}) }, async (args, extra) => {
|
|
923
|
+
const { projectId, agentId } = await ensureAgent(extra);
|
|
924
|
+
return text(boardWriteReceipt(await api("POST", `/projects/${projectId}/board`, { agent_id: agentId, ...args })));
|
|
925
|
+
});
|
|
926
|
+
mcp.registerTool("board_update", { description: "Update a board item. `expected_version` is mandatory and must be the `version` you read from board_get: if anyone changed the item in between you get a 412 with the current version, and you must re-read and reapply rather than overwrite. Send only the fields you are changing.", inputSchema: z.object({
|
|
927
|
+
item_id: z.string(),
|
|
928
|
+
expected_version: z
|
|
929
|
+
.number()
|
|
930
|
+
.int()
|
|
931
|
+
.describe("The version you read. A stale value is refused with 412 rather than silently overwriting."),
|
|
932
|
+
title: z.string().optional(),
|
|
933
|
+
detail: z.string().nullable().optional(),
|
|
934
|
+
kind: boardKindEnum.optional(),
|
|
935
|
+
status: boardStatusEnum.optional(),
|
|
936
|
+
owners: z.array(boardPrincipal).optional(),
|
|
937
|
+
depends_on: z.array(z.string()).optional(),
|
|
938
|
+
estimated_paths: z.array(z.string()).optional(),
|
|
939
|
+
flags: z
|
|
940
|
+
.array(z.string())
|
|
941
|
+
.optional()
|
|
942
|
+
.describe("Short labels for cross-cutting concerns that are not status, kind, or owner — e.g. [\"token saver\"]. Canonicalised to lowercase with collapsed whitespace; letters, digits, spaces and _ . / - only, starting with a letter or digit. Max 16 flags, 48 characters each." + " Replaces the whole list; pass [] to clear."),
|
|
943
|
+
parent_id: z.string().nullable().optional().describe("Re-parent, or null to move to the top level. A cycle is refused."),
|
|
944
|
+
position: z.number().int().nullable().optional(),
|
|
945
|
+
}) }, async (args, extra) => {
|
|
946
|
+
const { item_id, expected_version, ...patch } = args;
|
|
947
|
+
const { projectId, agentId } = await ensureAgent(extra);
|
|
948
|
+
// Only send keys the caller actually set: the route treats key *presence*
|
|
949
|
+
// as "change this field", so forwarding undefined would blank things.
|
|
950
|
+
const body = { agent_id: agentId };
|
|
951
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
952
|
+
if (value !== undefined)
|
|
953
|
+
body[key] = value;
|
|
954
|
+
}
|
|
955
|
+
return text(boardWriteReceipt(await api("PATCH", `/projects/${projectId}/board/${item_id}`, body, {
|
|
956
|
+
"if-match": `"${expected_version}"`,
|
|
957
|
+
})));
|
|
958
|
+
});
|
|
959
|
+
mcp.registerTool("board_history", { description: "Read the revision history of a board item — every version, who changed it, when, and a complete snapshot of what it said at the time. Use this to answer 'what did this say when that decision was made' rather than guessing from the current text.", inputSchema: z.object({ item_id: z.string(), limit: z.number().int().min(1).max(500).optional() }) }, async (args, extra) => {
|
|
960
|
+
const { item_id, limit } = args;
|
|
961
|
+
const { projectId } = await ensureAgent(extra);
|
|
962
|
+
const suffix = limit === undefined ? "" : `?limit=${limit}`;
|
|
963
|
+
return text(await api("GET", `/projects/${projectId}/board/${item_id}/history${suffix}`));
|
|
964
|
+
});
|
|
965
|
+
mcp.registerTool("document_list", { description: "List reference documents uploaded to this project's Organization page — specs, style guides, and other material every agent on the project can read. Returns metadata only (filename, content type, size, who uploaded it, when); call document_read for one file's actual content.", inputSchema: z.object({}) }, async (_args, extra) => {
|
|
966
|
+
const { projectId } = await ensureAgent(extra);
|
|
967
|
+
return text(await api("GET", `/projects/${projectId}/documents`));
|
|
968
|
+
});
|
|
969
|
+
mcp.registerTool("document_read", { description: "Read one uploaded reference document's content, by id from document_list. Text-like content (markdown, plain text, JSON, code, etc.) is returned inline; anything else is reported by name, type and size only — a binary blob is not useful as tool output.", inputSchema: z.object({ document_id: z.string() }) }, async (args, extra) => {
|
|
970
|
+
const { document_id } = args;
|
|
971
|
+
const { projectId } = await ensureAgent(extra);
|
|
972
|
+
const { contentType, buffer } = await apiRaw(`/projects/${projectId}/documents/${document_id}`);
|
|
973
|
+
const isText = /^text\//.test(contentType) || /json|xml|yaml|markdown|csv/.test(contentType);
|
|
974
|
+
return text(isText
|
|
975
|
+
? { content_type: contentType, size_bytes: buffer.length, content: buffer.toString("utf8") }
|
|
976
|
+
: { content_type: contentType, size_bytes: buffer.length, content: null, note: "Binary content is not returned as tool text." });
|
|
977
|
+
});
|
|
978
|
+
mcp.registerTool("surface_delta_publish", { description: "Publish every exported symbol, route, schema, config key, env var, or dependency you changed — BEFORE releasing your lease. Other agents depend on this to avoid building against a stale picture of your code. Changes of kind signature/removed trigger contract verification against other agents' in-flight work.", inputSchema: z.object({
|
|
979
|
+
intent_id: z.string(),
|
|
980
|
+
changes: z.array(z.object({
|
|
981
|
+
kind: z.string(),
|
|
982
|
+
path: z.string(),
|
|
983
|
+
name: z.string(),
|
|
984
|
+
change: z.string(),
|
|
985
|
+
before: z.string().nullable().default(null),
|
|
986
|
+
after: z.string().nullable().default(null),
|
|
987
|
+
})),
|
|
988
|
+
commit_sha: z.string().optional(),
|
|
989
|
+
}) }, async (args, extra) => {
|
|
990
|
+
await ensureAgent(extra);
|
|
991
|
+
const changes = withLocalCallSites(args.changes, ["signature", "removed"]);
|
|
992
|
+
return text(await api("POST", "/surface-deltas", { ...args, changes }));
|
|
993
|
+
});
|
|
994
|
+
mcp.registerTool("surface_delta_recent", { description: "List surface deltas other agents have published (optionally since an ISO timestamp). Check before building against any interface you have not read this session.", inputSchema: z.object({ since: z.string().optional() }) }, async (args, extra) => {
|
|
995
|
+
const { since } = args;
|
|
996
|
+
const { projectId } = await ensureAgent(extra);
|
|
997
|
+
const q = since ? `&since=${encodeURIComponent(since)}` : "";
|
|
998
|
+
return text(await api("GET", `/surface-deltas?project_id=${projectId}${q}`));
|
|
999
|
+
});
|
|
1000
|
+
mcp.registerTool("blast_radius_preview", { description: "Preview the cost of an interface change while planning, before committing to an intent: how many call sites, and whose leases they sit in. Cheap — call freely.", inputSchema: z.object({ interfaces_touched: z.array(interfaceSchema), paths: z.array(z.string()).default([]) }) }, async (args, extra) => {
|
|
1001
|
+
const { agentId } = await ensureAgent(extra);
|
|
1002
|
+
const interfaces = withLocalCallSites(args.interfaces_touched);
|
|
1003
|
+
// A declare with no lease acquisition is a preview; abandon it right after.
|
|
1004
|
+
const declared = await api("POST", "/intents", {
|
|
1005
|
+
agent_id: agentId,
|
|
1006
|
+
summary: "[blast radius preview]",
|
|
1007
|
+
paths: args.paths,
|
|
1008
|
+
interfaces_touched: interfaces,
|
|
1009
|
+
});
|
|
1010
|
+
return text({ call_sites: declared.blast_radius, verdict: declared.blast_radius?.verdict });
|
|
1011
|
+
});
|
|
1012
|
+
mcp.registerTool("contract_acknowledge", { description: "Acknowledge a contract.broken event AFTER fixing the named call sites. Blocking-severity breaks prevent intent_complete until acknowledged.", inputSchema: z.object({ break_id: z.string(), resolution: z.string().default("fixed") }) }, async (args, extra) => {
|
|
1013
|
+
const { break_id, resolution } = args;
|
|
1014
|
+
await ensureAgent(extra);
|
|
1015
|
+
return text(await api("POST", `/contract-breaks/${break_id}/ack`, { resolution }));
|
|
1016
|
+
});
|
|
1017
|
+
mcp.registerTool("contract_breaks_pending", { description: "List unacknowledged contract breaks targeted at YOU — call sites another agent's change broke in your in-flight work. Fix these before completing your intent.", inputSchema: z.object({}) }, async (_args, extra) => {
|
|
1018
|
+
const { projectId, agentId } = await ensureAgent(extra);
|
|
1019
|
+
return text(await api("GET", `/contract-breaks?project_id=${projectId}&target_agent=${agentId}&open=1`));
|
|
1020
|
+
});
|
|
1021
|
+
// ---------------------------------------------------------------------------
|
|
1022
|
+
// Local graph tools
|
|
1023
|
+
//
|
|
1024
|
+
// These answer from the index built beside the checkout, per ADR 002 and
|
|
1025
|
+
// inv_01KZWPAM9QCZ0DD7789N55X647: parsing and traversal happen here and the
|
|
1026
|
+
// server never sees code. Both are read-only, take no lease, and cost one
|
|
1027
|
+
// index build per process, cached thereafter.
|
|
1028
|
+
//
|
|
1029
|
+
// They exist because an agent currently answers "what does this change reach"
|
|
1030
|
+
// by grepping and then opening every file that matched. The graph answers from
|
|
1031
|
+
// bindings — a call resolves only where the name is defined in the calling
|
|
1032
|
+
// file or imported into it — and states what it could not see.
|
|
1033
|
+
// ---------------------------------------------------------------------------
|
|
1034
|
+
/**
|
|
1035
|
+
* One index per process, keyed by root. Rebuilding per call would be absurd
|
|
1036
|
+
* — and so, at N agent CLIs sharing one checkout, is N processes each
|
|
1037
|
+
* holding their own copy. `KEEL_MCP_DAEMON=1` moves that copy into one
|
|
1038
|
+
* shared background process (daemon.ts); `daemonEnabled()` off (the
|
|
1039
|
+
* default) or the daemon being unreachable both fall through to exactly
|
|
1040
|
+
* this process's own build, unchanged from before daemon mode existed.
|
|
1041
|
+
*/
|
|
1042
|
+
let graphCache = null;
|
|
1043
|
+
async function localGraph() {
|
|
1044
|
+
const root = repoRoot();
|
|
1045
|
+
if (graphCache && graphCache.root === root)
|
|
1046
|
+
return graphCache;
|
|
1047
|
+
graphCache = await buildLocalGraph(root);
|
|
1048
|
+
return graphCache;
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* Answer one local-graph operation from the daemon if one is reachable and
|
|
1052
|
+
* on-protocol; otherwise from this process's own (possibly newly built)
|
|
1053
|
+
* copy. Every caller below is the same three lines: try the daemon, spawn
|
|
1054
|
+
* one for next time if it wasn't there, fall back.
|
|
1055
|
+
*/
|
|
1056
|
+
async function viaDaemonOrLocal(op, args, runLocal) {
|
|
1057
|
+
const root = repoRoot();
|
|
1058
|
+
const fromDaemon = await tryDaemon(root, op, args);
|
|
1059
|
+
if (fromDaemon !== null)
|
|
1060
|
+
return fromDaemon;
|
|
1061
|
+
ensureDaemonSpawned(root);
|
|
1062
|
+
return runLocal(await localGraph());
|
|
1063
|
+
}
|
|
1064
|
+
const handleImpact = async (args, _extra) => {
|
|
1065
|
+
const impactArgs = { paths: args.paths, max_distance: args.max_distance };
|
|
1066
|
+
return text(await viaDaemonOrLocal("impact", impactArgs, (local) => runImpact(local, impactArgs)));
|
|
1067
|
+
};
|
|
1068
|
+
const impactDesc = "Which files must you edit if you change these? Returns affected paths each with a CHECKABLE reason (\"handler() calls query() at line 41\"), plus what the graph could not see. Resolved from real import and call bindings rather than name matching, so a comment mentioning a symbol never appears. Read-only, no lease. Prefer this over grepping for callers.";
|
|
1069
|
+
const impactSchema = z.object({
|
|
1070
|
+
paths: z.array(z.string()).describe("Repo-root-relative paths you intend to change."),
|
|
1071
|
+
max_distance: z
|
|
1072
|
+
.number()
|
|
1073
|
+
.int()
|
|
1074
|
+
.min(1)
|
|
1075
|
+
.max(6)
|
|
1076
|
+
.default(1)
|
|
1077
|
+
.describe("1 (default) = files that directly reference these, which is what must be edited. " +
|
|
1078
|
+
"Higher follows the chain further: 2 includes callers of those callers, and so on. " +
|
|
1079
|
+
"Raise it only to ask what might behave differently downstream, not what must change."),
|
|
1080
|
+
});
|
|
1081
|
+
mcp.registerTool("sync_impact", { description: impactDesc, inputSchema: impactSchema }, handleImpact);
|
|
1082
|
+
const handleContextCard = async (args, _extra) => {
|
|
1083
|
+
const cardArgs = { path: args.path, max_callers: args.max_callers, include_skeleton: args.include_skeleton };
|
|
1084
|
+
return text(await viaDaemonOrLocal("context_card", cardArgs, (local) => runContextCard(local, cardArgs)));
|
|
1085
|
+
};
|
|
1086
|
+
const contextCardDesc = "Everything worth knowing before editing one file: exports, imports, callers with reasons, callees, and a signature skeleton, in one bounded response instead of a read-then-grep-then-read loop. Local only — the card is never transmitted to the server. Read-only, no lease.";
|
|
1087
|
+
const contextCardSchema = z.object({
|
|
1088
|
+
path: z.string().describe("Repo-root-relative path of the file you are about to edit."),
|
|
1089
|
+
max_callers: z.number().int().min(1).max(100).default(20),
|
|
1090
|
+
include_skeleton: z.boolean().default(true),
|
|
1091
|
+
});
|
|
1092
|
+
mcp.registerTool("sync_context_card", { description: contextCardDesc, inputSchema: contextCardSchema }, handleContextCard);
|
|
1093
|
+
const handleRetrieve = async (args, extra) => {
|
|
1094
|
+
// Identity and lease state are per-agent and never the shared daemon's to
|
|
1095
|
+
// know — resolved here, client-side, exactly as before daemon mode, and
|
|
1096
|
+
// handed to whichever side (daemon or local) actually runs the query.
|
|
1097
|
+
const { projectId, agentId } = await ensureAgent(extra);
|
|
1098
|
+
const leases = (await api("GET", `/leases?project_id=${projectId}`));
|
|
1099
|
+
const leasedPaths = leases
|
|
1100
|
+
.filter((lease) => lease.agent_id !== agentId)
|
|
1101
|
+
.flatMap((lease) => [...(lease.paths ?? []), ...(lease.secondary_paths ?? [])]);
|
|
1102
|
+
const retrieveArgs = {
|
|
1103
|
+
question: args.question,
|
|
1104
|
+
limit: args.limit,
|
|
1105
|
+
offset: args.offset,
|
|
1106
|
+
exclude_paths: args.exclude_paths,
|
|
1107
|
+
refinement: args.refinement,
|
|
1108
|
+
leasedPaths,
|
|
1109
|
+
};
|
|
1110
|
+
return text(await viaDaemonOrLocal("retrieve", retrieveArgs, (local) => runRetrieve(local, retrieveArgs)));
|
|
1111
|
+
};
|
|
1112
|
+
const retrieveDesc = "Search local derived retrieval pages; supports stateless follow-up refinement, pagination, and exclusions, and returns ranked files, quoted facts, reasons, leg ranks, agreement, continuation metadata, and lease warnings. Source pages never cross the coordination server.";
|
|
1113
|
+
const retrieveSchema = z.object({
|
|
1114
|
+
question: z.string().min(1),
|
|
1115
|
+
limit: z.number().int().min(1).max(50).default(10),
|
|
1116
|
+
offset: z.number().int().min(0).max(100).default(0),
|
|
1117
|
+
exclude_paths: z.array(z.string().min(1)).max(100).default([]),
|
|
1118
|
+
refinement: z.string().max(2000).optional(),
|
|
1119
|
+
});
|
|
1120
|
+
mcp.registerTool("sync_retrieve", { description: retrieveDesc, inputSchema: retrieveSchema }, handleRetrieve);
|
|
1121
|
+
return mcp;
|
|
1122
|
+
}
|
|
1123
|
+
if (TEST_IMPORT_MODE) {
|
|
1124
|
+
// Imported by focused unit tests; registering tools must not start a stdio
|
|
1125
|
+
// transport or keep the test process alive.
|
|
1126
|
+
}
|
|
1127
|
+
else if (SETUP_MODE) {
|
|
1128
|
+
await runSetup(process.argv.slice(3));
|
|
1129
|
+
}
|
|
1130
|
+
else if (LOGIN_MODE) {
|
|
1131
|
+
await runLogin(process.argv.slice(3));
|
|
1132
|
+
}
|
|
1133
|
+
else {
|
|
1134
|
+
// A piped stdin data listener is not a referenced event-loop handle on every
|
|
1135
|
+
// supported Node build. Without an explicit lifetime handle the adapter can
|
|
1136
|
+
// connect and then exit(0) before Claude Code sends `initialize`, surfacing as
|
|
1137
|
+
// the opaque MCP -32000 "Connection closed" error. Keep the process alive
|
|
1138
|
+
// until its client closes stdin; do not leave a heartbeat timer behind after
|
|
1139
|
+
// the transport is gone.
|
|
1140
|
+
const stdioLifetime = setInterval(() => { }, 24 * 60 * 60 * 1000);
|
|
1141
|
+
const endStdioLifetime = () => clearInterval(stdioLifetime);
|
|
1142
|
+
process.stdin.resume();
|
|
1143
|
+
process.stdin.once("end", endStdioLifetime);
|
|
1144
|
+
try {
|
|
1145
|
+
serveStdio((requestContext) => createSyncMcpServer(requestContext));
|
|
1146
|
+
}
|
|
1147
|
+
catch (error) {
|
|
1148
|
+
endStdioLifetime();
|
|
1149
|
+
throw error;
|
|
1150
|
+
}
|
|
1151
|
+
}
|