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,90 @@
1
+ /**
2
+ * Declared paths that match nothing in the checkout.
3
+ *
4
+ * Computed by the AGENT, in the agent's own checkout, and sent with the
5
+ * declaration. It used to be computed on the server against `repo_path` — a
6
+ * copy of the repository on the server's own disk, at whatever commit it last
7
+ * pulled, which is nobody's working tree. That made the same declaration
8
+ * warn on one instance and stay silent on another, and it was the last thing
9
+ * keeping a filesystem dependency inside a Keel process
10
+ * (inv_01KZWPAT93H32HB3X48FNPTA1E).
11
+ *
12
+ * Only filenames are read, never file contents, so this never breached the
13
+ * "must not process raw code" half of the boundary the way the blast-radius
14
+ * fallback did. It was simply asking the wrong machine.
15
+ *
16
+ * Living in lib/ rather than in the server because both ends need it: the
17
+ * adapter to produce the warnings, and the tests to check the logic without
18
+ * standing up an HTTP server for arithmetic on strings.
19
+ */
20
+ import { globMatches, isGlob } from "./globs.js";
21
+ export function declaredPathWarnings(paths, files) {
22
+ // An EMPTY file list is "could not look", not "the repository is empty",
23
+ // and conflating them turns this into a machine for crying wolf. That is not
24
+ // hypothetical: `repo_path` still said /opt/charter/sync after the
25
+ // Charter -> Keel rename, the walk returned [], and every declared path came
26
+ // back as a warning — including `keel/src/server/app.ts`, which obviously
27
+ // exists. A check that fires on everything is worse than no check, because
28
+ // it teaches the reader to skip the one that matters.
29
+ //
30
+ // The guard lives here rather than in each caller because the caller that
31
+ // forgets it is exactly how the incident happened.
32
+ if (files.length === 0)
33
+ return null;
34
+ const dirs = new Set();
35
+ const byBasename = new Map();
36
+ for (const file of files) {
37
+ const parts = file.split("/");
38
+ for (let i = 1; i < parts.length; i++)
39
+ dirs.add(parts.slice(0, i).join("/"));
40
+ const base = parts[parts.length - 1];
41
+ const bucket = byBasename.get(base);
42
+ if (bucket)
43
+ bucket.push(file);
44
+ else
45
+ byBasename.set(base, [file]);
46
+ }
47
+ const warnings = [];
48
+ for (const path of paths) {
49
+ if (typeof path !== "string" || !path.trim())
50
+ continue;
51
+ const clean = path.replace(/^\.\//, "").replace(/\/+$/, "");
52
+ // Paths under .claude/, .codex/, .github/ or .agents/ are never warned
53
+ // about. Those directories hold the hook configs agents legitimately edit.
54
+ //
55
+ // This used to be compensation rather than policy: the old `listRepoFiles`
56
+ // walk skipped any directory whose name began with a dot, so such files
57
+ // could not appear in `files` at all, and warning would have reported the
58
+ // walker's blind spot as the caller's mistake. That walk is gone —
59
+ // `discoverFiles` asks git, which returns tracked dot-directory files
60
+ // normally — so the skip is now a deliberate choice rather than a
61
+ // workaround, and is kept because the behaviour it produces was right for
62
+ // its own reasons. Revisit it as policy, not as a bug.
63
+ //
64
+ // A dotfile at a visible path is still checked: only directory segments
65
+ // are skipped, so `.mcp.json` at the root is warned about as normal.
66
+ if (clean.split("/").slice(0, -1).some((segment) => segment.startsWith(".")))
67
+ continue;
68
+ if (isGlob(clean)) {
69
+ // A glob that matches nothing today is weaker evidence — it may be
70
+ // written for files this very intent will create. Still worth saying.
71
+ if (!files.some((f) => globMatches(clean, f))) {
72
+ warnings.push({ path, reason: "no_match", did_you_mean: [] });
73
+ }
74
+ continue;
75
+ }
76
+ if (files.includes(clean) || dirs.has(clean))
77
+ continue;
78
+ const slash = clean.lastIndexOf("/");
79
+ const parent = slash === -1 ? "" : clean.slice(0, slash);
80
+ const base = clean.slice(slash + 1);
81
+ warnings.push({
82
+ path,
83
+ // A missing parent directory is the strong signal: the file cannot be
84
+ // new, because there is nowhere for it to be new in.
85
+ reason: parent && !dirs.has(parent) ? "no_parent_directory" : "no_match",
86
+ did_you_mean: (byBasename.get(base) ?? []).slice(0, 3),
87
+ });
88
+ }
89
+ return warnings;
90
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The coordination protocol, as a single source of truth.
3
+ *
4
+ * This text is served to every MCP client through the server's
5
+ * `instructions` field at connect time, which is what makes the
6
+ * experience uniform: a client with no hook support and no repo-local
7
+ * doc file (Cursor, Gemini CLI, a homegrown agent) still receives the
8
+ * protocol before its first tool call. `CLAUDE.md` / `AGENTS.md` remain
9
+ * useful for hook-enforced clients, but they are no longer the only way
10
+ * an agent learns the rules.
11
+ */
12
+ /** Rendered into MCP `instructions`; also returned by the protocol tool. */
13
+ export const PROTOCOL_INSTRUCTIONS = `Sync — cross-agent coordination. MANDATORY protocol.
14
+
15
+ You share this codebase with other AI agents working simultaneously,
16
+ possibly on other machines, directed by a different human. You cannot see
17
+ their work directly. Other agents are relying on your calls being real.
18
+
19
+ PATHS. Every path you pass to a Sync tool is relative to the git
20
+ repository root, never to your current working directory. In a monorepo
21
+ that means "packages/api/src/db.ts", never "src/db.ts". Getting this
22
+ wrong means two agents hold "leases" on the same physical file under
23
+ different strings and never see each other — collision detection
24
+ silently stops working while every call still succeeds. Call
25
+ sync_whoami if you are unsure what the repository root is.
26
+
27
+ AT SESSION START. Call sync_get. It returns the project's purpose,
28
+ non-goals, and binding invariants. If a task requires violating an
29
+ invariant, do not proceed: call sync_propose_amendment and work on
30
+ something else while a human decides.
31
+
32
+ BOARD TRACKING. Material work goes on the board before it starts. A change
33
+ spanning two or more files, or changing a named interface, is material:
34
+ create or find an active board item first and pass that item's id as task_id
35
+ to intent_declare. An unlinked material declaration is escalated and must not
36
+ acquire leases until a human approves an exception. The gate is re-checked
37
+ when you acquire the lease, against the paths you actually ask for — a narrow
38
+ declaration does not buy a wide lease. Every declaration response carries
39
+ board_policy, which tells you whether the requirement is on for this project
40
+ and what it is missing.
41
+
42
+ BEFORE EDITING ANY FILE.
43
+ 1. intent_declare with a one-sentence summary, every path you will
44
+ touch, and every interface you will add, remove, or change the
45
+ signature of. The response includes an arbiter verdict, a blast
46
+ radius, and an "overlaps" list naming any other agent already working
47
+ on those files or directories.
48
+ 2. If the verdict is not "clear", stop this line of work. A human has
49
+ been notified. Pick up a different task.
50
+ 3. lease_acquire on those paths.
51
+ 4. If the lease is denied, the response names the blocking agent and
52
+ their intent. Take the partial grant, re-scope to non-conflicting
53
+ paths, or claim a different task. Do not wait idly, and never edit a
54
+ path you were denied.
55
+ 5. If you are unsure whether a specific file is covered, call
56
+ lease_check before writing to it. Some clients block unleased edits
57
+ automatically; if yours does not, lease_check is how you self-police.
58
+
59
+ WHILE WORKING. Leases expire. Call lease_renew on long tasks.
60
+
61
+ HUMAN DECISIONS. When intent_declare returns an escalation, the MCP call
62
+ actively waits for the durable arbiter decision and returns automatically
63
+ when it is resolved. The adapter listens to the coordinator SSE stream,
64
+ reconnects after interruptions, and polls as a fallback. Do not ask the
65
+ human to come back and repeat an approval they already gave. If the bounded
66
+ wait expires, the escalation remains durable and the response includes its id.
67
+
68
+ WHEN FINISHED, IN THIS ORDER.
69
+ 1. Commit the finished work. intent_complete fails while any leased path
70
+ is still uncommitted or when the checkout cannot be verified. Its result
71
+ warns, but does not block, when HEAD is ahead of its configured upstream
72
+ or the upstream cannot be checked; push before calling work shared or
73
+ deployed when your repository workflow requires it.
74
+ 2. surface_delta_publish with every exported symbol, route, schema,
75
+ config key, env var, or dependency you changed. Other agents build
76
+ against this; omitting it hands them a stale picture of your code.
77
+ 3. intent_complete.
78
+ 4. lease_release.
79
+
80
+ ON INTERFACE CHANGES. When your intent changes a signature or removes an
81
+ export, the server resolves every call site before granting your lease.
82
+ Free call sites are added to your lease — you are then expected to fix
83
+ them before releasing. Never release a lease leaving known-broken call
84
+ sites behind.
85
+
86
+ ENFORCEMENT. Coverage differs by client: some block unleased writes at
87
+ the tool level, others cannot. Writes made through a shell (redirects,
88
+ sed -i) bypass tool-level blocking everywhere. Unleased changes to the
89
+ working tree are detected and reported to both humans regardless of
90
+ client, so the protocol is not optional for anyone.`;
91
+ /** Compact reminder suitable for embedding in a tool response. */
92
+ export const PROTOCOL_REMINDER = "Sync protocol: intent_declare -> lease_acquire -> edit -> commit -> " +
93
+ "surface_delta_publish -> intent_complete -> lease_release. " +
94
+ "All paths are relative to the git repository root.";
95
+ export const TASK_REOPEN_RULE = "Completed tasks may be reopened only through POST /tasks/:id/reopen with a durable reason; the transition is recorded as in_progress.";
@@ -0,0 +1,69 @@
1
+ export const DEFAULT_SETTINGS = {
2
+ lease_default_ttl_seconds: 900,
3
+ heartbeat_interval_seconds: 30,
4
+ arbiter_model: "claude-haiku-4-5",
5
+ model_roles: {},
6
+ arbiter_enabled: true,
7
+ escalation_batch_window_seconds: 60,
8
+ stuck_threshold_seconds: 600,
9
+ escalation_ttl_days: 7,
10
+ declared_intent_ttl_hours: 4,
11
+ blast_radius_enabled: true,
12
+ blast_radius_escalation_threshold: 15,
13
+ blast_radius_auto_widen: true,
14
+ shadow_merge_enabled: false,
15
+ preflight_mode: "fail_open",
16
+ // Default to same_file, not same_dir. Directory proximity between three
17
+ // agents in one monorepo is constant, and an alert that always fires trains
18
+ // everyone to ignore Telegram — which costs the actionable alerts too.
19
+ overlap_alerts: "same_file",
20
+ // On by default. The gate landed opt-in and every project inherited `false`,
21
+ // which meant the requirement existed only in the protocol text agents are
22
+ // asked to follow — and a rule nothing checks is a rule that is followed
23
+ // when it is convenient. Material work is a change spanning two or more
24
+ // files or changing a named interface; that is the class of work another
25
+ // agent, or a human reading the board a week later, has to be able to see
26
+ // coming. Small investigations stay frictionless, and a project that wants
27
+ // the old behaviour sets this to false explicitly rather than by omission.
28
+ board_required_for_material_work: true,
29
+ };
30
+ // ---------------------------------------------------------------------------
31
+ // M9b — the board (§9 of the 12 Aug decision list)
32
+ // ---------------------------------------------------------------------------
33
+ /**
34
+ * The canonical status set, ratified rather than inferred.
35
+ *
36
+ * These are the values `tasks.status` already used (`available`,
37
+ * `in_progress`, `completed`) plus the three a board needs to say why nothing
38
+ * is happening. The deployed M9d page guessed a different union; §9 decision 2
39
+ * settles it here so the UI has one list to render and the importer has one
40
+ * mapping to write: open -> available, in flight / `[~]` -> in_progress,
41
+ * done -> completed.
42
+ */
43
+ export const BOARD_STATUSES = [
44
+ "available",
45
+ "in_progress",
46
+ "blocked",
47
+ "needs_human",
48
+ "completed",
49
+ "abandoned",
50
+ ];
51
+ /**
52
+ * The canonical kind set. `decision` and `finding` are why this is not just a
53
+ * task tracker: the 8.11/8.12 pages spent most of their length on findings
54
+ * that were not work items and decisions that were not either, and losing
55
+ * those to a feature/chore/bug vocabulary is what made each page go stale
56
+ * within a day.
57
+ */
58
+ export const BOARD_KINDS = ["phase", "item", "debt", "decision", "finding"];
59
+ /**
60
+ * An owner, §9 decision 3: a *set* of typed principals rather than one
61
+ * `owner_vendor` string.
62
+ *
63
+ * Two facts force this. Items on the current board are jointly owned ("human
64
+ * ratifies, agent implements"), and several agents legitimately share one
65
+ * vendor — so a vendor string identifies a population, not a party. The live
66
+ * agent id stays in `claimed_by`, which is a different question: ownership is
67
+ * durable, a claim is a lease on attention.
68
+ */
69
+ export const BOARD_PRINCIPAL_TYPES = ["vendor", "account"];
@@ -0,0 +1,21 @@
1
+ /** Release version shared by the server and the published MCP adapter. */
2
+ export const KEEL_RELEASE_VERSION = "0.2.3";
3
+ /**
4
+ * Adapter/API compatibility level.
5
+ *
6
+ * This changes only when the adapter's HTTP calls stop being compatible with
7
+ * the server. It is deliberately separate from the npm package version so a
8
+ * bug-fix release does not manufacture a protocol break.
9
+ */
10
+ export const KEEL_ADAPTER_PROTOCOL_VERSION = 1;
11
+ export const KEEL_ADAPTER_VERSION_HEADER = "x-keel-adapter-version";
12
+ export const KEEL_ADAPTER_PROTOCOL_HEADER = "x-keel-adapter-protocol";
13
+ export const KEEL_SERVER_VERSION_HEADER = "x-keel-server-version";
14
+ export const KEEL_SUPPORTED_ADAPTER_PROTOCOL_HEADER = "x-keel-supported-adapter-protocol";
15
+ /** Headers attached to every adapter-to-server request. */
16
+ export function adapterRequestHeaders() {
17
+ return {
18
+ [KEEL_ADAPTER_VERSION_HEADER]: KEEL_RELEASE_VERSION,
19
+ [KEEL_ADAPTER_PROTOCOL_HEADER]: String(KEEL_ADAPTER_PROTOCOL_VERSION),
20
+ };
21
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Unleased-write detection.
3
+ *
4
+ * Tool-level blocking (Claude Code hooks, Codex apply_patch hooks) is not
5
+ * available on every client, and no client blocks shell writes. This module
6
+ * closes that gap the one way that works everywhere: it reads the local git
7
+ * working tree and reports files that changed while nobody held a lease on
8
+ * them. It runs inside whichever process has the checkout — the MCP adapter
9
+ * or the CLI — so a Cursor, Gemini, or homegrown client is covered by the
10
+ * same mechanism as a hook-enforced one.
11
+ *
12
+ * Detection, not prevention: the point is that a silent violation becomes a
13
+ * visible one, alerted to both humans.
14
+ *
15
+ * What this module can and cannot know is load-bearing. `git status` reports
16
+ * that the *worktree* changed, never which process changed it. When several
17
+ * agents share one checkout — the normal case on a developer's machine — every
18
+ * one of them observes every dirty file. So a report is an *observation*, and
19
+ * naming the observer as the writer is a claim the mechanism cannot support.
20
+ * Attribution is decided server-side, where the set of agents sharing a
21
+ * checkout is known; see POST /unleased-writes in server/app.ts.
22
+ */
23
+ import { execFileSync } from "node:child_process";
24
+ import { join } from "node:path";
25
+ import { globMatches, isGlob, pathsOverlap } from "./globs.js";
26
+ /**
27
+ * Paths changed in the working tree, relative to the git root.
28
+ * Renames report both sides. Throws when git cannot inspect the checkout.
29
+ *
30
+ * Completion uses this fail-closed form: an unreadable worktree is unknown,
31
+ * not clean, and therefore cannot prove that an intent's work landed.
32
+ */
33
+ export function listWorktreeChangesStrict(repoRoot) {
34
+ // -uall expands untracked directories into individual files. Without it
35
+ // git reports a collapsed "dir/" entry, which no lease path would match
36
+ // — a false unleased-write alert for files that are in fact covered.
37
+ const out = execFileSync("git", ["-C", repoRoot, "status", "--porcelain=v1", "-z", "-uall"], {
38
+ encoding: "utf8",
39
+ maxBuffer: 8 * 1024 * 1024,
40
+ stdio: ["ignore", "pipe", "pipe"],
41
+ });
42
+ const changes = [];
43
+ // NUL-separated records; a rename/copy record is followed by an extra
44
+ // NUL-terminated field holding the original path.
45
+ const fields = out.split("\0");
46
+ for (let i = 0; i < fields.length; i++) {
47
+ const record = fields[i];
48
+ if (record.length < 4)
49
+ continue;
50
+ const status = record.slice(0, 2);
51
+ const path = record.slice(3);
52
+ if (status[0] === "R" || status[0] === "C") {
53
+ const from = fields[++i];
54
+ if (from)
55
+ changes.push({ path: from, status });
56
+ }
57
+ if (path)
58
+ changes.push({ path, status });
59
+ }
60
+ return changes;
61
+ }
62
+ /**
63
+ * Number of local commits absent from the branch's configured upstream.
64
+ *
65
+ * Like the strict worktree reader above, this throws when git cannot prove the
66
+ * answer. Callers decide policy: intent completion blocks on unknown dirtiness,
67
+ * but an unknown/ahead upstream is a warning because holding a lease until a
68
+ * deploy or push succeeds would obstruct every other agent.
69
+ */
70
+ export function commitsAheadOfUpstreamStrict(repoRoot) {
71
+ const out = execFileSync("git", ["-C", repoRoot, "rev-list", "--count", "@{upstream}..HEAD"], {
72
+ encoding: "utf8",
73
+ maxBuffer: 1024 * 1024,
74
+ stdio: ["ignore", "pipe", "pipe"],
75
+ }).trim();
76
+ if (!/^\d+$/.test(out)) {
77
+ throw new Error(`git returned an invalid upstream ahead count: ${JSON.stringify(out)}`);
78
+ }
79
+ const count = Number(out);
80
+ if (!Number.isSafeInteger(count)) {
81
+ throw new Error(`git returned an unsafe upstream ahead count: ${JSON.stringify(out)}`);
82
+ }
83
+ return count;
84
+ }
85
+ /** Non-blocking completion metadata derived from the strict ahead count. */
86
+ export function upstreamLandingReport(repoRoot) {
87
+ try {
88
+ const aheadCount = commitsAheadOfUpstreamStrict(repoRoot);
89
+ if (aheadCount === 0) {
90
+ return { upstream_status: "synced", ahead_count: 0 };
91
+ }
92
+ return {
93
+ upstream_status: "ahead",
94
+ ahead_count: aheadCount,
95
+ warning: `Intent completed, but HEAD is ${aheadCount} ` +
96
+ `commit${aheadCount === 1 ? "" : "s"} ahead of its configured upstream. ` +
97
+ "Push before treating this work as shared or deployed.",
98
+ };
99
+ }
100
+ catch {
101
+ return {
102
+ upstream_status: "unknown",
103
+ ahead_count: null,
104
+ warning: "Intent completed, but Keel could not verify whether HEAD is published to an " +
105
+ "upstream branch. Configure a tracking branch or verify and push manually.",
106
+ };
107
+ }
108
+ }
109
+ /**
110
+ * Best-effort worktree inspection for background unleased-write detection.
111
+ * Detection must never break unrelated tool calls, so this compatibility
112
+ * wrapper preserves the historical [] result when git is unavailable.
113
+ */
114
+ export function listWorktreeChanges(repoRoot) {
115
+ try {
116
+ return listWorktreeChangesStrict(repoRoot);
117
+ }
118
+ catch {
119
+ return [];
120
+ }
121
+ }
122
+ /**
123
+ * Changed paths not covered by any of `leasedPaths`.
124
+ *
125
+ * `ignore` drops paths that are never coordination-relevant (Keel's own
126
+ * per-session state, build output, dependencies). Directories must be given
127
+ * with a trailing slash.
128
+ */
129
+ export function findUnleasedWrites(changes, leasedPaths, ignore = DEFAULT_IGNORES) {
130
+ const unleased = [];
131
+ const seen = new Set();
132
+ for (const change of changes) {
133
+ if (ignore.some((i) => (i.endsWith("/") ? change.path.startsWith(i) : change.path === i))) {
134
+ continue;
135
+ }
136
+ const covered = leasedPaths.some((p) => isGlob(p) ? globMatches(p, change.path) : pathsOverlap(p, change.path));
137
+ if (covered || seen.has(change.path))
138
+ continue;
139
+ seen.add(change.path);
140
+ unleased.push(change);
141
+ }
142
+ return unleased;
143
+ }
144
+ /**
145
+ * Attach a content hash to each change.
146
+ *
147
+ * `git hash-object` is the cheap way to get git's own blob id without staging
148
+ * anything. Deleted files have no content, and a hash that cannot be taken
149
+ * falls back to a *stable* placeholder — a changing placeholder would defeat
150
+ * the dedupe it exists to feed and reinstate the alert storm.
151
+ */
152
+ export function fingerprintWrites(repoRoot, changes) {
153
+ const present = changes.filter((c) => !c.status.includes("D"));
154
+ const hashes = new Map();
155
+ if (present.length > 0) {
156
+ try {
157
+ const out = execFileSync("git", ["-C", repoRoot, "hash-object", "--stdin-paths"], {
158
+ input: present.map((c) => join(repoRoot, c.path)).join("\n") + "\n",
159
+ encoding: "utf8",
160
+ maxBuffer: 8 * 1024 * 1024,
161
+ });
162
+ const lines = out.split("\n").filter((line) => line.length > 0);
163
+ // A partial result cannot be aligned back to its inputs, so take it only
164
+ // when every path answered.
165
+ if (lines.length === present.length) {
166
+ present.forEach((change, i) => hashes.set(change.path, lines[i]));
167
+ }
168
+ }
169
+ catch {
170
+ // unreadable file, race with a delete, no git — fall through
171
+ }
172
+ }
173
+ return changes.map((change) => ({
174
+ ...change,
175
+ fingerprint: hashes.get(change.path) ?? (change.status.includes("D") ? "deleted" : "unhashed"),
176
+ }));
177
+ }
178
+ const describe = (a) => `${a.vendor} · ${a.human_owner}`;
179
+ /** Telegram text for an unleased-write report. */
180
+ export function formatUnleasedWriteAlert(report, writes) {
181
+ const { observer, attributed } = report;
182
+ const machine = observer.machine_id ? ` · ${observer.machine_id}` : "";
183
+ const lines = attributed
184
+ ? ["🚨 Unleased write detected", `${describe(observer)}${machine}`]
185
+ : ["🚨 Unleased change in a shared checkout", `seen by ${describe(observer)}${machine}`];
186
+ lines.push("", "changed without holding a lease:");
187
+ for (const w of writes.slice(0, 10)) {
188
+ lines.push(` ${w.path}${w.status.includes("?") ? " (untracked)" : ""}`);
189
+ }
190
+ if (writes.length > 10)
191
+ lines.push(` …and ${writes.length - 10} more`);
192
+ if (!attributed) {
193
+ const others = report.sharing ?? [];
194
+ lines.push("", `${others.length + 1} agents share this checkout, so the writer cannot be identified:`, [observer, ...others].map(describe).join(", "));
195
+ }
196
+ return lines.join("\n");
197
+ }
198
+ export const DEFAULT_IGNORES = [
199
+ ".keel/",
200
+ "node_modules/",
201
+ "dist/",
202
+ "build/",
203
+ ".git/",
204
+ // Per-agent client configuration. These live in the repo so the protocol
205
+ // travels with a checkout, but they are edited by whichever client owns
206
+ // them and are never coordination-relevant. Left in, an untracked one is a
207
+ // permanent dirty file and therefore a permanent alert.
208
+ ".claude/",
209
+ ".codex/",
210
+ ".gemini/",
211
+ ];
Binary file
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Privacy-bounded outbound connection from a local MCP runtime to Syncstaff
3
+ * Cloud. The cloud may ask for one Phase 1 operation (`health_check`), but all
4
+ * execution remains local and the response is a bounded status code.
5
+ */
6
+ const DEFAULT_INTERVAL_MS = 30_000;
7
+ export function startCloudConnector(options) {
8
+ let installationId = null;
9
+ let priorLatencyMs = null;
10
+ let pendingErrorCode = null;
11
+ let active = null;
12
+ let stopped = false;
13
+ const cycle = async () => {
14
+ if (stopped)
15
+ return;
16
+ try {
17
+ if (!installationId) {
18
+ const registration = await options.api('POST', '/cloud/installations', {
19
+ machine_id: options.machineId,
20
+ display_name: options.displayName ?? options.machineId,
21
+ client_version: options.clientVersion,
22
+ protocol_version: options.protocolVersion,
23
+ platform: options.platform,
24
+ });
25
+ if (typeof registration.id !== 'string' || !registration.id) {
26
+ pendingErrorCode = 'registration_invalid';
27
+ return;
28
+ }
29
+ installationId = registration.id;
30
+ }
31
+ let agentCount = 1;
32
+ let activeAgentCount = 1;
33
+ try {
34
+ const agents = await options.api('GET', `/agents?project_id=${encodeURIComponent(options.projectId)}`);
35
+ if (Array.isArray(agents)) {
36
+ agentCount = agents.length;
37
+ activeAgentCount = agents.filter((agent) => agent.status === 'active').length;
38
+ }
39
+ }
40
+ catch {
41
+ // The connector remains useful when the aggregate snapshot is
42
+ // unavailable; one running local adapter is still known to be active.
43
+ }
44
+ const heartbeatStarted = Date.now();
45
+ await options.api('POST', `/cloud/installations/${installationId}/heartbeat`, {
46
+ client_version: options.clientVersion,
47
+ protocol_version: options.protocolVersion,
48
+ platform: options.platform,
49
+ latency_ms: priorLatencyMs,
50
+ error_code: pendingErrorCode,
51
+ agent_count: agentCount,
52
+ active_agent_count: activeAgentCount,
53
+ // Token reporting has its own explicit tool and provider semantics.
54
+ // Until a durable per-installation aggregate exists, zero is truthful;
55
+ // inventing or double-counting usage here would be worse.
56
+ input_tokens_total: 0,
57
+ output_tokens_total: 0,
58
+ });
59
+ priorLatencyMs = Math.max(0, Date.now() - heartbeatStarted);
60
+ pendingErrorCode = null;
61
+ try {
62
+ const command = await options.api('GET', `/cloud/installations/${installationId}/commands/next`);
63
+ if (typeof command?.id !== 'string' || !command.id)
64
+ return;
65
+ const supported = command.kind === 'health_check';
66
+ await options.api('POST', `/cloud/installations/${installationId}/commands/${encodeURIComponent(command.id)}/result`, supported
67
+ ? { status: 'succeeded', result_code: 'healthy' }
68
+ : { status: 'failed', result_code: 'unsupported_command' });
69
+ }
70
+ catch {
71
+ pendingErrorCode = 'command_poll_failed';
72
+ }
73
+ }
74
+ catch {
75
+ pendingErrorCode = 'cloud_connection_failed';
76
+ }
77
+ };
78
+ const runOnce = () => {
79
+ if (active)
80
+ return active;
81
+ active = cycle().finally(() => {
82
+ active = null;
83
+ });
84
+ return active;
85
+ };
86
+ const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
87
+ const timer = intervalMs > 0 ? setInterval(() => void runOnce(), intervalMs) : null;
88
+ timer?.unref();
89
+ const ready = runOnce();
90
+ return {
91
+ ready,
92
+ runOnce,
93
+ stop() {
94
+ stopped = true;
95
+ if (timer)
96
+ clearInterval(timer);
97
+ },
98
+ };
99
+ }