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
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Updating an index without rebuilding it, and the one rule that makes that
3
+ * safe to do.
4
+ *
5
+ * The rule: an incrementally updated index must be byte-identical to a full
6
+ * rebuild of the same working tree. Not "close enough", not "eventually
7
+ * consistent" — identical, including the fingerprint.
8
+ *
9
+ * That is not a tidiness preference. The fingerprint is what two agents on two
10
+ * machines compare to decide whether a blast radius computed over there means
11
+ * anything over here. If an incremental update can produce a different
12
+ * fingerprint from a full build over identical bytes, then the comparison
13
+ * starts failing for reasons that have nothing to do with the code, agents
14
+ * stop trusting each other's analysis, and the cheapest way to make the system
15
+ * work again is to turn incremental updates off. Worse, if it produces the
16
+ * same fingerprint over *different* content, two agents believe they agree
17
+ * when they do not — which is the failure this whole subsystem exists to
18
+ * prevent, arrived at by a new route.
19
+ *
20
+ * So the invariant has a test that builds both ways and compares the whole
21
+ * serialised index, and every design decision below is downstream of keeping
22
+ * that test true.
23
+ *
24
+ * The second rule follows from the first: when the change set cannot be
25
+ * trusted, rebuild everything. A partial update from an incomplete change list
26
+ * is the one outcome worse than a slow one, because it is wrong and looks
27
+ * fine.
28
+ */
29
+ import { buildFileIndex, isProbablyBinary } from "./file-index.js";
30
+ import { aliasSignature, discoverAliases } from "./aliases.js";
31
+ import { discoverFiles } from "./discovery.js";
32
+ import { hashContent, indexFingerprint } from "./fingerprint.js";
33
+ import { readFileSync } from "node:fs";
34
+ import { execFileSync } from "node:child_process";
35
+ import { join } from "node:path";
36
+ import { byCodeUnit } from "./order.js";
37
+ import { invalidateVectorCache } from "./vector-cache.js";
38
+ /**
39
+ * Load a local snapshot when one exists, then keep it current through the
40
+ * digest-verified incremental path. A cold checkout is written immediately so
41
+ * the next hook invocation can skip unchanged parsing work.
42
+ */
43
+ export function loadOrUpdateFileIndex(root, registry, options) {
44
+ const now = options.now?.() ?? new Date();
45
+ const currentCommit = options.currentCommit === undefined ? readCurrentCommit(root) : options.currentCommit;
46
+ const previousMetadata = options.archive.getMetadata();
47
+ const persisted = options.archive.getIndex();
48
+ const stale = Boolean(persisted &&
49
+ previousMetadata.indexedCommit &&
50
+ currentCommit &&
51
+ previousMetadata.indexedCommit !== currentCommit);
52
+ const stalenessWarning = stale
53
+ ? `Local index was built against ${previousMetadata.indexedCommit}, but live HEAD is ${currentCommit}; rebuilt.`
54
+ : null;
55
+ const metadata = {
56
+ indexedCommit: currentCommit ?? previousMetadata.indexedCommit,
57
+ indexedAt: now.toISOString(),
58
+ };
59
+ if (!persisted || persisted.root !== root || stale) {
60
+ if (persisted)
61
+ invalidateVectorCache(persisted.fingerprint);
62
+ const index = buildFileIndex(root, registry, options);
63
+ options.archive.putIndex(index, metadata);
64
+ return {
65
+ index,
66
+ reason: "full-rebuild",
67
+ rebuildCause: stale
68
+ ? "indexed commit diverged from live HEAD"
69
+ : persisted
70
+ ? "persisted snapshot belongs to another checkout"
71
+ : "no persisted snapshot",
72
+ changed: { added: [], modified: [], removed: [] },
73
+ reparsed: index.files.length,
74
+ source: "cold",
75
+ freshness: freshness(metadata, now, stalenessWarning),
76
+ };
77
+ }
78
+ const update = updateFileIndex(persisted, root, registry, options);
79
+ const indexChanged = update.reparsed > 0 || update.index.fingerprint !== persisted.fingerprint;
80
+ const indexedAt = !indexChanged && previousMetadata.indexedAt
81
+ ? previousMetadata.indexedAt
82
+ : now.toISOString();
83
+ options.archive.putIndex(update.index, { indexedCommit: metadata.indexedCommit, indexedAt });
84
+ return {
85
+ ...update,
86
+ source: update.reparsed === 0 ? "restored" : "incremental",
87
+ freshness: freshness({ indexedCommit: metadata.indexedCommit, indexedAt }, now, stalenessWarning),
88
+ };
89
+ }
90
+ function freshness(metadata, now, stalenessWarning) {
91
+ let age = null;
92
+ if (metadata.indexedAt) {
93
+ const timestamp = Date.parse(metadata.indexedAt);
94
+ if (Number.isFinite(timestamp))
95
+ age = Math.max(0, now.getTime() - timestamp);
96
+ }
97
+ return {
98
+ indexed_commit: metadata.indexedCommit,
99
+ index_age: age,
100
+ staleness_warning: stalenessWarning,
101
+ };
102
+ }
103
+ function readCurrentCommit(root) {
104
+ try {
105
+ return execFileSync("git", ["-C", root, "rev-parse", "--verify", "HEAD"], {
106
+ encoding: "utf8",
107
+ stdio: ["ignore", "pipe", "ignore"],
108
+ }).trim() || null;
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ }
114
+ /**
115
+ * Produce the current index, re-reading as little as possible.
116
+ *
117
+ * Discovery still runs in full every time. It is one `git ls-files`, it is the
118
+ * only way to notice additions and deletions, and skipping it to save
119
+ * milliseconds is how an index quietly stops matching the repository.
120
+ */
121
+ export function updateFileIndex(previous, root, registry, options = {}) {
122
+ const readFile = options.readFile ?? ((path) => readFileSync(path, "utf8"));
123
+ // The parsers that produced `previous` are not the parsers running now if a
124
+ // backend was registered, removed, or upgraded. Every cached parse result is
125
+ // then suspect, and reusing them would produce an index that claims to have
126
+ // been built by the current registry when it was not.
127
+ const previousSignature = signatureOf(previous);
128
+ const currentSignature = registry.signature();
129
+ if (previousSignature !== null && previousSignature !== currentSignature) {
130
+ invalidateVectorCache(previous.fingerprint);
131
+ return fullRebuild(root, registry, options, `parser registry changed (${previousSignature} -> ${currentSignature})`);
132
+ }
133
+ let paths;
134
+ try {
135
+ paths = discoverFiles(root, options);
136
+ }
137
+ catch (error) {
138
+ invalidateVectorCache(previous.fingerprint);
139
+ return fullRebuild(root, registry, options, `discovery failed: ${describe(error)}`);
140
+ }
141
+ const before = new Map(previous.files.map((file) => [file.path, file]));
142
+ const currentPaths = new Set(paths);
143
+ const hinted = options.changedHint ? new Set(options.changedHint) : null;
144
+ const added = [];
145
+ const modified = [];
146
+ const removed = previous.files.map((f) => f.path).filter((path) => !currentPaths.has(path)).sort();
147
+ const files = [];
148
+ let reparsed = 0;
149
+ for (const path of paths) {
150
+ const cached = before.get(path);
151
+ // A hint can say "this changed" and be believed into re-reading, but it can
152
+ // never say "this did not change" and be believed into skipping: a missed
153
+ // event would leave a stale entry that no later pass ever revisits.
154
+ let source;
155
+ try {
156
+ source = readFile(join(root, path));
157
+ }
158
+ catch {
159
+ // Unreadable now. Re-index it properly so the failure is recorded as a
160
+ // blind spot rather than silently inheriting the last good parse.
161
+ const rebuilt = reindexOne(root, path, registry, options);
162
+ reparsed += 1;
163
+ files.push(rebuilt);
164
+ if (!cached)
165
+ added.push(path);
166
+ else
167
+ modified.push(path);
168
+ continue;
169
+ }
170
+ const contentHash = hashContent(source);
171
+ if (cached && cached.contentHash === contentHash && !hinted?.has(path)) {
172
+ // Identical bytes, same parsers: the previous result is not an
173
+ // approximation of the current one, it IS the current one.
174
+ files.push(cached);
175
+ continue;
176
+ }
177
+ const result = registry.parse(path, source);
178
+ const parser = registry.parserFor(path);
179
+ files.push({
180
+ path,
181
+ contentHash,
182
+ language: parser?.language ?? null,
183
+ parser: parser && result.status !== "unsupported" ? `${parser.id}@${parser.version}` : null,
184
+ result,
185
+ // Must match buildFileIndex exactly. Omitting it here would leave every
186
+ // reparsed file unsearchable by its own contents while untouched files
187
+ // stayed searchable — retrieval quality decaying as the repo is edited,
188
+ // which is the hardest kind of regression to notice.
189
+ ...(isProbablyBinary(source) ? {} : { searchText: source }),
190
+ });
191
+ reparsed += 1;
192
+ if (!cached)
193
+ added.push(path);
194
+ else if (cached.contentHash !== contentHash)
195
+ modified.push(path);
196
+ }
197
+ // Re-discovered every pass rather than inherited. A tsconfig edit changes
198
+ // how every specifier resolves while leaving every source file untouched,
199
+ // so carrying the old map forward would produce a stale graph from an index
200
+ // that looks perfectly fresh. Parse results are unaffected, so this needs no
201
+ // full rebuild — only the identity and the resolution change.
202
+ const aliases = discoverAliases(paths, { readFile: (path) => readFile(join(root, path)) });
203
+ const result = {
204
+ index: assemble(root, registry, files, aliases),
205
+ reason: "incremental",
206
+ rebuildCause: null,
207
+ changed: { added: added.sort(), modified: modified.sort(), removed },
208
+ reparsed,
209
+ };
210
+ if (result.index.fingerprint !== previous.fingerprint)
211
+ invalidateVectorCache(previous.fingerprint);
212
+ return result;
213
+ }
214
+ function reindexOne(root, path, registry, options) {
215
+ const single = buildFileIndex(root, registry, {
216
+ ...options,
217
+ // buildFileIndex discovers; restrict it to the one path by excluding
218
+ // nothing and filtering after, which keeps one code path for parsing.
219
+ });
220
+ const found = single.files.find((file) => file.path === path);
221
+ if (found)
222
+ return found;
223
+ return {
224
+ path,
225
+ contentHash: "",
226
+ language: null,
227
+ parser: null,
228
+ result: {
229
+ status: "failed",
230
+ imports: [],
231
+ exports: [],
232
+ diagnostics: [{ code: "vanished", message: `${path} disappeared during indexing` }],
233
+ },
234
+ };
235
+ }
236
+ /**
237
+ * Rebuild the derived parts of a FileIndex from a file list.
238
+ *
239
+ * Shares nothing with buildFileIndex's own assembly by accident: coverage and
240
+ * fingerprint are recomputed here with the same rules, and the byte-identity
241
+ * test is what holds the two in agreement. If that test ever fails, this
242
+ * function and buildFileIndex have drifted and the incremental path must be
243
+ * considered wrong until they match again.
244
+ */
245
+ function assemble(root, registry, files, aliases) {
246
+ const sorted = [...files].sort((a, b) => byCodeUnit(a.path, b.path));
247
+ const counts = { parsed: 0, partial: 0, failed: 0, unsupported: 0 };
248
+ const blindSpots = [];
249
+ for (const file of sorted) {
250
+ counts[file.result.status] += 1;
251
+ if (file.result.status === "failed" || file.result.status === "partial") {
252
+ blindSpots.push({ path: file.path, reason: file.result.diagnostics[0]?.message ?? file.result.status });
253
+ }
254
+ }
255
+ const claimed = counts.parsed + counts.partial + counts.failed;
256
+ const fingerprints = sorted.map((file) => ({
257
+ path: file.path,
258
+ contentHash: file.contentHash,
259
+ parser: file.parser,
260
+ }));
261
+ return {
262
+ root,
263
+ // Must match buildFileIndex's identity inputs exactly. It did not, once:
264
+ // the resolver signature was added there and not here, and five tests
265
+ // failed within the minute. That is the byte-identity gate doing its job,
266
+ // and the reason this function is worth the duplication it costs.
267
+ fingerprint: indexFingerprint({
268
+ files: fingerprints,
269
+ registrySignature: registry.signature(),
270
+ resolverSignature: aliasSignature(aliases),
271
+ }),
272
+ aliases,
273
+ files: sorted,
274
+ coverage: {
275
+ total: sorted.length,
276
+ ...counts,
277
+ ratio: claimed === 0 ? 1 : (counts.parsed + counts.partial) / claimed,
278
+ },
279
+ blindSpots: blindSpots.sort((a, b) => byCodeUnit(a.path, b.path)),
280
+ };
281
+ }
282
+ function fullRebuild(root, registry, options, cause) {
283
+ const index = buildFileIndex(root, registry, options);
284
+ return {
285
+ index,
286
+ reason: "full-rebuild",
287
+ rebuildCause: cause,
288
+ changed: { added: [], modified: [], removed: [] },
289
+ reparsed: index.files.length,
290
+ };
291
+ }
292
+ /** The registry signature the previous index was built with, if recoverable. */
293
+ function signatureOf(previous) {
294
+ const parsers = [...new Set(previous.files.map((file) => file.parser).filter((p) => p !== null))];
295
+ return parsers.length === 0 ? null : parsers.sort().join(",");
296
+ }
297
+ function describe(error) {
298
+ return error instanceof Error ? error.message : String(error);
299
+ }
300
+ import { buildFileIndexAsync } from "./file-index.js";
301
+ export async function updateFileIndexAsync(previous, root, registry, options = {}) {
302
+ const previousSignature = signatureOf(previous);
303
+ const currentSignature = registry.signature();
304
+ if (previousSignature !== null && previousSignature !== currentSignature) {
305
+ invalidateVectorCache(previous.fingerprint);
306
+ return fullRebuildAsync(root, registry, options, `parser registry changed (${previousSignature} -> ${currentSignature})`);
307
+ }
308
+ let paths;
309
+ try {
310
+ paths = discoverFiles(root, options);
311
+ }
312
+ catch (error) {
313
+ invalidateVectorCache(previous.fingerprint);
314
+ return fullRebuildAsync(root, registry, options, `discovery failed: ${describe(error)}`);
315
+ }
316
+ // To properly parallelize the incremental pass, we could gather files that need reparsing
317
+ // and dispatch them. However, since incremental changes are overwhelmingly small, falling
318
+ // back to the synchronous path for the delta is perfectly sufficient, as long as full
319
+ // rebuilds are asynchronous.
320
+ // We'll just run the synchronous updateFileIndex. It handles the delta quickly.
321
+ return updateFileIndex(previous, root, registry, options);
322
+ }
323
+ async function fullRebuildAsync(root, registry, options, cause) {
324
+ const index = await buildFileIndexAsync(root, registry, options);
325
+ return {
326
+ index,
327
+ reason: "full-rebuild",
328
+ rebuildCause: cause,
329
+ changed: { added: [], modified: [], removed: [] },
330
+ reparsed: index.files.length,
331
+ };
332
+ }