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.
Files changed (52) hide show
  1. package/README.md +86 -0
  2. package/dist/lib/agent-state.js +119 -0
  3. package/dist/lib/blast.js +462 -0
  4. package/dist/lib/client-config.js +81 -0
  5. package/dist/lib/env-compat.js +66 -0
  6. package/dist/lib/globs.js +0 -0
  7. package/dist/lib/ids.js +24 -0
  8. package/dist/lib/index/aliases.js +244 -0
  9. package/dist/lib/index/call-sites.js +178 -0
  10. package/dist/lib/index/checker-resolver.js +257 -0
  11. package/dist/lib/index/context-card.js +140 -0
  12. package/dist/lib/index/coverage.js +218 -0
  13. package/dist/lib/index/delivery.js +66 -0
  14. package/dist/lib/index/discovery.js +90 -0
  15. package/dist/lib/index/embedding.js +110 -0
  16. package/dist/lib/index/file-index.js +222 -0
  17. package/dist/lib/index/fingerprint.js +0 -0
  18. package/dist/lib/index/git-history.js +136 -0
  19. package/dist/lib/index/graph.js +234 -0
  20. package/dist/lib/index/impact.js +174 -0
  21. package/dist/lib/index/incremental.js +332 -0
  22. package/dist/lib/index/lexical.js +462 -0
  23. package/dist/lib/index/order.js +43 -0
  24. package/dist/lib/index/pages.js +357 -0
  25. package/dist/lib/index/persistence.js +233 -0
  26. package/dist/lib/index/pipeline.js +527 -0
  27. package/dist/lib/index/registry.js +106 -0
  28. package/dist/lib/index/resolve.js +280 -0
  29. package/dist/lib/index/semantic.js +381 -0
  30. package/dist/lib/index/surfaces.js +27 -0
  31. package/dist/lib/index/symbols.js +426 -0
  32. package/dist/lib/index/transformers-embedder.js +73 -0
  33. package/dist/lib/index/typescript-parser.js +532 -0
  34. package/dist/lib/index/vector-cache.js +176 -0
  35. package/dist/lib/index/verification.js +58 -0
  36. package/dist/lib/mcp-compaction.js +241 -0
  37. package/dist/lib/model-roles.js +206 -0
  38. package/dist/lib/path-warnings.js +90 -0
  39. package/dist/lib/protocol.js +95 -0
  40. package/dist/lib/types.js +69 -0
  41. package/dist/lib/version.js +21 -0
  42. package/dist/lib/worktree.js +211 -0
  43. package/dist/mcp/approval.js +0 -0
  44. package/dist/mcp/cloud-connector.js +99 -0
  45. package/dist/mcp/daemon-client.js +156 -0
  46. package/dist/mcp/daemon-protocol.js +100 -0
  47. package/dist/mcp/escalation-waiter.js +183 -0
  48. package/dist/mcp/graph-ops.js +169 -0
  49. package/dist/mcp/index.js +1151 -0
  50. package/dist/mcp/login.js +169 -0
  51. package/dist/mcp/setup.js +90 -0
  52. package/package.json +42 -0
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # Syncstaff MCP adapter
2
+
3
+ `syncstaff-mcp` connects an MCP-capable coding agent to a hosted Syncstaff
4
+ server. It contains only the local adapter; the Syncstaff API server, PostgreSQL
5
+ driver, and arbiter SDK are not part of this package.
6
+
7
+ ## Login
8
+
9
+ Run this from the repository you want your agent to edit:
10
+
11
+ ```bash
12
+ npx -y syncstaff-mcp@0.2.3 setup
13
+ ```
14
+
15
+ It uses the folder name as the project and defaults to the hosted server, so
16
+ you can accept every prompt. Approving in the browser creates your workspace
17
+ and project if you don't have them yet — **you do not need an account first.**
18
+
19
+ No sign-in yet? The approval page is where you get one: it asks you to
20
+ continue with GitHub, then finishes the connection.
21
+
22
+ <https://www.syncstaff.ai>
23
+
24
+ It asks for the Syncstaff server, project, and agent vendor, completes device login,
25
+ and writes the repo-local `.mcp.json` configuration. Restart the coding agent
26
+ when it finishes.
27
+
28
+ From the repository the agent will edit, start a device login:
29
+
30
+ ```bash
31
+ npx -y syncstaff-mcp@0.2.3 login \
32
+ --server https://api.syncstaff.ai \
33
+ --project your-project \
34
+ --vendor codex
35
+ ```
36
+
37
+ Open the displayed link, approve the short code, and leave the command running
38
+ until it confirms the connection. Syncstaff stores the returned credential in
39
+ `.keel/config.json` with owner-only permissions and never prints the token.
40
+ The repository's `.keel/` directory should remain ignored by Git.
41
+
42
+ ## MCP configuration
43
+
44
+ Run the adapter from the repository the agent will edit so Syncstaff can inspect
45
+ that checkout for call sites and unleased writes:
46
+
47
+ ```json
48
+ {
49
+ "mcpServers": {
50
+ "sync": {
51
+ "command": "npx",
52
+ "args": ["-y", "syncstaff-mcp@0.2.3"]
53
+ }
54
+ }
55
+ }
56
+ ```
57
+
58
+ Node.js 22.5 or newer is required. The adapter communicates over stdio and
59
+ registers with the configured Syncstaff server on its first tool call.
60
+ Values supplied through `SYNCSTAFF_URL`, `SYNCSTAFF_PROJECT`, `SYNCSTAFF_TOKEN`, or
61
+ `SYNCSTAFF_VENDOR` override the saved login when an explicit per-client setting is
62
+ needed.
63
+
64
+ Version 0.2.3 and newer bind every agent registration to the coding client's
65
+ task/session id. If a client cannot expose that id automatically, call
66
+ `sync_get(session_id="<the id shown by the client>")` once at session start.
67
+ The adapter will not create an anonymous agent record, because an agent that
68
+ cannot be correlated cannot be represented reliably in Workforce.
69
+
70
+ Every HTTP request carries the adapter package version and an independent
71
+ protocol version. A server that knows the protocol is incompatible answers
72
+ with `426` and the protocol it supports, instead of letting a stale adapter
73
+ silently call a changed route. Headerless web and legacy clients remain valid
74
+ so the server and adapter can be deployed in either order.
75
+
76
+ ## Release verification
77
+
78
+ From `keel/`, run:
79
+
80
+ ```bash
81
+ npm run test:mcp-package
82
+ ```
83
+
84
+ The smoke test builds only the adapter's TypeScript dependency graph, packs
85
+ the exact npm artifact, installs it into a new temporary directory, and starts
86
+ the installed `syncstaff-mcp` executable through an MCP initialize exchange.
@@ -0,0 +1,119 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { keelEnv } from "./env-compat.js";
5
+ /** Resolve the git root used by both the MCP process and lifecycle hooks. */
6
+ export function repoRoot(cwd = process.cwd()) {
7
+ try {
8
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], {
9
+ cwd,
10
+ encoding: "utf8",
11
+ stdio: ["ignore", "pipe", "ignore"],
12
+ }).trim();
13
+ }
14
+ catch {
15
+ return resolve(cwd);
16
+ }
17
+ }
18
+ const slug = (s) => s.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 160);
19
+ /**
20
+ * The session id of the client this process belongs to, or undefined.
21
+ *
22
+ * Every supported client exports one to its child processes, which is what
23
+ * lets a hook process and an MCP process for the *same* session agree on an
24
+ * identity without either being told: they read the same variable. The
25
+ * generic `CHARTER_SESSION_ID` wins so a harness can pin one explicitly.
26
+ *
27
+ * This must be consulted for **every** vendor. It previously read only
28
+ * `CODEX_THREAD_ID`, which is why Codex was the only client whose parallel
29
+ * sessions were ever isolated — see the note on `stateFile`.
30
+ */
31
+ export function vendorSessionId() {
32
+ return (process.env.CHARTER_SESSION_ID ||
33
+ process.env.CODEX_THREAD_ID ||
34
+ process.env.CLAUDE_CODE_SESSION_ID ||
35
+ undefined);
36
+ }
37
+ /**
38
+ * Where a session's agent identity lives.
39
+ *
40
+ * Scoped by `vendor` **and** session, because a hook process and an MCP
41
+ * process for the *same session* must share one identity, while any two
42
+ * sessions must not — whether they are different clients or two windows of
43
+ * the same one. Sharing gives them a single agent id, and two agents with one
44
+ * id cannot collide, so coordination between them silently stops working.
45
+ *
46
+ * That second half went unenforced until 2026-08-20. The session segment was
47
+ * populated from `CODEX_THREAD_ID` alone, so every non-Codex client dropped it
48
+ * and every session of that client in a checkout shared one file, one agent id,
49
+ * and therefore one lease-holder. Two Claude Code sessions could take leases on
50
+ * the same path and each be told it was free. `sessionId` reaching here as
51
+ * null or undefined is what that bug looked like, so prefer
52
+ * :func:`vendorSessionId` over passing nothing.
53
+ */
54
+ export function stateFile(root = repoRoot(), sessionId = vendorSessionId(), vendor = process.env.CHARTER_VENDOR) {
55
+ const explicit = keelEnv("STATE_FILE");
56
+ if (explicit)
57
+ return explicit;
58
+ const parts = ["state"];
59
+ if (vendor)
60
+ parts.push(slug(vendor));
61
+ if (sessionId)
62
+ parts.push(slug(sessionId));
63
+ return join(root, ".keel", `${parts.join("-")}.json`);
64
+ }
65
+ /**
66
+ * The state file to *read*, tolerating identities written before sessions were
67
+ * scoped.
68
+ *
69
+ * Writes always go to the session-scoped path from :func:`stateFile`. Reads
70
+ * come through here, which falls back to the legacy vendor-only path **only
71
+ * when this process cannot name its own session** — there is nothing more
72
+ * specific to prefer, so the pre-migration file is the best available guess.
73
+ *
74
+ * When a session id IS known, the scoped path is returned even if it does not
75
+ * exist yet on disk, and the legacy file is never consulted. That used to be
76
+ * a fallback for the boot-order case too — the scoped file for a real session
77
+ * genuinely not existing yet, indistinguishable on disk from a pre-migration
78
+ * checkout. In practice that made the fallback adoptable repeatedly and
79
+ * indefinitely: any process that starts before its own session-scoped file is
80
+ * written (an MCP server racing the SessionStart hook that creates it) picks
81
+ * up whichever *other* session last left a legacy file, caches that identity,
82
+ * and never notices the mismatch — two live sessions collapse onto one agent
83
+ * id, which is exactly the isolation failure session-scoping exists to
84
+ * prevent. A missing scoped file now simply means "not registered under this
85
+ * session yet"; the caller re-registers, which is cheap and correct.
86
+ */
87
+ export function resolveStateFile(root = repoRoot(), sessionId = vendorSessionId(), vendor = process.env.CHARTER_VENDOR) {
88
+ const explicit = keelEnv("STATE_FILE");
89
+ if (explicit)
90
+ return explicit;
91
+ const scoped = stateFile(root, sessionId, vendor);
92
+ if (existsSync(scoped) || sessionId)
93
+ return scoped;
94
+ const legacy = stateFile(root, null, vendor);
95
+ return existsSync(legacy) ? legacy : scoped;
96
+ }
97
+ export function loadAgentState(file = stateFile()) {
98
+ if (!existsSync(file))
99
+ return null;
100
+ try {
101
+ const state = JSON.parse(readFileSync(file, "utf8"));
102
+ if (typeof state.server !== "string" ||
103
+ typeof state.project_id !== "string" ||
104
+ typeof state.agent_id !== "string" ||
105
+ typeof state.repo_root !== "string") {
106
+ return null;
107
+ }
108
+ return state;
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ }
114
+ export function saveAgentState(state, file = stateFile()) {
115
+ mkdirSync(dirname(file), { recursive: true });
116
+ const temporary = `${file}.${process.pid}.tmp`;
117
+ writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
118
+ renameSync(temporary, file);
119
+ }
@@ -0,0 +1,462 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import { join } from "node:path";
5
+ import { globMatches, isGlob } from "./globs.js";
6
+ import { now } from "./ids.js";
7
+ import { buildGraphCallSiteIndex } from "./index/call-sites.js";
8
+ import { discoverFiles } from "./index/discovery.js";
9
+ import { LanguageRegistry } from "./index/registry.js";
10
+ import { createTypeScriptParser } from "./index/typescript-parser.js";
11
+ const SOURCE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py"]);
12
+ const ANALYZABLE_KINDS = new Set(["function", "export", "class", "method", "constant"]);
13
+ const MAX_REPORTED_SITES = 20_000;
14
+ const MAX_CONTEXT_CHARS = 200;
15
+ const SKIP_DIRS = new Set([
16
+ "node_modules", ".git", "dist", "build", "out", ".venv", "venv",
17
+ "__pycache__", ".next", ".turbo", "coverage", ".keel",
18
+ ]);
19
+ /**
20
+ * Files git is tracking, for resolving call sites against.
21
+ *
22
+ * An untracked file is **not** a call site. Nothing can break against a file
23
+ * that is not in the index: no other agent is building on it, it appears in
24
+ * nobody's commit, and it is not part of the contract this repository owes
25
+ * anyone. Counting it is not merely noise — it actively breaks coordination.
26
+ *
27
+ * Observed 20 Aug 2026 (`int_01M0FYFA6RTM45M2GZH0CPD974`). Three spent
28
+ * one-shot codemods sat untracked in the tree, each mentioning the declared
29
+ * symbols inside regex and string literals:
30
+ *
31
+ * content.replace(/let projectId: string \| null = null;\n/, '')
32
+ * content.indexOf('async function ensureAgent(')
33
+ *
34
+ * They call nothing. They contributed 12 apparent sites out of 40, which was
35
+ * enough to push the verdict to `wide`, raise an escalation, and have
36
+ * `lease_acquire` refuse the lease with "intent is escalated; awaiting human
37
+ * decision". The lease was then widened onto those same files, and
38
+ * `intent_complete` refused because they were dirty — a file the system added
39
+ * on the agent's behalf, blocking the agent. Clearing it took a human.
40
+ *
41
+ * Falls back to the full walk when git cannot answer, because a wrong-but-wide
42
+ * radius is a slower review, while a wrongly-empty one is a missed contract
43
+ * break — the same asymmetry `mergeCallSites` is built around.
44
+ *
45
+ * NOT for declared-path existence warnings. Those ask "is this path really
46
+ * there", and an untracked file genuinely is; filtering them to tracked-only
47
+ * would warn about real files an agent is about to add. Use
48
+ * :func:`discoverFiles` for that — its `-co` view includes them.
49
+ */
50
+ export function listTrackedFiles(repoPath, maxFiles = 20000) {
51
+ try {
52
+ const out = execFileSync("git", ["-C", repoPath, "ls-files", "-z", "--cached"], {
53
+ encoding: "utf8",
54
+ maxBuffer: 64 * 1024 * 1024,
55
+ stdio: ["ignore", "pipe", "ignore"],
56
+ });
57
+ const tracked = out.split("\0").filter(Boolean);
58
+ // An empty result is not proof of an empty repo — a fresh checkout with
59
+ // nothing staged looks identical to a directory git does not own. Only a
60
+ // non-empty answer is evidence, so anything else falls back.
61
+ if (tracked.length > 0) {
62
+ return tracked
63
+ .map((file) => file.split("\\").join("/"))
64
+ .filter((file) => !file.split("/").some((segment) => SKIP_DIRS.has(segment)))
65
+ .slice(0, maxFiles);
66
+ }
67
+ }
68
+ catch {
69
+ // Not a git repository, or git is unavailable. Fall through.
70
+ }
71
+ // `discoverFiles` carries the walk fallback for a non-git checkout, and is
72
+ // the one enumerator this repository keeps. Its `-co` view is wider than
73
+ // `--cached` — it includes untracked-but-not-ignored files — which is the
74
+ // right direction to fail: a wide radius costs a slower review, an empty one
75
+ // costs a missed contract break.
76
+ return discoverFiles(repoPath, { limit: maxFiles });
77
+ }
78
+ function escapeRe(s) {
79
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
80
+ }
81
+ function pathCoveredBy(path, patterns) {
82
+ return patterns.some((p) => (isGlob(p) ? globMatches(p, path) : p === path));
83
+ }
84
+ /**
85
+ * The graph-backed analyzer, built lazily and once per process.
86
+ *
87
+ * `undefined` means "not yet attempted", `null` means "attempted and
88
+ * unavailable" — a repository with no parseable source, or an install without
89
+ * typescript. The distinction matters because retrying a failed load on every
90
+ * intent would pay the cost repeatedly to reach the same answer.
91
+ *
92
+ * Keyed by repo path: one agent process can be asked about more than one
93
+ * checkout, and an index of the wrong repository is worse than none.
94
+ */
95
+ const graphIndexes = new Map();
96
+ function graphIndexFor(repoPath) {
97
+ const cached = graphIndexes.get(repoPath);
98
+ if (cached !== undefined)
99
+ return cached;
100
+ const built = buildGraphCallSiteIndex(repoPath, () => {
101
+ // require rather than import: this module is loaded by the server and by
102
+ // the published adapter, and typescript must stay optional for the latter.
103
+ // A missing parser degrades to grep, which is why the analyzer is named in
104
+ // every report.
105
+ try {
106
+ const require = createRequire(import.meta.url);
107
+ const ts = require("typescript");
108
+ return new LanguageRegistry().register(createTypeScriptParser(ts));
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ });
114
+ graphIndexes.set(repoPath, built);
115
+ return built;
116
+ }
117
+ /** Test seam: forget cached indexes so a fixture repo is re-read. */
118
+ export function resetGraphCallSiteCache() {
119
+ graphIndexes.clear();
120
+ }
121
+ /**
122
+ * Combine graph-confirmed sites with textual ones, keeping every location and
123
+ * preferring the confirmed label where both found the same place.
124
+ *
125
+ * Returns null only when BOTH analyzers returned null, which means nothing
126
+ * could look at this repository at all — the "unknown" status the protocol
127
+ * already understands. An empty array is a real answer; null is not.
128
+ */
129
+ export function mergeCallSites(confirmed, textual) {
130
+ if (confirmed === null && textual === null)
131
+ return null;
132
+ const byLocation = new Map();
133
+ for (const site of textual ?? []) {
134
+ byLocation.set(`${site.path}:${site.line}`, { ...site, confidence: "unconfirmed" });
135
+ }
136
+ for (const site of confirmed ?? []) {
137
+ const key = `${site.path}:${site.line}`;
138
+ const existing = byLocation.get(key);
139
+ byLocation.set(key, {
140
+ // The graph knows the calling function; grep knows the source line.
141
+ // Where both saw a location, keep grep's richer context and the graph's
142
+ // verdict, because the verdict is the part a reader acts on.
143
+ ...(existing ?? site),
144
+ path: site.path,
145
+ line: site.line,
146
+ symbol: site.symbol,
147
+ context: existing?.context || site.context,
148
+ confidence: "confirmed",
149
+ });
150
+ }
151
+ return [...byLocation.values()].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0) || a.line - b.line);
152
+ }
153
+ /**
154
+ * Deterministic reference resolution (§5.8). Grep-grade rather than
155
+ * language-server-grade: finds call sites of the symbol's final dotted
156
+ * segment across TS/JS/Python source, skipping definition lines.
157
+ *
158
+ * Retained as the fallback for languages the graph has no backend for, and
159
+ * for installs without a parser. It cannot tell a call from a comment — see
160
+ * lib/index/call-sites.ts for what that cost — so callers must label which
161
+ * analyzer produced a report.
162
+ */
163
+ export function resolveCallSites(repoPath, symbol, repoFiles) {
164
+ const lastSeg = symbol.split(".").pop() ?? symbol;
165
+ if (!lastSeg)
166
+ return [];
167
+ const sourceFiles = repoFiles.filter((f) => SOURCE_EXTS.has(f.slice(f.lastIndexOf("."))));
168
+ if (sourceFiles.length === 0)
169
+ return null; // no supported language → unknown
170
+ const callRe = new RegExp(`\\b${escapeRe(lastSeg)}\\s*\\(`);
171
+ const defRe = new RegExp(`\\b(def|function|class)\\s+${escapeRe(lastSeg)}\\b|\\b${escapeRe(lastSeg)}\\s*[:=]\\s*(async\\s+)?(function|\\()`);
172
+ const sites = [];
173
+ for (const file of sourceFiles) {
174
+ let content;
175
+ try {
176
+ content = readFileSync(join(repoPath, file), "utf8");
177
+ }
178
+ catch {
179
+ continue;
180
+ }
181
+ if (!content.includes(lastSeg))
182
+ continue;
183
+ const lines = content.split("\n");
184
+ for (let i = 0; i < lines.length; i++) {
185
+ const line = lines[i];
186
+ if (!callRe.test(line))
187
+ continue;
188
+ if (defRe.test(line))
189
+ continue; // declaration, not a call site
190
+ sites.push({ path: file, line: i + 1, symbol, context: line.trim().slice(0, 200) });
191
+ }
192
+ }
193
+ return sites;
194
+ }
195
+ export function attachClientCallSiteReports(repoPath, interfaces, options = {}) {
196
+ // Tracked files only: an untracked file cannot be a call site anyone can
197
+ // break against. See listTrackedFiles for what counting them cost.
198
+ const repoFiles = listTrackedFiles(repoPath);
199
+ const includeContext = options.includeContext ?? true;
200
+ const analyzeChanges = new Set(options.analyzeChanges ?? ["signature", "added", "removed", "behavior"]);
201
+ const generatedAt = now();
202
+ return interfaces.map((iface) => {
203
+ if (!analyzeChanges.has(iface.change) || !ANALYZABLE_KINDS.has(iface.kind)) {
204
+ return iface;
205
+ }
206
+ // Union, not replacement, and the distinction was earned the hard way.
207
+ //
208
+ // The graph binds a call only where the name is defined in the calling
209
+ // file or imported into it, so it cannot be fooled by a comment. Using it
210
+ // ALONE looked right until the fixtures failed: code that calls through an
211
+ // unimported global resolves to nothing, and a contract break that grep
212
+ // had caught stopped being caught. Trading a false positive for a missed
213
+ // contract break is not an improvement — it is the same error pointed the
214
+ // other way.
215
+ //
216
+ // So both run. Graph hits are "confirmed", textual-only hits are
217
+ // "unconfirmed", and nothing is discarded. Recall is at least what it was;
218
+ // precision now lives in the label rather than in the count, and a
219
+ // consumer that wants the trustworthy subset filters on it.
220
+ // References, not calls. The intent-time surface and the hook-time surface
221
+ // now resolve through one function (R12.0), because two analyzers that
222
+ // answer "what references this symbol" differently is how an agent learns
223
+ // to trust neither. Before this, declaring an intent over a constant found
224
+ // only what grep found — `KEEL_RELEASE_VERSION(` appears nowhere, so a
225
+ // change to a constant four modules read produced an empty confirmed set.
226
+ //
227
+ // Import lines are the one kind left out, and the reason is not tidiness.
228
+ // A file that imports a symbol is already named by whatever it imported it
229
+ // FOR, so counting the import as well inflates the site count — which feeds
230
+ // the width threshold, which escalates to a human — without naming one
231
+ // additional file. A file that imports and never uses is dead code. Barrels
232
+ // are not affected: `export { X } from "./x.js"` is a re-export, which is
233
+ // counted, and is exactly the file that breaks when X is removed.
234
+ const graph = graphIndexFor(repoPath);
235
+ const graphSites = graph
236
+ ? graph.referencesFor(iface.name).filter((site) => site.kind !== "import")
237
+ : null;
238
+ const grepSites = resolveCallSites(repoPath, iface.name, repoFiles);
239
+ // Only label when the graph actually looked. Labelling grep-only results
240
+ // "unconfirmed" would be technically true and practically harmful: the
241
+ // width verdict discounts unconfirmed sites, so a repository with no
242
+ // parser backend would have every site discounted and could never be
243
+ // judged wide. Absent confidence means "nobody assessed this", which the
244
+ // verdict treats as countable — the behaviour that existed before.
245
+ const sites = graphSites ? mergeCallSites(graphSites, grepSites) : grepSites;
246
+ const analyzer = graphSites ? `${graph.analyzer}+grep-v1` : "grep-v1";
247
+ const report = {
248
+ source: "client",
249
+ analyzer,
250
+ generated_at: generatedAt,
251
+ status: sites === null ? "unknown" : "resolved",
252
+ truncated: (sites?.length ?? 0) > MAX_REPORTED_SITES,
253
+ call_sites: (sites ?? []).slice(0, MAX_REPORTED_SITES).map((site) => ({
254
+ ...site,
255
+ context: includeContext ? site.context.slice(0, MAX_CONTEXT_CHARS) : "",
256
+ })),
257
+ };
258
+ return { ...iface, call_site_report: report };
259
+ });
260
+ }
261
+ /**
262
+ * Analyzers whose reports this server will read.
263
+ *
264
+ * "grep-v1" is the original. "keel-graph-v1(...)" carries a parser signature,
265
+ * and a union report is "keel-graph-v1(...)+grep-v1". Matching by prefix means
266
+ * a client with an upgraded parser is not rejected by an older server, which
267
+ * is the failure mode that makes an agent's analysis silently disappear.
268
+ */
269
+ const KNOWN_ANALYZER_PREFIXES = ["grep-v1", "keel-graph-v1"];
270
+ function isKnownAnalyzer(analyzer) {
271
+ return (typeof analyzer === "string" &&
272
+ KNOWN_ANALYZER_PREFIXES.some((prefix) => analyzer.startsWith(prefix)));
273
+ }
274
+ function reportedSites(iface) {
275
+ const report = iface.call_site_report;
276
+ // The analyzer must be one this server understands, but the check is a
277
+ // prefix rather than an equality: it was `!== "grep-v1"` while grep was the
278
+ // only analyzer, and the moment the graph started labelling its reports
279
+ // honestly, every client report became unrecognised. The server then fell
280
+ // back to resolving against a checkout it does not have, so blast radius
281
+ // came back "unknown" and contract-break detection silently stopped — from
282
+ // a string comparison, with nothing logged.
283
+ //
284
+ // Anything a client sends is still validated below, path by path. This gate
285
+ // is about provenance, not trust, and it must not reject a newer analyzer
286
+ // from a client that is simply ahead of this server.
287
+ if (!report || report.source !== "client" || !isKnownAnalyzer(report.analyzer)) {
288
+ return undefined;
289
+ }
290
+ if (report.status !== "resolved")
291
+ return null;
292
+ if (!Array.isArray(report.call_sites))
293
+ return null;
294
+ const sites = [];
295
+ for (const raw of report.call_sites.slice(0, MAX_REPORTED_SITES)) {
296
+ if (!raw || typeof raw.path !== "string")
297
+ continue;
298
+ const path = raw.path.replaceAll("\\", "/");
299
+ if (path.length === 0 ||
300
+ path.length > 1_000 ||
301
+ path.startsWith("/") ||
302
+ path.split("/").includes("..") ||
303
+ path.includes("\0") ||
304
+ !Number.isInteger(raw.line) ||
305
+ raw.line < 1) {
306
+ continue;
307
+ }
308
+ sites.push({
309
+ path,
310
+ line: raw.line,
311
+ symbol: iface.name,
312
+ context: typeof raw.context === "string" ? raw.context.slice(0, MAX_CONTEXT_CHARS) : "",
313
+ // Carried through, because the verdict depends on it and rebuilding the
314
+ // site without it silently made every client report fully countable.
315
+ // Only the two known values are accepted; anything else becomes absent,
316
+ // which the verdict counts — the conservative direction.
317
+ //
318
+ // This is client-supplied, and so is the site list it belongs to: a
319
+ // client that wanted to suppress a wide verdict could already report no
320
+ // sites at all. Accepting the label adds no capability it did not have.
321
+ ...(raw.confidence === "confirmed" || raw.confidence === "unconfirmed"
322
+ ? { confidence: raw.confidence }
323
+ : {}),
324
+ });
325
+ }
326
+ return {
327
+ sites,
328
+ truncated: report.truncated === true || report.call_sites.length > MAX_REPORTED_SITES,
329
+ };
330
+ }
331
+ /**
332
+ * Resolve one interface's call sites from client metadata, falling back to a
333
+ * server checkout only when the report is absent or unknown.
334
+ */
335
+ export function resolveSubjectCallSites(subject) {
336
+ const clientReport = reportedSites(subject);
337
+ if (clientReport) {
338
+ return {
339
+ sites: clientReport.sites,
340
+ source: "client",
341
+ truncated: clientReport.truncated,
342
+ };
343
+ }
344
+ // No server-side fallback, and `unavailable` is the honest answer.
345
+ //
346
+ // This used to grep a checkout on the server's own disk when a client sent
347
+ // no report. Three things were wrong with that, in increasing order of
348
+ // seriousness.
349
+ //
350
+ // It read the wrong code. The server's checkout is nobody's working tree —
351
+ // it is a third copy, at whatever commit it last pulled — so the answer
352
+ // described files no agent was editing.
353
+ //
354
+ // It used the worst analyzer to do it. `resolveCallSites` is grep-v1, whose
355
+ // failure mode is documented at length in index/call-sites.ts: asked for a
356
+ // method named `check` it returned 38 sites, sixteen in an unrelated Python
357
+ // package, one of them a comment reading "# side of the ORPHAN check (but
358
+ // NOT FTS". That count tripped the width threshold and summoned a human.
359
+ // The agent's own report comes from the TypeScript compiler over the exact
360
+ // files being changed, so the fallback was strictly worse evidence carrying
361
+ // exactly the same weight.
362
+ //
363
+ // And it broke a ratified invariant. inv_01KZWPAM9QCZ0DD7789N55X647 holds
364
+ // that the central server "must not process raw code"; `resolveCallSites`
365
+ // reads source files and returns 200 characters of the matched line. The
366
+ // privacy boundary this module documents for the client was breached from
367
+ // inside the server.
368
+ //
369
+ // A missing report now becomes the `unknown` verdict, which the protocol
370
+ // already understands as "proceed, but flagged as unanalysed". An honest
371
+ // gap is worth more than a confident answer about the wrong tree.
372
+ return { sites: null, source: "unavailable", truncated: false };
373
+ }
374
+ /**
375
+ * Resolve the blast radius of a declared intent (§5.8).
376
+ * - clean: all call sites in free files → widen the lease
377
+ * - conflict: call sites inside another agent's leased files
378
+ * - wide: total call sites over threshold → human decision
379
+ * - unknown: language not resolvable → proceed but flag
380
+ * - skipped: no interface changes / feature disabled / no repo checkout
381
+ */
382
+ export function resolveBlastRadius(opts) {
383
+ const base = {
384
+ resolved_at: now(),
385
+ verdict: "skipped",
386
+ call_sites_total: 0,
387
+ call_sites_in_free_files: 0,
388
+ call_sites_in_leased_files: 0,
389
+ files_added_to_lease: [],
390
+ conflicting_files: [],
391
+ };
392
+ const changed = opts.interfaces.filter((i) => i.change !== "none" && ANALYZABLE_KINDS.has(i.kind));
393
+ if (!opts.enabled || changed.length === 0)
394
+ return base;
395
+ const hasClientReport = changed.some((iface) => iface.call_site_report !== undefined);
396
+ // No report at all is "nobody looked", which is what `skipped` already
397
+ // means. It is not "nothing calls this", and the server is no longer
398
+ // willing to invent the difference from a checkout of its own.
399
+ if (!hasClientReport)
400
+ return base;
401
+ let unknown = false;
402
+ const allSites = [];
403
+ for (const iface of changed) {
404
+ const resolved = resolveSubjectCallSites(iface);
405
+ if (resolved.truncated)
406
+ unknown = true;
407
+ const sites = resolved.sites;
408
+ if (sites === null) {
409
+ unknown = true;
410
+ continue;
411
+ }
412
+ allSites.push(...sites);
413
+ }
414
+ if (unknown && allSites.length === 0) {
415
+ base.verdict = "unknown";
416
+ return base;
417
+ }
418
+ const widen = new Set();
419
+ const conflictsByFile = new Map();
420
+ let covered = 0;
421
+ for (const site of allSites) {
422
+ if (pathCoveredBy(site.path, opts.intentPaths)) {
423
+ covered++;
424
+ continue;
425
+ }
426
+ const holder = opts.otherHeldLeases.find((l) => pathCoveredBy(site.path, l.paths));
427
+ if (holder) {
428
+ const entry = conflictsByFile.get(site.path) ?? { held_by_agent: holder.agent_id, call_sites: [] };
429
+ entry.call_sites.push(site);
430
+ conflictsByFile.set(site.path, entry);
431
+ }
432
+ else {
433
+ widen.add(site.path);
434
+ }
435
+ }
436
+ // The width verdict counts sites the graph could stand behind, plus any it
437
+ // never assessed. An explicitly unconfirmed site — a textual match nobody
438
+ // could bind to a definition — is still reported, still visible, and no
439
+ // longer able to escalate an intent on its own.
440
+ //
441
+ // This is the last piece of the 13 August failure. The comment
442
+ // "# side of the ORPHAN check (but NOT FTS" was one of 38 sites that tripped
443
+ // the width threshold. It is still in the report, because dropping evidence
444
+ // is its own bug, but it can no longer stop an agent and summon a human.
445
+ const countable = allSites.filter((site) => site.confidence !== "unconfirmed");
446
+ const unconfirmed = allSites.length - countable.length;
447
+ base.call_sites_total = allSites.length;
448
+ base.call_sites_unconfirmed = unconfirmed;
449
+ base.call_sites_in_leased_files = [...conflictsByFile.values()].reduce((n, c) => n + c.call_sites.length, 0);
450
+ base.call_sites_in_free_files = allSites.length - base.call_sites_in_leased_files - covered;
451
+ base.files_added_to_lease = [...widen].sort();
452
+ base.conflicting_files = [...conflictsByFile.entries()].map(([path, c]) => ({ path, ...c }));
453
+ if (countable.length > opts.threshold)
454
+ base.verdict = "wide";
455
+ else if (base.conflicting_files.length > 0)
456
+ base.verdict = "conflict";
457
+ else if (unknown)
458
+ base.verdict = "unknown";
459
+ else
460
+ base.verdict = "clean";
461
+ return base;
462
+ }