@intentsolutionsio/dolt-mcp-vcs 0.1.0

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.
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env bash
2
+ # check-agent-safety.sh — the §10 union safety gate (blueprint §3 / blocker B1-iii).
3
+ #
4
+ # Asserts the mutation-verb taxonomy is enforced at the GRANT layer, across BOTH
5
+ # the MCP and the Bash surfaces (the original gate inspected only `mcp__*` and was
6
+ # blind to the Bash door). For every agent (and the core skill) it checks the
7
+ # tool ALLOWLIST — never the denylist — for:
8
+ #
9
+ # 1. no `Bash(<cmd>:*)` wildcard that reaches a history-affecting op
10
+ # (bash/sh = arbitrary; dolt/bd/bd-sync/git = push/reset/branch -D/killall);
11
+ # 2. no granted MCP tool outside the read/safe set (so a future `…__exec`,
12
+ # `…__merge`, `…__push`, `…__reset` grant fails the build).
13
+ #
14
+ # `disallowedTools` (the kebab/camel denylists) are intentionally NOT scanned —
15
+ # a destructive pattern there is the mitigation, not a violation.
16
+ #
17
+ # Usage: scripts/check-agent-safety.sh (exit 0 = pass, 1 = violation)
18
+ set -uo pipefail
19
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
20
+
21
+ FORBIDDEN_WILDCARD='Bash\((bash|sh|dolt|bd|bd-sync|git):\*\)'
22
+ ALLOWED_MCP='query|list_databases|list_dolt_commits|list_dolt_branches|show_tables'
23
+ fail=0
24
+ checked=0
25
+
26
+ check_allowlist() {
27
+ local f="$1" field="$2" line val
28
+ line=$(grep -m1 -E "^${field}:" "$f" 2>/dev/null || true)
29
+ [ -n "$line" ] || return 0
30
+ checked=$((checked + 1))
31
+ val=${line#*:}
32
+
33
+ if echo "$val" | grep -qE "$FORBIDDEN_WILDCARD"; then
34
+ echo "FAIL: $(basename "$f") — allowlist holds a forbidden wildcard Bash grant:" \
35
+ "$(echo "$val" | grep -oE "$FORBIDDEN_WILDCARD" | tr '\n' ' ')"
36
+ fail=1
37
+ fi
38
+
39
+ local tok name
40
+ for tok in $(echo "$val" | grep -oE 'mcp__[A-Za-z0-9_-]+__[A-Za-z0-9_]+' || true); do
41
+ name=${tok##*__}
42
+ if ! echo "$name" | grep -qE "^(${ALLOWED_MCP})$"; then
43
+ echo "FAIL: $(basename "$f") — grants non-read MCP tool '$tok' (not in the read/safe set)"
44
+ fail=1
45
+ fi
46
+ done
47
+ }
48
+
49
+ shopt -s nullglob
50
+ for f in "$ROOT"/agents/*.md; do
51
+ check_allowlist "$f" "tools"
52
+ done
53
+ for f in "$ROOT"/skills/*/SKILL.md; do
54
+ check_allowlist "$f" "allowed-tools"
55
+ done
56
+
57
+ if [ "$fail" -eq 0 ]; then
58
+ echo "PASS: §10 safety gate — $checked allowlist(s) clean (no history-affecting Bash wildcard; no non-read MCP grant)."
59
+ else
60
+ echo "----"
61
+ echo "The mutation-verb taxonomy is violated: a read/safe-write agent or the core skill"
62
+ echo "holds a grant that reaches a history-affecting operation. Narrow it to explicit"
63
+ echo "read-only subcommands and move destructive forms to disallowedTools / recommend-only."
64
+ fi
65
+ exit "$fail"
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env bash
2
+ # dep-graph.sh — bead dependency analysis through the dolt-mcp server.
3
+ # Surfaces bottlenecks (open issues blocking the most other open work) and any
4
+ # direct dependency cycles. Runs SQL via dolt-mcp-client.py.
5
+ #
6
+ # Usage: dep-graph.sh # uses $DOLT_PORT / $DOLT_DATABASE
7
+ # dep-graph.sh --port 35579 --database beads [--top 10]
8
+ #
9
+ # Requires: python3, dolt-mcp-server on PATH, a running bd dolt sql-server.
10
+ set -euo pipefail
11
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
12
+
13
+ PORT="${DOLT_PORT:-}"; DB="${DOLT_DATABASE:-}"; TOP=10
14
+ while [ $# -gt 0 ]; do
15
+ case "$1" in
16
+ --port) PORT="$2"; shift 2 ;;
17
+ --database) DB="$2"; shift 2 ;;
18
+ --top) TOP="$2"; shift 2 ;;
19
+ *) echo "unknown arg: $1" >&2; exit 2 ;;
20
+ esac
21
+ done
22
+ [ -n "$PORT" ] || { echo "error: set --port or DOLT_PORT (see 'bd dolt show')" >&2; exit 2; }
23
+ [ -n "$DB" ] || { echo "error: set --database or DOLT_DATABASE (see 'bd dolt show')" >&2; exit 2; }
24
+
25
+ q() { python3 "$SCRIPT_DIR/dolt-mcp-client.py" --port "$PORT" --database "$DB" query "$1"; }
26
+
27
+ echo "# Dependency analysis — database '$DB' (port $PORT)"
28
+ echo
29
+ echo "## Bottlenecks — open issues blocking the most other OPEN issues (top $TOP)"
30
+ q "SELECT b.id AS blocker, b.status, COUNT(*) AS blocking_open, LEFT(b.title,55) AS title
31
+ FROM dependencies d
32
+ JOIN issues b ON b.id=d.depends_on_id
33
+ JOIN issues blocked ON blocked.id=d.issue_id
34
+ WHERE d.type='blocks' AND b.status<>'closed' AND blocked.status<>'closed'
35
+ GROUP BY b.id, b.status, b.title
36
+ ORDER BY blocking_open DESC
37
+ LIMIT ${TOP}"
38
+
39
+ echo
40
+ echo "## Direct cycles — A blocks B and B blocks A (should be none)"
41
+ q "SELECT d1.issue_id AS a, d1.depends_on_id AS b
42
+ FROM dependencies d1
43
+ JOIN dependencies d2 ON d2.issue_id=d1.depends_on_id AND d2.depends_on_id=d1.issue_id
44
+ WHERE d1.type='blocks' AND d2.type='blocks' AND d1.issue_id < d1.depends_on_id"
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env python3
2
+ """descriptor-to-mcp-args.py — the connection-descriptor -> .mcp.json transform,
3
+ plus the descriptor validator rule (blueprint §2 / the creds-ref MAJOR).
4
+
5
+ Reads a `connection.descriptor.json` (the committed per-workspace home), VALIDATES
6
+ it, and emits the `dolt-mcp-server` args + env the .mcp.json wires — turning
7
+ `flavor`/`endpoint`/`database`/`maturity` from frozen literals into data.
8
+
9
+ Validator rule (fail-closed): the run is rejected (exit 2) if
10
+ * a required field is missing (flavor, endpoint, database, creds-ref, maturity);
11
+ * `flavor` is not a known flavor;
12
+ * `maturity` is not ga|beta|alpha|experimental;
13
+ * `creds-ref` does not start with a known `scheme:` (env:/sops:/pass:) — a
14
+ creds-ref is a pointer, never a literal, so anything without a known scheme is
15
+ refused rather than silently treated as a password.
16
+ Alpha/experimental flavors are emitted with DOLT_MATURITY set so the client gate
17
+ (sql_classifier.gate_decision) holds them to read-only.
18
+
19
+ Usage:
20
+ descriptor-to-mcp-args.py [--descriptor connection.descriptor.json] [--format json|args]
21
+ --format json (default) -> {"args": [...], "env": {...}} (machine-readable)
22
+ --format args -> the args, space-joined (for eyeballing)
23
+ Exit: 0 ok · 2 invalid descriptor / unknown scheme.
24
+ """
25
+ import argparse
26
+ import json
27
+ import os
28
+ import sys
29
+
30
+ REQUIRED = ("flavor", "endpoint", "database", "creds-ref", "maturity")
31
+ FLAVOR_CONNECT = {"dolt": "--dolt", "doltgres": "--doltgres"}
32
+ # alpha/experimental flavors have no wired connect flag yet (descriptor-stub only,
33
+ # decision 6) — they validate but cannot be transformed into a live connection.
34
+ FLAVOR_STUB = {"doltlite", "dumbo"}
35
+ MATURITIES = {"ga", "beta", "alpha", "experimental"}
36
+ KNOWN_CREDS_SCHEMES = ("env:", "sops:", "pass:")
37
+
38
+
39
+ def eprint(*a):
40
+ print(*a, file=sys.stderr)
41
+
42
+
43
+ def validate(d):
44
+ errs = []
45
+ for f in REQUIRED:
46
+ if not d.get(f):
47
+ errs.append(f"missing required field '{f}'")
48
+ flavor = d.get("flavor")
49
+ if flavor and flavor not in FLAVOR_CONNECT and flavor not in FLAVOR_STUB:
50
+ errs.append(f"unknown flavor '{flavor}' (known: "
51
+ f"{', '.join(sorted(set(FLAVOR_CONNECT) | FLAVOR_STUB))})")
52
+ maturity = (d.get("maturity") or "").lower()
53
+ if maturity and maturity not in MATURITIES:
54
+ errs.append(f"unknown maturity '{maturity}' (known: {', '.join(sorted(MATURITIES))})")
55
+ cref = d.get("creds-ref")
56
+ if cref and not str(cref).startswith(KNOWN_CREDS_SCHEMES):
57
+ errs.append(f"creds-ref '{cref}' has no known scheme prefix "
58
+ f"({', '.join(KNOWN_CREDS_SCHEMES)}); a creds-ref must be a pointer, "
59
+ "never a literal secret")
60
+ return errs
61
+
62
+
63
+ def split_endpoint(endpoint):
64
+ host, _, port = endpoint.partition(":")
65
+ return host or "127.0.0.1", port or "3308"
66
+
67
+
68
+ def transform(d):
69
+ flavor = d["flavor"]
70
+ if flavor in FLAVOR_STUB:
71
+ raise ValueError(f"flavor '{flavor}' is a descriptor-stub (pre-beta) — no live "
72
+ "connection flag is wired yet (decision 6). It validates but "
73
+ "cannot be transformed until dolt-watch reports it has reached beta.")
74
+ host, port = split_endpoint(d["endpoint"])
75
+ user = os.environ.get("DOLT_USER", "root")
76
+ args = ["--stdio", FLAVOR_CONNECT[flavor],
77
+ "--host", host, "--port", port,
78
+ "--user", user, "--database", d["database"]]
79
+ env = {
80
+ # the secret is resolved at runtime from the creds-ref pointer (never inlined)
81
+ "DOLT_PASSWORD": "${DOLT_PASSWORD:-}",
82
+ "DOLT_MATURITY": d["maturity"].lower(),
83
+ }
84
+ return {"args": args, "env": env, "creds-ref": d["creds-ref"]}
85
+
86
+
87
+ def main():
88
+ ap = argparse.ArgumentParser(description="connection-descriptor -> .mcp.json args (validated)")
89
+ ap.add_argument("--descriptor", default="connection.descriptor.json")
90
+ ap.add_argument("--format", choices=["json", "args"], default="json")
91
+ args = ap.parse_args()
92
+
93
+ try:
94
+ with open(args.descriptor) as fh:
95
+ d = json.load(fh)
96
+ except (OSError, json.JSONDecodeError) as e:
97
+ eprint(f"error: cannot read descriptor '{args.descriptor}': {e}")
98
+ return 2
99
+
100
+ errs = validate(d)
101
+ if errs:
102
+ eprint(f"invalid descriptor '{args.descriptor}':")
103
+ for e in errs:
104
+ eprint(f" - {e}")
105
+ return 2
106
+
107
+ try:
108
+ out = transform(d)
109
+ except ValueError as e:
110
+ eprint(f"error: {e}")
111
+ return 2
112
+
113
+ if args.format == "args":
114
+ print(" ".join(out["args"]))
115
+ else:
116
+ print(json.dumps(out, indent=2))
117
+ return 0
118
+
119
+
120
+ if __name__ == "__main__":
121
+ sys.exit(main())
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env bash
2
+ # dolt-idle-reaper.sh — gracefully stop idle bd-managed `dolt sql-server`
3
+ # processes so they don't accumulate (the "18 sprawled servers" problem).
4
+ #
5
+ # Safe by design: bd auto-restarts a workspace's dolt server on the next bd
6
+ # command, so stopping an idle one is non-destructive — worst case is a ~2s
7
+ # respawn latency next time that workspace is used. Data lives in Dolt (durable)
8
+ # + the .beads JSONL; stopping a server never loses anything.
9
+ #
10
+ # "Idle" = the workspace's .beads/ activity files (last-touched, issues.jsonl,
11
+ # dolt-server.log — the log updates on every query, so reads count too) have not
12
+ # changed in $IDLE_MIN minutes.
13
+ #
14
+ # Usage:
15
+ # dolt-idle-reaper.sh # reap servers idle > IDLE_MIN (default 90)
16
+ # dolt-idle-reaper.sh --dry-run # show what WOULD be reaped, change nothing
17
+ # IDLE_MIN=120 dolt-idle-reaper.sh # custom idle threshold
18
+ #
19
+ # Cron (every 30 min):
20
+ # 3,33 * * * * /path/to/dolt-idle-reaper.sh >> ~/.local/state/dolt-reaper/reap.log 2>&1
21
+ set -uo pipefail
22
+
23
+ IDLE_MIN="${IDLE_MIN:-90}"
24
+ DRY=0
25
+ [ "${1:-}" = "--dry-run" ] && DRY=1
26
+
27
+ LOGDIR="$HOME/.local/state/dolt-reaper"
28
+ mkdir -p "$LOGDIR"
29
+ LOCK="$LOGDIR/.lock"
30
+ exec 9>"$LOCK"
31
+ flock -n 9 || { echo "[$(date '+%F %T')] another reaper run is active; skip."; exit 0; }
32
+
33
+ now=$(date +%s)
34
+ idle_secs=$(( IDLE_MIN * 60 ))
35
+ reaped=0 kept=0 total=0
36
+
37
+ # Resolve a server process's workspace root from its cwd (cwd is either the
38
+ # workspace root or its .beads/dolt data-dir).
39
+ ws_root() {
40
+ local cwd="$1"
41
+ case "$cwd" in
42
+ */.beads/dolt) echo "${cwd%/.beads/dolt}" ;;
43
+ */.beads) echo "${cwd%/.beads}" ;;
44
+ *) echo "$cwd" ;;
45
+ esac
46
+ }
47
+
48
+ # Most-recent activity timestamp (epoch) across a workspace's .beads activity files.
49
+ latest_activity() {
50
+ local bd="$1/.beads" newest=0 m
51
+ for f in last-touched issues.jsonl dolt-server.log interactions.jsonl; do
52
+ [ -f "$bd/$f" ] || continue
53
+ m=$(stat -c %Y "$bd/$f" 2>/dev/null || echo 0)
54
+ [ "$m" -gt "$newest" ] && newest=$m
55
+ done
56
+ echo "$newest"
57
+ }
58
+
59
+ echo "[$(date '+%F %T')] reaper start (IDLE_MIN=$IDLE_MIN, dry-run=$DRY)"
60
+ for pid in $(pgrep -f 'dolt sql-server' 2>/dev/null); do
61
+ total=$((total+1))
62
+ cwd=$(readlink "/proc/$pid/cwd" 2>/dev/null) || { continue; }
63
+ ws=$(ws_root "$cwd")
64
+ short=$(echo "$ws" | sed "s#$HOME/##")
65
+ act=$(latest_activity "$ws")
66
+ if [ "$act" -eq 0 ]; then
67
+ # No activity files found — fall back to the process start time via /proc.
68
+ act=$(stat -c %Y "/proc/$pid" 2>/dev/null || echo "$now")
69
+ fi
70
+ age_min=$(( (now - act) / 60 ))
71
+ if [ "$(( now - act ))" -ge "$idle_secs" ]; then
72
+ if [ "$DRY" -eq 1 ]; then
73
+ echo " WOULD REAP pid=$pid idle=${age_min}m $short"
74
+ else
75
+ kill -TERM "$pid" 2>/dev/null && echo " reaped pid=$pid idle=${age_min}m $short" || echo " reap-failed pid=$pid $short"
76
+ fi
77
+ reaped=$((reaped+1))
78
+ else
79
+ kept=$((kept+1))
80
+ fi
81
+ done
82
+ echo "[$(date '+%F %T')] reaper done — $total servers, $reaped $([ $DRY -eq 1 ] && echo would-reap || echo reaped), $kept kept (active within ${IDLE_MIN}m). bd respawns on next use."
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ dolt-mcp-client.py — minimal, robust stdio client for the dolthub/dolt-mcp server.
4
+
5
+ The reusable foundation the dolt-mcp-vcs agents/scripts use to run SQL against a
6
+ bd Dolt server *through the MCP* (not by shelling `dolt` directly), so the path
7
+ exercised in production is the same one the plugin ships.
8
+
9
+ Spawns `dolt-mcp-server --stdio`, performs the JSON-RPC handshake, calls one tool,
10
+ prints the tool's text result, and exits. Reads until the matching response id
11
+ arrives (no sleep-based timing).
12
+
13
+ This client is the plugin's mutation chokepoint (blueprint §3 / blocker B1): every
14
+ `query`/`exec` SQL string is run through the verb-class statement classifier
15
+ (`sql_classifier.py`) and gated BEFORE it reaches the server. Reads execute freely;
16
+ safe-writes require `--allow-mutation` on a non-`main` agent branch and a GA flavor;
17
+ history-affecting statements (push / merge / reset --hard / branch-delete / DROP
18
+ DATABASE / unknown CALL) are always refused (recommend-only).
19
+
20
+ Requires: python3 (stdlib only) + a PINNED `dolt-mcp-server` on PATH. The plugin
21
+ pins the binary (blocker B4) — do NOT install `@latest`:
22
+ go install github.com/dolthub/dolt-mcp/mcp/cmd/dolt-mcp-server@v0.3.6
23
+ Pinned: github.com/dolthub/dolt-mcp v0.3.6
24
+ Module checksum (verified by `go install @v0.3.6` against sum.golang.org):
25
+ h1:uwjh1zf0er51VBT6uY3tI7JLj5pYxWyk9uB6CYQOhfU=
26
+ A bump is proposed only by the dolt-watch routine (never auto-trusted).
27
+
28
+ Usage:
29
+ dolt-mcp-client.py --port 35579 --database beads query "SELECT COUNT(*) FROM issues"
30
+ dolt-mcp-client.py --port 35579 list_databases
31
+ echo "SELECT ..." | dolt-mcp-client.py --port 35579 --database beads query -
32
+ # a safe-write must opt in AND target an agent branch (never main):
33
+ dolt-mcp-client.py --port 35579 --database beads --allow-mutation \
34
+ --branch agent/my-task exec "INSERT INTO issues ..."
35
+
36
+ Connection defaults come from env when flags are omitted:
37
+ DOLT_HOST (127.0.0.1), DOLT_PORT, DOLT_USER (root), DOLT_DATABASE, DOLT_PASSWORD ('')
38
+ DOLT_MATURITY (ga) — alpha/experimental restrict the connection to read-only.
39
+ Exit codes: 0 ok · 2 bad usage · 3 binary missing · 4 connection/tool error ·
40
+ 5 timeout · 6 mutation refused by the verb-class gate
41
+ """
42
+ import argparse
43
+ import json
44
+ import os
45
+ import shutil
46
+ import subprocess
47
+ import sys
48
+ import threading
49
+
50
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
51
+ from sql_classifier import gate_decision # noqa: E402
52
+
53
+ BIN = "dolt-mcp-server"
54
+ PINNED_VERSION = "v0.3.6" # blocker B4 — see module docstring; bump only via dolt-watch
55
+ INSTALL_HINT = (f"go install github.com/dolthub/dolt-mcp/mcp/cmd/"
56
+ f"dolt-mcp-server@{PINNED_VERSION}")
57
+
58
+
59
+ def eprint(*a):
60
+ print(*a, file=sys.stderr)
61
+
62
+
63
+ def main():
64
+ ap = argparse.ArgumentParser(description="stdio client for dolthub/dolt-mcp")
65
+ ap.add_argument("--host", default=os.environ.get("DOLT_HOST", "127.0.0.1"))
66
+ ap.add_argument("--port", default=os.environ.get("DOLT_PORT"))
67
+ ap.add_argument("--user", default=os.environ.get("DOLT_USER", "root"))
68
+ ap.add_argument("--database", default=os.environ.get("DOLT_DATABASE", "information_schema"))
69
+ ap.add_argument("--branch", default=os.environ.get("DOLT_BRANCH", "main"),
70
+ help="working branch (the dolt-mcp query/exec tools require it; default main)")
71
+ ap.add_argument("--password", default=os.environ.get("DOLT_PASSWORD", ""))
72
+ ap.add_argument("--maturity", default=os.environ.get("DOLT_MATURITY", "ga"),
73
+ help="flavor maturity (ga|beta|alpha|experimental); pre-GA is read-only")
74
+ ap.add_argument("--allow-mutation", action="store_true",
75
+ help="opt in to safe-write SQL (still refused on main / pre-GA / for "
76
+ "history-affecting statements)")
77
+ ap.add_argument("--timeout", type=float, default=25.0, help="seconds")
78
+ ap.add_argument("tool", help="MCP tool name, e.g. query, exec, list_databases, list_dolt_commits")
79
+ ap.add_argument("sql", nargs="?", help="SQL for query/exec ('-' to read from stdin)")
80
+ args = ap.parse_args()
81
+
82
+ if not args.port:
83
+ eprint("error: --port (or DOLT_PORT) is required")
84
+ return 2
85
+ if not shutil.which(BIN):
86
+ eprint(f"error: '{BIN}' not found on PATH. Install (pinned): {INSTALL_HINT}")
87
+ return 3
88
+
89
+ # Build tool arguments
90
+ tool_args = {}
91
+ if args.tool in ("query", "exec"):
92
+ sql = args.sql
93
+ if sql == "-" or (sql is None and not sys.stdin.isatty()):
94
+ sql = sys.stdin.read()
95
+ if not sql or not sql.strip():
96
+ eprint(f"error: tool '{args.tool}' requires a SQL string")
97
+ return 2
98
+ sql = sql.strip()
99
+ # The mutation chokepoint (blocker B1): classify + gate BEFORE the server call.
100
+ allowed, verb_class, reason = gate_decision(
101
+ sql, allow_mutation=args.allow_mutation,
102
+ branch=args.branch, maturity=args.maturity)
103
+ if not allowed:
104
+ eprint(f"refused [{verb_class}]: {reason}")
105
+ return 6
106
+ tool_args["query"] = sql
107
+ tool_args["working_database"] = args.database
108
+ tool_args["working_branch"] = args.branch # server enforces this for query/exec
109
+ elif args.tool in ("list_dolt_commits", "list_dolt_branches", "show_tables"):
110
+ tool_args["working_database"] = args.database
111
+ if args.tool == "list_dolt_commits":
112
+ tool_args["working_branch"] = args.branch
113
+
114
+ cmd = [BIN, "--stdio", "--dolt",
115
+ "--host", args.host, "--port", str(args.port),
116
+ "--user", args.user, "--database", args.database]
117
+ env = dict(os.environ)
118
+ if args.password:
119
+ env["DOLT_PASSWORD"] = args.password
120
+
121
+ requests = [
122
+ {"jsonrpc": "2.0", "id": 1, "method": "initialize",
123
+ "params": {"protocolVersion": "2024-11-05", "capabilities": {},
124
+ "clientInfo": {"name": "dolt-mcp-client", "version": "0.1"}}},
125
+ {"jsonrpc": "2.0", "method": "notifications/initialized"},
126
+ {"jsonrpc": "2.0", "id": 2, "method": "tools/call",
127
+ "params": {"name": args.tool, "arguments": tool_args}},
128
+ ]
129
+
130
+ try:
131
+ proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
132
+ stderr=subprocess.PIPE, text=True, env=env)
133
+ except OSError as e:
134
+ eprint(f"error: failed to spawn {BIN}: {e}")
135
+ return 3
136
+
137
+ # Watchdog: kill the process if it runs past the timeout
138
+ timer = threading.Timer(args.timeout, proc.kill)
139
+ timer.start()
140
+ try:
141
+ for r in requests:
142
+ proc.stdin.write(json.dumps(r) + "\n")
143
+ proc.stdin.flush()
144
+
145
+ result_text, err = None, None
146
+ for line in proc.stdout:
147
+ line = line.strip()
148
+ if not line.startswith("{"):
149
+ continue
150
+ try:
151
+ o = json.loads(line)
152
+ except json.JSONDecodeError:
153
+ continue
154
+ if o.get("id") == 2:
155
+ if "error" in o:
156
+ err = o["error"].get("message", str(o["error"]))
157
+ else:
158
+ res = o.get("result", {})
159
+ parts = [c.get("text", "") for c in res.get("content", []) if c.get("type") == "text"]
160
+ result_text = "\n".join(parts)
161
+ if res.get("isError"):
162
+ err = result_text or "tool reported isError"
163
+ result_text = None
164
+ break
165
+ finally:
166
+ timer.cancel()
167
+ try:
168
+ proc.stdin.close()
169
+ except Exception:
170
+ pass
171
+ proc.terminate()
172
+
173
+ if not timer.is_alive() and result_text is None and err is None:
174
+ eprint(f"error: timed out after {args.timeout}s")
175
+ return 5
176
+ if err is not None:
177
+ eprint(f"MCP error: {err}")
178
+ return 4
179
+ if result_text is None:
180
+ eprint("error: no result returned (connection failed?). stderr:")
181
+ eprint((proc.stderr.read() or "").strip()[:500])
182
+ return 4
183
+ print(result_text)
184
+ return 0
185
+
186
+
187
+ if __name__ == "__main__":
188
+ sys.exit(main())
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env bash
2
+ # dolt-push-dolthub.sh — flush + push a bd workspace's Dolt database to its
3
+ # DoltHub remote. Built to run on a schedule (cron/systemd timer) so DoltHub
4
+ # stays current, instead of pushing per-command (too slow). Idempotent and safe
5
+ # to run when nothing changed.
6
+ #
7
+ # Safety (blocker B2):
8
+ # * A failed `bd export` flush ABORTS the push — we never push on an unverified
9
+ # flush (the `|| true` swallow that masked flush failures was removed).
10
+ # * A flock guard makes overlapping scheduled runs a no-op (no double-apply).
11
+ # * On an ambiguous push (non-zero exit), we poll the DoltHub SQL API to read
12
+ # the TERMINAL state before reporting — a push that timed out client-side may
13
+ # have actually landed; we surface the real state instead of blind-retrying.
14
+ # The script itself never auto-retries; a Dolt push is idempotent, so a human
15
+ # (or the next scheduled run) can safely re-run it.
16
+ #
17
+ # Usage: dolt-push-dolthub.sh [WORKSPACE_DIR] [--remote NAME]
18
+ # WORKSPACE_DIR bd workspace to push (default: current dir). Must contain .beads/.
19
+ # --remote NAME Dolt remote to push (default: origin).
20
+ #
21
+ # Cron example (every 20 min):
22
+ # */20 * * * * /path/to/dolt-push-dolthub.sh ~/my-workspace >> ~/.local/state/dolt-push.log 2>&1
23
+ #
24
+ # Requires: bd >= 1.0.4 with a Dolt remote already configured
25
+ # (bd dolt remote add origin https://doltremoteapi.dolthub.com/ORG/REPO).
26
+ # Optional: curl (enables the DoltHub SQL-API terminal-state poll).
27
+ # Exit: 0 pushed or nothing-to-do · 2 bad usage · 3 no remote configured ·
28
+ # 4 push failed (and the poll could not confirm it landed) ·
29
+ # 5 flush failed (did NOT push) · 0 also when a failed push is confirmed-landed by the poll.
30
+ set -uo pipefail
31
+
32
+ WORKSPACE="."; REMOTE="origin"
33
+ while [ $# -gt 0 ]; do
34
+ case "$1" in
35
+ --remote) REMOTE="$2"; shift 2 ;;
36
+ -*) echo "unknown arg: $1" >&2; exit 2 ;;
37
+ *) WORKSPACE="$1"; shift ;;
38
+ esac
39
+ done
40
+ command -v bd >/dev/null 2>&1 || { echo "error: bd not on PATH" >&2; exit 2; }
41
+ cd "$WORKSPACE" || { echo "error: cannot cd to $WORKSPACE" >&2; exit 2; }
42
+ [ -d .beads ] || { echo "error: $WORKSPACE is not a bd workspace (no .beads/)" >&2; exit 2; }
43
+
44
+ ts() { date '+%Y-%m-%dT%H:%M:%S'; }
45
+
46
+ # Idempotency guard: serialize concurrent runs for THIS workspace so two timers
47
+ # can't push the same workspace at once. Non-blocking — a second run just exits.
48
+ LOCKDIR="${XDG_STATE_HOME:-$HOME/.local/state}/dolt-push"
49
+ mkdir -p "$LOCKDIR"
50
+ LOCK="$LOCKDIR/$(echo "$PWD" | tr '/' '_').lock"
51
+ exec 9>"$LOCK"
52
+ flock -n 9 || { echo "[$(ts)] another push for $PWD is in progress — skip."; exit 0; }
53
+
54
+ # No remote => nothing to push; say so clearly (the #1 "beads not in DoltHub" cause).
55
+ if ! bd dolt remote list 2>/dev/null | grep -q "$REMOTE"; then
56
+ echo "[$(ts)] no Dolt remote '$REMOTE' configured in $WORKSPACE — nothing pushed." >&2
57
+ echo " fix: bd dolt remote add $REMOTE https://doltremoteapi.dolthub.com/ORG/REPO" >&2
58
+ exit 3
59
+ fi
60
+
61
+ # Poll the DoltHub SQL API for a terminal state (a cheap COUNT(*) read). Best-effort:
62
+ # resolves ORG/REPO from the remote URL; prints the count or nothing. Never fatal.
63
+ poll_remote_terminal() {
64
+ command -v curl >/dev/null 2>&1 || return 1
65
+ local url org_repo
66
+ url="$(bd dolt remote list 2>/dev/null | grep -oE 'https://doltremoteapi\.dolthub\.com/[^ ]+' | head -1)"
67
+ [ -n "$url" ] || return 1
68
+ org_repo="${url#https://doltremoteapi.dolthub.com/}"
69
+ org_repo="${org_repo%/}"
70
+ curl -sf --max-time 15 \
71
+ "https://www.dolthub.com/api/v1alpha1/${org_repo}/main?q=SELECT%20COUNT(*)%20AS%20n%20FROM%20issues" \
72
+ 2>/dev/null
73
+ }
74
+
75
+ # Flush the JSONL projection. A failed flush is a real signal — DO NOT push on it.
76
+ if ! bd export >/dev/null 2>&1; then
77
+ echo "[$(ts)] bd export (flush) FAILED — NOT pushing on an unverified flush." >&2
78
+ echo " The Dolt DB may be locked or the server down. Resolve, then re-run." >&2
79
+ exit 5
80
+ fi
81
+
82
+ echo "[$(ts)] pushing $WORKSPACE -> remote '$REMOTE' ..."
83
+ if bd dolt push --remote "$REMOTE" 2>&1; then
84
+ echo "[$(ts)] push complete."
85
+ exit 0
86
+ fi
87
+
88
+ # Ambiguous failure: poll the remote's terminal state before declaring defeat. The
89
+ # push may have landed despite a client-side error; report what actually happened.
90
+ echo "[$(ts)] push returned non-zero — polling DoltHub for terminal state ..." >&2
91
+ if poll_remote_terminal >/dev/null 2>&1; then
92
+ echo "[$(ts)] remote is reachable and reflects an 'issues' table — the push likely landed." >&2
93
+ echo " Verify: curl the DoltHub SQL API for COUNT(*); re-running this script is safe (idempotent)." >&2
94
+ exit 0
95
+ fi
96
+ echo "[$(ts)] push FAILED and could not be confirmed landed (remote unreachable, creds, or DoltHub repo missing)." >&2
97
+ echo " Not auto-retrying — a Dolt push is idempotent, so re-run after fixing the cause." >&2
98
+ exit 4
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env bash
2
+ # epic-closure-audit.sh — find OPEN epics whose entire child set is already closed
3
+ # (the "stale-open epic" drift: every parent-child child is closed but the epic
4
+ # itself is still open, so its GitHub/Plane cluster issue never gets the close
5
+ # fan-out). Runs the audit SQL through the dolt-mcp server via dolt-mcp-client.py.
6
+ #
7
+ # Usage: epic-closure-audit.sh # uses $DOLT_PORT / $DOLT_DATABASE
8
+ # DOLT_PORT=35579 DOLT_DATABASE=beads epic-closure-audit.sh
9
+ # epic-closure-audit.sh --port 35579 --database beads
10
+ #
11
+ # Requires: python3, dolt-mcp-server on PATH, a running bd dolt sql-server.
12
+ # Exit: 0 ok (whether or not drift was found) · non-zero on connection/tool error.
13
+ set -euo pipefail
14
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15
+
16
+ PORT="${DOLT_PORT:-}"; DB="${DOLT_DATABASE:-}"
17
+ while [ $# -gt 0 ]; do
18
+ case "$1" in
19
+ --port) PORT="$2"; shift 2 ;;
20
+ --database) DB="$2"; shift 2 ;;
21
+ *) echo "unknown arg: $1" >&2; exit 2 ;;
22
+ esac
23
+ done
24
+ [ -n "$PORT" ] || { echo "error: set --port or DOLT_PORT (see 'bd dolt show')" >&2; exit 2; }
25
+ [ -n "$DB" ] || { echo "error: set --database or DOLT_DATABASE (see 'bd dolt show')" >&2; exit 2; }
26
+
27
+ read -r -d '' SQL <<'EOSQL' || true
28
+ SELECT e.id AS epic, COUNT(d.issue_id) AS children,
29
+ SUM(CASE WHEN c.status='closed' THEN 1 ELSE 0 END) AS closed,
30
+ LEFT(e.title,60) AS title
31
+ FROM issues e
32
+ JOIN dependencies d ON d.depends_on_id=e.id AND d.type='parent-child'
33
+ JOIN issues c ON c.id=d.issue_id
34
+ WHERE e.issue_type='epic' AND e.status<>'closed'
35
+ GROUP BY e.id, e.title
36
+ HAVING children>0 AND closed=children
37
+ ORDER BY children DESC
38
+ EOSQL
39
+
40
+ echo "# Epic-closure drift audit — database '$DB' (port $PORT)"
41
+ echo "# OPEN epics whose every parent-child child is already closed (candidates to close)."
42
+ OUT="$(python3 "$SCRIPT_DIR/dolt-mcp-client.py" --port "$PORT" --database "$DB" query "$SQL")"
43
+ echo "$OUT"
44
+ # Rows beyond the header/separator => drift found
45
+ if [ "$(printf '%s\n' "$OUT" | sed '1,2d' | grep -c .)" -eq 0 ]; then
46
+ echo "# ✓ No stale-open-epic drift found."
47
+ else
48
+ echo "# ⚠ Above epics have all children closed — close each via: bd-sync close <epic> --also-close-gh"
49
+ fi