syncstaff-mcp 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -0
- package/dist/lib/agent-state.js +119 -0
- package/dist/lib/blast.js +462 -0
- package/dist/lib/client-config.js +81 -0
- package/dist/lib/env-compat.js +66 -0
- package/dist/lib/globs.js +0 -0
- package/dist/lib/ids.js +24 -0
- package/dist/lib/index/aliases.js +244 -0
- package/dist/lib/index/call-sites.js +178 -0
- package/dist/lib/index/checker-resolver.js +257 -0
- package/dist/lib/index/context-card.js +140 -0
- package/dist/lib/index/coverage.js +218 -0
- package/dist/lib/index/delivery.js +66 -0
- package/dist/lib/index/discovery.js +90 -0
- package/dist/lib/index/embedding.js +110 -0
- package/dist/lib/index/file-index.js +222 -0
- package/dist/lib/index/fingerprint.js +0 -0
- package/dist/lib/index/git-history.js +136 -0
- package/dist/lib/index/graph.js +234 -0
- package/dist/lib/index/impact.js +174 -0
- package/dist/lib/index/incremental.js +332 -0
- package/dist/lib/index/lexical.js +462 -0
- package/dist/lib/index/order.js +43 -0
- package/dist/lib/index/pages.js +357 -0
- package/dist/lib/index/persistence.js +233 -0
- package/dist/lib/index/pipeline.js +527 -0
- package/dist/lib/index/registry.js +106 -0
- package/dist/lib/index/resolve.js +280 -0
- package/dist/lib/index/semantic.js +381 -0
- package/dist/lib/index/surfaces.js +27 -0
- package/dist/lib/index/symbols.js +426 -0
- package/dist/lib/index/transformers-embedder.js +73 -0
- package/dist/lib/index/typescript-parser.js +532 -0
- package/dist/lib/index/vector-cache.js +176 -0
- package/dist/lib/index/verification.js +58 -0
- package/dist/lib/mcp-compaction.js +241 -0
- package/dist/lib/model-roles.js +206 -0
- package/dist/lib/path-warnings.js +90 -0
- package/dist/lib/protocol.js +95 -0
- package/dist/lib/types.js +69 -0
- package/dist/lib/version.js +21 -0
- package/dist/lib/worktree.js +211 -0
- package/dist/mcp/approval.js +0 -0
- package/dist/mcp/cloud-connector.js +99 -0
- package/dist/mcp/daemon-client.js +156 -0
- package/dist/mcp/daemon-protocol.js +100 -0
- package/dist/mcp/escalation-waiter.js +183 -0
- package/dist/mcp/graph-ops.js +169 -0
- package/dist/mcp/index.js +1151 -0
- package/dist/mcp/login.js +169 -0
- package/dist/mcp/setup.js +90 -0
- package/package.json +42 -0
|
@@ -0,0 +1,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
|
+
}
|