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
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
|
+
}
|